Building a Simple Notes Manager with Vue.js

Written by: Yanis Triandaphilov

Vue.js fascinates me. The author is Evan You, a former Angular developer. Somehow he managed to take the best of the two worlds -- Angular and React -- and build a simple, easy to learn, and extremely productive framework.

To show you the benefits of using Vue.js, I thought I'd make something useful from the ground up. I decided to go for apps like SimpleNote; clean and straightforward apps for taking notes. And the core part seems to be small enough to implement in a day. Then I decided to be even more down-to-earth and build a Chrome extension that will replace the new tab page with the notes app.

So in this article, you are going to learn:

  • How to set up a Vue.js project from the ground up (not hard at all)

  • How to create components and make them interact

  • A little bit of theory, so you know how it works underneath

  • How to create a Google Chrome extension

  • How to store data in chrome.storage

Setting Up the Scene

Okay, let's get started. We are going to use yarn for dependency management.

# create a directory and cd into it
mkdir notes && cd $_
# create a new package.json with defaulted answers
yarn init --yes

All right, that's a start. Now we need to set up webpack and babel because we want to use ES6 features and make sure they are properly transpiled to ES5, which is better understood by major browsers.

We will also need a special vue-loader that deals with Vue's single file components (more on it later). We will also install a bunch of other dependencies, but don't worry. I'll explain why we need each one.

# Let's install webpack and babel
yarn add webpack babel-core babel-loader babel-preset-es2015 --dev
# Install vue-loader which works for .vue files
yarn add vue-loader --dev
# Plugin that creates an index.html file for us, so we don't need to care
yarn add html-webpack-plugin --dev
# We want to use pug instead of plain html
yarn add pug vue-template-compiler --dev
# Process style files
yarn add style-loader css-loader --dev
# File loader will help us to copy manifest.json
yarn add file-loader --dev
# Finally this is our production dependency
yarn add vue

We will also need the webpack.config.js. You can find it here. Download it and place in the root of your project. The essential part of every webpack config is the rules section, in which you define how different files should be processed.

// ...
    rules: [
      // Vue-loader is needed to process .vue files.
      // We then tell it that any js code should be transpiled with babel.
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          loaders: {
            js: 'babel-loader?presets[]=es2015'
          }
        }
      },
      // Any other JS files should be also transpiled
      {
        test: /\.js$/,
        exclude: [/node_modules/],
        use: [{
          loader: 'babel-loader',
          options: { presets: ['es2015'] },
        }],
      },
      // Simply copy .json and .png files from src to dist
      {
        test: /\.(json|png)$/,
        use: 'file-loader?name=[name].[ext]'
      },
      // Compile and copy any css files
      {
        test: /\.css$/,
        use: [ 'style-loader', 'css-loader' ]
      }
    ]
// ...

You can now try to run yarn start. It should start up webpack and start complaining that there's no src/index.js file. We'll fix this later. But first, let's take a closer look at what we are going to deal with.

Vue.js Overview

As a long-time Angular 1 developer, I find Vue.js fascinating. We all know these Angular drawbacks: steep learning curve, confusing dependency injection system (which gets even worse with ES6 modules), overcomplicated directives/components, and a huge set of docs.

Vue.js managed to keep the Angular productivity spirit, but keep things simple. It is much easier to learn, component-oriented, and faster, too, because it's not using the dirty-checking thing. Instead, it employs the virtual DOM, much like React does. So it's productive from Day One.

Vue.js encourages the use of components from the start. Here's how a typical component might look:

Vue.component('my-component', {
  template: '<span>{{ message }}</span>',
  data: {
    message: 'hello'
  }
})
new Vue({
  el: '#example'
})

Let's pause there and talk a little bit. As I said earlier, Vue.js kind of takes all the good things from Angular and React. It means that it still supports the two-way binding, and every Angular developer will find it very familiar. But the way it works underneath is not the same.

There's no dirty-checking. So how does Vue track the changes and know when to update the view? Well, it utilizes getters and setters. When you pass a data object to Vue, it walks through its properties and converts them to getter/setters using Object.defineProperty. Getters and setters are ES5-only, which is why Vue doesn’t support IE8 and below (well, who cares, right?).

Another caveat that follows from that is that you always need to specify properties that you will use in a data object, even though they are undefined at the start.

Building a Chrome Extension

Chrome extensions are easy. They just regular HTML + JavaScript web pages with access to some Chrome APIs. We'll need a manifest file to glue it all together. Create src/manifest.json and add to it:

{
  "manifest_version": 2,
  "name": "Zenotes",
  "description": "Replace the new tab page with clean, distraction-free notes",
  "version": "1.0",
  "chrome_url_overrides" : {
    "newtab": "index.html"
  },
  "permissions": [
    "activeTab",
    "storage"
  ]
}

chrome_url_overrides tells the browser that we want to replace the new tab page with our content. And then we also add permissions to get access to APIs.

Now let's also create a simple Vue app that does nothing much. Let's create our first component:

