Lessons from rewriting the next major version of Vue.js

Over the past year, the Vue team has been working on the next major release of vue.js, which we hope to release in the first half of 2020. (This is still a work in progress at the time of writing). The major release of Vue was finalized at the end of 2018, when the Vue 2 codelbase was two and a half years old. That may not sound long in the life cycle of general-purpose software, but during that time, the front-end environment has changed dramatically.

There were two main considerations that led us to develop (and rewrite) the new major version of Vue: First, the new JavaScript language features were generally available in major browsers. Second, over time, design and architectural issues in the current code base have come to light.

[Why rewrite]

Take advantage of new language features

With the standardization of ES2015, JavaScript (formally known as ECMAScript, or ES for short) has been significantly improved, and mainstream browsers are finally starting to provide decent support for these new features. Some, in particular, offer us the opportunity to dramatically improve Vue functionality.

The most notable of these is [Proxy](Proxy), which allows the framework to intercept operations on objects. The core functionality of Vue is the ability to listen for changes to user-defined state and update the DOM in a reactive manner. Vue 2 achieves this reactivity by replacing properties on state objects with getters and setters. Switching to Proxy will allow us to remove existing limitations of Vue, such as the inability to detect new attribute additions and provide better performance.

However, Proxy has compatibility issues in Internet Explorer or older browsers. To take advantage of it, we had to adjust the scope of browser support for the framework, which was a major breakthrough and could only be released in a new major release.

Resolving architectural issues

In the process of maintaining Vue2, we accumulated a number of problems that were difficult to solve due to the limitations of the existing architecture. For example, the template compiler is written in a way that makes proper source mapping support challenging. Similarly, while Vue 2 technically allows you to build higher-level renderers for non-DOM platforms, we had to derive a code base and copy a lot of code to do this. Solving these problems in the current code base would require a lot of risky refactoring, almost equivalent to rewriting.

At the same time, we accumulated technical debt in the form of implicit coupling between the internals of various modules and floating code that didn’t seem to belong anywhere. This makes it more difficult to understand portions of the code base in isolation, and we have noticed that contributors rarely feel confident about making significant changes. The rewrite will give us an opportunity to rethink the code organization with these considerations in mind.

Solving these problems in the current code base would require a lot of risky refactoring, almost equivalent to rewriting.

[Initial prototype stage]

We began prototyping Vue 3 at the end of 2018 with the initial goal of validating solutions to these problems. At this stage, our main focus is to lay a solid foundation for further development.

Switch to the Typescript

Vue2 was originally written in pure ES. Not long after the prototyping phase, we realized that a type system would be very helpful for a project of this size. Type checking greatly reduces the chance of introducing unexpected errors during refactoring and helps contributors feel more confident about making important changes. We went through Facebook’s Flow: A Static Type Checker for JavaScript because it can be progressively added to existing ES projects. Traffic helped up to a point, but we didn’t get much out of hope. In particular, the ever-changing changes make upgrading painful. Support for integrated development environments is also less than ideal compared to TypeScript’s deep integration with Visual Studio Code.

We’ve also noticed that users are increasingly using both Vue and TypeScript. To support their use cases, we must compose and maintain TypeScript declarations separately from source code that uses different type systems. Switching to TypeScript will allow us to automatically generate declaration files, reducing the maintenance burden.

Decouple internal encapsulation

We also adopted the Monorepo setup, where the framework consists of internal software packages, each with its own separate API, type definitions, and tests. We want to make the dependencies between these modules more explicit, making it easier for developers to read, understand, and make all the changes. This is key to our efforts to lower project contribution barriers and improve their long-term maintainability.

Set the RFC flow

By the end of 2018, we had a working prototype using a new reactive system and a virtual DOM renderer. We have verified the internal architectural improvements we want to make, but only included a draft of the API changes for the public. Now it’s time to turn them into concrete designs.

We know we have to do this early and carefully. The widespread use of Vue means that breakthrough changes can result in significant user migration costs and potential ecosystem fragmentation. To ensure that users can provide feedback on significant changes, we adopted the [RFC (Request for Comments) process](VUEJS/RFCS) in early 2019. Each RFC follows a template that focuses on motivation, design details, trade-offs, and adoption strategies. Because this process takes place in the GitHub repository and the proposal is submitted as a request request, the discussion unfolds naturally in comments.

The RFC process has proved enormously helpful as a framework for thinking through all aspects of potential change, engaging our community in the design process, and submitting thoughtful functional requirements.

[Faster and smaller]

Performance is critical to a front-end framework. Despite the excellent performance of Vue 2, rewriting offers the opportunity to evolve further by experimenting with new rendering strategies.

Overcome the virtual DOM bottleneck

Vue has a fairly unique rendering strategy: it provides htML-like template syntax, but compiles templates into rendering functions that return a virtual DOM tree. The framework determines which parts of the actual DOM need to be updated by recursively traversing two virtual DOM trees and comparing each property on each node. Due to the advanced optimizations that modern JavaScript engines perform, this somewhat brute-force algorithm is usually fast, but updates still involve a lot of unnecessary CPU work. The inefficiencies are especially evident when you look at templates with a lot of static content and only a small number of dynamic bindings (the entire virtual DOM) and still need to recursively walk up the tree to see what has changed.

Fortunately, the template compilation step gives us the opportunity to statically analyze the template and extract information about the dynamic parts. Vue2 does this to some extent by skipping static subtrees, but more advanced optimizations are difficult to implement due to the simplistic compiler architecture. In Vue3, we rewrote the compiler with an appropriate AST transformation pipeline, which allowed us to write compile-time optimizations in the form of transformation plug-ins.

