The process: Making Vue 3

The process: Making Vue 3

Lessons from rewriting the next major version of Vue.js.
Part of
Issue 13 May 2020


Over the past year, the Vue team has been working on the next major version of Vue.js, which we hope to release in the first half of 2020. (This work is ongoing at the time of writing.) The idea for a new major version of Vue took shape in late 2018, when the codebase of Vue 2 was about two-and-a-half years old. That may not sound like a long time in the life span of generic software, but the frontend landscape had changed drastically during that period.

Two key considerations led us to the new major version (and rewrite) of Vue: First, the general availability of new JavaScript language features in mainstream browsers. Second, design and architectural issues in the current codebase that had been exposed over time.

Why rewrite

Leveraging new language features

With the standardization of ES2015, JavaScript—formally known as ECMAScript, abbreviated to ES—received major improvements, and mainstream browsers were finally starting to provide decent support for these new additions. Some in particular presented opportunities for us to greatly improve Vue’s capabilities.

The most noteworthy among them is Proxy, which allows the framework to intercept operations on objects. A core feature of Vue is the ability to listen to changes made to the user-defined state and reactively update the DOM. Vue 2 implements this reactivity by replacing the properties on state objects with getters and setters. Switching to Proxy would allow us to eliminate Vue’s existing limitations, such as the inability to detect new property additions, and provide better performance.

However, Proxy is a native language feature that cannot be fully polyfilled in legacy browsers. In order to leverage it, we knew we’d have to adjust the framework’s browser support range—a big breaking change that could only be shipped in a new major version.

Addressing architectural issues

Over the course of maintaining Vue 2, we’ve accumulated a number of issues that have been difficult to address due to the limitations of the existing architecture. For example, the template compiler was written in a way that makes proper source-map support very challenging. Also, while Vue 2 technically enables building higher-level renderers that target non-DOM platforms, we had to fork the codebase and duplicate lots of code in order to make this possible. Fixing these issues in the current codebase would require huge, risky refactors that are almost equivalent to a rewrite.

At the same time, we’ve accumulated technical debt in the form of implicit coupling between the internals of various modules and floating code that doesn’t seem to belong anywhere. This made it harder to understand a part of the codebase in isolation, and we noticed that contributors rarely felt confident making nontrivial changes. The rewrite would give us the opportunity to rethink the code organization with these things in mind.

Initial prototyping phase

We started prototyping Vue 3 in late 2018 with the preliminary goal of validating the solutions to these problems. During this stage, we mostly focused on building a solid foundation for further development.

Switching to TypeScript

Vue 2 was originally written in plain ES. Shortly after the prototyping stage, we realized that a type system would be very helpful for a project of this magnitude. Type checks greatly reduce the chance of introducing unintended bugs during refactors and help contributors be more confident in making nontrivial changes. We adopted Facebook’s Flow type checker because it can be gradually added to an existing plain-ES project. Flow helped to a certain extent, but we didn’t benefit from it as much as we’d hoped; in particular, the constant breaking changes made upgrading a pain. The support for integrated development environments was also not ideal compared to TypeScript’s deep integration with Visual Studio Code.

We also noticed that users were increasingly using Vue and TypeScript together. To support their use cases, we had to author and maintain the TypeScript declarations separately from the source code, which used a different type system. Switching to TypeScript would allow us to automatically generate the declaration files, alleviating the maintenance burden.

Decoupling internal packages

We also adopted a monorepo setup in which the framework is made up of internal packages, each with their own individual APIs, type definitions, and tests. We wanted to make the dependencies between these modules more explicit, making it easier for developers to read, understand, and make changes to all. This was key to our endeavor to lower the project’s contribution barriers and improve its long-term maintainability.

Setting up the RFC process

By the end of 2018, we had a working prototype with the new reactivity system and virtual DOM renderer. We had validated the internal architectural improvements we wanted to make, but only had rough drafts of the public-facing API changes. It was time to turn them into concrete designs.

