尤雨溪在今年年初 Vue 3 正式发布之前撰写了这篇长文,详述 Vue 3 的设计过程。前端之巅将全文翻译如下,希望能帮助你更好地了解 Vue 3 背后的故事。
在过去的一年中,Vue 团队一直都在开发 Vue.js 的下一个主要版本,我们希望能在今年上半年发布它(本文完成时这项工作尚在进行)。Vue 新版本的理念成型于 2018 年末,当时 Vue 2 的代码库已经有两岁半了。比起通用软件的生命周期来这好像也没那么久,但在这段时期,前端世界已经今昔非比了。
在我们更新(和重写)Vue 的主要版本时,主要考虑两点因素:首先是新的 JavaScript 语言特性在主流浏览器中的受支持水平;其次是当前代码库中随时间推移而逐渐暴露出来的一些设计和架构问题。
为什么重写利用新的语言特性
随着 ES2015 标准的落地,JavaScript(以前被称为 ECMAScript,缩写为 ES)获得了诸多重大改进,同时主流浏览器也终于开始对这些新特性提供良好的支持了。其中的一些特性使我们能够大幅提升 Vue 的能力。
这里面最值得一提的就是
Proxy ,它为框架提供了拦截针对对象的操作的能力。Vue 的一项核心特性就是监听用户定义状态的变化,并响应式更新 DOM。Vue 2 是通过替换状态对象属性的 getter 和 setter 来实现这种响应能力的。转向 Proxy 后,我们就能解决 Vue 当下存在的诸多局限(比如无法检测新增属性等),还能提供更好的性能。
但 Proxy 是一个原生的语言特性,无法在老式浏览器中提供完整的 polyfill。为此我们需要改动新版框架的浏览器支持范围——这是一项破坏性变更,只有新的主要版本才能实现。
解决架构问题
在现有代码库上修复这些问题需要大量高风险的重构工作,几乎等同于重写了。
在维护 Vue 2 的过程中,我们积累的很多问题受限于现有的架构是很难解决的。例如,模板编译器的写法使我们很难实现良好的源映射支持。另外,虽然 Vue 2 技术上支持构建以非 DOM 平台为目标的高级渲染器,但为了实现这一支持,我们需要 fork 代码库,还得复制一大堆代码。在现有代码库上修复这些问题需要大量高风险的重构工作,几乎已经等同于重写了。
同时,我们在很多内部模块与看起来无处可去的零散代码之间生成了很多隐藏的耦合关系,结果积累了不少技术债。现在我们很难单独理解代码库中某一部分的含义,而且我们也注意到贡献者们很少有信心做出突破性的更改。通过重写,我们得以基于这些问题重新思考代码的组织方式。
早期的原型阶段
我们是从 2018 年末开始创建 Vue 3 的原型的,主要目标是验证针对上述问题的解决方案。在这一阶段,我们主要是为后续的开发工作打下牢固的基础。
转向 TypeScript
Vue 2 最初是用纯粹的 ES 编写的。原型阶段开始后不久,我们意识到对于这么大规模的项目来说,类型系统会非常有用。类型检查可以大幅降低在重构中引入意外 bug 的几率,也能提升贡献者在做出突破性更改时的信心。我们采用了 Facebook 的
Flow 类型检查器,因为它可以渐进添加到一个现有的纯 ES 项目中。Flow 起了一定作用,但我们的收益不及预期;特别是它的重大更改太多了,升级起来相当痛苦。它对集成开发环境的支持也不如 TypeScript 与 VS Code 的深度集成水平。
我们还注意到越来越多的用户在结合使用 Vue 和 TypeScript。为了支持他们的使用场景,我们需要在源码之外单独编写和维护一套 TypeScript 声明,其使用了另一套类型系统。转向 TypeScript 后,我们就能自动生成声明文件,降低维护成本。解耦内部包
我们还采用了一个单体仓库方案,其中框架是由众多内部包组成的,每个包都有自己独立的 API、类型定义和测试用例。我们想让各个模块间的依赖关系更明显,让开发人员更容易阅读、理解和修改所有这些依赖项。这是我们降低项目贡献门槛,提升其长期可维护性的关键举措。
制定 RFC 流程
2018 年末,我们有了一个带有新的响应系统和虚拟 DOM 渲染器的原型。我们验证了计划中的内部架构优化,但只是粗略起草了面向外部的 API 更改想法。现在该将这些想法转变为具体的设计了。
我们知道这一步要在早期谨慎进行。Vue 的广泛流行意味着重大更改可能会给用户带来巨大的迁移成本,还可能让生态碎片化。为了让用户对重大更改提交反馈,我们在 2019 年初制定了一套 RFC(征求意见)
流程。所有 RFC 都有一个模板,包括动机、设计细节、权衡以及采用策略等内容。这套流程的实现形式,是在一个 Github 仓库上将提案提交成拉取请求,这样自然就可以在评论中讨论提案了。
结果表明这个 RFC 流程非常有用。作为一个思维框架,它强制我们全面考虑一个潜在更改的所有层面,并让整个社区可以参与到设计过程中,并提交经过充分思考的特性需求。
更快,更小
前端框架的性能至关重要。
前端框架的性能至关重要。尽管 Vue 2 已经提供了颇具竞争力的性能表现,但这次重写让我们有机会试验新的渲染策略来进一步提升性能。
突破虚拟 DOM 的瓶颈
Vue 有一套独特的渲染策略:它提供了一个类 HTML 的模板语法,但将模板编译成了一个返回虚拟 DOM 树的渲染函数。框架会递归遍历两个虚拟 DOM 树,对比每个节点的所有属性来判断该更新 DOM 的哪些部分。这种相对暴力的算法一般还是很快的,这要感谢现代 JS 引擎实现的那么多高级优化措施。但是更新过程还是会涉及很多不必要的 CPU 工作。当你的模板存在大量静态内容,却只有少量动态绑定时,更新的效率就会显得尤为低下——还是要递归遍历整个虚拟 DOM 树,才能找出要更新的部分。
所幸模板编译这一步让我们可以对模板进行静态分析,并提取动态部分的信息。Vue 2 跳过了静态子树,在一定程度上做到了这一点;但是由于过度简化的编译器架构,更高级的优化就很难实现了。在 Vue 3 中我们重写了编译器,加入了一个合适的 AST transform 管道,让我们能以 transform 插件的形式进行编译时优化。
现在有了新的架构,我们想要找到一个尽可能减少额外开销的渲染策略。一个选项是抛弃虚拟 DOM 并直接生成命令式 DOM 操作,但这会失去直接编写虚拟 DOM 渲染函数的能力,我们发现这是对于高级用户和库作者们非常有价值的能力。此外,这也会是一个影响巨大的重大更改。
接下来的选项就是摆脱不必要的虚拟 DOM 树遍历和属性对比,这也是更新过程中性能开销最大的部分。为此,编译器和运行时需要协同工作:编译器分析模板,生成带有优化线索的代码,而运行时获取线索并选择最快路径。这里有三大优化工作:
首先,在树级别,我们注意到没有动态调整节点结构的模板指令(如 v-if 和 v-for)时,节点的结构完全保持静态。如果我们将模板根据这些结构化指令拆分为一些嵌套 ” 块 “,每一个块中的节点结构也会保持静态。当我们更新一个块中的节点时,就不必再递归遍历整个树了——块内的动态绑定可以在一个平面数组里追踪。这一优化极大减少了需要遍历的树的数量,规避了大部分虚拟 DOM 树开销。
其次,编译器会激进检测模板中的静态节点、子树甚至数据对象,并在生成的代码中将它们提取到渲染函数之外。这就可以避免在每次渲染时重新创建这些对象,大幅减少了内存占用,并减少了垃圾收集的频率。
最后,在元素级别,编译器会为每一个有动态绑定的元素,根据其需要进行的更新类型生成一个优化标志。比如说一个元素有一个动态的 class 绑定和一些静态属性,它会获得一个标志,表示这里只需要进行 class 检查。运行时会获取这些标志,然后选择最快的路径。
CPU 时间:是指 JavaScript 运算所消耗的时间,不包括浏览器 DOM 操作所用的时间。
结合这些优化,我们的渲染更新速度获得了显著改进,在某些场景下 Vue 3 的CPU 时间仅为 Vue 2 的十分之一不到。缩小包体积
框架的大小也会影响其性能。这是 Web 应用程序特有的现象,因为资产需要在线下载,而应用需要等到浏览器解析完必要的 JavaScript 代码后才能开始交互。单页面应用程序在这方面的矛盾尤为明显。尽管 Vue 一直以来都是相对轻量级的框架——Vue 2 的运行时大小为 23KB(gzip 压缩后),我们还是注意到了两个问题:
首先,不是所有人都需要框架的全部功能。例如,从来不需要过渡特性的应用还是需要下载和解析相关代码。
另外,我们在不断给框架增加新特性,框架也在不断变大,没有止境。这样我们在权衡新特性的利弊时,就得非常在意包大小这个权重。结果,我们会倾向于只加入那些大多数用户都会用到的特性。
理想状态下,用户可以在构建时去掉框架中自己不需要的特性(也就是 ” 摇树优化 “),只保留自己用到的特性。这样我们在添加只有部分用户会用到的特性时,并不会给其他用户增添应用体积的负担。
在 Vue 3 中,我们把大多数全局 API 和内部 helper 移到了 ES 模块导出中,从而实现了这个目标。这样现代的打包器就可以静态分析模块依赖项,并去掉与未使用导出相关的代码。模板编译器也会生成适合摇树优化的代码,只会对模板确实用到的特性导入 helper。
框架的有些部分是永远无法摇树优化的,因为它们对于所有应用类型来说都很重要。我们将这部分无法舍弃的代码的体积称作基线大小。虽然 Vue 3 增加了很多新特性,但其基线大小只有大约 10KB(gzip 后),不到 Vue 2 的一半。
满足扩展需求
我们还想改善 Vue 应对大规模应用程序的能力。我们最初设计 Vue 时主要想的是降低入门门槛并平滑学习曲线。但随着 Vue 愈加流行,我们也看到了越来越多的项目需求随着时间推移不断扩大,后期甚至包含数以百计的模块,需要几十名开发人员来维护。对于这种类型的项目,TypeScript 这样的类型系统和可以提供组织清晰、易于复用的代码的能力是必不可少的,但 Vue 2 在这些方面的支持水平不甚理想。
在 Vue 3 的早期设计阶段,我们尝试内置对使用 class 编写组件的支持,从而更好地整合 TypeScript。这里的问题在于,为了让 class 可用而需要的很多语言特性(例如 class fields 和 decorators)都还处在提案阶段,有可能在正式版中出现变化。随之而来的复杂性和不确定性让我们开始质疑 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 script、基于 HTML 的模板以及直观的 Options API 顺利学习入门。而专家可以通过全功能的 CLI、渲染函数以及 Composition API 来处理复杂需求。
要实现我们的愿景还有很多工作要做,其中最重要的就是更新支持库、文档和工具,以保证平滑的迁移。我们会在未来的几个月中继续努力,而且我们迫不及待想要看到社区能用 Vue 3 创造怎样的精彩了。