With the new architecture, we wanted to find a rendering strategy to minimize overhead. One option is to abandon the virtual DOM and generate imperative DOM operations directly, but this would eliminate the ability to write the virtual DOM rendering functionality directly, which we find very valuable for power users and library authors. Plus, it would be a huge breakthrough.

Second, the best approach is to eliminate unnecessary virtual DOM tree traversals and attribute comparisons, which tend to incur the greatest performance overhead during updates. To do this, the compiler and the runtime need to work together: the compiler analyzes the template and generates code with optimization prompts, while the runtime picks up the prompts and takes a quick path when possible. There are three main optimizations:

First, at the tree level, we note that the node structure remains completely static without template instructions to dynamically change the node structure (e.g., ‘V-if’ and ‘V-for’). If we divide the template into nested “block trees “separated by these structural instructions, the node structure within each block becomes completely static again. When we update nodes within a block, we no longer need to recursively traverse the tree and can track the dynamic binding within the block in a flat array. This optimization avoids most of the overhead of the virtual DOM by reducing the amount of tree traversal we need to perform by an order of magnitude.

Second, the compiler actively detects static nodes, subtrees, and even data objects in the template and promotes them beyond the render function in the generated code. This avoids recreating these objects on every render, greatly increasing memory usage and reducing the frequency of garbage collection.

Third, at the element level, the compiler also generates an optimization flag for each dynamically bound element based on the type of update that needs to be performed. For example, elements with dynamic class bindings and many static attributes will receive a flag indicating that only class checking is required. The runtime takes these hints and takes a dedicated fast path.

Minimize package size

The size of the framework also affects its performance. This is the sole concern of the Web application, because the asset needs to be downloaded dynamically, and the application will be interactive until the browser parses the necessary JavaScript. This is especially true for single-page applications. Although Vue has always been relatively lightweight (Vue2 has a runtime size compression of 23KB), we noticed two issues:

First, not everyone uses all the features of the framework. For example, apps that never use transitions will still have to pay for the download and parsing of transition-related code.

Second, the framework grows indefinitely as we add new features. This makes the size of the bundle out of proportion when we consider the tradeoff of adding new features. Therefore, we tend to include only features that will be used by the majority of users.

Ideally, users should be able to remove unused framework functionality at build time in code also known as “tree-shaking” so that performance wastage becomes more elastic, and the fewer methods used the better performance will be. This will also allow us to release features that some users will find useful without increasing the payload costs for the rest of the users.

In Vue3, we achieved this goal by moving most of the global API and internal helpers to the ES module export. This allows modern bundlers to statically analyze module dependencies and remove code associated with unused exports.

Some of the core design parts of the framework will not change because they are essential for any type of application. We refer to these essential measurements as reference dimensions. Despite the many new features, Vue3’s base size is about 10KB compressed, less than half the size of Vue2.

[Meet scale requirements]

We also want to improve Vue’s ability to handle large applications. Our original Vue design focused on lower barriers to entry and a gentle learning curve. But as Vue became more widely adopted, we learned more about the requirements of projects that contained hundreds of modules and were maintained by dozens of developers over time. For these types of projects, a type system like TypeScript and the ability to organize reusable code cleanly are critical, and Vue2’s support in these areas is not ideal.

In the early stages of designing Vue 3, we tried to improve TypeScript integration by providing built-in support for writing components using classes. The challenge is that many of the language features we need to make classes usable, such as classes and decorators, are still proposals and may change before they become a formal part of JavaScript. The complexity and uncertainty involved makes us wonder if it really makes sense to add a Class API that doesn’t provide anything other than better TypeScript integration.

We decided to look at other approaches to the scaling problem. Inspired by React Hooks, we considered exposing lower-level reactivity and component lifecycle apis to enable a more free-form way of writing component logic, Called [Composition] apis (Composition API RFC | Vue Composition API). Instead of defining components by specifying a long list of options, the Composition API allows users to express stateful component logic as freely as they write functions, while providing excellent TypeScript support.

We are very excited about this idea. Although the Composition API is intended to solve a specific class of problems, technically you can only use it when writing components. In the first draft of the proposal, we got ahead of the curve and hinted that we might replace the existing Options API with the Composition API in a future release. This led to a lot of pushback from community members, which provided us with valuable lessons in clearly communicating their long-term plans and intentions, as well as understanding their users’ needs. After listening to feedback from our community, we completely redesigned the proposal to make it clear that the Composition API would complement and complement Options. The revised proposal received was more positive and many constructive suggestions were received.

[Strike a balance]

Among Vue’s more than a million developers, there are beginners to THE basics of HTML/CSS, front-end engineers migrating from jQuery, developers migrating from another framework, back-end engineers looking for front-end solutions, and software architects working on large-scale software.

The diversity of developers corresponds to the diversity of use cases: some developers may want to add interactivity on older versions of the application, while others may need a one-time project with a quick turnaround but limited maintenance needs. Engineers may have to deal with large, multi-year projects and a fluctuating development team throughout the life of the project.

Vue’s design is constantly influenced and inspired by these requirements as we seek to balance the various compromises. Vue’s slogan, “Progressive framework,” encapsulates the layered API design that results from this process. Beginners can easily learn using CDN scripts, HTML-based templates, and the intuitive Options API, while the experienced can use the full-featured CLI, Render Functions, and Composition API to handle your problems.

There is still a lot of work to be done to achieve our vision and most importantly, update the support libraries, documentation and tools to ensure a smooth migration. As we continue to work on this in the coming months, we can’t wait to see what the community will create with Vue 3.

[The Process: Making Vue 3 — Increment: Frontend]