Header: Code is never die!

本文主要介绍了 Vue 3 的设计过程。原文为英文版,对原文感兴趣的可查看(英文版): Vue 3

Vue 3 的设计过程

重写 Vue.js 下个主版本的经验总结
作者:尤雨溪
创作日期:2020 年 5 月

在过去的一年里,Vue 团队一直在开发 Vue.js 的下一个主版本,我们打算在 2020 年上半年发布它(原文注释:在写作本文时,这个工作仍在继续)。与新的 Vue 主版本有关的想法是在 2018 年底成形的,那时 Vue 2 的代码库大约诞生了两年半。对于一个通用软件的整个生命周期来说,这个时间不算长,但是在这段时期内,前端技术已经发生了翻天覆地的变化。

设计(和重写)Vue 的下一个主版本主要基于两点考虑:1. 主流浏览器中 JavaScript 新特性的普遍可用性;2. 随着时间的推移,当前代码库的设计和结构上的缺陷逐渐暴露了出来。

为什么要重写?

一、利用新的语言特性

随着 ES2015 的标准化,以及 JavaScript(正式名称为 ECMAScript,缩写为 ES) 进行了重大升级,主流浏览器也开始对这些新特性提供不错的支持。其中一些为我们提供了极大提升 Vue 性能的机会。

其中最值得注意的是 Proxy,它允许框架拦截对对象的操作。Vue 的一个核心特色就是能监听用户自定义 state 的变化,并且响应式地更新 DOM。Vue 2 通过替换 state 内对象属性的 getters 和 setters 来实现这一点。使用 Proxy 实现可以帮助我们消除现有的限制,比如无法检测新添加的属性,并且它还可以改善 Vue 的性能。

不过,Proxy 是一个原生的语言特性,在旧浏览器中无法被完全 polyfill 。为了使用它,我们必须调整框架所支持的浏览器范围,这是一个只有在新的主版本中才可以做出的重大改变。

二、解决架构问题

在维护 Vue 2 的过程中,我们积累了大量由于当前架构的限制而无法解决的问题。例如,模板编译器的编写方式使得生成正确的 source-map 非常有挑战性。另外,虽然 Vue 2 在技术上支持编写高阶渲染函数,从而面向无 DOM(non-DOM)平台使用,但为了实现它,我们必须创建代码库分支,并复制大量的代码。在当前版本中修复这些问题需要进行大规模、高风险的重构,这几乎相当于重写框架。

与此同时,各个模块内部和一些似乎不属于任何地方的浮动代码存在隐式耦合,这积累了一些技术债务。这使得单独理解代码库的一部分变得很困难,并且我们注意到,很少有贡献者有信心对框架做出重要的改变。重写给了我们重新思考代码组织结构的机会。

初始原型阶段
我们在 2018 年底开始构建 Vue 3 的原型,初步目标是验证以下问题的解决方案。

三、切换到 TypeScript

Vue 2 最初是由原生 ES 编写的。在原型阶段过后不久,我们意识到类型系统对这种规模的项目是非常有用的。类型检查大大降低了在重构过程中引入意外 bug 的几率,也可以帮助贡献者增强做出重大改进的信心。我们选择了 FaceBook 的 Flow type checker ,因为它可以逐步添加到已经存在的纯 ES 项目中。Flow 起到了一定的作用,但是带来的好处不如我们预期的那么多;特别是 Flow 不断进行的重大修改使得升级非常痛苦。相比于 TypeScript 与 Visual Studio Code 的深度集成,Flow 对集成开发环境的支持也不够理想。

我们还注意到,同时使用 Vue 和 TypeScript 的用户在不断增长。为了支持他们的使用场景,我们必须独立于源代码编写和维护使用了不同类型系统(译者注:相对于 Flow 而言)的 TypeScript 声明。切换到 TypeScript 使得我们可以自动生成声明文件,以降低维护的负担。

四、解耦内部包

