原文链接:The process: Making Vue 3 by EVAN YOU

重写 Vue.js 的下一个主要版本的经验教训

在过去的一年中,Vue 团队一直在研究 Vue.js 的下一个主要版本,我们希望在2020年上半年发布该版本。(在撰写本文时,这项工作仍在进行中)。 Vue 的主要版本于2018年底形成,当时 Vue 2 的代码库已有两年半的历史了。在通用软件的生命周期中听起来可能并不长,但在此期间,前端环境发生了巨大变化。

有两个主要的考虑因素使我们开发了 Vue 的新主要版本(并重写了它):首先,主流浏览器普遍提供了新的 JavaScript 语言功能。其次,随着时间的推移,当前代码库中的设计和体系结构问题已经暴露出来。

为什么要改写

利用新的语言功能

随着 ES2015 的标准化,JavaScript(正式称为 ECMAScript,缩写为 ES )获得了重大改进,主流浏览器终于​​开始为这些新功能提供不错的支持。特别是一些为我们提供了极大提高 Vue 功能的机会。

其中最值得注意的是 Proxy,它允许框架拦截对象上的操作。Vue 的核心功能是能够侦听对用户定义状态所做的更改并以反应方式更新 DOM 的能力。Vue 2 通过使用 getter 和 setter 替换状态对象上的属性来实现这种反应性。切换到代理将使我们消除 Vue 的现有限制,例如无法检测到新的属性添加并提供更好的性能。

但是,代理是本机语言功能,不能在旧版浏览器中完全填充。为了利用它,我们知道我们必须调整框架的浏览器支持范围,这是一个重大突破,只能在新的主要版本中发布。

解决架构问题

要在当前的代码库中解决这些问题,将需要进行大量风险较大的重构,这几乎等同于 rewrite。

在维护 Vue 2 的过程中,由于现有架构的局限性,我们积累了许多难以解决的问题。例如,模板编译器的编写方式使适当的源映射支持非常具有挑战性。同样,虽然 Vue 2 从技术上允许构建针对非DOM平台的更高级别的渲染器,但我们必须派生代码库并复制大量代码,才能实现这一点。要在当前的代码库中解决这些问题,将需要进行大量风险较大的重构,这几乎等同于 rewrite 。

同时,我们以各种模块的内部与浮动代码(似乎不属于任何地方)之间的隐式耦合的形式积累了技术债务。这使得孤立地理解代码库的一部分变得更加困难,并且我们注意到,贡献者很少会对进行重要的更改充满信心。重写将使我们有机会牢记这些注意事项来重新考虑代码组织。

初始原型阶段

我们于2018年底开始对 Vue 3 进行原型设计,其初步目标是验证这些问题的解决方案。在此阶段,我们主要致力于为进一步发展奠定坚实的基础。

切换到TYPESCRIPT

Vue 2 最初是用纯 ES 编写的。在原型开发阶段之后不久,我们意识到类型系统对于这种规模的项目将非常有帮助。类型检查极大地减少了在重构期间引入意外错误的机会,并有助于贡献者更自信地进行重要的更改。我们通过Facebook的流量类型检查,因为它可以逐步添加到现有的平纹ES项目。流量在一定程度上有所帮助,但是我们没有从希望中获得太多收益。特别是不断变化的变化使升级变得很痛苦。与 TypeScript 与 Visual Studio Code 的深度集成相比,对集成开发环境的支持也不理想。

我们还注意到,用户越来越多地同时使用 Vue 和 TypeScript。为了支持它们的用例,我们必须与使用不同类型系统的源代码分开创作和维护TypeScript 声明。切换到 TypeScript 将使我们能够自动生成声明文件,从而减轻了维护负担。

解耦内部封装

我们还采用了 monorepo 设置,其中的框架由内部软件包组成,每个内部软件包都具有自己的单独 API,类型定义和测试。我们希望使这些模块之间的依赖关系更加明确,从而使开发人员更容易阅读,理解并进行所有更改。这是我们努力降低项目贡献壁垒并提高其长期可维护性的关键。

设置RFC流程

到2018年底,我们有了一个使用新的反应性系统和虚拟 DOM 渲染器的工作原型。我们已经验证了我们想要进行的内部体系结构改进,但是只包含了面向公众的 API 更改的草稿。现在是将它们变成具体设计的时候了。

