最近在阅读霍春阳的《Vue.js设计与实现》,记录了其中一些重点内容,刚读第一遍有些章节还是有些难以理解,多读几遍就会有不一样的收获~~
vue是一个声明式、运行时+编译时的框架, 那么它为什么要采用这种方式?
1. 权衡的艺术
“ 框架设计里到处都体现了权衡的艺术。”
在深入讨论 Vue.js 3 各个模块的实现思路和细节之前,我认为有必要先来讨论视图层框架设计方面的内容。为什么呢?这是因为当我们设计一个框架的时候,框架本身的各个模块之间并不是相互独立的,而是相互关联、相互制约的。
同样作为学习者,我们在学习框架的时候,也应该从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。
1.1命令式和声明式
从范式上来看,视图层框架通常分为命令式和声明式。
命令式:早年间流行的 jQuery 就是典型的命令式框架。命令式框架的一大特点就是关注过程,每一步都有确定的执行步骤。
javascript"> $('#app') // 获取 div.text('hello world') // 设置文本内容.on('click', () => { alert('ok') }) // 绑定点击事件
声明式:更加关注结果,至于实现该“结果”的过程,则是由 Vue.js 帮我们完成的。换句话说,Vue.js 帮我们封装了过程。因此,我们能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
<div @click="() => alert('ok')">hello world</div>
性能与可维护性的权衡:
命令式代码的性能 > 声明式代码的性能
如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B。
命令式代码的更新性能消耗 = A
javascript">div.textContent = 'hello vue3' // 直接修改
声明式代码的更新性能消耗 = B + A
<!-- 之前: -->
<div @click="() => alert('ok')">hello world</div>
<!-- 之后: -->
<div @click="() => alert('ok')">hello vue3</div>
👻Vue.js 要选择声明式的设计方案呢?
原因就在于声明式代码的可维护性更强。
在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。
虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。
1.2 运行时和编译时
纯运行的框架:它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后Render 函数会根据该对象递归地将数据渲染成 DOM 元素
我们规定树型结构的数据对象如下:
javascript">01 const obj = {
02 tag: 'div',
03 children: [
04 { tag: 'span', children: 'hello world' }
05 ]
06 }
每个对象都有两个属性:tag 代表标签名称,children 既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点)
Render函数:
javascript">01 function Render(obj, root) {
02 const el = document.createElement(obj.tag)
03 if (typeof obj.children === 'string') {
04 const text = document.createTextNode(obj.children)
05 el.appendChild(text)
06 } else if (obj.children) {
07 // 数组,递归调用 Render,使用 el 作为 root 参数
08 obj.children.forEach((child) => Render(child, el))
09 }
10
11 // 将元素添加到 root
12 root.appendChild(el)
13 }
缺点:手写树型结构的数据对象太麻烦了,而且不直观。那么如何可以用类似于 HTML 标签的方式描述树型结构的数据对象呢?
纯编译的框架:编写了一个叫作 Compiler 的程序,把 HTML 字符串 —> 树型结构的数据对象
运行时 + 编译时的框架:先编写一个叫作 Compiler的程序,它的作用就是把 HTML 字符串编译成树型结构的数据,再调用Render函数转为dom元素。
javascript"> const html = `<div><span>hello world</span></div>
`// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用 Render 进行渲染Render(obj, document.body)
它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。
2. 框架设计的核心要素
2.1 提升用户的开发体验
始终提供友好的警告信息不仅能够帮助用户快速定位问题,节省用户的时间,还能够让框架收获良好的口碑,让用户认可框架的专业性。
javascript"> createApp(App).mount('#not-exist')
当我们创建一个 Vue.js 应用并试图将其挂载到一个不存在的 DOM 节点时,就会收到一条警告信息。
在 Vue.js 的源码中,我们经常能够看到warn 函数的调用:
javascript"> warn(`Failed to mount app: mount target selector "${container}" returned null.`)
2.2 控制框架代码的体积
如果我们去看 Vue.js 3 的源码,就会发现每一个 warn
函数的调用都会配合__DEV__
常量的检查
Vue.js 使用 rollup.js 对项目进行构建,这里的 DEV 常量实际上是通过 rollup.js的插件配置来预定义的,其功能类似于webpack 中的 DefinePlugin 插件。
Vue.js 在输出资源的时候,会输出两个版本,其中一个用于开发环境,如vue.global.js,另一个用于生产环境,如vue.global.prod.js,通过文件名我们也能够区分。
当 Vue.js 构建用于开发环境的资源时,会把 DEV 常量设置为 true,生产环境为 false。
javascript"> if (__DEV__ && !res) {warn(`Failed to mount app: mount target selector "${container}" returned null.`)}
这样我们就做到了只在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积。
2.3 框架要做到良好的 Tree-Shaking
简单地说,Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,现在无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。
想要实现 Tree-Shaking,必须满足一个条件,即模块必须是 ESM(ES Module),因为 Tree-Shaking 依赖 ESM 的静态结构。
如果一个函数调用会产生副作用,那么就不能将其移除。
什么是副作用?
简单地说,副作用就是,当调用函数的时候会对外部产生影响,例如修改了全局变量、修改输入变量等。
注意注释代码 /*#__PURE__*/
,其作用就是告诉 rollup.js该函数是一个纯函数(即给定相同的输入总是返回相同的输出,并且没有副作用),对函数的调用不会产生副作用,你可以放心地对其进行Tree-Shaking。
javascript">export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)
2.4 框架应该输出怎样的构建产物
Vue.js 会为开发环境和生产环境输出不同的包,例如 vue.global.js 用于开发环境,它包含必要的警告信息,而vue.global.prod.js 用于生产环境,不包含警告信息。
实际上,Vue.js 的构建产物除了有环境上的区分之外,还会根据使用场景的不同而输出其他形式的产物。
1.首先我们希望用户可以直接在 HTML 页面中使用 <script>
标签引入框架并使用:
<body><script src="/path/to/vue.js"></script><script>javascript">var Vue = (function(exports){// ...exports.createApp = createApp;// ...return exports}({})) const { createApp } = Vue// ...</script></body>
为了实现这个需求,我们需要输出一种叫作 IIFE 格式的资源(立即执行函数)
在 rollup.js 中,我们可以通过配置format: ‘iife’ 来输出这种形式的资源
2. 在主流浏览器对原生 ESM 的支持都不错,所以用户除了能够使用 <script>
标签引用 IIFE 格式的资源外,还可以直接引入 ESM 格式的资源
javascript"> <script type="module" src="/path/to/vue.esm-browser.js"></script>
为了输出 ESM 格式的资源,rollup.js 的输出格式需要配置为:format: ‘esm’。
当我们构建提供给打包工具的 ESM 格式的资源时,要使用 (process.env.NODE_ENV !=='production')
替换 DEV 常量。
ESM 格式的资源有两种:用于浏览器的esm-browser.js 和用于打包工具的 esm-bundler.js。
它们的区别在于对预定义常量__DEV__ 的处理,前者直接将 DEV 常量替换为字面量 true 或 false,后者则将 DEV 常量替换为 process.env.NODE_ENV !== ‘production’ 语句。
3.Node.js 中通过 require 语句引用资源(在服务端渲染时)
在 Node.js 环境中,资源的模块格式应该是 CommonJS,简称 cjs。为了能够输出 cjs 模块的资源,我们可以通过修改 rollup.config.js 的配置 format:‘cjs’
javascript">01 // rollup.config.js
02 const config = {
03 input: 'input.js',
04 output: {
05 file: 'output.js',
06 format: 'cjs' // 指定模块形式
07 }
08 }
09
10 export default config
2.5 特性开关
在设计框架时,框架会给用户提供诸多特性(或功能),例如我们提供 A、B、C 三个特性给用户,同时还提供了 a、b、c 三个对应的特性开关,用户可以通过设置a、b、c 为 true 或 false 来代表开启或关闭对应的特性,这将会带来很多益处。
对于用户关闭的特性,我们可以利用Tree-Shaking 机制让其不包含在最终的资源中。
如果明确知道自己只会使用组合式 API(Composition API),而不会使用选项对象式 API(Options API),可以通过配置关闭选项对象式 API 的相关特性来减少打包体积。
如何关闭选项对象式 API?
Vite: 默认支持 Tree Shaking,会自动移除未使用的代码。如果你只使用组合式 API,Vite 会自动优化掉选项对象式 API 的相关代码。
Webpack:也支持 Tree Shaking。确保你的 package.json 中有 “sideEffects”: false,并且没有显式导入选项对象式 API 的相关模块。
那怎么实现特性开关呢?其实很简单,原理和上文提到的 DEV 常量一样,本质上是利用 rollup.js 的预定义常量插件来实现。拿 Vue.js 3 源码中的一段 rollup.js 配置来说:
javascript">01 {
02 __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
03 }
javascript">01 // support for 2.x options
02 if (__FEATURE_OPTIONS_API__) {
03 currentInstance = instance
04 pauseTracking()
05 applyOptions(instance, Component)
06 resetTracking()
07 currentInstance = null
08 }
用于打包中:
javascript">01 // support for 2.x options
02 if (__VUE_OPTIONS_API__) { // 注意这里
03 currentInstance = instance
04 pauseTracking()
05 applyOptions(instance, Component)
06 resetTracking()
07 currentInstance = null
08 }·
VUE_OPTIONS_API 是一个特性开关,用户可以通过设置__VUE_OPTIONS_API__ 预定义常量的值来控制是否要包含这段代码
2.6 错误处理
提供统一的错误处理接口
javascript">01 // utils.js
02 let handleError = null
03 export default {
04 foo(fn) {
05 callWithErrorHandling(fn)
06 },
07 // 用户可以调用该函数注册统一的错误处理函数
08 registerErrorHandler(fn) {
09 handleError = fn
10 }
11 }
12 function callWithErrorHandling(fn) {
13 try {
14 fn && fn()
15 } catch (e) {
16 // 将捕获到的错误传递给用户的错误处理程序
17 handleError(e)
18 }
19 }
供了 registerErrorHandler
函数,用户可以使用它注册错误处理程序,然后在callWithErrorHandling
函数内部捕获错误后,把错误传递给用户注册的错误处理程序
2.7 良好的 TypeScript 类型支持
TS 的好处有很多,如代码即文档、编辑器自动提示、一定程度上能够避免低级bug、代码的可维护性更强等。