我们还在由多个内部包构成的框架内使用了单一设置,尽管这些包拥有各自的私有 API、类型定义和测试代码。我们希望使模块之间的依赖关系更加明确,使它更易于被开发者阅读、理解和修改。这是我们努力降低为项目做贡献的难度并提高其长期可维护性的关键。

五、启用 RFC 流程

在 2018 年末,我们创建了一个有着新的响应式系统和虚拟 DOM 渲染器的工作原型。我们已经验证了我们想要的内部架构的改进,但是公开 API(public-facing API,译者注:指面向开发者的 API)部分只有一个大致草稿,是时候把它们变成具体的设计了。

我们知道我们必须尽快并且谨慎地做这件事。Vue 的大量使用意味着重大改变会带来巨大的迁移成本和潜在的框架生态分裂。为了确保用户能对重大变化提供反馈,我们在 2019 年初启用了 RFC(Request For Comments)流程。每个 RFC 使用一个固定模板,包括方案目的、设计细节、方案权衡和采用的策略。由于该过程是在 GitHub 仓库中进行的,建议以 pull request 的形式提交,相关讨论会在评论中展开。

RFC 在构建一个成熟框架的过程中是非常有用的,它迫使我们对一个变化的所有方面进行全面的考虑,并允许我们的社区参与设计过程,提交经过深思熟虑的功能设计。

更快,更小
性能对前端框架极其重要。尽管 Vue 2 在性能方面已经很有竞争力,但是通过新的渲染策略,重写使得性能可以进一步提升。

六、克服虚拟 DOM 的瓶颈

Vue 有一个相当独特的渲染策略:它提供一个接近 HTML(HTML-like)的模板语法,并最终把它编译为一个可以返回虚拟 DOM 树的渲染函数。该框架通过递归遍历两个虚拟 DOM 树并比较每个节点上的每个属性来确定实际 DOM 的哪些部分需要更新。感谢现代 JavaScript 引擎所执行的高级优化,这个有些粗糙的算法通常执行得很快,但是更新过程仍然涉及很多不必要的 CPU 操作。当你观察一个包含大量静态内容而只有少量动态绑定的模板时,效率低下问题就会变得很明显 – 整个虚拟 DOM 树仍然需要递归遍历来算出哪里发生了变化。

幸运的是,模板编译步骤给了我们分析静态模板和动态部分的机会。Vue 2 通过跳过静态子树在一定程度上做到了这一点,但是由于编译器架构过于简单,更进一步的优化很难实现。在 Vue 3 中,我们用更合适的 AST 转换管道(AST transform pipeline)重写了编译器,它使得我们能以转换插件的形式进行编译时优化。

随着新架构的实施,我们希望找到一种开销尽可能低的渲染策略。一个选择是舍弃虚拟 DOM,直接生成必要的 DOM 操作,但是那会丧失直接编写虚拟 DOM 渲染函数的能力,而我们发现这个能力对高级用户和库的开发者非常有用。另外,这又将是一个重大更新。

接下来最好的方法是消除不必要的虚拟 DOM 树遍历和属性比较,而这在更新过程中的性能损耗是最大的。为了实现这一点,编译器和运行时必须同时工作:编译器分析模板和生成带有优化提示的代码,同时,运行时拾取这些提示,并采取尽可能快的更新策略。这里主要有三个优化:

第一,从树的层面看,我们注意到,在没有使用可以动态改变树结构的指令(例如 v-if 和 v-for)的情况下,节点结构是完全静态的。如果我们将模板划分为由这些结构指令分隔的嵌套“块”,那么每个“块”中的节点结构又会变成完全静态的。当我们在一个“块”内部更新节点时,我们不再需要递归遍历整棵树 – 因为“块”内的动态绑定可以在一个扁平数组(译者注:即一维数组)中被追踪到。通过将需要执行的树遍历运算减少一个数量级,这种优化规避了虚拟 DOM 的大部分开销。