我们知道我们必须尽早而仔细地做到这一点。Vue 的广泛使用意味着突破性变化可能导致用户大量迁移成本和潜在的生态系统碎片化。为了确保用户能够提供有关重大更改的反馈,我们在2019年初采用了 RFC(征求意见)流程。每个RFC遵循一个模板,其中侧重于动机,设计细节,权衡和采用策略。由于此过程是在 GitHub 存储库中进行的,提案是作为请求请求提交的,因此讨论会在注释中自然展开。

该RFC的过程已经证明了极大的帮助,作为一个思想框架,它迫使我们要充分考虑潜在变化的方方面面,让我们的社区参与设计过程,并提交深思熟虑出来的功能要求。

更快更小

性能对于前端框架至关重要。

性能对于前端框架至关重要。尽管 Vue 2 具有出色的性能,但通过尝试新的渲染策略,重写提供了进一步发展的机会。

克服虚拟DOM的瓶颈

Vue 有一个相当独特的呈现策略:它提供类似于 HTML 的模板语法,但将模板编译为可返回虚拟 DOM 树的呈现函数。该框架通过递归遍历两个虚拟 DOM 树并比较每个节点上的每个属性来确定实际 DOM 的哪些部分需要更新。由于现代 JavaScript 引擎执行了高级优化,因此这种有点蛮力的算法通常很快,但是更新仍然涉及许多不必要的CPU工作。当您查看包含大量静态内容且只有少量动态绑定(整个虚拟 DOM)的模板时,效率低下尤其明显仍然需要递归地行走到树上,以了解发生了什么变化。

幸运的是,模板编译步骤使我们有机会对模板进行静态分析并提取有关动态零件的信息。Vue 2 通过跳过静态子树在某种程度上做到了这一点,但是由于过于简单的编译器体系结构,难以实施更高级的优化。在 Vue 3 中,我们使用适当的 AST 转换管道重写了编译器,这使我们能够以转换插件的形式编写编译时优化。

有了新的体系结构,我们希望找到一种渲染策略,以尽可能减少开销。一种选择是放弃虚拟 DOM 并直接生成命令式 DOM 操作,但这将消除直接编写虚拟 DOM 渲染功能的能力,我们发现这对高级用户和库作者非常有价值。另外,这将是一个巨大的突破性变化。

其次,最好的方法是消除不必要的虚拟 DOM 树遍历和属性比较,这在更新过程中往往会带来最大的性能开销。为了实现这一点,编译器和运行时需要协同工作:编译器分析模板并生成带有优化提示的代码,而运行时将拾取提示并在可能的情况下采用快速路径。这里有三个主要的优化工作:

首先,在树的层面上,我们注意到,节点结构在没有模板指令动态改变节点结构的留完全静态的(例如,v-if 和 v-for)。如果我们将模板分为嵌套由这些结构指令分隔的「块」,每个块内的节点结构再次变得完全静态。当我们更新一个块内的节点时,我们不再需要递归遍历树-可以在平面数组中跟踪该块内的动态绑定。通过将我们需要执行的树遍历量减少一个数量级,此优化可避免虚拟 DOM 的大部分开销。

其次,编译器会主动检测模板中的静态节点,子树甚至数据对象,并将其提升到生成代码中的 render 函数之外。这样可以避免在每个渲染上重新创建这些对象,从而大大提高了内存使用率并减少了垃圾回收的频率。

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

这些技术结合在一起,大大改善了我们的渲染更新基准,Vue 3 的 CPU time 有时不到 Vue 2 的十分之一。

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

最小化包装尺寸

框架的大小也会影响其性能。这是Web应用程序的唯一关注点,因为需要动态下载资产,并且在浏览器解析必要的 JavaScript 之前,该应用程序将是交互式的。对于单页应用程序尤其如此。尽管Vue一直是相对轻量级的( Vue 2 的运行时大小压缩为 23KB),但我们注意到了两个问题:

首先,并不是每个人都使用框架的所有功能。例如,从未使用过渡功能的应用仍需支付与过渡相关的代码的下载和解析费用。

其次,当我们添加新功能时,该框架会无限期地增长。当我们考虑新功能添加的折衷时,这使束的尺寸不成比例。因此,我们倾向于仅包含将由大多数用户使用的功能。

