[译] 制作 Vue 3 的过程


原文链接: //increment.com/frontend/making-vue-3

在过去的一年里,Vue 团队一直在研究 Vue.js 的下一个主要版本,我们希望在 2020 年上半年发布。这项工作在撰写本文时还在进行中),关于 Vue 新的主要版本的想法是在 2018 年底形成的,当时 Vue 2 的代码库大约有两年半的时间。在通用软件的生命周期中,这听起来可能并不长,但在这段时间内,前端的格局发生了翻天覆地的变化。

有两个关键的考虑因素促使我们对 Vue 进行了新的主要版本(和重写)。第一,主流浏览器中新的 JavaScript 语言功能的普遍存在。第二,当前代码库中暴露出来的设计和架构问题。

为什么要重写

利用新的语言功能

随着 ES2015 的标准化,JavaScript(即 ECMAScript,缩写为 ES 的缩写)得到了重大改进,主流浏览器终于开始为这些新增加的功能提供了像样的支持。特别是一些新添加的功能为我们提供了极大的机会,使 Vue 的能力得到了极大的提升。

其中最值得一提的是 Proxy,它允许框架对对象进行拦截操作。Vue 的一个核心特性是能够监听用户定义状态的变化,并对 DOM 进行反应式更新。Vue 2 通过用 getter 和 setter 替换状态对象上的属性,实现了这种反应性。切换到 Proxy 可以让我们消除 Vue 现有的限制,比如无法检测到新的属性添加,并提供更好的性能。

然而,Proxy 是一个原生语言的功能,在传统的浏览器中无法完全复用。为了利用它,我们知道我们必须调整框架的浏览器支持范围–这是一个重大的突破性改变,只能在新的主要版本中提供。

解决架构问题

在现有的代码库中修复这些问题,需要进行巨大的、危险的重构,几乎相当于重写。
在维护 Vue 2 的过程中,由于现有架构的限制,我们已经积累了很多问题,这些问题很难解决。例如,模板编译器的编写方式使得适当的源码映射支持变得非常具有挑战性。此外,虽然 Vue 2 在技术上可以构建针对非 DOM 平台的更高级别的渲染器,但我们不得不对代码库进行分叉,并重复了大量的代码来实现这一点。在当前的代码库中修复这些问题,将需要进行巨大的、危险的重构,几乎相当于重写。

同时,我们还以各种模块内部的隐式耦合形式积累了技术债务,以及似乎不属于任何地方的浮动代码。这使得我们很难孤立地理解代码库中的某一部分,而且我们注意到,贡献者很少有信心进行一些无关紧要的修改。重写将给我们提供了一个机会,让我们在考虑到这些问题的情况下重新思考代码组织。

初步原型设计阶段

我们在 2018 年底开始了 Vue 3 的原型开发,初步目标是验证这些问题的解决方案。在这个阶段,我们主要集中在为进一步开发打下坚实的基础上。

切换到 typescript

Vue 2 最初是用普通的 ES 编写的。在原型设计阶段后不久,我们意识到类型系统对于这样的项目来说是非常有帮助的。类型检查大大减少了在重构过程中引入意外 bug 的机会,并帮助贡献者更有信心地进行简单的修改。我们采用了 Facebook 的 Flow 类型检查器,因为它可以逐步添加到现有的纯 ES 项目中。Flow 在一定程度上起到了一定的帮助,但我们并没有如愿以偿地受益;特别是,不断的破坏性修改让升级成为一种痛苦。与 TypeScript 与 Visual Studio Code 的深度集成相比,对集成开发环境的支持也并不理想。

我们还注意到,用户越来越多地将 Vue 和 TypeScript 一起使用。为了支持他们的用例,我们不得不将 TypeScript 声明与源代码分开编写和维护,而源代码使用的是不同的类型系统。转换到 TypeScript 将使我们能够自动生成声明文件,减轻了维护负担。

内部包的解耦

我们还采用了一个单体化的设置,框架由内部包组成,每个包都有自己的 API、类型定义和测试。我们希望让这些模块之间的依赖关系更加明确,让开发者更容易阅读、理解和修改。这对于我们努力降低项目的贡献障碍和提高项目的长期可维护性是非常关键的。

设置 RFC 流程

到 2018 年年底,我们已经有了一个工作原型,有了新的反应式系统和虚拟 DOM 渲染器。我们已经验证了我们想做的内部架构改进,但只有面向公众的 API 改动的粗略草稿。现在是时候把它们变成具体的设计了。