第二,编译器会主动监测模板中的静态节点、静态子树甚至数据对象,并且把它们提取到结果代码中的渲染函数之外。这避免了在每个渲染函数中重新创建这些对象,极大的改善了内存使用,降低了垃圾回收频率。

第三,从标签元素的角度来说,编译器还会根据需要执行的更新类型为每个元素动态绑定生成一个优化标志。例如,一个有动态 class 和一些静态属性的元素会被标记为只需要进行类名检查。运行时会拾取这些提示并采取专门的快速更新策略。

结合这些技术,Vue 3 占用的 CPU 时间 还不到 Vue 2 的十分之一,极大地改善了我们的渲染更新基准测试性能。

七、最小化包体积

框架的体积同样影响它的性能。这是 web 应用程序遇到的一个独特问题,因为资源需要在使用时下载,并且在浏览器解析完必要的 JavaScript 代码之前,应用无法产生交互。对于单页面应用程序来说尤其如此。尽管 Vue 一直以来是比较轻量的 – Vue 2xx 版本的运行时使用 gzip 压缩后只有 23KB,我们还是注意到两个问题:

第一,不是所有人都会用到框架的所有功能。例如,一个不需要使用 transition 组件的应用仍然需要付出下载和解析与 transition 有关代码的代价。

第二,随着我们不断增加新特性,框架也在不断增长。当我们在权衡新特性的利弊时,包的体积必须考虑在内。最终,我们倾向于只添加大多数用户会用到的功能。

理想情况下,用户应该能够在构建时删除那些未使用的框架特性相关的代码 – 也叫 tree-shaking,只留下他们用到的东西。这也使得我们可以在不增加其他用户成本的情况下,为一部分用户提供有用的特性。

在 Vue 3 中,我们通过把大部分全局 API 和内置帮助程序(internal helpers)转移到 ES 模块中来实现这一点。这允许现代打包器静态地分析模块依赖关系,并删除与未使用的特性相关的代码。模板编译器也可以生成 tree-shaking 友好的代码,它只会在模板中实际使用了该特性时才导入与该特性相关的帮助程序。

框架中的一些部分永远不能被 tree-shaken,因为它们对任何一个应用都是必要的。我们称这些不可缺少的部分的体积为基准体积。尽管增加了大量的新特性,但 Vue 3 的基准体积用 gzip 压缩后只有大约 10KB - 比 Vue 2 的一半还小。

八、解决对规模化的需求

我们还想提升 Vue 应对大型应用的能力。我们最初的 Vue 设计专注于较低的准入门槛和平缓的学习曲线。但是随着 Vue 的使用越来越广泛,我们意识到支持包含数百个模块以及由数十名开发者维护的大型项目是必要的。对这类项目,像 TypeScript 这样的类型系统,以及干净地组织可重用代码的能力是至关重要的,然而 Vue 2 在这方面的支持不够理想。

在 Vue 3 设计的早期阶段,我们尝试通过支持使用类编写组件来改进 TypeScript 集成。挑战在于,class 所依赖的许多语言特性,例如类字段和修饰器,仍处于建议阶段。而在成为正式的 JavaScript 标准之前,这些特性仍然可能变化。这些问题所涉及的复杂性和不确定性让我们怀疑添加类 API 是否真的合理,因为它除了提供稍好的 TypeScript 集成之外,没有带来任何好处。

我们决定研究解决规模化问题的其他方法。受 React Hooks 的启发,我们考虑通过暴露更底层的响应式和组件生命周期 API,来启用一种更自由的方式编写组件逻辑,我们称之为 Composition API。与通过指定一长串 option 来定义组件不同,Composition API 允许用户自由地像编写函数一样表达、组合和重用有状态组件逻辑,并且这些都提供了很好的 TypeScript 支持。

我们对这个想法感到兴奋。尽管 Composition API 设计出来是为了解决某些特定的问题,但在编写组件时只使用这类 API 来实现(译者注:指完全使用 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 社区将会创造什么了。