理想情况下,用户应该能够在构建时删除未使用的框架功能的代码-也称为「摇摇欲坠」 -只为他们使用的东西付费。这也将使我们能够发布一部分用户会觉得有用的功能,而不会增加其余用户的有效负载成本。

在 Vue 3 中,我们通过将大多数全局 API 和内部帮助程序移至ES模块导出来实现了这一目标。这使现代的捆绑器可以静态分析模块依赖性并删除与未使用的导出相关的代码。模板编译器还会生成摇树友好的代码,如果该功能实际上在模板中使用,则该代码仅导入该功能的帮助程序。

框架的某些部分永远不会摇晃,因为它们对于任何类型的应用程序都是必不可少的。我们将这些必不可少的部分的度量标准称为基准尺寸。尽管增加了许多新功能,但 Vue 3 的基准大小大约压缩了 10KB ,不到 Vue 2 的一半。

满足规模需求

我们还想提高 Vue 处理大型应用程序的能力。我们最初的 Vue 设计着重于降低进入障碍和温和的学习曲线。但是随着 Vue 越来越广泛地被采用,我们了解了更多有关项目需求的信息,这些项目包含数百个模块,并且随着时间的流逝由数十名开发人员维护。对于这些类型的项目,像 TypeScript 这样的类型系统以及干净组织可重用代码的能力至关重要,而 Vue 2 在这些领域的支持并不理想。

在设计 Vue 3 的早期阶段,我们尝试通过提供对使用类编写组件的内置支持来改善 TypeScript 集成。挑战在于,我们需要使类可用的许多语言功能(如类字段和装饰器)仍然是提案,并且在正式加入 JavaScript 之前可能会发生变化。涉及的复杂性和不确定性使我们怀疑添加 Class API 是否真的合理,因为它除了提供更好的 TypeScript 集成之外没有提供任何其他功能。

我们决定研究其他解决扩展问题的方法。受 React Hooks 的启发,我们考虑过公开较低级别的反应性和组件生命周期 API,以实现一种更自由形式的编写组件逻辑的方式,称为 Composition API。无需通过指定一长串选项来定义组件,Composition API 允许用户像编写函数一样自由地表达,编写和重用有状态组件逻辑,同时还提供了出色的 TypeScript 支持。

我们对这个想法感到非常兴奋。尽管 Composition API 旨在解决特定类别的问题,但从技术上讲,仅在编写组件时才可以使用它。在该提案的初稿中,我们有所领先,并暗示我们可能会在将来的版本中将现有的 Options API 替换为 Composition API。这导致社区成员的大量反击,这为我们提供了宝贵的课程,可以使他们清楚地传达长期计划和意图,以及了解用户的需求。在听取了我们社区的反馈之后,我们对提案进行了完全的重新设计,从而明确表明 Composition API 将是对 Options 的补充和补充 API。收到修改后的提案更加积极,并收到了许多建设性的建议。

寻求平衡

开发人员资料的多样性与用例的多样性相对应

在Vue超过一百万的开发人员中,只有 HTML/CSS 的基础知识的初学者,从 jQuery 迁移的专业人员,从另一个框架迁移的退伍军人,正在寻找前端解决方案的后端工程师以及大规模处理软件的软件架构师。开发人员配置文件的多样性与用例的多样性相对应:一些开发人员可能希望在旧版应用程序上增加交互性,而另一些开发人员可能需要快速周转但维护需求有限的一次性项目。在项目的整个生命周期中,建筑师可能不得不处理大型的,多年期的项目和一个波动的开发团队。

在我们寻求平衡各种折衷方案的同时,Vue 的设计不断受到这些需求的影响和启发。Vue 的口号,「渐进框架」封装了由此过程产生的分层 API 设计。初学者可以使用 CDN 脚本,基于 HTML 的模板和直观的 Options API 来轻松学习,而专家可以使用功能齐全的 CLI,渲染功能和 Composition API 来处理雄心勃勃的用例。

要实现我们的愿景,还有许多工作要做-最重要的是,更新支持库,文档和工具以确保顺利迁移。在接下来的几个月中,我们将继续努力,我们迫不及待地想看看社区将通过 Vue 3 创造什么。