Categories
Vue Vue Answers

How to add a copy of a persistent object to a repeated array with Vue.js?

Sometimes, we want to add a copy of a persistent object to a repeated array with Vue.js.

In this article, we’ll look at how to add a copy of a persistent object to a repeated array with Vue.js.

How to add a copy of a persistent object to a repeated array with Vue.js?

To add a copy of a persistent object to a repeated array with Vue.js, we can use the object spread operator.

For instance, we write:

<template>
  <button @click="addItem">add</button>
  <div>{{ items }}</div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      newItem: { name: "" },
      items: [],
    };
  },
  methods: {
    addItem() {
      this.items.push({ ...this.newItem });
    },
  },
};
</script>

to add a button that calls addItem when we click it.

In addItem, we call this.items.push with a shallow clone of this.newItem that we created with the object spread operator.

Therefore, when we click on add, we see a new entry of items added.

Conclusion

To add a copy of a persistent object to a repeated array with Vue.js, we can use the object spread operator.

Categories
Vue

How to Add Ripple Effect to UI Elements in Your Vue.js App

A ripple effect is a situation where something starts inward and move outward.

A ripple effect is a situation where something starts inward and move outward. In a web app, this is the situation where the color changes from the inside and then spreads outward. You can add the ripple effect to your Vue.js app with the Vue-Ripple-Directive. More details about it are located at https://github.com/PygmySlowLoris/vue-ripple-directive.

In this article, we will make a grocery list app where users can search for dishes they want to cook with the MealDB API and add the ingredients they want from it. We will add ripple effects to buttons and list items to highlight them when the mouse pointer goes over them.

To start building the app, we run the Vue CLI by running npx @vue/cli grocery-app . In the wizard, select ‘Manually select features’, then select Babel, Vuex, Vue Router, and CSS Preprocessor. Next, we install some packages that we need. We need Axios for making HTTP requests, BootstrapVue for styling, Vue Font Awesome for adding icons, Vee-Validate for form validation and Vue Ripple Directive for adding the ripple effect. To install them, we run:

npm i axios @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/vue-fontawesome axios bootstrap-vue vee-validate vue-ripple-directive

With all the packages installed, we can start writing the app. We start by adding a form for adding groceries. Create a GroceryForm.vue file in the components folder and add:

<template>
  <div>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Search for Dish and Add Ingredients From Result">
          <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
            <b-form-input
              type="text"
              :state="errors.length == 0"
              v-model="form.keyword"
              required
              placeholder="Search for Dish and Add Ingredients From Result"
              name="keyword"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>

        <b-button v-ripple.mouseover.500 type="submit" variant="primary">Find Dishes</b-button>
      </b-form>
    </ValidationObserver>

    <b-card v-for="(m, i) in meals" :title="m.strMeal" :key="i">
      <b-card-text>
        <b-list-group>
          <b-list-group-item
            v-for="(key, index) in Object.keys(m).filter(k => k.includes('strIngredient') && m[k])"
            :key="index"
            v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
          >{{m[key]}}</b-list-group-item>
        </b-list-group>
      </b-card-text>
      <b-button
        v-ripple.mouseover.500
        variant="primary"
        @click="addToGroceryList(i)"
      >Add Ingredients to Grocery List</b-button>
    </b-card>

    <h4>Your Chosen Ingredients</h4>
    <b-list-group>
      <b-list-group-item v-for="(ingredient, i) of ingredients" :key="i" v-ripple.mouseover>
        {{ingredient}}
        <font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
      </b-list-group-item>
    </b-list-group>

    <br />

    <b-button v-ripple.mouseover.500 type="button" variant="primary" @click="saveGroceryList()">Save</b-button>
    <b-button v-ripple.mouseover.500 type="reset" variant="danger" @click="cancel()">Cancel</b-button>
  </div>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import { faTimes } from "@fortawesome/free-solid-svg-icons";

