Replacing Vue With Vanilla JavaScript

November 2, 2019 8 min read

After a few recent tweaks to my blog I realised that a large portion of its JavaScript bundle size was taken up with Vue.js. I’m a huge fan of Vue - I think it’s a great framework for developing applications but given I was using it just for a search box and a pop up it was hard to justify keeping it.

My experience of using Vanilla JS for interactivity on the web is pretty minimal. I’ve used Angular, Vue, React, Preact, jQuery, but using native DOM manipulation is something I’ve managed to avoid so I was keen to see how easy it would be to replace Vue.

If you want to go ahead and just see the code that changed the commits that stripped Vue from the build are 681b9bc and cf04420 and I think they’re a good case study in the trade-offs between a framework, like Vue, and using Vanilla JS.

#What I Started With

My search component was a relatively simple Vue file, 168 lines total1.

<template>
  <div class="search-box" v-if="ready">
    <input
      @input="query = $event.target.value"
      aria-label="Search"
      :value="query"
      :class="{ 'focused': focused }"
      autocomplete="off"
      spellcheck="false"
      @focus="focused = true"
      @blur="focused = false"
      @keyup.enter="go(focusIndex)"
      @keyup.up="onUp"
      @keyup.down="onDown"
    />
    <ul class="suggestions" v-if="showSuggestions" @mouseleave="unfocus">
      <li
        class="suggestion"
        :key="s.href"
        v-for="(s, i) in suggestions"
        :class="{ focused: i === focusIndex }"
        @mousedown="go(i)"
        @mouseenter="focus(i)"
      >
        <a :href="s.href" @click.prevent>
          <span class="page-title">{{ s.title || s.href }}</span>
        </a>
      </li>
    </ul>
  </div>
  <div v-else-if="loading">
    <div class="sp sp-hydrogen no-js-hidden"></div>
  </div>
</template>

<script>
import lunr from "lunr";
const clamp = (a, min = 0, max = 1) => Math.min(max, Math.max(min, a));
export default {
  name: "SearchBox",
  data() {
    return {
      query: "",
      focused: false,
      focusIndex: 0,
      lunrIndex: null,
      documents: null,
      loading: true
    };
  },
  computed: {
    ready() {
      return this.lunrIndex && this.documents;
    },
    getCurrentHostname() {
      return window.location.protocol + "//" + window.location.host;
    },
    showSuggestions() {
      return this.focused && this.suggestions && this.suggestions.length;
    },
    suggestions() {
      const query = this.query.trim().toLowerCase();
      if (!query) {
        return;
      }
      // Find the item in our index corresponding to the lunr one to have more info
      // Lunr result:
      //  {ref: '/section/page1', score: 0.2725657778206127}
      // Our result:
      //  {title:'Page1', href:'/section/page1', ...}
      const documents = this.documents;
      const results = this.lunrIndex.search(query);
      return (
        results
          .map(r => documents.find(p => p.href === r.ref))
          // Take 5 suggestions
          .filter((i, index) => index < 5)
      );
    }
  },
  mounted() {
    let request = new XMLHttpRequest();
    const url = this.getCurrentHostname + "/js/lunr/index.json";
    request.open("GET", url, true);
    request.onload = function() {
      if (request.status < 200 || request.status >= 400) {
        this.loading = false;
        console.error("Unable to fetch Lunr data.");
      }
      this.documents = JSON.parse(request.responseText);
      const documents = this.documents;
      // Set up lunrjs by declaring the fields we use
      // Also provide their boost level for the ranking
      try {
        this.lunrIndex = lunr(function() {
          this.field("title", {
            boost: 5
          });
          this.field("tags", {
            boost: 2
          });
          this.field("content");
          // ref is the result item identifier (I chose the page URL)
          this.ref("href");
          // Feed lunr with each file and let lunr actually index them
          for (let i = 0; i < documents.length; i++) {
            this.add(documents[i]);
          }
        });
      } catch (e) {
        console.error("Error accessing lunr");
      } finally {
        this.loading = false;
      }
    }.bind(this);
    request.onerror = function() {
      console.error("There was a connection error of some sort");
      this.loading = false;
    }.bind(this);
    request.send();
  },
  methods: {
    onUp() {
      if (!this.showSuggestions) {
        return;
      }
      if (this.focusIndex > 0) {
        this.focusIndex--;
      } else {
        this.focusIndex = this.suggestions.length - 1;
      }
    },
    onDown() {
      if (!this.showSuggestions) {
        return;
      }
      if (this.focusIndex < this.suggestions.length - 1) {
        this.focusIndex++;
      } else {
        this.focusIndex = 0;
      }
    },
    go(i) {
      if (!this.showSuggestions) {
        return;
      }
      window.location.href = this.getCurrentHostname + this.suggestions[i].href;
      this.query = "";
      this.focusIndex = 0;
    },
    focus(i) {
      this.focusIndex = i;
    },
    unfocus() {
      this.focusIndex = -1;
    }
  }
};
</script>

