我从 Vuejs 中学到了什么

脑洞前端

共 12581字,需浏览 26分钟

 ·

2021-10-26 11:25

框架设计远没有大家想的那么简单,并不是说只把功能开发完成,能用就算完事儿了,这里面还是有很多学问的。比如说,我们的框架应该给用户提供哪些构建产物?产物的模块格式如何?当用户没有以预期的方式使用框架时是否应该打印合适的警告信息从而提升更好的开发体验,让用户快速定位问题?开发版本的构建和生产版本的构建有何区别?热跟新(HMR:Hot Module Replacement)需要框架层面的支持才行,我们是否也应该考虑?再有就是当你的框架提供了多个功能,如果用户只需要其中几个功能,那么用户是否可以选择关闭其他功能从而减少资源的打包体积?所有以上这些问题我们都会在本节内容进行讨论。

本节内容需要大家对常用的模块打包工具有一定的使用经验,尤其是 rollup.js 以及 webpack。如果你只用过或了解过其中一个也没关系,因为它们很多概念其实是类似的。如果你没有使用任何模块打包工具那么需要你自行去了解一下,至少有了初步认识之后再来看本节内容会更好一些。

提升用户的开发体验

衡量一个框架是否足够优秀的指标之一就是看它的开发体验如何,我们拿 Vue3 举个例子:

createApp(App).mount('#not-exist')

当我们创建一个 Vue 应用并试图将其挂载到一个不存在的 DOM 节点时就会得到一个警告信息:

warn

从这条信息中我们得知挂载失败了,并说明了失败的原因:Vue 根据我们提供的选择器无法找到相应的 DOM 元素(返回 null),正式因为这条信息的存在使得我们能够清晰且快速的了解并定位问题,可以试想一下如果 Vue 内部不做任何处理,那么很可能得到的是一个 JS 层面的错误信息,例如:Uncaught TypeError: Cannot read property 'xxx' of null,但是根据此信息我们很难知道问题出在哪里。

所以在框架设计和开发的过程中,提供友好的警告信息是至关重要的,如果这一点做得不好那么很可能经常收到用户的抱怨。始终提供友好的警告信息不仅能够快速帮助用户定位问题,节省用户的时间,还能够为框架收获良好的口碑,让用户认为你是非常专业的。

在 Vue 的源码中,你经常能够看到 warn() 函数的调用,例如上面图片中的信息就是由这句 warn() 函数调用打印的:

warn(
  `Failed to mount app: mount target selector "${container}" returned null.`
)

对于 warn() 函数来说,由于它需要尽可能的提供有用的信息,因此它需要收集当前发生错误的组件的组件栈信息,所以如果你去看源码你会发现有些复杂,但其实最终就是调用了 console.warn() 函数。

对于开发体验来说,除了提供必要的警告信息,还有很多其他方面可以作为切入口,可以进一步提升用户的开发体验。例如在 Vue3 中当我们在控制台打印一个 Ref 数据时:

const count = ref(0)
console.log(count)

打开控制台查看输出,如下图所示:

没有任何处理的输出

可以发现非常的不直观,当然我们可以直接打印 count.value ,这样就只会输出 0,但是有没有办法在打印 count 的时候让输出的信息更有好呢?当然可以,浏览允许我们编写自定义的 formatter,从而自定义输出的形式。在 Vue 的源码中你可以搜索到名为 initCustomFormatter 的函数,这个函数就是用来在开发环境下初始化自定义 formatter 的,以 chrome 为例我们可以打开 devtool 的设置,然后勾选 Console -> Enable custom formatters

然后刷新浏览器后查看控制台,会发现输出的内容变得非常直观:

控制框架代码的体积

框架的大小也是衡量框架的标准之一,在实现同样功能的情况下当然是用越少的代码越好,这样体积就会越小,最后浏览器加载资源的时间也就越少。这时我们不禁会想,提供越完善的警告信息就意味着我们要编写更多的代码,这不是与控制代码体积相驳吗?没错,所以我们要想办法解决这个问题。

如果我们去看 Vue 的源码会发现,每一个 warn() 函数的调用都会配合 __DEV__ 常量的检查,例如:

if (__DEV__ && !res) {
  warn(
    `Failed to mount app: mount target selector "${container}" returned null.`
  )
}

可以看到,打印警告信息的前提是:__DEV__ 这个常量一定要为真,这里的 __DEV__ 常量就是达到目的的关键。

Vue 使用的是 rollup.js 对项目进行构建的,这里的 __DEV__ 常量实际上是通过 rollup 的配置来预定义的,其功能类似于 webpack 中的 DefinePlugin 插件。

Vue 在输出资源的时候,会输出两个版本的资源,其中一个资源用于开发环境,如 vue.global.js ;另一个与其对应的用于生产环境,如:vue.global.prod.js ,通过文件名称我们也能够区分。

当 Vue 构建用于开发环境的资源时,会把 __DEV__ 常量设置为 true,这时上面那段输出警告信息的代码就等价于:

if (true && !res) {
  warn(
    `Failed to mount app: mount target selector "${container}" returned null.`
  )
}

可以看到这里的 __DEV__ 被替换成了字面量 true ,所以这段代码在开发环境是肯定存在的。

当 Vue 构建用于生产环境的资源时,会把 __DEV__ 常量设置为 false,这时上面那段输出警告信息的代码就等价于:

if (false && !res) {
  warn(
    `Failed to mount app: mount target selector "${container}" returned null.`
  )
}

可以看到 __DEV__ 常量被替换为字面量 false ,这时我们发现这段分支代码永远都不会执行,因为判断条件始终为假,这段永远不会执行的代码被称为 Dead Code,它不会出现在最终的产物中,在构建资源的时候就会被移除,因此在 vue.global.prod.js 中是不会存在这段代码的。

