Vue3 设计背后的思考

在过去的一年中,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 是原生的语言特性,并且没办法完全通过 polyfill 的方式去支持老的浏览器。为了使用这个特性,我们必须调整框架的浏览器支持范围——这是一个巨大的不兼容更新,所以只能通过大的新版本来发布。

解决架构问题

在维护 Vue 2 的过程中,我们累积了很多问题,并且因为目前的架构限制很难被解决。举个例子,当前的模板编译器实现导致很难支持良好的 source-map 功能。另外,虽然 Vue 2 在技术上可以实现面向非 DOM 平台的高阶渲染器,但需要 folk 代码库并且复制很多代码来实现。在当前代码库中解决这些问题需要进行巨大且有风险的重构,几乎等于重写。

同时,因为隐式地耦合了很多内部模块和放哪儿都不合适的孤岛代码,我们因此累积了很多技术债。这使得单独去理解代码中的一部分变得很困难,并且我们发现很多贡献者不敢轻易对代码做改动。重写提供了一次机会,让我们带着这些问题重新思考代码的组织。

最初的原型阶段

我们在 2018 年末开始 Vue 3 的原型工作,最初目标是为了验证上面这些问题的解决方案。在这个阶段,我们主要专注于为以后的开发工作构建一个坚实的基础。

转换到 Typescript

Vue 2 最初是用普通的 ES 写的。在原型阶段后不久,我们意识到类型系统对于这种体量的项目是非常有帮助的。类型检查可以极大地减少重构中不小心引入的 bug,并且可以帮助贡献者们更有信心地去改动代码。我们使用了 Facebook 的 Flow 类型检查,因为它可以被渐进地添加到已经存在的普通的 ES 项目中。Flow 确实带来了一定程度的帮助,但并没有我们希望的那么多。尤其是,它持续的不兼容更新使得升级非常痛苦。并且相比于 Typescript 和 VSCode 的深度整合,它对开发环境的支持并不理想。

我们也注意到越来越多的用户在一起使用 Vue 和 Typescript。为了支持他们的使用场景,我们必须单独撰写和维护 Typescript 的类型声明文件,而同时源码却在使用另一种类型系统。转换到 Typescript 后,我们可以自动生成类型声明文件,减轻了维护的负担。

解耦内部模块

我们同时也采用了 monorepo 的方式通过各个内部模块来组成框架。每个模块都有它们自己的 API,类型定义,和测试。我们希望模块之间的依赖更显式清晰,方便开发者阅读、理解以及修改它们。这是我们努力降低项目贡献的门槛以及提高项目长期可维护性的方式。

启用 RFC 过程

2018 年快结束的时候,我们有了可以工作的原型,实现了新的响应式系统和虚拟 DOM 渲染器。我们已经验证了我们想实现的内部架构改善,但公共 API 的修改只有粗略的草稿。是时候开始对它们进行具体的设计了。

我们知道我们必须及早地并且小心地做这件事。Vue 的大规模使用意味着不兼容变更会导致用户巨大的迁移成本以及可能的生态割裂。为了确保用户能够对不兼容变更提供反馈,我们在 2019 年初启用了 RFC (征求意见)过程。每个 RFC 都会遵循一个模版,模板中有各个区块分别聚焦于动机、设计细节,利弊权衡,以及采用策略。因为这个过程是通过向 github 仓库提交相关建议 PR 推进的,讨论很自然地在评论区中展开。

RFC 过程证明了非常有用,它提供了一个思考框架来强制我们全面地考虑一个潜在变更的各个方面。并且允许我们的社区参与设计过程以及提交经过深思熟虑的功能请求。

更快更小

性能对于前端框架是至关重要的。虽然 Vue 2 以极具竞争力的性能自傲,但重写提供了一个机会通过实验新的渲染策略把性能优化做得更好。

突破虚拟 DOM 的瓶颈