我们知道我们必须尽早、谨慎地完成这项工作。Vue 的广泛使用意味着破坏性的改变可能会导致用户的大量迁移成本和潜在的生态系统碎片化。为了确保用户能够提供对打破性改动的反馈,我们在 2019 年初采用了 RFC(征求意见)流程。每个 RFC 都遵循一个模板,其中的章节集中在动机、设计细节、权衡和采用策略等方面。由于该流程是在 GitHub repo 中进行的,建议以拉动请求的形式提交,因此讨论在评论中有机地展开。

事实证明,RFC 流程非常有帮助,它作为一个思想框架,迫使我们充分考虑到了潜在变革的所有方面,并允许我们的社区参与到设计过程中,提交深思熟虑的功能请求。

更快、更小

性能对于前端框架来说是至关重要的。尽管 Vue 2 拥有极具竞争力的性能,但通过实验新的渲染策略,重写提供了一个更进一步的机会。

克服虚拟 DOM 的瓶颈

Vue 有一个相当独特的渲染策略。它提供了一个类似于 HTML 的模板语法,但将模板编译成了返回虚拟 DOM 树的渲染函数。该框架通过递归地走过两个虚拟 DOM 树,并比较每个节点上的每一个属性,计算出实际 DOM 的哪些部分需要更新。由于现代 JavaScript 引擎进行了高级优化,这种有点蛮力的算法一般来说是相当快的,但是更新仍然会涉及到很多不必要的 CPU 工作。当你看一个基本上是静态内容和只有几个动态绑定的模板时,效率低下的问题就特别明显–整个虚拟 DOM 树仍然需要递归地走一遍,以找出改变了什么。

幸运的是,模板编译步骤让我们有机会对模板进行静态分析并提取动态部分的信息。Vue 2 通过跳过静态子树在一定程度上做到了这一点,但由于编译器架构过于简单化,更高级的优化很难实现。在 Vue 3 中,我们用适当的 AST transform pipeline 重写了编译器,这使得我们可以用变换插件的形式来进行编译时的优化。

有了新的架构,我们希望找到一种能够尽可能消除开销的渲染策略。一个选择是抛弃虚拟 DOM,直接生成必要的 DOM 操作,但这将消除直接编写虚拟 DOM 渲染函数的能力,我们发现这对高级用户和库作者来说是非常有价值的。另外,这将是一个巨大的突破性改变。

接下来最好的办法就是去掉不必要的虚拟 DOM 树遍历和属性比较,这些往往在更新过程中的性能开销最大。为了实现这个目标,编译器和运行时需要协同工作。编译器分析模板,并生成带有优化提示的代码,而运行时则接收这些提示并尽可能地采取快速路径。这里有三个主要的优化工作。

首先,在树级,我们注意到,在没有模板指令动态改变节点结构的情况下,节点结构保持完全静态(例如,v-if 和 v-for)。如果我们把一个模板划分成由这些结构指令分隔的嵌套 “块”,那么每个块内的节点结构又变得完全静态。当我们更新块内的节点时,我们不再需要递归地遍历块内的树形动态绑定,可以在一个平面数组中跟踪。这种优化避免了虚拟 DOM 的大部分开销,减少了我们需要执行的树状遍历量,减少了一个数量级。

其次,编译器会主动检测模板中的静态节点、子树,甚至是数据对象,并在生成的代码中把它们挂在渲染函数之外。这就避免了在每次渲染时重新创建这些对象,极大地提高了内存使用量,减少了垃圾回收的频率。

第三,在元素层面,编译器还会根据每个具有动态绑定的元素需要执行的更新类型,为其生成一个优化标志。例如,一个具有动态类绑定和多个静态属性的元素将收到一个标志,提示只需要进行类检查。运行时将接收到这些提示并采取专用的快速路径。

CPU 时间 即执行 JavaScript 计算所花费的时间,不包括浏览器 DOM 操作。

综合起来,这些技术大大改善了我们的渲染更新基准,Vue 3 有时只需要不到 Vue 2 的十分之一的 CPU 时间。

最大限度地减少软件包的大小

框架的大小也会影响到它的性能。这对于 Web 应用来说是一个独特的问题,因为资产需要实时下载,在浏览器解析了必要的 JavaScript 之后,应用程序才会进行交互。这对于单页面应用来说尤其如此。虽然 Vue 一直以来都是相对轻量级的–Vue 2 的运行时大小约为 23 KB gzipped,但我们注意到了两个问题。

首先,不是每个人都会使用该框架的所有功能。例如,一个从未使用过过渡功能的应用仍然要支付过渡相关代码的下载和解析成本。

