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 提供了内置的组件例如 <Transition> ,如果我们的项目中根本就没有使用到该组件,那么 <Transition> 组件的代码需要包含在我们项目最终的构建资源中吗?答案是当然不需要,那如何做到这一点呢?这就不得不提到本节的主角 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.js 和 utils.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 文件中定义并导出了两个函数,分别是 foo 和 bar,然后在 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 页面中使用 <script> 标签引入框架并使用:
为了能够实现这个需求,我们就需要输出一种叫做 IIFE 格式的资源,IIFE 的全称是 Immediately Invoked Function Expression ,即”立即调用的函数表达式“,可以很容易的用 JS 来表达:
(function () {
// …
}())
如上代码所示,这就是一个立即执行的函数表达式。实际上 vue.globale.js 文件就是 IIFE 形式的资源,大家可以看一下它的代码结构:
var Vue = (function(exports){
// …
exports.createApp = createApp;
// …
return exports
}({}))
这样当我们使用 <script> 标签直接引入 vue.global.js 文件后,那么全局变量 Vue 就是可用的了。
在 rollup 中我们可以通过配置 format: 'iife' 来实现输出这种形式的资源:
// rollup.config.js
const config = {
input: ‘input.js’,
output: {
file: ‘output.js’,
format: ‘iife’ // 指定模块形式
}
}
export default config
不过随着技术的发展和浏览器的支持,现在主流浏览器对原生 ESM 模块的支持都不错,所以用户除了能够使用 <script> 标签引用 IIFE 格式的资源外,还可以直接引如 ESM 格式的资源,例如 Vue3 会输出 vue.esm-browser.js 文件,用户可以直接用 <script> 标签引入:
为了输出 ESM 格式的资源就需要我们配置 rollup 的输出格式为:format: 'esm'。
你可能已经注意到了,为什么 vue.esm-browser.js 文件中会有 -browser 字样,其实对于 ESM 格式的资源来说,Vue 还会输出一个 vue.esm-bundler.js 文件,其中 -browser 变成了 -bundler。为什么这么做呢?我们知道无论是 rollup 还是 webpack 在寻找资源时,如果 package.json 中存在 module 字段,那么会优先使用 module 字段指向的资源来代替 main 字段所指向的资源。我们可以打开 Vue 源码中的 packages/vue/package.json 文件看一下:
{
“main”: “index.js”,
“module”: “dist/vue.runtime.esm-bundler.js”,
}
其中 module 字段指向的是 vue.runtime.esm-bundler.js 文件,意思就是说如果你的项目是使用 webpack 构建的,那你使用的 Vue 资源就是 vue.runtime.esm-bundler.js ,也就是说带有 -bundler 字样的 ESM 资源是给 rollup 或 webpack 等打包工具使用的,而带有 -browser 字样的 ESM 资源是直接给 <script type="module"> 去使用的。
那他们之间的区别是什么呢?那这就不得不提到上文中的 __DEV__ 常量,当构建用于 <script> 标签的 ESM 资源时,如果是用于开发环境,那么 __DEV__ 会设置为 true;如果是用于生产环境,那么 __DEV__ 常量会被设置为 false ,从而被 Tree-Shaking 移除。但是当我们构建提供给打包工具的 ESM 格式的资源时,我们不能直接把 __DEV__ 设置为 true 或 false,而是使用 (process.env.NODE_ENV !== 'production') 替换掉 __DEV__ 常量。例如下面的源码:
if (DEV) {
warn(useCssModule() is not supported in the global build.)
}
在带有 -bundler 字样的资源中会变成:
if ((process.env.NODE_ENV !== ‘production’)) {
warn(useCssModule() is not supported in the global build.)
}
这样用户侧的 webpack 配置可以自己决定构建资源的目标环境,但是最终的效果其实是一样的,这段代码也只会出现在开发环境。
用户除了可以直接使用 <script> 标签引入资源,我们还希望用户可以在 Node.js 中通过 require 语句引用资源,例如:
const Vue = require(‘vue’)
为什么会有这种需求呢?答案是服务端渲染,当服务端渲染时 Vue 的代码是运行在 Node.js 环境的,而非浏览器环境,在 Node.js 环境下资源的模块格式应该是 CommonJS ,简称 cjs。为了能够输出 cjs 模块的资源,我们可以修改 rollup 的配置:format: 'cjs' 来实现:
// rollup.config.js
const config = {
input: ‘input.js’,
output: {
file: ‘output.js’,
format: ‘cjs’ // 指定模块形式
}
}
export default config
特性开关
在设计框架时,框架会提供诸多特性(或功能)给用户,例如我们提供 A、B、C 三个特性给用户,同时呢我们还提供了 a、b、c 三个对应的特性开关,用户可以通过设置 a、b、c 为 true 和 false 来代表开启和关闭,那么将会带来很多收益:
1. 对于用户关闭的特性,我们可以利用 Tree-Shaking 机制让其不包含在最终的资源中。
2. 该机制为框架设计带来了灵活性,可以通过特性开关任意为框架添加新的特性而不用担心用不到这些特性的用户侧资源体积变大,同时当框架升级时,我们也可以通过特性开关来支持遗留的 API,这样新的用户可以选择不适用遗留的 API,从而做到用户侧资源最小化。
那怎么实现特性开关呢?其实很简单,原理和上文提到的 __DEV__ 常量一样,本质是利用 rollup 的预定义常量插件来实现,那一段 Vue3 的 rollup 配置来看:
{
FEATURE_OPTIONS_API: isBundlerESMBuild ? __VUE_OPTIONS_API__ : true,
}
其中 __FEATURE_OPTIONS_API__ 类似于 __DEV__,我们可以在 Vue3 的源码中搜索,可以找到很多类似如下代码这样的判断分支:
// support for 2.x options
if (FEATURE_OPTIONS_API) {
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}
当 Vue 构建资源时,如果构建的资源是用于给打包工具使用的话(即带有 -bundler 字样的资源),那么上面代码在资源中会变成:
// support for 2.x options
if (VUE_OPTIONS_API) { // 这一这里
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}
其中 __VUE_OPTIONS_API__ 就是一个特性开关,用户侧就可以通过设置 __VUE_OPTIONS_API__ 来控制是否包含这段代码。通常用户可以使用 webpack.DefinePlugin 插件实现:
// webpack.DefinePlugin 插件配置
new webpack.DefinePlugin({
VUE_OPTIONS_API: JSON.stringify(true) // 开启特性
})
最后再来详细解释一下 __VUE_OPTIONS_API__ 开关是干嘛用的,在 Vue2 中我们编写的组件叫做组件选项 API:
export default {
data() {}, // data 选项
computed: {}, // computed 选项
//  其他选项…
}
但是在 Vue3 中,更推荐使用 Composition API 来编写代码,例如:
export default {
setup() {
const count = ref(0)
const doubleCount = computed(() => count.value * 2) // 相当于 Vue2 中的 computed 选项
}
}
但是为了兼容 Vue2,在 Vue3 中仍然可以使用选项 API 的方式编写代码,但是对于明确知道自己不会使用选项 API 的用户来说,它们就可以选择使用 __VUE_OPTIONS_API__ 开关来关闭该特性,这样在打包的时候 Vue 的这部分代码就不会包含在最终的资源中,从而减小资源体积。
错误处理
错误处理是开发框架的过程中非常重要的环节,框架的错误处理做的好坏能够直接决定用户应用程序的健壮性,同时还决定了用户开发应用时处理错误的心智负担。
为了让大家对错误处理的重要性有更加直观的感受,我们从一个小例子说起。假设我们开发了一个工具模块,代码如下:
// utils.js
export default {
foo(fn) {
fn && fn()
}
}
该模块导出一个对象,其中 foo 属性是一个函数,接收一个回调函数作为参数,调用 foo 函数时会执行回调函数,在用户侧使用时:
import utils from ‘utils.js’
utils.foo(() => {
// …
})
大家思考一下如果用户提供的回调函数在执行的时候出错了怎么办?此时有两个办法,其一是让用户自行处理,这需要用户自己去 try...catch:
import utils from ‘utils.js’
utils.foo(() => {