export default {
  name: "GroceryForm",
  mixins: [requestsMixin],
  components: {
    faTimes
  },
  data() {
    return {
      form: {},
      meals: [],
      ingredients: []
    };
  },
  computed: {
    grocery() {
      return this.$store.state.grocery;
    }
  },
  methods: {
    cancel() {
      this.$emit("cancelled");
    },

    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const { data } = await this.findDishes(this.form.keyword);
      this.meals = data.meals;
    },

    addToGroceryList(index) {
      const meal = this.meals[index];
      const keys = Object.keys(meal).filter(
        k => k.includes("strIngredient") && meal[k]
      );
      const ingredients = keys.map(k => meal[k]);
      this.ingredients = Array.from(
        new Set(this.ingredients.concat(ingredients))
      );
    },

    removeIngredient(index) {
      this.ingredients.splice(index, 1);
    },

    async saveGroceryList() {
      const payload = { ingredients: this.ingredients };
      if (!this.groceryListId) {
        await this.addGrocery(payload);
      } else {
        await this.editGrocery(payload);
      }
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
      this.$emit("saved");
    }
  },
  watch: {
    grocery: {
      handler(val) {
        this.ingredients = val.ingredients || [];
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

<style lang="scss" scoped>
.delete {
  cursor: pointer;
}
</style>

This form lets users search for dishes with the given keyword, then return a list of ingredients for the dishes and then the user can add them to a list with the duplicates removed. We use Vee-Validate to validate our inputs. We use the ValidationObserver component to watch for the validity of the form inside the component and ValidationProvider to check for the validation rule of the inputted value of the input inside the component. Inside the ValidationProvider , we have our BootstrapVue input for the text input fields. In the b-form-input components. We also add Vee-Validate validation to make sure that users have filled out the date before submitting it. We make the keyword field required in the rules prop so that users will have to enter something before searching.

We have buttons in the list at the bottom of the form, which has the list of ingredients, to delete each of them. This is why we imported the faTimes icon here, which displays as an ‘x’, so that users can click on it and delete it. If that element is clicked, the removeIngredient function is called. Then the user clicks Save at the bottom of the form, then the saveGroceryList function is called, which saves the list to our back end.

In this component, we also have a watch block to watch the grocery value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the grocery value is updated.

We have the ripple effect applied to the buttons and the list rows with Vue Ripple. v-ripple.mouseover.500 means that the ripple effect will show for 500 milliseconds when the mouse is over the element with this directive. To apply a different color to the ripple effect than the default we can also specify the color value in the parameter of the directive as we have in v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’” . The ripple will have the color specified.

We style the element for deleting the form with cursor:pointer so the mouse icon will show up a hand instead of the arrow.

Next, we create a mixins folder and add requestsMixin.js into the mixins folder. In the file, we add:

const APIURL = "http://localhost:3000";
const MEAL_DB_URL = "https://www.themealdb.com/api/json/v1/1/search.php?s=";

const axios = require("axios");

export const requestsMixin = {
  methods: {
    getGrocery() {
      return axios.get(`${APIURL}/grocery`);
    },

    addGrocery(data) {
      return axios.post(`${APIURL}/grocery`, data);
    },

    editGrocery(data) {
      return axios.put(`${APIURL}/grocery`, data);
    },

    findDishes(keyword) {
      return axios.get(`${MEAL_DB_URL}${keyword}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to get and save our grocery data and search the Meal DB API for dishes.

Next in Home.vue , replace the existing code with:

<template>
  <div class="page">
    <h1 class="text-center">Grocery List</h1>
    <b-button-toolbar class="button-toolbar">
      <b-button
        v-ripple.mouseover.500
        @click="openAddModal()"
        variant="primary"
      >Add Ingredients to Grocery List</b-button>
    </b-button-toolbar>

    <h4>Your Grocery List</h4>
    <b-list-group>
      <b-list-group-item
        v-for="(ingredient, i) of grocery.ingredients"
        :key="i"
        v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
      >
        {{ingredient}}
        <font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
      </b-list-group-item>
    </b-list-group>

    <b-modal id="add-modal" title="Add Ingredients to Grocery List" hide-footer>
      <GroceryForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="false"
        :groceryListId="grocery.id"
      />
    </b-modal>
  </div>
</template>

<script>
// @ is an alias to /src
import GroceryForm from "@/components/GroceryForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "home",
  components: {
    GroceryForm
  },
  mixins: [requestsMixin],
  computed: {
    grocery() {
      return this.$store.state.grocery;
    }
  },
  data() {
    return {
      ingredients: []
    };
  },
  beforeMount() {
    this.getGroceryList();
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
    },
    async getGroceryList() {
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
    },
    async removeIngredient(index) {
      this.ingredients.splice(index, 1);
      const payload = { id: this.grocery.id, ingredients: this.ingredients };
      await this.editGrocery(payload);
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
    }
  },
  watch: {
    grocery: {
      handler(val) {
        this.ingredients = val.ingredients || [];
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

};

This is the component of the home page. We display the list of ingredients chosen obtained from our back end here. Also, we have a button to open a modal with the GroceryForm that we created earlier to add ingredients to our grocery list. Getting data is done in the getGroceryList function. We put the obtained data into our Vuex store in the last line of the function.

Also, we let users remove ingredients that they saved to the list in this page with the removeIngredient function. We call splice on the this.ingredients array, which we got from the grocery state in the store then set to the current value in the handler of the watch block of grocery .

Again, we have the ripple effect applied to the buttons and the list rows with Vue Ripple. v-ripple.mouseover.500 to show the ripple effect for 500 milliseconds for the buttons and v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’” . The ripple will have the color specified in the list items like we did in GroceryForm .

Next in App.vue , we replace the existing code with:

<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand to="/">Grocery List App</b-navbar-brand>

      <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>

      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path  == '/'">Home</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>

<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

<style lang="scss">
.page {
  padding: 20px;
}

button,
.btn.btn-primary {
  margin-right: 10px !important;
}

.button-toolbar {
  margin-bottom: 10px;
}
</style>

to add a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define. This style section isn’t scoped so the styles will apply globally. In the .page selector, we add some padding to our pages. We add some padding to the buttons in the remaining style code.

Then in main.js , replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value, max_value } from "vee-validate/dist/rules";
import Ripple from "vue-ripple-directive";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";

library.add(faTimes);
Vue.component("font-awesome-icon", FontAwesomeIcon);
Vue.directive("ripple", Ripple);
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules, the Vue-Ripple library, and the Vue Font Awesome packages here. The faTimes is added to our app with the library.add function of Vue Font Awesome so that we can use it in our app.

In router.js we replace the existing code with:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    }
  ]
});

to include the home page in our routes so users can see the page.

And in store.js , we replace the existing code with:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    grocery: {}
  },
  mutations: {
    setGrocery(state, payload) {
      state.grocery = payload;
    }
  },
  actions: {}
});

to add our grocery state to the store so we can observe it in the computed block of GroceryForm and HomePage components. We have the setGrocery function to update the grocery state and we use it in the components by call this.$store.commit(“setGrocery”, data); like we did in GroceryForm and HomePage .

Finally, in index.html , we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
    <title>Grocery List App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-ripple-tutorial-app doesn't work properly without
        JavaScript enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the title of our app.

After all the hard work, we can start our app by running npm run serve.

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "`grocery`": {}
}

So we have the grocery endpoints defined in the requests.js available.

Categories
Vue Answers

How to add web workers to a Vue app created with Vue-CLI?

Sometimes, we want to add web workers to a Vue app created with Vue-CLI.

In this article, we’ll look at how to add web workers to a Vue app created with Vue-CLI.

How to add web workers to a Vue app created with Vue-CLI?

To add web workers to a Vue app created with Vue-CLI, we can use the worker-plugin package.

To install it, we run:

npm i worker-plugin

Next, ion vue.config.js in the project root, we add:

const WorkerPlugin = require("worker-plugin");

module.exports = {
  configureWebpack: {
    output: {
      globalObject: "this"
    },
    plugins: [new WorkerPlugin()]
  }
};

to load the worker-plugin.

Then we create the workers folder in the src folder with the following 2 files:

worker.js

onmessage = (e) => {
  const {data} = e
  console.log(data);
  postMessage({ foo: "bar" });
};

We call postMessage to send a message to the component that invoked the worker.

index.js

const worker = new Worker("./worker.js", { type: "module" });

const send = (message) =>
  worker.postMessage({
    message
  });

export default {
  worker,
  send
};

Next, we use the worker in a component by writing the following in src/App.vue:

<template>
  <div id="app"></div>
</template>

<script>
import w from "@/workers";

export default {
  name: "App",
  beforeMount() {
    const { worker, send } = w;
    worker.onmessage = (e) => {
      const { data } = e;
      console.log(data);
    };
    send({ foo: "baz" });
  },
};
</script>

We import the workers/index.js with:

import w from "@/workers";

Then we destructure the worker from the imported module with:

const { worker, send } = w;

Then we add a message handler to it with:

worker.onmessage = (e) => {
  const { data } = e;
  console.log(data);
};

And then we send a message to the worker with:

send({ foo: "baz" });

Now we get {foo: 'bar'} from the worker in the worker.onmessage in App.

And in worker.js‘s onmessage function, we get:

{
  message: {
    foo: 'baz'
  }
}

Conclusion

To add web workers to a Vue app created with Vue-CLI, we can use the worker-plugin package.

Categories
JavaScript Nuxt.js Vue

How to run background tasks in a Nuxt app with web workers?

(The source code is at https://github.com/jauyeunggithub/bravado-quest)

Sometimes, we want to run background tasks in a Nuxt app with web workers.

In this article, we’ll look at how to run background tasks in a Nuxt app with web workers.

How to run background tasks in a Nuxt app with web workers?

To run background tasks in a Nuxt app with web workers, we can add the Webpack’s worker-loader package into our Nuxt project.

Also, we’ve to make sure that web workers are only loaded on client side.

We’ll make a project that loads a large JSON file and searches it.

To start, we create the Nuxt project with:

npx create-nuxt-app quest

Then we run:

cd quest
npm run dev

to change to the project folder and run the project.

Next, we add Vuetify into the project with:

npm install @nuxtjs/vuetify -D

And we add the worker-loader package with:

npm i worker-loader@^1.1.1

To store data with IndexedDB, we install the Dexie package.

To install it, we run:

npm i dexie

Next, in nuxt.config.js, we change it to:

export default {
  // Global page headers: https://go.nuxtjs.dev/config-head
  head: {
    title: 'quest',
    htmlAttrs: {
      lang: 'en',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
      { name: 'format-detection', content: 'telephone=no' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  // Global CSS: https://go.nuxtjs.dev/config-css
  css: [],

  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
  plugins: [
    { src: '~/plugins/inject-ww', ssr: false }
  ],

  // Auto import components: https://go.nuxtjs.dev/config-components
  components: true,

  // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
  buildModules: ['@nuxtjs/vuetify'],

  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [],

  // Build Configuration: https://go.nuxtjs.dev/config-build
  mode: 'spa',
  build: {
    extend(config, { isClient }) {
      config.output.globalObject = 'this'

      if (isClient) {
        config.module.rules.push({
          test: /\.worker\.js$/,
          loader: 'worker-loader',
          exclude: /(node_modules)/,
        })
      }
    },
  },
  ssr: false,
}

We added the build.extend method to invoke the worker-loader when any file that ends with .worker.js is found.

isClient makes sure workers only load on client side apps.

config.output.globalObject = 'this' is needed so the hot reloading will work with the web workers loaded.

We also set ssr to false to disable server side rendering.

Also, we add { src: '~/plugins/inject-ww', ssr: false } to add the /plugins/inject-ww to load the worker loading plugin.

We add '@nuxtjs/vuetify' to load Vuetify in Nuxt.

In the plugins folder, we add:

import Worker from '~/assets/js/data.worker.js'

export default (_, inject) => {
  inject('worker', {
    createWorker () {
      return new Worker()
    }
  })
}

to load the worker from the /assets/js/data.worker.js.

To create worker, we create the data.worker.js file in the /assets/js/ and add:

import db from '@/db'

onmessage = async () => {
  if ((await db.avatars.toCollection().toArray()).length > 0) {
    postMessage({
      loaded: true,
    })
    return
  }
  const usersObj = await import(`@/data/users.json`)
  const users = Object.values(usersObj)
  const usersWithId = users.map((u, id) => ({ ...u, id }))
  await db.avatars.bulkPut(usersWithId)
  postMessage({
    loaded: true,
  })
}

@/data/users.json is a big JSON file saved in the project.

We import the users.json file from the data folder and store it in Indexed DB with Dexie.

db comes from db.js, which has:

import Dexie from 'dexie'

const db = new Dexie('db')
db.version(1).stores({
  avatars: '++id, address, avatar, city, email, name, title',
})

export default db

We create the db database with the avatars collection with the given keys.

We call bulkPut to write the retrieved data to the collection.

And we call postMessage to communicate that loading is done.

The message will be picked by by the message event handler when we invoke this worker.

In the pages, folder, we add _keyword.vue, to add an input and the search results component:

<template>
  <v-app>
    <v-card width="600px" elevation="0" class="mx-auto">
      <v-card-text :elevation="0">
        <v-text-field
          v-model="keyword"
          hide-details
          :prepend-inner-icon="mdiMagnify"
          full-width
          solo
          dense
          background-color="#FAFAFA"
        />

        <SearchResults :keyword="keyword" />
      </v-card-text>
    </v-card>
  </v-app>
</template>

<script >
import { mdiMagnify } from '@mdi/js'
import SearchResults from '@/components/SearchResults'

export default {
  components: {
    SearchResults,
  },
  data() {4
    return {
      keyword: '',
      mdiMagnify,
    }
  },
  beforeMount() {
    this.keyword = this.$route.params.keyword
  },
}
</script>

<style>
html {
  overflow: hidden;
}
</style>

Then in components/SearchResults.vue that we created, we add:

<template>
  <div>
    <div v-if="loading" class="my-3">
      <v-card>
        <v-card-text> Loading... </v-card-text>
      </v-card>
    </div>
    <div v-else id="scrollable" class="my-3">
      <v-card
        v-for="r of filteredSearchResultsWithHighlight"
        :key="r.id"
        class="my-3"
        :style="{
          border: highlightStatus[r.id] ? '2px solid lightblue' : undefined,
        }"
        @click="
          highlightStatus = {}
          $set(highlightStatus, r.id, !highlightStatus[r.id])
        "
      >
        <v-card-text class="d-flex pa-0 ma-0">
          <img :src="r.avatar" class="avatar" />
          <div
            class="flex-grow-1 pa-3 d-flex flex-column justify-space-between right-pane"
          >
            <div class="d-flex justify-space-between">
              <div>
                <h2 v-html="r.name"></h2>
                <p class="py-0 my-0">
                  <b v-html="r.title"></b>
                </p>
                <p class="py-0 my-0">
                  <span v-html="r.address"></span>,
                  <span v-html="r.city"></span>
                </p>
              </div>
              <div v-html="r.email"></div>
            </div>

            <div>
              <v-btn text color="#00897B">Mark as Suitable</v-btn>
            </div>
          </div>
        </v-card-text>
      </v-card>
    </div>
  </div>
</template>

<script>
import db from '@/db'

export default {
  props: {
    keyword: {
      type: String,
      default: '',
    },
  },
  data() {
    return {
      highlightStatus: {},
      filteredSearchResults: [],
      loading: false,
    }
  },
  computed: {
    filteredSearchResultsWithHighlight() {
      const { keyword } = this
      if (!Array.isArray(this.filteredSearchResults)) {
        return []
      }
      const highlighted = this.filteredSearchResults?.map((u) => {
        const highlightedEntries = Object.entries(u)?.map(([key, val]) => {
          if (key === 'avatar' || key === 'id') {
            return [key, val]
          }
          const highlightedVal = val?.replace(
            new RegExp(keyword, 'gi'),
            (match) => `<mark>${match}</mark>`
          )
          return [key, highlightedVal]
        })
        return Object.fromEntries(highlightedEntries)
      })
      return highlighted
    },
  },
  watch: {
    keyword: {
      immediate: true,
      handler() {
        this.search()
      },
    },
  },
  beforeMount() {
    this.loadData()
  },
  methods: {
    async search() {
      await this.$nextTick()
      const { keyword } = this
      if (keyword) {
        const filteredSearchResults = await db.avatars
          .filter((u) => {
            const { address, city, email, name, title } = u
            return (
              address?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              city?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              email?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              name?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              title?.toLowerCase()?.includes(keyword?.toLowerCase())
            )
          })
          .limit(10)
          .toArray()
        this.filteredSearchResults = filteredSearchResults
      } else {
        this.filteredSearchResults = await db.avatars
          ?.toCollection()
          ?.limit(10)
          .toArray()
      }
    },
    loadData() {
      this.loading = true
      const worker = this.$worker.createWorker()
      worker.onmessage = () => {
        this.search()
        this.loading = false
      }
      worker.postMessage('load')
    },
  },
}
</script>

<style>
.avatar {
  background: #bdbdbd;
  width: 150px;
}

.right-pane {
  background: #fafafa;
}

mark {
  background: yellow;
}

#scrollable {
  height: calc(100vh - 100px);
  overflow-y: auto;
}
</style>

In the loadData method. we call this.$worker.createWorker to create the worker that we injected.

this.$worker is available since the inject-ww.js script is run when the app is loaded.

We set worker.onmessage to a function so that we recent messages from the worker when postMessage in the onmessage function of the worker is called.

Once we received a message from the web workwer, we call the this.search method to do the filtering according to the keyword value.

And we call worker.postMessage with anything so that the worker’s onmessage function in the worker will start running.

Now when we type in something into the text box, we see results displayed in the cards.

We should be able to load large amounts of data in the web worker without hanging the browser since it’s run in the background.

Conclusion

To run background tasks in a Nuxt app with web workers, we can add the Webpack’s worker-loader package into our Nuxt project.

Also, we’ve to make sure that web workers are only loaded on client side.

We’ll make a project that loads a large JSON file and searches it.

Categories
JavaScript Nuxt.js

How to load large amounts of data in the background in a Nuxt app with web workers?

(The source code is at https://github.com/jauyeunggithub/bravado-quest)

Sometimes, we want to load large amounts of data in the background in a Nuxt app with web workers

In this article, we’ll look at how to load large amounts of data in the background in a Nuxt app with web workers

How to load large amounts of data in the background in a Nuxt app with web workers?

To run background tasks in a Nuxt app with web workers, we can add the Webpack’s worker-loader package into our Nuxt project.

Also, we’ve to make sure that web workers are only loaded on client side.

We’ll make a project that loads a large JSON file and searches it.

To start, we create the Nuxt project with:

npx create-nuxt-app quest

Then we run:

cd quest
npm run dev

to change to the project folder and run the project.

Next, we add Vuetify into the project with:

npm install @nuxtjs/vuetify -D

And we add the worker-loader package with:

npm i worker-loader@^1.1.1

To store data with IndexedDB, we install the Dexie package.

To install it, we run:

npm i dexie

Next, in nuxt.config.js, we change it to:

export default {
  // Global page headers: https://go.nuxtjs.dev/config-head
  head: {
    title: 'quest',
    htmlAttrs: {
      lang: 'en',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
      { name: 'format-detection', content: 'telephone=no' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  // Global CSS: https://go.nuxtjs.dev/config-css
  css: [],

  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
  plugins: [
    { src: '~/plugins/inject-ww', ssr: false }
  ],

  // Auto import components: https://go.nuxtjs.dev/config-components
  components: true,

  // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
  buildModules: ['@nuxtjs/vuetify'],

  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [],

  // Build Configuration: https://go.nuxtjs.dev/config-build
  mode: 'spa',
  build: {
    extend(config, { isClient }) {
      config.output.globalObject = 'this'

      if (isClient) {
        config.module.rules.push({
          test: /\.worker\.js$/,
          loader: 'worker-loader',
          exclude: /(node_modules)/,
        })
      }
    },
  },
  ssr: false,
}

We added the build.extend method to invoke the worker-loader when any file that ends with .worker.js is found.

isClient makes sure workers only load on client side apps.

config.output.globalObject = 'this' is needed so the hot reloading will work with the web workers loaded.

We also set ssr to false to disable server side rendering.

Also, we add { src: '~/plugins/inject-ww', ssr: false } to add the /plugins/inject-ww to load the worker loading plugin.

We add '@nuxtjs/vuetify' to load Vuetify in Nuxt.

In the plugins folder, we add:

import Worker from '~/assets/js/data.worker.js'

export default (_, inject) => {
  inject('worker', {
    createWorker () {
      return new Worker()
    }
  })
}

to load the worker from the /assets/js/data.worker.js.

To create worker, we create the data.worker.js file in the /assets/js/ and add:

import db from '@/db'

onmessage = async () => {
  if ((await db.avatars.toCollection().toArray()).length > 0) {
    postMessage({
      loaded: true,
    })
    return
  }
  const usersObj = await import(`@/data/users.json`)
  const users = Object.values(usersObj)
  const usersWithId = users.map((u, id) => ({ ...u, id }))
  await db.avatars.bulkPut(usersWithId)
  postMessage({
    loaded: true,
  })
}

@/data/users.json is a big JSON file saved in the project.

We import the users.json file from the data folder and store it in Indexed DB with Dexie.

We can load very large JSON files without crashing the browser since web workers run in a separate thread from the main browser thread.

db comes from db.js, which has:

import Dexie from 'dexie'

const db = new Dexie('db')
db.version(1).stores({
  avatars: '++id, address, avatar, city, email, name, title',
})

export default db

We create the db database with the avatars collection with the given keys.

We call bulkPut to write the retrieved data to the collection.

Now we cache the data in Indexed DB so they don’t have to load every time we load the app.

We make sure we try to load the cached data first with:

if ((await db.avatars.toCollection().toArray()).length > 0) {
  postMessage({
    loaded: true,
  })
  return
}

And we call postMessage to communicate that loading is done.

The message will be picked by by the message event handler when we invoke this worker.

In the pages, folder, we add _keyword.vue, to add an input and the search results component:

<template>
  <v-app>
    <v-card width="600px" elevation="0" class="mx-auto">
      <v-card-text :elevation="0">
        <v-text-field
          v-model="keyword"
          hide-details
          :prepend-inner-icon="mdiMagnify"
          full-width
          solo
          dense
          background-color="#FAFAFA"
        />

        <SearchResults :keyword="keyword" />
      </v-card-text>
    </v-card>
  </v-app>
</template>

<script >
import { mdiMagnify } from '@mdi/js'
import SearchResults from '@/components/SearchResults'

export default {
  components: {
    SearchResults,
  },
  data() {4
    return {
      keyword: '',
      mdiMagnify,
    }
  },
  beforeMount() {
    this.keyword = this.$route.params.keyword
  },
}
</script>

<style>
html {
  overflow: hidden;
}
</style>

Then in components/SearchResults.vue that we created, we add:

<template>
  <div>
    <div v-if="loading" class="my-3">
      <v-card>
        <v-card-text> Loading... </v-card-text>
      </v-card>
    </div>
    <div v-else id="scrollable" class="my-3">
      <v-card
        v-for="r of filteredSearchResultsWithHighlight"
        :key="r.id"
        class="my-3"
        :style="{
          border: highlightStatus[r.id] ? '2px solid lightblue' : undefined,
        }"
        @click="
          highlightStatus = {}
          $set(highlightStatus, r.id, !highlightStatus[r.id])
        "
      >
        <v-card-text class="d-flex pa-0 ma-0">
          <img :src="r.avatar" class="avatar" />
          <div
            class="flex-grow-1 pa-3 d-flex flex-column justify-space-between right-pane"
          >
            <div class="d-flex justify-space-between">
              <div>
                <h2 v-html="r.name"></h2>
                <p class="py-0 my-0">
                  <b v-html="r.title"></b>
                </p>
                <p class="py-0 my-0">
                  <span v-html="r.address"></span>,
                  <span v-html="r.city"></span>
                </p>
              </div>
              <div v-html="r.email"></div>
            </div>

            <div>
              <v-btn text color="#00897B">Mark as Suitable</v-btn>
            </div>
          </div>
        </v-card-text>
      </v-card>
    </div>
  </div>
</template>

<script>
import db from '@/db'

export default {
  props: {
    keyword: {
      type: String,
      default: '',
    },
  },
  data() {
    return {
      highlightStatus: {},
      filteredSearchResults: [],
      loading: false,
    }
  },
  computed: {
    filteredSearchResultsWithHighlight() {
      const { keyword } = this
      if (!Array.isArray(this.filteredSearchResults)) {
        return []
      }
      const highlighted = this.filteredSearchResults?.map((u) => {
        const highlightedEntries = Object.entries(u)?.map(([key, val]) => {
          if (key === 'avatar' || key === 'id') {
            return [key, val]
          }
          const highlightedVal = val?.replace(
            new RegExp(keyword, 'gi'),
            (match) => `<mark>${match}</mark>`
          )
          return [key, highlightedVal]
        })
        return Object.fromEntries(highlightedEntries)
      })
      return highlighted
    },
  },
  watch: {
    keyword: {
      immediate: true,
      handler() {
        this.search()
      },
    },
  },
  beforeMount() {
    this.loadData()
  },
  methods: {
    async search() {
      await this.$nextTick()
      const { keyword } = this
      if (keyword) {
        const filteredSearchResults = await db.avatars
          .filter((u) => {
            const { address, city, email, name, title } = u
            return (
              address?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              city?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              email?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              name?.toLowerCase()?.includes(keyword?.toLowerCase()) ||
              title?.toLowerCase()?.includes(keyword?.toLowerCase())
            )
          })
          .limit(10)
          .toArray()
        this.filteredSearchResults = filteredSearchResults
      } else {
        this.filteredSearchResults = await db.avatars
          ?.toCollection()
          ?.limit(10)
          .toArray()
      }
    },
    loadData() {
      this.loading = true
      const worker = this.$worker.createWorker()
      worker.onmessage = () => {
        this.search()
        this.loading = false
      }
      worker.postMessage('load')
    },
  },
}
</script>

<style>
.avatar {
  background: #bdbdbd;
  width: 150px;
}

.right-pane {
  background: #fafafa;
}

mark {
  background: yellow;
}

#scrollable {
  height: calc(100vh - 100px);
  overflow-y: auto;
}
</style>

In the loadData method. we call this.$worker.createWorker to create the worker that we injected.

this.$worker is available since the inject-ww.js script is run when the app is loaded.

We set worker.onmessage to a function so that we recent messages from the worker when postMessage in the onmessage function of the worker is called.

Once we received a message from the web workwer, we call the this.search method to do the filtering according to the keyword value.

And we call worker.postMessage with anything so that the worker’s onmessage function in the worker will start running.

Now when we type in something into the text box, we see results displayed in the cards.

We should be able to load large amounts of data in the web worker without hanging the browser since it’s run in the background.

Conclusion

To run background tasks in a Nuxt app with web workers, we can add the Webpack’s worker-loader package into our Nuxt project.

Also, we’ve to make sure that web workers are only loaded on client side.

We’ll make a project that loads a large JSON file and searches it.