Vue Basics Tutorial

In this tutorial, we will be building a website for a bookstore. This will focus on the front-end part of the application.

We will be doing the following:

  • Learn how to define routes and setup the Curi router.
  • Learn how to render Vue components based on the current location.
  • Learn how to navigate within the application.

Setup

We will be using @vue/cli to develop this website.

Note:

The instructions here assume that you have NodeJS and NPM > 5.2 installed on your computer. If you do not, cannot, or prefer to avoid setup altogether, you can follow along using CodeSandbox. Some of the boilerplate will be different, but the differences are minor.

Begin by opening a terminal and navigating to the directory where you want to save your code. Then, we will use @vue/cli to create the application. We

# install vue-cli if it isn't already
npm install --global @vue/cli
# create the application
vue create curi-bookstore
# select the default option

# enter the new app directory
cd curi-bookstore
# start the dev server
yarn serve

The dev server will automatically update when we change files, so we can leave that running. We will still be working in the terminal, so you will want to open up a new terminal window/tab and navigate to the application's directory. Once you have done that, there are a few packages that need to be installed.

yarn add @hickory/browser @curi/router @curi/vue

The @hickory/browser package will be used to create an object that interacts with the browser to power navigation (e.g. updates the URI in the address bar when you click a link). @curi/router provides the function to actually create the router. @curi/vue gives us a plugin for Vue and some Vue components that interact with the router.

History and Locations

URIs can be broken into parts to identify a location. With a single-page application, we don't care about the URI's protocol (http, https) or its hostname (www.example.com). The properties we care about are the pathname, hash, and query.

// uri = "https://example.com/one?key=value#id
{
  pathname: "/one",
  query: "key=value",
  hash: "id"
}

The routes define what the application renders for a particular location, but we also need to define how the application navigates. When we create the router, we will pass it a history function that will be used to enable navigation.

Curi uses the Hickory library for its history. There are a few Hickory packages to choose from for different environments. For most websites, the @hickory/browser is the right choice for the front end.

We can import the browser function from @hickory/browser in our index file (src/index.js, which create-react-app created for us).

// src/main.js
import Vue from 'vue'
import { browser } from '@hickory/browser'

import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App)
}).$mount('#app')

Defining the Routes

Routes are JavaScript objects that define the valid locations for a router. They have a name and a path.

// this is a route
{ name: "Home", path: "" }

A route's name needs to be unique. We will use route names when we navigate within the application. A route's path describes the location pathname that it should match.

Path basics

Route paths are strings describing the pathname segments they should match.

{ path: '' } // matches "/"
{ path: 'about/stuff' } // matches "/about/stuff"

Paths never begin with a slash.

// yes
{ path: '' }
// no
{ path: '/' }

Paths can include dynamic parameters. These are specified with a string that starts with a colon (:) followed by the name of the params.

// a param named "id"
{ path: ':id' }

Routes can be nested using the children property of a route. A nested route inherits the path from its ancestor route(s), so its path is only the additional part of the pathname that should be matched.

{
  name: "Parent",
  path: "parent", // matches /parent
  children: [
    // matches /parent/daughter
    { name: "Daughter", path: "daughter" },
    // matches /parent/son
    { name: "Son", path: "son" }
  ]
}

The website will start with four routes.

namepathDescription
Home""Lists books available for purchase
Book"book/:id"Details about an individual book
Checkout"checkout""Buy" the books in the shopping cart
Catch All"(.*)"Display a not found page for all other locations
Note:

The catch all route uses a regular expression syntax to indicate that it should match everything. Curi uses the path-to-regexp package for route matching. We will only be using some of its basic syntax, but you can read its documentation to learn about more advanced path syntax.

Inside of the src directory, we will create a routes.js file where we can define the application's routes.

touch src/routes.js

We can create an array of routes using the above names and paths.

@curi/router provides a prepareRoutes function, which is used to setup routes for the router. We will pass the routes array to prepareRoutes and export the result of that function call.

// src/routes.js
import { prepareRoutes } from "@curi/router";

export default prepareRoutes({
  routes: [
    {
      name: "Home",
      path: ""
    },
    {
      name: "Book",
      path: "book/:id"
    },
    {
      name: "Checkout",
      path: "checkout"
    },
    {
      name: "Catch All",
      path: "(.*)"
    }
  ]
});

The Router