Aside from the massive mounted() method you can see that the implementation is fairly clean - all the logic of rendering is declarative thanks to Vue. The <template /> is incredibly readable and there is a definite separation of state, logic, and asynchronous data loading. Whilst I’m not convinced this is the cleanest way to write this component 2 I don’t think it’d take too much work to understand how the different parts interact.

#The Madness of Vanilla JS

On the other hand, what I produced translating the search box to Vanilla JS is much more muddled. I don’t pretend to be an expert at writing Vanilla JS but I think this might be representative of the chaos that occurs when you mix rendering, state, and lifecycle together without any boundaries.

<div class="search-box hidden" id="search-box-container">
    <input aria-label="Search" autocomplete="off" spellcheck="false" id="search-box" />
    <ul class="suggestions hidden" id="search-box-suggestions">

    </ul>
</div>
<div class="sp sp-hydrogen no-js-hidden" id="search-box-spinner"></div> 
import lunr from "lunr";
const clamp = (a, min = 0, max = 1) => Math.min(max, Math.max(min, a));

let state = {
    query: "",
    isFocused: false,
    focusIndex: 0,
    lunrIndex: null,
    documents: null,
    loading: true,
    suggestions: []
};

function shouldShowSuggestions() {
    return state.isFocused && state.suggestions && state.suggestions.length;
}

const currentHostName = window.location.protocol + "//" + window.location.host;

const renderNewDom = (newSuggestions) => {
    // If we don't need to update the suggestions, use what's in state.
    if (!newSuggestions) {
        newSuggestions = state.suggestions;
    }

    const searchBoxSuggestions = document.getElementById('search-box-suggestions');

    // If no focus, hide
    if (!state.isFocused) {
        searchBoxSuggestions.classList.add('hidden');
        return;
    }

    const newDomNodes = suggestionsToDomNodes(newSuggestions);
    state.suggestions = newSuggestions;

    // Remove what's there
    let child;
    while (child = searchBoxSuggestions.firstChild) {
        child.remove();
    }

    for (let i = 0; i < newDomNodes.length; i++) {
        searchBoxSuggestions.appendChild(newDomNodes[i]);
    }

    if (newSuggestions.length === 0) {
        searchBoxSuggestions.classList.add('hidden');
    } else {
        searchBoxSuggestions.classList.remove('hidden');
    }
}

const suggestionsToDomNodes = (suggestions) => {
    const listElements = [];
    for (let i = 0; i < suggestions.length; i++) {
        const suggestion = suggestions[i];

        const li = document.createElement("li");
        li.classList.add("suggestion");

        if (i === state.focusIndex) {
            li.classList.add('focused');
        }

        li.addEventListener("mousedown", () => go(i));
        li.addEventListener("mouseenter", () => listFocus(i));

        const a = document.createElement("a");
        a.setAttribute("href", suggestion.href);
        a.onclick = (event) => event.preventDefault();

        const span = document.createElement("span");
        span.classList.add("page-title");

        const text = document.createTextNode(suggestion.title);

        span.appendChild(text);
        a.appendChild(span);
        li.appendChild(a);
        listElements.push(li);
    }
    return listElements;
}

const getSuggestions = (input) => {
    const query = input.trim().toLowerCase();
    if (!query) {
        return [];
    }
    // Find the item in our index corresponding to the lunr one to have more info
    // Lunr result:
    //  {ref: '/section/page1', score: 0.2725657778206127}
    // Our result:
    //  {title:'Page1', href:'/section/page1', ...}
    const documents = state.documents;
    const results = state.lunrIndex.search(query);
    return (
        results
            .map(r => documents.find(p => p.href === r.ref))
            // Take 5 suggestions
            .filter((i, index) => index < 5)
    );
}