Vue 有一个相当独特的渲染策略:它提供了类似 HTML 的模板语法,但会把模板编译成渲染函数返回虚拟 DOM 树。框架通过递归比较两个虚拟 DOM 树中每一个节点上的每一个属性,来决定实际需要更新的 DOM 部分。这种稍显粗暴的算法总的来说还挺快,这得感谢现代 Javascript 引擎做的优化,然而更新过程中还是引入了很多不必要的 CPU 开销。这种低效率在某些情况下特别明显,比如一个模板有大量的静态内容和少量的动态绑定——却依然要递归整个虚拟 DOM 来确定哪个地方更新了。

幸运的是,我们可以在模板编译阶段对模板做静态分析,并且提取动态部分的相关信息。Vue 2 通过跳过静态子树某种程度做了相关的事情,但更进一步的优化因为受限于过于简单的编译器架构而无法实现。在 Vue 3 中,我们重写了编译器,实现了适合的抽象语法树转换过程,允许我们组合转换插件来对编译时做优化。

有了新的架构,我们需要找到一种渲染策略尽可能地减少开销。一种选择是舍弃虚拟 DOM,直接生成命令式的 DOM 操作,但这样会失去直接写虚拟 DOM 渲染函数的能力,而这个能力对高级用户和库开发者是非常有价值的。同时这样做也会带来巨大的不兼容变更。

另一个好的选择是避免不必要的虚拟 DOM 树的遍历以及属性比较,它们是更新过程中最大的性能开销。为了实现这一点,编译器和运行时需要一起配合工作:编译器分析模板,生成带有优化相关提示的代码,然后运行时根据这些提示尽可能选择最快的更新路径。以下是三个主要的行之有效的优化:

第一,在树的层面,我们发现如果没有动态修改节点结构的模板指令(比如 v-if 和 v-for),节点结构就能保持完全静态。如果我们以这些结构性指令为切分点把模板分割成嵌套的“区块”,每一个区块中的节点结构就变成了完全静态的。当我们更新某个区块中的节点,我们不再需要递归遍历树——区块中的动态绑定可以在这个扁平的数组中被跟踪。这个优化通过数量级程度地减少树遍历来避免大量的虚拟 DOM 开销。

第二,编译器尽可能地检测模板中的静态节点,子树,甚至数据对象,并在生成的代码中把它们提升到渲染函数之外。这样避免了在每次渲染中重复创建这些对象,极大改善了内存使用,降低了垃圾回收的频率。

第三,在元素层面,编译器针对每个有动态绑定的元素,根据它需要实现的更新类型,生成相应的优化标识。举个例子,一个带有动态 class 绑定和一些静态属性的元素会收到一个标识表示只需要检查 class 更新就可以了。运行时会根据这些提示选择最快的更新路径。

综合以上的优化技术,显著地提高了渲染更新的 benchmark 数据,Vue 3 经常只需要 Vue 2 十分之一不到的 CPU 运行时间。

减少打包体积

框架的体积大小也会影响它的性能。这是 web 应用中独特的需要考虑的地方,因为资源需要在应用被访问的时候去下载,并且只有等到浏览器下载且解析完必需的 Javascript,应用才能进行交互。单页应用尤其如此。虽然 Vue 一直相对轻量——Vue 2 运行时大概 23KB gzipped——我们还是发现了两个问题:

第一,不是所有用户都需要使用框架的所有功能。举个例子,一个应用从来不用 transition 功能却仍然需要下载和解析 transition 相关的代码。

第二,框架随着我们添加新功能会无限地成长。这导致我们在增加新功能的时候会权衡体积增加的性价比。结果是,我们趋向于只包含被大多数用户使用的功能。

理想的情况是,用户在构建时应该能够舍弃掉框架中没有使用到的功能的相关代码——也就是所谓的“tree shaking”——只为他们需要的付出开销就可以了。这也允许我们发布一些对部分用户有用的功能,同时不给其他用户新增负担。