/ src/js/components/App.vue
<template>
  <h1>Hello World</h1>
</template>
<script>
export default {
  // we'll add stuff later
  data: function() {
    return {};
  }
}
</script>
<style>
  h1 { font-size: 22px; }
</style>

Note that a component is described in a file with extension .vue and it has markup (HTML), JavaScript code and CSS stylings all in one file. In Vue terminology, this is called Single File Components. While this approach is not without its flaws, it makes a lot of sense to me, since all those are essential parts of a component and they are very coupled together. So it's only logical to keep them in one file.

On the dark side of things, this file can get very large, and your editor (Vim in my case) can blow up or start working veeeeeery slowly.

In order for webpack to be able to process single file components, we need to include vue-loader (see the previous section).

One last piece that we need is to give Vue control over our page.

// src/js/index.js
// loading manifest (webpack will simply copy it into ./dist)
require("../manifest.json");
import Vue from 'vue';
import App from './components/App.vue';
new Vue({
  el: 'body',
  render: h => h(App)
});

Time to see if it works. Run in a command line:

yarn start

Now open Chrome. Go to chrome://extensions/. Make sure that Developer Mode is turned on. And then Load unpacked extension. Specify the dist directory. Now your extension is loaded, and you should see "Hello World" when you open a new tab.

Displaying the List of Notes

All right, that's a great start. Let's move on and make something interesting.

If you never saw an app like SimpleNotes, it's very intuitive. You have a sidebar on the left with the list of all your notes, and then the central area is an editor. You can switch the current note by clicking on it in the sidebar, and then edit it in the editor. Our editor will be able to use markdown. But let's start with a sidebar.

// src/components/App.vue
body
  .notes.row
    .column.column-20.sidebar
      h5
        a(href='#' @click="addNote") +
      ul
        li(v-for="note in notes" :key="note.id" :class='{ active: note === selected }' @click="selectNote(note)") {{ note.body }}
    .column.column-80(style='position: relative')
      textarea(v-if='selected' v-model="selected.body" placeholder="Edit me")

Oh well. A lot of stuff going on here. Let's got through it line by line.

Note that we're using Milligram here to split our screen into two parts. Classes like row, column, column-20, etc. are all part of Milligram's grid API which allows us to quickly markup our grid. Of course we need to install it first:

yarn add milligram