function setUpState() {
    let request = new XMLHttpRequest();
    const url = currentHostName + "/js/lunr/index.json";
    request.open("GET", url, true);
    request.onload = function (e) {
        if (request.readyState === 4) {
            if (request.status < 200 || request.status >= 400) {
                state.loading = false;
                console.error("Unable to fetch Lunr data.");
            }

            state.documents = JSON.parse(request.responseText);

            // Set up lunrjs by declaring the fields we use.
            // Here we also provide their boost level for the ranking.
            try {
                state.lunrIndex = lunr(function () {
                    this.field("title", {
                        boost: 5
                    });
                    this.field("tags", {
                        boost: 2
                    });
                    this.field("content");

                    // ref is the result item identifier (I chose the page URL)
                    this.ref("href");

                    // Feed lunr with each file and let lunr actually index them.
                    for (let i = 0; i < state.documents.length; i++) {
                        this.add(state.documents[i]);
                    }
                });
                setUpEventListeners();
            } catch (e) {
                console.error("Error accessing lunr");
            } finally {
                state.loading = false;
            }
        }
    };

    request.onerror = function () {
        console.error("There was a connection error of some sort");
        state.loading = false;
    };

    request.send(null);
}


// Suggestions Handlers

const listFocus = (i) => {
    state.focusIndex = i;
    renderNewDom();
}

const listUnfocus = (i) => {
    state.focusIndex = -1;
    renderNewDom();
}


// Box Event Handlers

const onInput = (event) => {
    state.query = event.target.value.trim();

    if (state.query === '') {
        state.isFocused = false;
    } else {
        state.isFocused = true;
    }

    const newSuggestions = getSuggestions(event.target.value);
    renderNewDom(newSuggestions);
}

const onBlur = () => {
    state.isFocused = false;
}

const onFocus = () => {
    state.isFocused = true;
}

const keyHandler = event => {
    // Ignore IME composition: https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
    if (event.isComposing || event.keyCode === 229) {
        return;
    }

    // https://keycode.info/
    // Up key
    if (event.keyCode === 38) {
        onUp();
    }

    // Down key
    if (event.keyCode === 40) {
        onDown();
    }

    // Enter
    if (event.keyCode === 13) {
        go(state.focusIndex)
    }
}

// Key Handle Functions
const onUp = () => {
    state.focusIndex = clamp(
        state.focusIndex - 1,
        0,
        state.suggestions.length - 1
    );
    renderNewDom();
}

const onDown = () => {
    state.focusIndex = clamp(
        state.focusIndex + 1,
        0,
        state.suggestions.length - 1
    );
    renderNewDom();
}

const go = (i) => {
    if (!shouldShowSuggestions()) {
        return;
    }

    window.location.href = currentHostName + state.suggestions[i].href;
    state.query = "";
    state.focusIndex = 0;
}

const setUpEventListeners = () => {
    // Search Box Listeners.
    const searchBox = document.getElementById('search-box');
    searchBox.oninput = onInput;
    searchBox.onfocus = onFocus;
    searchBox.onblur = onBlur;
    searchBox.addEventListener("keyup", keyHandler);

    // Suggestions Listeners
    const searchBoxSuggestions = document.getElementById('search-box-suggestions');
    searchBoxSuggestions.addEventListener("mouseleave", listUnfocus);

    const spinner = document.getElementById('search-box-spinner');
    const searchContainer = document.getElementById('search-box-container');
    spinner.classList.add('hidden');
    searchContainer.classList.remove('hidden');
}

setUpState(); 

There’s an extra ~100 lines of code that appear to handle rendering and it’s clear that the coupling between functions become much tighter.

Functions to manipulate the DOM become unwieldy and end up being called from multiple functions. Setting up event listeners becomes explicit without shortcuts like @keyup.enter and adding and removing utility classes becomes an explicit check without v-if.

I reckon an extra hour could make this a little bit more sane and cut ~5-15 lines of functional code but I suspect the overall line count wouldn’t be touched much as there are a lot of functions deserving of more comments.

#Why Do It?

Removing Vue from the site has had a dramatic effect on bundle size.

Before my JavaScript totalled 426kb parsed (104kb gzipped), by removing Vue it now weighs in at just 155kb parsed (39kb gzipped) - a 63.6% reduction.

Before I was using the wrong tool for the job - I didn’t need a massive heavyweight framework like Vue, it was just more convenient.


  1. I’ve excluded styles for brevity, as they are the same in both implementations ↩︎

  2. The state in particular is a bit of a mess ↩︎

See Also

Last Updated: 2019-11-02 14:19