这样我们就做到了在开发环境为用户提供友好的警告信息的同时,还不会增加生产环境代码的体积

框架要做到良好的 Tree-Shaking

上文中我们提到通过构建工具设置预定义的常量 __DEV__ ,就能够做到在生产环境使得框架不包含打印警告信息的代码,从而使得框架自身的代码量变少。但是从用户的角度来看,这么做仍然不够,还是拿 Vue 来举个例子,我们知道 Vue 提供了内置的组件例如 ,如果我们的项目中根本就没有使用到该组件,那么 组件的代码需要包含在我们项目最终的构建资源中吗?答案是当然不需要,那如何做到这一点呢?这就不得不提到本节的主角 Tree-Shaking

那什么是 Tree-Shaking 呢?在前端领域这个概念因 rollup 而普及,简单的说所谓 **Tree-Shaking **指的就是消除哪些永远不会执行的代码,也就是排除 dead-code,现在无论是 rollup 还是 webpack 都支持 Tree-Shaking

想要实现 Tree-Shaking 必须满足一个条件,即模块必须是 ES Module,因为 Tree-Shaking 依赖 ESM 的静态结构。我们使用 rollup 通过一个简单的例子看看 Tree-Shaking 如何工作,我们 demo 的目录结构如下:

├── demo
│   └── package.json
│   └── input.js
│   └── utils.js

首先安装 rollup

yarn add rollup -D # 或者 npm install rollup -D

下面是 input.jsutils.js 文件的内容:

// input.js
import { foo } from './utils.js'
foo()
// utils.js
export function foo(obj{
  obj && obj.foo
}
export function bar(obj{
  obj && obj.bar
}

代码很简单,我们在 utils.js 文件中定义并导出了两个函数,分别是 foobar,然后在 input.js 中导入了 foo 函数并执行,注意我们并没有导入 bar 函数。

接着我们执行如下命令使用 rollup 构建:

npx rollup input.js -f esm -o bundle.js

这句命令的意思是以 input.js 文件问入口,输出 ESM 模块,输出的文件名叫做 bundle.js 。命令执行成功后,我们打开 bundle.js 来查看一下它的内容:

// bundle.js
function foo(obj{
  obj && obj.foo
}
foo();

可以看到,其中并不包含 bar 函数,这说明 Tree-Shaking 起了作用,由于我们并没有使用 bar 函数,因此它作为 dead-code 被删除了。但是如果我们仔细观察会发现,foo 函数的执行也没啥意义呀,就是读取了对象的值,所以它执行还是不执行也没有本质的区别呀,所以即使把这段代码删了,也对我们的应用没啥影响,那为什么 rollup 不把这段代码也作为 dead-code 移除呢?

这就涉及到 Tree-Shaking 中的第二个关键点,即副作用。如果一个函数调用会产生副作用,那么就不能将其移除。什么是副作用?简单地说副作用的意思是当调用函数的时候,会对外部产生影响,例如修改了全局变量。这时你可能会说,上面的代码明显是读取对象的值怎么会产生副作用呢?其实是有可能的,想想一下如果 obj 对象是一个通过 Proxy 创建的代理对象那么当我们读取对象属性时就会触发 Getter ,在 Getter 中是可能产生副作用的,例如我们在 Getter 中修改了某个全局变量。而到底会不会产生副作用,这个只有代码真正运行的时候才能知道, JS 本身是动态语言,想要静态的分析哪些代码是 dead-code 是一件很有难度的事儿,上面只是举了一个简单的例子。

正因为静态分析 JS 代码很困难,所以诸如 rollup 等这类工具都会给我提供一个机制,让我们有能力明确的告诉 rollup :”放心吧,这段代码不会产生副作用,你可以放心移除它“,那具体怎么做呢?如下代码所示,我们修改 input.js 文件:

import {foo} from './utils'

/*#__PURE__*/ foo()

注意这段注释代码 /*#__PURE_*_/,该注释的作用就是用来告诉 rollup 对于 foo() 函数的调用不会产生副作用,你可以放心的对其进行 Tree-Shaking,此时再次执行构建命令并查看 bundle.js 文件你会发现它的内容是空的,这说明 Tree-Shaking 生效了。

基于这个案例大家应该明白的是,在编写框架的时候我们需要合理的使用 /*#__PURE_*_/ 注释,如果你去搜索 Vue 的源码会发现它大量的使用了该注释,例如下面这句:

export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)

也许你会觉得这会不会对编写代码带来很大的心智负担?其实不会,这是因为通常产生副作用的代码都是模块内函数的顶级调用,什么是顶级调用呢?如下代码所示:

foo() // 顶级调用

function bar({
  foo() // 函数内调用
}

可以看到对于顶级调用来说是可能产生副作用的,但对于函数内调用来说只要函数 bar 没有被调用,那么 foo 函数的调用当然不会产生副作用。因此你会发现在 Vue 的源码中,基本都是在一些顶级调用的函数上使用 /*#__PURE__*/ 注释的。当然该注释不仅仅作用与函数,它可以使用在任何语句上,这个注释也不是只有 rollup 才能识别,webpack 以及压缩工具如 terser 都能识别它。

框架应该输出怎样的构建产物

上文中我们提到 Vue 会为开发环境和生产环境输出不同的包,例如 vue.global.js 用于开发环境,它包含了必要的警告信息,而 vue.global.prod.js 用于生产环境,不包含警告信息。实际上 Vue 的构建产物除了有环境上的区分之外,还会根据使用场景的不同而输出其他形式的产物,这一节我们将讨论这些产物的用途以及在构建阶段如何输出这些产物。

不同类型的产物一定是有对应的需求背景的,因此我们从需求讲起。首先我们希望用户可以直接在 html 页面中使用