转载声明:本文翻译自尤雨溪微博链接原文,主要介绍了Vue 3的设计过程。对原文感兴趣的可查看原文(英文版):The process: Making 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社区将会创造什么了。