Now, to Vue-specific things:

  • @click directive is a shortcut for v-on:click. It binds the DOM click event to a method in your component (we'll get to that later). We use it twice in this snippet: first, to add a new note, and second, to set the active one.

  • v-for iterates over an array of things, in this case, notes. We also provide a :key directive for it to be able to differentiate between the notes.

  • {{ note.body }} is a common one-way binding syntax. Again it's very similar to Angular 1.

  • Finally, v-model creates two-way data bindings on a form input and textarea elements.

Now all these should be very intuitive to Angular 1 users. I guess that's why I got so easily hooked in the first place.

Let's define our component:

// src/components/App.vue
<script>
// requiring milligram framework styles
require("milligram");
export default {
  data: () => {
    return {
      notes: [],
      selected: undefined
    };
  },
  methods: { addNote, selectNote }
};
function selectNote(note) {
  if (note === this.selected) return;
  this.selected = note;
}
function addNote() {
  const note = { id: guid(), body: '# ' };
  this.notes.unshift(note);
  this.selectNote(note);
}
// generate unique IDs
function guid () {
  return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
    s4() + '-' + s4() + s4() + s4();
}
function s4() {
  return Math.floor((1 + Math.random()) * 0x10000)
    .toString(16)
    .substring(1);
}
</script>

Here we define our data object and our methods. Note that in methods we can refer to this, which contains both data and methods. So if we want to add a new note, we do this.notes.unshift({ id: guid(), body: '# ' });. Changes are immediately displayed in the view.

You can try it yourself. Reload the browser and click on "+" to add a note.

Storing Data

We can add notes. That's nice. But how can we save stuff so that when you open a new tab, all your notes are still there? Chrome storage to the rescue!

Chrome storage is very similar to localStorage with several differences. The most important is that we can use storage.sync API and have all our data synchronized across every browser user have. Let's add a save function that's going to save all our notes into storage.

const SKEY = "ZENOTES";
function addNote() {
  const note = { id: guid(), body: '# ' };
  this.notes.unshift(note);
  this.selectNote(note);
  save(this.notes);
}
function save(notes) {
  if (!notes) return;
  chrome.storage.sync.set({[SKEY]: notes});
}

Note, that we use "ZENOTES" as our global key for storing notes array.

We also should load notes when the component is bound. It is easy to do with the components life-cycle hooks.

export default = {
  data: () => {
    return {
      notes: [],
      selected: undefined
    };
  },
  methods: { addNote, selectNote },
  mounted: function() {
    const vm = this;
    loadNotes(this);
  }
}
function loadNotes(vm) {
  chrome.storage.sync.get(SKEY, ({ [SKEY]: list = [] }) => {
    // Push all notes to our array with ES6 fancy splat syntax
    vm.notes.push(...list);
    // Select the most recent note
    vm.selectNote(list[0]);
  });
}

Just in case this ({ [SKEY]: list = [] }) drives you crazy, this is a new ES6 destructuring syntax. It says that whatever we get, we expect it to be an object and take the value by the key SKEY and put it into list variable. And if it's not there, set an empty array. I know it might not seem readable the first time, but it's a very expressive language syntax that saves several lines of code.

Reload your page. Now you should be able to add notes and they will be there every time you reload or open a new tab. But if you edit an existing note, these changes won't be saved. This is because we need to set up a watcher.

Vue watchers provide a way to track changes in data and react to it somehow. In our case, we want to save notes every time the body of a selected note is changed. Here's how we do it.

// ...
export default {
  // ...
  mounted: function() {
    // ...
    this.$watch(
      watchable.bind(this),
      onChange.bind(this),
      { deep: true }
    );
  }
};
function watchable() {
  if (!this.selected) return undefined;
  return { id: this.selected.id, body: this.selected.body };
}
function onChange(val, prev) {
  if (!prev) return;
  save(this.notes);
}

Update the page. Now you can type anything and it will be immediately stored in Chrome. Open a new tab, reload the browser, and it will still be there.

!Sign up for a free Codeship Account

Markdown Editor

I wanted to be able to edit my notes in markdown just because I like it so much. But also because it is such a perfect way to demonstrate how can we integrate a third-party JavaScript library into Vue ecosystem. We will use simplemde, so install it with yarn.

yarn add simplemde

Then guess what? We're going to make a component! Here's how it will be used:

editor(v-if='selected' v-model='selected.body' :key="selected.id")

(Replace our regular textarea with this snippet)

 // src/js/components/Editor.vue
  <template lang='pug'>
    .note-area
      textarea
  </template>
  <script>
  // 3rd party markdown editor
  import SimpleMDE from 'simplemde';
  // and don't forget the styles
  require("simplemde/dist/simplemde.min.css");
  // Exporting our component
  export default {
    props: ['value'],
    mounted
  };
  function mounted() {
    // Init the editor
    const md = initEditor(this.$el.childNodes[0], this.value);
    // Focusing into and setting cursor at the end (100 is long enought)
    md.codemirror.focus();
    md.codemirror.setCursor(100);
    // On change communicate to v-model
    md.codemirror.on("change", () => {
      const val = this.md.value();
      this.$emit('input', val);
    });
    this.md = md;
  }
  function initEditor(el, val) {
    return new SimpleMDE({
      element: el,
      initialValue: val,
      spellChecker: false,
      placeholder: "Type here...",
      toolbar: false,
      toolbarTips: false,
      status: false,
      autoDownloadFontAwesome: false,
      forceSync: true
    });
  }
  </script>

So what's important here? This part:

props: ['value']

Value is a property that is going to be mounted by v-model so it's accessible via this.value.

And then this part.

md.codemirror.on("change", () => {
    const val = this.md.value();
    this.$emit('input', val);
  });

Every time the content of the editor changes, we are signaling that the model is changed with $emit function. These two attributes are a kind of interface for v-model. So every time you want your component to support v-model, you need both of these things. They are the key.

We also need to register this new component. I prefer to do it in the index.js file:

// ...
import Editor from './components/Editor.vue';
Vue.component('editor', Editor);
// ...

This is all we need. So now we have a fully functional notes manager, which works perfectly well in a browser. But there's still one thing that I want to talk about.

Transitions

We've come to the understanding that proper transitions and animations are an essential part of any decent website. Vue gets it perfectly right and the documentation talks in detail about specifics and best practices. Let's see how we can make the selected note appear and slide down from the top. Turns out this is very easy.

Vue provides a component called transition. So all we need to do is to wrap our element with it.

transition(name="fade" appear)
  editor(v-if='selected' v-model='selected.body' :key="selected.id")

Then add those styles to App.vue component

<style>
.note-area {
  padding-left: 20px;
  position: absolute;
  width: 100%;
}
.fade-enter-active, .fade-leave-active {
  transition: opacity .2s ease-in, transform .2s ease-in;
}
.fade-enter, .fade-leave-to {
  opacity: 0;
  transform: translateY(-30px);
}
</style>

That's it. Try to update the page now and switch to different notes.

Wrapping It All Up

So finally we've created our Chrome extension so that we can add notes right into our new tab page. We also learned a few things along the way:

  • We know how to create a simple Chrome extension now and utilize internal API like chrome.storage.

  • We set up a development environment for a Vue project even without the Vue CLI (you still can give it a try).

  • It's clear how Vue handles changes, is Reactive, and doesn't do dirty-checking.

  • We know how to create components and make them interact with each other.

  • We can wrap third-party libraries in Vue-ready components with the help of v-model.

  • Finally, we learned how easy it is to implement CSS transactions with Vue.

I hope that by this time you are starting to feel that extraordinary power that Vue.js puts in your hands.

This is just a start.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.