With the history object created and the routes defined, we are ready to create the router. Back in the src/index.js file, we should import the createRouter function from @curi/router as well as our routes from src/routes.js. Creating the router is done by calling the createRouter function and passing it the history function and the routes array.

// src/main.js
import Vue from 'vue'
import { createRouter } from "@curi/router";
import { browser } from '@hickory/browser'

import routes from './routes';
import App from './App.vue'

Vue.config.productionTip = false

const router = createRouter(browser, routes)

new Vue({
  render: h => h(App)
}).$mount('#app')
Note:

The Eslint warning has now moved to the router, but this is still nothing to worry about.

We will add router support to the Vue application using a plugin. This plugin does a couple of things. First, it makes some Curi components available within the application. The only one of these components that we will be using is the curi-link. Second, it makes router related values accessible to the components in the application. The router is available as this.$router and the response and navigation (we will cover these next) are grouped under this.$curi. When the CuriPlugin is installed, the router as passed in the options object.

// src/main.js
import Vue from 'vue'
import { createRouter } from "@curi/router";
import { browser } from '@hickory/browser'
import { CuriPlugin } from '@curi/vue'

import routes from './routes';
import App from './App.vue'

Vue.config.productionTip = false

const router = createRouter(browser, routes)
Vue.use(CuriPlugin, { router })

new Vue({
  render: h => h(App)
}).$mount('#app')

Rendering with Vue

We can now render our application. We will re-use the provide App.vue file.

Responses and Navigation

Whenever Curi receives a location, it matches its routes against it and generates a response. This is an object with data related to the route that matched the location. Later on we will modify this data ourselves, but for now the important thing to know is that the response lets us know about the current route.

// a sample response object
{
  body: undefined,
  data: undefined,
  location: { pathname: '/', ... },
  name: 'Home',
  params: {},
  partials: [],
  meta: {
    status: 200
  }
}

The router uses an observer model to let functions subscribe to be called when a new response is generated. The CuriPlugin sets up an observer so that it can trigger a re-render whenever there is a new one.

The navigation object contains additional information about a navigation that doesn't make sense to include in the response object. This includes the navigation's "action" (push, pop, or replace) and the previous response object. This can be useful for animation and modals.

Most of the time, the response is the only property you will need to use to render, but the other two may occasionally be useful.

How do we use the response to render? Any way you want. Based on the sample response above, the name stands out as the best way to identify which route matched. We can make this even easier by adding another property to the response: body.

Earlier it was mentioned that response objects can be modified. This is done by returning an object from a route's respond function. respond receives an object with a whole bunch of properties that we can use to help determine how to modify the response, but for the time being, we don't care about any of those. All we need to know is that if we return an object with a body property, that value will be set on our response object.

{
  name: "Home",
  path: "",
  respond() {
    return {
      body: "Home, sweet home."
    };
    /*
      * response = {
      *   body: "Home, sweet home.",
      *   // ...
      * }
      */
  }
}

If the return object's body property is a Vue component, we can render it using <Component :is>.

We haven't actually defined components for our routes yet, so we should throw together some placeholders.

touch src/components/Home.vue src/components/Book.vue \
  src/components/Checkout.vue src/components/NotFound.vue
<!-- src/components/Home.vue -->
<template>
  <div>Home</div>
</template>
<!-- src/components/Book.vue -->
<template>
  <div>Book</div>
</template>
<!-- src/components/Checkout.vue -->
<template>
  <div>Checkout</div>
</template>
<!-- src/components/NotFound.vue -->
<template>
  <div>Not Found</div>
</template>

These components can be imported in src/routes.js and attached to their respective routes.

// src/routes.js
import { prepareRoutes } from "@curi/router";

import Home from './components/Home';
import Book from './components/Book';
import Checkout from './components/Checkout';
import NotFound from './components/NotFound';

export default prepareRoutes({
  routes: [
    {
      name: "Home",
      path: "",
      respond() {
        return {
          body: Home
        };
      }
    },
    {
      name: "Book",
      path: "book/:id",
      respond() {
        return {
          body: Book
        };
      }
    },
    {
      name: "Checkout",
      path: "checkout",
      respond() {
        return {
          body: Checkout
        };
      }
    },
    {
      name: "Catch All",
      path: "(.*)",
      respond() {
        return {
          body: NotFound
        };
      }
    }
  ]
});

We can now update App.vue to render response.body as a component, which as mentioned above is available through this.$curi.