We knew we had to do this early and carefully. Vue’s widespread usage means breaking changes can lead to massive migration costs for users and potential ecosystem fragmentation. To ensure users would be able to provide feedback on breaking changes, we adopted an RFC (Request for Comments) process at the beginning of 2019. Each RFC follows a template, with sections focused on motivation, design details, trade-offs, and adoption strategies. Since the process is conducted in a GitHub repo with proposals submitted as pull requests, discussions unfold organically in the comments.

The RFC process has proven immensely helpful, serving as a thought framework that forces us to fully consider all aspects of a potential change, and allowing our community to participate in the design process and submit well-thought-out feature requests.

Faster and smaller

Performance is essential to frontend frameworks. Although Vue 2 boasts competitive performance, the rewrite offers an opportunity to go even further by experimenting with new rendering strategies.

Overcoming the bottleneck of virtual DOM

Vue has a fairly unique rendering strategy: It provides an HTML-like template syntax but compiles the templates into render functions that return virtual DOM trees. The framework figures out which parts of the actual DOM to update by recursively walking two virtual DOM trees and comparing every property on every node. This somewhat brute-force algorithm is generally pretty quick, thanks to the advanced optimizations performed by modern JavaScript engines, but updates still involve a lot of unnecessary CPU work. The inefficiency is particularly obvious when you look at a template with largely static content and only a few dynamic bindings—the whole virtual DOM tree still needs to be recursively walked to figure out what’s changed.

Luckily, the template compilation step gives us a chance to perform a static analysis of the template and extract information about dynamic parts. Vue 2 did this to some extent by skipping static sub-trees, but more advanced optimizations were difficult to implement due to the overly simplistic compiler architecture. In Vue 3, we rewrote the compiler with a proper AST transform pipeline, which allows us to compose compile-time optimizations in the form of transform plug-ins.

With the new architecture in place, we wanted to find a rendering strategy that would eliminate as much overhead as possible. One option was to ditch virtual DOM and directly generate imperative DOM operations, but that would eliminate the ability to directly author virtual DOM render functions, which we’ve found to be highly valuable to advanced users and library authors. Plus, it would be a huge breaking change.

The next best thing was to get rid of unnecessary virtual DOM tree traversals and property comparisons, which tend to have the most performance overhead during updates. In order to achieve this, the compiler and the runtime need to work together: The compiler analyzes the template and generates code with optimization hints, while the runtime picks up the hints and takes fast paths whenever possible. There are three major optimizations at work here:

First, at the tree level, we noticed that node structures stay completely static in the absence of template directives that dynamically alter the node structure (e.g., v-if and v-for). If we divide a template into nested “blocks” separated by these structural directives, the node structures within each block become completely static again. When we update the nodes within a block, we no longer need to recursively traverse the tree—dynamic bindings within the block can be tracked in a flat array. This optimization circumvents much of the virtual DOM’s overhead by reducing the amount of tree traversal we need to perform by an order of magnitude.

Second, the compiler aggressively detects static nodes, subtrees, and even data objects in a template and hoists them outside the render function in the generated code. This avoids recreating these objects on each render, greatly improving memory usage and reducing the frequency of garbage collection.

Third, at the element level, the compiler also generates an optimization flag for each element with dynamic bindings based on the type of updates it needs to perform. For example, an element with a dynamic class binding and a number of static attributes will receive a flag that indicates only a class check is needed. The runtime will pick up these hints and take the dedicated fast paths.

Combined, these techniques have significantly improved our render update benchmarks, with Vue 3 sometimes taking less than a tenth of Vue 2’s CPU time.

Minimizing bundle size

The size of the framework also affects its performance. This is a unique concern for web applications because assets need to be downloaded on the fly, and the app will not be interactive until the browser has parsed the necessary JavaScript. This is particularly true for single-page applications. While Vue has always been relatively lightweight—Vue 2’s runtime size is around 23 KB gzipped—we’ve noticed two problems:

First, not everyone uses all of the framework’s features. For example, an app that never uses the transition features still pays the download and parse costs of transition-related code.