在 Vue 3 中,我们通过把大多数的全局 API 和内部的 helper 作为 ES 模块导出来实现这一点。这样就能允许现代的打包工具对模块的依赖做静态分析,并且舍弃没有使用到的代码。模板编译器同样会生成 tree-shaking 友好的代码,只有模板中实际使用的功能才会在生成的代码中导入相应的 helper。

框架中的某些部分没办法被 tree-shaken,因为它们对所有类型的应用都是必需的。我们称这些不可或缺的部分的体积叫基准大小。Vue 3 的基准大小大概是 10KB gzipped——比 Vue 2 的一半还少,尽管增加了大量的新功能。

解决扩展性需求

我们也想提升 Vue 处理大体量应用的能力。Vue 初始的设计聚焦于使用的低门槛和温和的学习曲线。但随着 Vue 被更广泛地使用,我们收到越来越多包含数百个模块和需要几十个开发长期维护的项目的需求。对于这些类型的项目,像 Typescript 这样的类型系统以及能够清晰地组织可复用代码的能力是非常重要的,但 Vue 2 对这些方面的支持并不是很理想。

在设计 Vue 3 的早期阶段,我们尝试提供内置支持用类写组件来提高 Typescript 的整合程度。这样做的挑战在于,我们想让类组件更可用而依赖的相关语言特性,比如 class field 和 decorator,仍然在提议阶段——在成为 Javascript 标准的一部分之前依然可能变动。这其中涉及到的复杂性和不确定性让我们思考添加 Class API 是否真的恰当,因为它除了提供更好的 Typescript 整合外并无其他。

我们决定调研其他解决可扩展性问题的方案。受到 React Hooks 的启发,我们考虑通过暴露更底层的响应式和组件生命周期 API 来提供更自由的方式写组件逻辑,也即 Composition API。与设置一长串的选项来定义组件不同,Composition API 允许用户自由地表达,组合,复用带状态的组件逻辑,就像写函数一样,同时提供极佳的 Typescript 支持。

对于这个想法我们真的非常激动。虽然 Composition API 是设计用于处理某一类具体问题的,但可以仅仅用它就能实现组件的开发。在提案的第一版草稿中,我们走得过于超前,暗示我们可能会用 Composition API 来替换已经存在的 Options API。这导致了来自社区成员的巨大反对。这件事给我们上了宝贵的一课,提醒我们需要清晰地沟通长期计划和意图,同时深入理解用户的需求。收到社会的反馈后,我们完全重写了提案,清楚地确定了 Composition API 会是新增的功能,是对 Options API 的补充。修改后的提案得到了社区更积极正面的接纳,并且收到了许多建设性的建议。

寻求平衡

在 Vue 的超过一百万的开发者群体中,有仅仅懂 HTML/CSS 基础的初学者,有从 jQuery 过渡过来的职业开发者,有从其他框架转移过来的老手,有寻找前端解决方案的后端工程师,也有为了解决软件扩展性的软件架构师。开发者画像的多样性对应的是用户使用场景的多样性:一些开发者只是想在遗存的应用中添加一些交互,另外一些开发者想快速搭建一次性的项目而不用太担心维护成本;架构师可能要处理大体量、持续多年的项目,以及在项目生命周期中不稳定的团队成员构成。

Vue 的设计持续被这些不同的需求形塑,因为我们想在各种利弊权衡中保持平衡。Vue 的口号是“渐进式框架”,包含的分层 API 设计就来自于这个过程。初学者能够享受平滑的学习曲线,因为只需要一个 CDN 脚本,基于 HTML 的模板,以及符合直觉的 Options API。同时,专家们能够通过全能的 CLI,渲染函数以及 Composition API 来实现他更有远大志向的使用场景。

为了实现我们的愿景,还有很多工作需要做。最重要的包括,更新支持的库、文档和工具确保平滑迁移。我们会在未来几个月努力工作,并且迫不及待想要看到社区会用 Vue 3 来创造什么。

Topic:#vue