<!-- src/App.vue -->
<template>
  <component :is="$curi.response.body" />
</template>

We can also remove the HelloWorld component.

rm src/components/HelloWorld.vue

At this point in time our app is rendering, but is isn't very interesting because we cannot navigate between locations.

Let's go shopping

We want to be able to add books to our shopping cart. Since this is a play site, we will store the cart data in memory.

touch src/cart.js

The shopping cart implementation will be a JavaScript Map. We can call its set method to add books, its clear method to reset the cart, and iterate over its entries with a for...of loop.

// src/cart.js
const cart = new Map();

export default {
  add(book, quantity) {
    cart.set(book, quantity);
  },
  items() {
    const books = [];
    for (let [book, quantity] of cart.entries()) {
      books.push({
        title: book.title,
        quantity
      });
    }
    return books;
  },
  reset() {
    cart.clear();
    return [];
  }
};

As stated above, we can access our router in the Book component using this.$router. The router's navigate function can be used to navigate to a new location. This means that when the user clicks a button to add a book to their shopping cart, we can automatically navigate to the checkout page.

The Router's URL & Navigate Methods

The router has a url method that is used to generate a URL string using the name of a route and an object of the route's params.

const url = router.url({ name: "New" });

The router's navigate method is used to navigate; it takes a URL (such as one defined using router.url). The function can also take a method type for the navigation: push, replace, or anchor.

push pushes a new location after the current index, removing any locations after the current location.

// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/new", method: "push" });
// session = ['/one', '/two', '/new']
// index = 2
// current = '/new'

replace replaces the location at the current index.

// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/replacement", method: "replace" });
// session = ['/one', '/replacement', '/three']
// index = 1
// current = '/replacement'

anchor is a mix between push and replace. It mimics the behavior of clicking on links, so if you navigate to the same location as the current one it will replace, and if you navigate to a new location it will push.

If method.navigate is called without a navigation method, it will default to anchor.

// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/two", method: "anchor" });
// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/new", method: "anchor" });
// session = ['/one', '/two', '/new']
// index = 2
// current = '/new'`}

We also want to import our shopping cart API so that we can add a book to the cart.

<!-- src/components/Book.vue -->
<template>
  <div v-if="book">
    <h1>{{book.title}}</h1>
    <h2>by {{book.author}}</h2>
    <p>Published in {{book.published}}</p>
    <p>{{book.pages}} pages</p>
    <button type="button" v-on:click="onClick">
      Add to Cart
    </button>
  </div>
  <div v-else>
    The requested book could not be found
  </div>
</template>

<script>
  import books from '../books';
  import cart from '../cart';

  export default {
    name: 'book',
    computed: {
      book() {
        const id = parseInt(this.$curi.response.params.id, 10);
        return books.find(b => b.id === id);
      }
    },
    methods: {
      onClick: function() {
        cart.add(this.book, 1);
        const url = this.$router.url({ name: "Checkout "});
        this.$router.navigate({ url });
      }
    }
  }
</script>

Finally, we can update our Checkout component to display the books in the shopping cart. To do this, we will import our cart and books. Our cart only stores book ids, so we will need to merge the book data with the cart data.

When a user "buys" the books in their shopping cart, we need to clear out the cart. We will also replace the current location with one whose location.hash is the string "thanks". When that is present in the URI, we can render a "Thanks for your purchase" message to "confirm" the purchase.

<!-- src/components/Checkout.vue -->
<template>
  <div v-if="books.length">
    <h1>Checkout</h1>
    <ScrollableTable>
      <thead>
        <tr>
          <th>Title</th>
          <th>Quantity</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="book in books" :key="book.title">
          <td>{{book.title}}</td>
          <td>{{book.quantity}}</td>
        </tr>
      </tbody>
    </ScrollableTable>
    <button type="button" v-on:click="onClick">
      Buy
    </button>
  </div>
  <div v-else-if="$curi.response.location.hash === 'thanks'">
    Thanks for your purchase!
  </div>
  <div v-else>
    The cart is currently empty
  </div>
</template>

<script>
  import cart from '../cart';

  export default {
    name: 'checkout',
    data() {
      return {
        books: cart.items()
      };
    },
    methods: {
      onClick: function() {
        this.books = cart.reset();
        const url = this.$router.url({
          name: "Checkout",
          hash: "thanks"
        });
        this.$router.navigate({ url, method: "replace" });
      }
    }
  }
</script>