Second, the framework keeps growing indefinitely as we add new features. This gives bundle size disproportionate weight when we consider the trade-offs of a new feature addition. As a result, we tend to only include features that will be used by the majority of our users.

Ideally, the user should be able to drop code for unused framework features at build time—also known as “tree-shaking”—and only pay for what they use. This would also allow us to ship features that a subset of our users would find useful without adding payload costs for the rest.

In Vue 3, we achieved this by moving most of the global APIs and internal helpers to ES module exports. This allows modern bundlers to statically analyze the module dependencies and drop code related to unused exports. The template compiler also generates tree-shaking friendly code, which only imports helpers for a feature if the feature is actually used in the template.

Some parts of the framework can never be tree-shaken because they’re essential to any type of app. We call the measure of these indispensable parts the baseline size. Vue 3’s baseline size is around 10 KB gzipped—less than half that of Vue 2, despite the addition of numerous new features.

Addressing the need for scale

We also wanted to improve Vue’s ability to handle large-scale applications. Our initial Vue design focused on a low barrier to entry and a gentle learning curve. But as Vue became more widely adopted, we learned more about the needs of projects that contain hundreds of modules and are maintained by dozens of developers over time. For these types of projects, a type system like TypeScript and the ability to cleanly organize reusable code are critical, and Vue 2’s support in these areas was less than ideal.

In the early stages of designing Vue 3, we attempted to improve TypeScript integration by offering built-in support for authoring components using classes. The challenge was that many of the language features we needed to make classes usable, such as class fields and decorators, were still proposals—and subject to change before officially becoming part of JavaScript. The complexity and uncertainty involved made us question whether the addition of the Class API was really justified, since it didn’t offer anything other than slightly better TypeScript integration.

We decided to investigate other ways to attack the scaling problem. Inspired by React Hooks, we thought about exposing the lower-level reactivity and component lifecycle APIs to enable a more free-form way of authoring component logic, called Composition API. Instead of defining a component by specifying a long list of options, the Composition API allows the user to freely express, compose, and reuse stateful component logic just like writing a function, all while providing excellent TypeScript support.

We were really excited about this idea. Although the Composition API was designed to address a specific category of problems, it’s technically possible to use it only when authoring components. In the first draft of the proposal, we got a bit ahead of ourselves and hinted that we might replace the existing Options API with the Composition API in a future release. This resulted in massive pushback from community members, which taught us a valuable lesson about communicating longer-term plans and intentions clearly, as well as understanding users’ needs. After listening to feedback from our community, we completely reworked the proposal, making it clear that the Composition API would be additive and complementary to the Options API. The reception of the revised proposal was much more positive, and received many constructive suggestions.

Seeking balance

Among Vue’s user base of over a million developers are beginners with only a basic knowledge of HTML/CSS, professionals moving on from jQuery, veterans migrating from another framework, backend engineers looking for a frontend solution, and software architects dealing with software at scale. The diversity of developer profiles corresponds to the diversity of use cases: Some developers might want to sprinkle interactivity on legacy applications, while others may be working on one-off projects with a fast turnaround but limited maintenance concerns; architects may have to deal with large-scale, multiyear projects and a fluctuating team of developers over the project’s lifetime.

Vue’s design has been continuously shaped and informed by these needs as we seek to strike a balance between various trade-offs. Vue’s slogan, “the progressive framework,” encapsulates the layered API design that results from this process. Beginners can enjoy a smooth learning curve with a CDN script, HTML-based templates, and the intuitive Options API, while experts can handle ambitious use cases with full-featured CLI, render functions, and the Composition API.

There’s still a lot of work left to do to realize our vision—most importantly, updating supporting libraries, documentation, and tools to ensure a smooth migration. We’ll be working hard in the coming months, and we can’t wait to see what the community will create with Vue 3.

About the author

Evan You is an independent open-source developer. He is the creator and project lead of Vue.js, one of the most widely used frontend frameworks today.


Artwork by

Ori Toor


Buy the print edition

Visit the Increment Store to purchase print issues.


Continue Reading

Explore Topics

All Issues