第二,随着我们添加新的功能,框架不断地无限增长。当我们考虑增加新功能的权衡时,这就赋予了捆绑大小不成比例的权重。因此,我们倾向于只包含大多数用户会使用的功能。

理想的情况下,用户应该能够在构建的时候,为未使用的框架功能丢弃代码,也就是所谓的 “动摇树”–只为他们使用的功能付费。这也可以让我们在不增加其他用户的付费成本的情况下,为一部分用户提供有用的功能。

在 Vue 3 中,我们通过将大部分的全局 API 和内部帮助器转移到 ES 模块导出中来实现了这一点。这使得现代捆绑器能够静态分析模块的依赖性,并丢弃与未使用的导出相关的代码。模板编译器还生成了树状的友好代码,只有在模板中实际使用某个功能的时候才会导入帮助器。

框架中的一些部分永远不能被树形动摇,因为它们对于任何类型的 app 都是必不可少的。我们把这些必不可少的部分的衡量标准称为基线大小。Vue 3 的基线大小约为 10KB 左右,尽管增加了许多新功能,但它的基线大小还不到 Vue 2 的一半。

解决规模化的需求

我们还希望提高 Vue 的处理大规模应用的能力。我们最初的 Vue 设计的重点是低准入门槛和温和的学习曲线。但随着 Vue 的应用越来越广泛,我们更多的了解到了包含数百个模块的项目的需求,并且由几十个开发人员长期维护的项目。对于这些类型的项目来说,像 TypeScript 这样的类型系统和干净利落地组织可重用代码的能力是至关重要的,而 Vue 2 在这些方面的支持并不理想。

在设计 Vue 3 的早期阶段,我们试图通过提供对使用类编写组件的内置支持来改进 TypeScript 的集成。我们所面临的挑战是,我们需要的许多语言特性,如类字段和装饰符等,在正式成为 JavaScript 的一部分之前,仍然是建议–而且可能会发生变化。所涉及的复杂性和不确定性让我们怀疑增加类 API 是否真的合理,因为它除了稍微好一点的 TypeScript 集成之外,并没有提供其他任何东西。

我们决定研究其他方式来攻击缩放问题。受 React Hooks 的启发,我们想到了暴露出底层的反应性和组件生命周期 API,以实现一种更自由的方式来编写组件逻辑,称为 Composition API。与其通过指定一长串选项来定义一个组件,Composition API 允许用户像写函数一样自由地表达、编译和重用有状态的组件逻辑,同时提供了出色的 TypeScript 支持。

我们对这个想法非常兴奋。虽然 Composition API 是为了解决特定类别的问题而设计的,但在技术上,只有在编写组件时才可以使用它。在提案的第一稿中,我们有点超前了,并暗示我们可能会在未来的版本中用 Composition API 替换现有的 Options API。这导致了社区成员的巨大反击,这给我们上了宝贵的一课,让我们明白了如何清晰地传达长期计划和意图,以及了解用户的需求。在听取了社区成员的反馈后,我们对提案进行了彻底的修改,明确了 Composition API 将是对 Options API 的补充和补充。修改后的提案得到了更多的好评,并收到了很多建设性的建议。

寻求平衡

开发者简介的多样性与用例的多样性相对应。
在 Vue 的用户群中,超过 100 万的开发者中,有只懂 HTML/CSS 的初学者,也有从 jQuery 迁移过来的专业人士,有从其他框架迁移过来的老手,有寻找前端解决方案的后端工程师,也有处理规模化软件的软件架构师。开发者配置文件的多样性与用例的多样性相对应。一些开发人员可能希望在传统的应用程序上洒上交互性,而另一些开发人员可能是一次性的项目,周转速度快,但维护问题有限;架构师可能需要处理大型的、多年期的项目,并且在项目的生命周期内,开发人员的团队起伏不定。

Vue 的设计一直在不断地塑造和了解这些需求,在各种权衡中寻求平衡。Vue 的口号是 “渐进式框架”,这句话概括了这个过程中产生的分层 API 设计。初学者可以通过 CDN 脚本、基于 HTML 的模板和直观的 Options API 享受平稳的学习曲线,而专家们可以通过全功能的CLI、渲染函数和 Composition API 来处理宏大的用例。

为了实现我们的愿景,还有很多工作要做–最重要的是,更新支持库、文档和工具以确保顺利迁移。我们将在未来的几个月里努力工作,我们迫不及待地想看看社区将用 Vue 3 创造出什么。