05_第_2_章_框架设计的核心要素
约 18593 字大约 62 分钟
2026-10-05
开场白:框架设计不只是把功能做出来
框架设计比你想象得复杂 —— 并不是说把功能开发完、能跑就算大功告成了。
打个比方:开一家奶茶店,你以为把奶茶做出来就完事了?
还得想:
- 用什么杯子装?(构建产物)
- 顾客不按套路点单怎么办?(错误处理)
- 怎么让人一眼看出哪杯是少糖的?(开发体验)
- 用不到的配料能不能不放?(特性开关)
同样,框架设计也要考虑这些:
- 应该给用户提供哪些构建产物?产物的模块格式如何?
- 用户没按预期方式使用框架时,要不要打印警告信息帮他们定位问题?
- 开发版和生产版构建有什么区别?
- 热更新(hot module replacement,HMR)需不需要框架层面的支持?
- 框架提供了多个功能,用户只需要其中几个时,能不能关闭其他功能来减少打包体积?
这些问题都要在框架设计阶段考虑周全。
先认识几个关键词
上面这些问题的答案,都指向几个后面会反复出现的词。
热更新(hot module replacement,HMR):
你改一行代码,浏览器不用整页刷新,只把“变化的那一块”换掉。
打个比方:你调一个按钮的颜色,保存的瞬间页面上那个按钮当场变色,页面不会闪一下,你已经登录的账号也不会被踢出去。
模块打包工具(bundler,rollup.js 和 webpack 都属于这一类):
一个专门负责“收代码”的程序。
本章讲的 __DEV__ 替换、Tree-Shaking 都发生在它身上。它主要干三件事:
- 收集:从入口文件出发,顺着
import把所有用到的文件搜罗到一起 - 摇树:把“其实没人用”的代码摇掉(2.3 节讲这个)
- 压缩:把注释、空格、换行统统删掉,把长变量名换成
a、b、c这种极短符号,让体积再小一截
生活中的比喻:打包工具就像搬家的货车。你的项目是一堆散装纸箱(一个个源文件),货车先照着清单(依赖关系)把该搬的搬上车,再把“你一年没用过的那箱”(dead code)退回仓库,最后把车上每只箱子捆到最紧 —— 送到用户浏览器门口的东西就又快又小了。
压缩(minify / minification):
上面第 3 步干的事。压缩工具(本章后面会提到 terser)只删掉“给人看的部分”,不改变程序的行为。
⚠️ 压缩 ≠ Tree-Shaking。压缩是把“啰嗦的写法”变短;Tree-Shaking 是把“永远不会被执行”整段删掉。一个函数哪怕被压缩成了一行,只要它还有可能被用上,压缩工具就一定留着它 —— 删错了程序就跑不起来。
阅读前提:学习本章时,希望大家对 rollup.js 或 webpack 这类模块打包工具有基本使用经验。如果你只了解过其中一个也没关系,因为它们的很多概念是相通的。如果完全没用过,建议先了解一下“模块打包”的基本概念再来读本章。
2.1 提升用户的开发体验
一个衡量框架的“硬指标”
衡量一个框架是否足够优秀的指标之一,就是看它的开发体验做得怎么样。
拿 Vue.js 3 举个例子:
挂载(mount):
把框架渲染出来的内容“贴”到 HTML 页面的某个位置上。
第一次贴上去叫挂载;之后数据变了再重新贴一次,就叫更新(update)。
DOM(Document Object Model,文档对象模型):
浏览器把整份 HTML 文档在内存里表示成一棵“树”,每个标签都是这棵树上的一个节点,JS 可以通过它创建、修改、删除页面上的内容。
打个比方:HTML 文件是图纸,DOM 是照着图纸盖出来的实物楼,你在控制台打印出来的就是这棵“树”。
createApp(App).mount('#not-exist')这一行看上去只有一个调用,其实里面连做了好几件事,我们一句一句拆开看:
createApp(App):创建一个 Vue 应用。createApp读作“create app”(创建应用),App是你写好的根组件 —— 一个描述“页面长什么样、数据变了怎么反应”的大对象。它返回一个“应用实例”,你可以理解成“这个应用的管理员”.mount('#not-exist'):mount就是上面说的“挂载”。它把应用渲染出来的内容贴到页面的某个元素里 —— 就像把一张照片贴进相册的某一页'#not-exist':传给mount的选择器(selector),用 CSS 的写法告诉浏览器“我要挂到哪个元素上”。#在 CSS 里表示“按 id 找”,所以它去找id="not-exist"的那个元素- 两段是连着写的:
createApp(App)的返回值被直接当成对象,紧接着对它调用.mount(...)。JS 里这种写法叫链式调用,好处是不用先起个变量名把中间结果存起来
上面这行代码的意图,就是创建一个 Vue 应用,并把它挂载到 id 为 not-exist 的 DOM 节点上。但是页面里压根没有这个节点,会发生什么呢?
Vue.js 会给我们打印一条警告信息:

图 2-1 警告信息
这条信息告诉我们两件事:
- 挂载失败了
- 失败的原因是:根据选择器找不到 DOM 元素(
null)
有了这条信息,我们能快速定位问题:哦,原来是没找到节点。
假如没有警告,会怎样?
试想一下,如果 Vue.js 内部不做任何处理,我们很可能会得到一条 JavaScript 层面的错误:
Uncaught TypeError: Cannot read property 'xxx' of null
看到这条错误,你会怎么想?“哪里出错了?null 是哪个变量?我该怎么修?”
—— 大概率会一头雾水。
所以在框架层面抛出有意义的警告信息非常重要。不仅能帮用户快速定位问题、节省时间,还能让框架收获良好的口碑、让用户觉得这个框架“很专业”。
warn 函数:警告信息的“幕后功臣”
在 Vue.js 源码里,我们经常能看到 warn 函数的调用。图 2-1 那条警告就是由下面这段代码打印出来的:
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)这段代码翻译成中文是:“挂载应用失败:挂载目标选择器 "${container}" 返回了 null。”
我们逐行看:
warn(:调用 Vue 内部的warn函数,打印一条警告(注意不是错误 —— 警告不会让程序停下来,程序会继续跑,只是提醒你有问题)- 反引号包起来的整句话:这是模板字符串(template string,也叫模板字面量),用一对反引号包住。它比普通引号
'...'多两个本事:一是能直接换行,不用拼\n;二是可以在里面插值 ${container}:插值语法,写法是$加花括号。这是运行时的插值 —— 执行到这一行时,JS 才把container当时的值填进去。假设你写的是mount('#app'),最终打印出来就是mount target selector "#app" returned null.—— 用户一眼就知道自己挂错到哪个 id 上了。反过来说,如果这里没有${container},警告里就只剩一句干巴巴的“挂载失败”,用户还得自己回去翻代码找哪里写错了- 结尾的
):warn只接收一个参数(要打印的那句话),所以这个右括号是在给调用收尾
这个 warn 函数其实并不简单,它需要尽可能提供有用的信息,所以它内部会收集当前发生错误的组件栈信息。如果你去看源码会发现挺复杂,但其实最终就是调用了 console.warn 这个浏览器内置函数。
组件栈(component stack):
一串“从根组件到出错组件”的父子关系名单。
报错时把它一起打出来,就相当于寄快递时附上一串详细地址(省 → 市 → 区 → 门牌),你立刻知道是哪一层出了问题;如果只有一句“某个 computed 计算错了”,你就只能满项目瞎找了。
还有一招:自定义控制台输出格式
除了警告信息,还有很多方面可以提升开发体验。比如 Vue.js 3 里,当我们用 ref 创建一个响应式数据,然后在控制台打印它时:
const count = ref(0)
console.log(count)const count = ref(0):创建一个响应式数据。ref是 “reference”(引用)的缩写,它的作用是把一个普通数字0包装成一个“特殊盒子”,盒子里的数字以后能被框架盯着。往盒子里放数用count.value = 1,从盒子里取数也用count.value。之所以要多这一步,背后的道理要到第 4、5 章才讲 —— 你现在只要记住“响应式数据都得带.value”这条规则就行console.log(count):把这个盒子打印到控制台。注意这里打印的是count本身,不是count.value
打开控制台一看,结果如图 2-2 所示:

图 2-2 控制台输出结果
这串东西非常不直观。直接打印 count.value 倒是只有 0,但那需要开发者“记得”要 .value。有没有办法让打印 count 本身就友好呢?
有!浏览器允许我们编写自定义的 formatter(格式化器),从而自定义输出形式。
在 Vue.js 3 的源码里,你可以搜到名为 initCustomFormatter 的函数,它就是用来在开发环境下初始化自定义 formatter 的。
以 Chrome 浏览器为例:
- 打开 DevTools 的设置
- 找到 “Console” → “Enable custom formatters” 选项
- 勾选它

图 2-3 勾选“Console”→“Enable custom formatters”选项
然后刷新浏览器再打开控制台,会发现输出变得非常直观:
图 2-4 直观的输出内容
这种小细节虽然不影响功能,但能让开发体验提升一个档次。一个真正优秀的框架,会在这些看不见的地方下功夫。
2.2 控制框架代码的体积
鱼与熊掌的问题
框架大小也是衡量框架的重要标准 —— 同样的功能,代码越少越好。这样打包体积小,浏览器加载就快。
但这就出现了一个矛盾:
提供越完善的警告信息 → 意味着要写更多代码 → 体积越大
这两件事看起来是相悖的,那该怎么解决?
__DEV__ 常量:开发环境的“开关”
解决方案其实很优雅 —— 用一个预定义常量 __DEV__ 来区分开发环境。
我们看看 Vue.js 的源代码里是怎么用它的:
if (__DEV__ && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}我们逐行看:
if (__DEV__ && !res) {:一个“双重把关”的条件,只有两个条件同时成立才报警__DEV__:用来确认“现在是不是开发环境”。它不是 JS 语言自带的变量,全局根本不存在这个东西 —— 它是 Vue 在构建时凭空造出来的&&:JS 的“逻辑与”运算符,意思是“并且”。它的特点是短路:左边一旦是假值,右边根本不求值!res:!是“取反”的意思,合起来就是“并且res不存在”,也就是“没找到挂载目标节点”warn(...):条件成立时打印警告,用的还是 2.1 那个warn函数- 结尾的
}:这个 if 分支到此结束
意思是:
- 如果当前是开发环境(
__DEV__为true),并且res不存在,就调用warn打印警告 - 如果是生产环境(
__DEV__为false),整个if就直接被移除
⚠️ 这里有个关键点别看漏了:
__DEV__是构建期就被替换掉的常量,产物里根本没有这个变量了。所以写成if (!res && __DEV__)效果完全一样 —— 两种写法经过常量折叠后得到的是同一段代码,最终产物不会有任何差别。Vue 官方源码统一把__DEV__写在条件最前面,那是源码层面的一致性习惯,不是构建产物的差异。
Vue.js 在构建不同环境的资源时,会替换 __DEV__ 常量。这里的 __DEV__ 实际上是通过 rollup.js 的插件配置来预定义的,其功能类似于 webpack 中的 DefinePlugin 插件 —— 它不是一个运行时要读的配置,所以替换完成后它在产物里就彻底消失了。整个过程长这样:
开发环境(vue.global.js):__DEV__ 被替换为字面量 true:
if (true && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}if (true && !res):true && 任何东西恒等于那个“任何东西”,所以这个条件其实就等于!res—— “没找到挂载点才报警”。这段代码被原封不动保留在vue.global.js里,开发时随时能给你提示
生产环境(vue.global.prod.js):__DEV__ 被替换为字面量 false:
if (false && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}if (false && !res):false && 任何东西恒等于false—— 就算res真的不存在,这个if也永远进不去,warn一次都不会被调用
注意:false && anything 永远是 false,所以这段代码永远不会被执行。这种“永远不会被执行的代码”有个专门的名字 —— dead code(死代码)。
死代码(dead code):
看着存在、实际上永远跑不到的代码。
打个比方:商场扶梯旁边立着一块指示牌,写着“六楼电影院由此上”,但扶梯上到三楼就被封死了 —— 牌子看得见、摸得着,却永远用不上,就是死代码。既然用户永远用不到它,留着它就纯属浪费用户的流量,所以构建工具会顺手把它清掉。
构建工具在打包时会自动移除这些 dead code,因此 vue.global.prod.js 里压根不存在这段代码。
所以 __DEV__ 的价值在哪? 关键在于:__DEV__ 不需要在运行时做任何判断。它是一个“构建期常量”,在代码跑起来之前就已经被换成 true 或 false 了。如果改成运行时判断(比如从 process.env 里读一个配置),那这个变量本身、这行判断,就都得留在产物里 —— 省下来的那点体积又还回去了。
一箭双雕
这样我们做到了:
结论:在开发环境中提供友好警告信息的同时,不会增加生产环境的代码体积。
这就是预定义常量的威力!
2.3 框架要做到良好的 Tree-Shaking
为什么还需要 Tree-Shaking?
上一节我们讲了 __DEV__ 常量,可以让生产环境不包含警告信息代码。但是从用户的角度看,这还不够。
举个例子:Vue.js 内建了很多组件,比如 <Transition>。如果你的项目压根没用 <Transition>,那它的代码还需要被打包进最终产物吗?
完全不需要!这就是 Tree-Shaking 登场的时候了。
Tree-Shaking 是什么?
Tree-Shaking:
消除那些永远不会被执行的代码(也就是排除 dead code)。这个概念最早因 rollup.js 而在前端圈普及。
生活中的比喻:它就像秋天把树摇晃一下,把枯黄的叶子(dead code)抖落下来。所以叫“摇树”。现在 rollup.js 和 webpack 都支持 Tree-Shaking。
对上这张图再看一次 2.2 节的 __DEV__:if (false && !res) 那段警告代码之所以能被删掉,正是因为它在打包时就已经被证明“永远进不去”,摇树把干枯的叶子抖落了。<Transition> 靠的是另一条路 —— 它能被摇掉,是因为工具能静态地看出来这个项目一次都没有 import 它,而不是靠 __DEV__ 这种编译时常量。
不过,想要实现 Tree-Shaking,必须满足一个条件:
⚠️ 模块必须是 ESM(ES Module)格式。因为 Tree-Shaking 依赖 ESM 的静态结构(
import和export这种声明式的写法)才能分析出哪些代码没用。
ESM(ECMAScript Module,ES 模块):
浏览器和 Node.js 都能认识的标准模块格式,用 import(引入)和 export(导出)声明“我从哪儿拿什么、我对外提供什么”。
打个比方:它像图书馆的借阅登记册 —— 你要哪本书、哪一章,白纸黑字写清楚,管理员一眼就知道该给你搬哪些书、哪些书可以留在架上。
CommonJS(简称 cjs):
Node.js 早年用的模块格式,写法是 const x = require('xxx') 加 module.exports = x。
打个比方:它像老式的文件夹交接 —— 你把整个文件夹拿走,里面有什么得自己翻。ESM 的 import 一定写在代码最外层、白纸黑字;CommonJS 的 require() 可以藏在 if 里面、可以拼一个变量名进来,工具就分析不出它到底要什么了。
静态结构(static structure):
指“这些声明在代码里是写死的,不随运行情况变化”。
这是 Tree-Shaking 能成立的前提 —— 打包的时候你的代码一行都没运行过,工具只做文本层面的分析。所以只有 import { foo } from './utils.js' 这种写死在代码里的依赖,它才敢摇;如果写成 import(变量) 这种“运行时才知道要什么”的形式,工具就只能全部保守地留下。
用 rollup.js 看 Tree-Shaking 怎么工作
假设我们的项目结构是这样的:
图里四个文件各自的作用都标出来了。其中 package.json 的字段会在 2.4 节展开讲。
先安装 rollup.js:
yarn add rollup -D
# 或者 npm install rollup -Dyarn add rollup -D:把 rollup 装进项目。add是“添加”;-D是--devDependencies的简写,意思是“这是开发时才用的工具,线上不跑它”,所以装进“开发依赖”那一栏,而不是“线上也要的依赖”栏# 或者 npm install rollup -D:#开头的是注释,写给人看的,工具会直接忽略。这一行的意思是“用 npm 也一样”,npm和yarn是两个不同的包管理器,功能基本重合,你用哪个都行
下面是 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
}解释一下:
// input.js/// utils.js:注释,标出这段代码分别属于哪个文件 —— 两个文件的内容被写在了一起给你看,实际项目里它们是两个独立文件utils.js里定义并导出了两个函数:foo和bar。export function foo(obj)的意思是“对外提供一个叫foo的函数”,于是别的文件才能拿到它input.js里只导入了foo并执行,没有用到barimport { foo } from './utils.js':{ foo }叫具名导入(named import),花括号里写明“我要这个文件里的foo”;'./utils.js'是相对路径,./表示“当前文件夹里”obj && obj.foo:一句“安全读取”。意思是“如果obj存在,才去读它的foo属性”。这就是 JS 的短路写法:&&左边一旦是假值,右边根本不求值,这里先记住它不报错就行- 现在请你盯住这组关系:
utils.js提供了foo和bar两个函数,但input.js碰都没碰bar。构建工具接下来要做的事,就是把bar那一坨从产物里拿走
接着执行构建命令:
npx rollup input.js -f esm -o bundle.js这句命令的意思是:以 input.js 为入口,输出 ESM 格式,输出文件名是 bundle.js。
npx:从项目本地把rollup这个命令调用出来跑一次(用你装的那个版本,不去网上再下一个)input.js:不带名字的那个参数,也就是“入口”—— 从这个文件开始收集代码-f esm:-f是--format的简写(format = 格式),esm表示输出成 ESM 格式-o bundle.js:-o是--output的简写,后面跟输出的文件名- 记法:
-f想成“发”什么格式,-o想成“出”到哪个文件
打开 bundle.js 看内容:
// bundle.js
function foo(obj) {
obj && obj.foo
}
foo();// bundle.js:注释,表明下面就是打包产物。产物(bundle)就是 rollup 把所有源文件揉成的那一个文件function foo(obj) { ... }:foo被留下了 —— 因为input.js真的 import 了它,属于“树上还挂着、摇不掉的叶子”foo();:那句调用也被留下了。注意bar整个不见了- 还有个细节:产物里的代码多了一句分号,这是打包工具顺手补的,不影响任何东西
发现没有 bar 函数! 因为我们没有用 bar,所以它被当成 dead code 删掉了。这就是 Tree-Shaking 起作用了。
副作用问题
仔细观察会发现,foo() 这句调用也没什么意义 —— 它只是读取了对象的值,删了也不影响应用。但 rollup.js 并没有把它也删除。
这就涉及到 Tree-Shaking 的第二个关键点 —— 副作用(side effect)。
副作用(side effect):
调用函数时对函数之外的世界产生了影响。比如修改了全局变量、修改了传入的参数、触发了某个事件、发了网络请求……
打个比方:console.log('你好') 返回一个字符串就结束了,什么都没改变 —— 这是无副作用;而 count = count + 1 改掉了外面那个 count,这就是有副作用。
为什么打包工具这么在意副作用? 因为它要删代码,就必须保证“删掉之后程序行为一模一样”。如果一行代码有副作用,那它哪怕结果没人用,也不能删 —— 删了,用户的页面就悄悄坏掉了。所以打包工具在拿不准的时候,一律选择保守地留下。
举个例子:
// 假设 obj 是一个 Proxy 代理对象
obj.foo// 假设 obj 是一个 Proxy 代理对象:注释,声明下面的obj不是普通对象obj.foo:整个程序就这一句 —— 读了一下foo属性,读完就扔在那儿了
Proxy(代理):前言里已经讲过——一个“中间人”对象去代替另一个对象,所有对它的读写都可以被拦截。
打个比方:明星身边的经纪人——你跟经纪人说话,他背后可以过滤、改写、记录,但真正干活的还是明星本人。
夹子 / trap(拦截钩子):
new Proxy(目标对象, { ... }) 的第二个参数里,属性名就是夹子的名字,比如 get、set。读到属性触发 get 夹子,写入属性触发 set 夹子。
读取 obj.foo 看似无害,但如果 obj 是 Proxy,读取属性时会触发它的 get 夹子(trap)。在 get 夹子里完全可能产生副作用 —— 比如偷偷修改某个全局变量。
JavaScript 是一门动态语言,到底会不会产生副作用,只有代码真正运行的时候才能知道。想纯静态分析出“哪些代码是 dead code”非常困难。
所以像 rollup.js 这类工具,都提供了一个机制 —— 让我们自己声明“这段代码没有副作用”。怎么做?看下面:
import {foo} from './utils'
/*#__PURE__*/ foo()import {foo} from './utils':和之前一样,把foo引进来了/*#__PURE__*/ foo():这个注释必须紧挨着它所修饰的那次调用的前面(中间不能插别的东西)。它的作用就是给这次调用贴一张“免检标签”- 这张标签在运行时不起任何作用 ——
foo()照常执行,程序行为一个字都不变。rollup 读它只是为了做判断时多一条依据
看到那个特殊的注释 /*#__PURE__*/ 了吗?它的作用就是告诉 rollup.js:
“放心吧,对 foo 函数的调用不会产生副作用,你可以放心地摇掉它。”
⚠️ 它得“由写代码的人来贴”:工具不敢自己判断,只能把判断的责任交给最清楚这段代码在干嘛的人 —— 也就是写框架的人。贴错了,工具会老老实实相信你,然后在你项目里某处悄悄出问题。
再次构建,你会发现 bundle.js 是空的,说明 Tree-Shaking 生效了。
对比一下两次构建的结果,差别只是多了 /*#__PURE__*/ 这几个字符:
| 产物内容 | 说明 |
|---|---|
function foo(obj){...} foo(); | 没贴标签,工具不确定,只能保守留下 |
| (空文件) | 贴了标签,工具确认可以删,全摇掉了 |
在 Vue.js 3 源码中的应用
如果你去搜 Vue.js 3 的源码,会发现 /*#__PURE__*/ 注释用得非常多,例如:
export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)export const isHTMLTag:对外导出一个常量isHTMLTag,它是个函数,作用是判断一个名字是不是标准的 HTML 标签(比如你手写<div>,Vue 要认出它不是组件)/*#__PURE__*/:贴在那次调用前面,告诉打包工具“这次调用不产生副作用”makeMap(HTML_TAGS):makeMap是“根据一张清单生成一个查表函数”的工具函数,HTML_TAGS是那份 HTML 标签名清单。整句话的意思是“用这份清单生成一个查表函数,然后交给isHTMLTag”。它只是“造个函数出来、存进变量”,不碰外面的任何东西 —— 所以这个承诺是诚实的,框架作者贴得理直气壮
这会不会让写代码变得很麻烦?
其实不会。通常产生副作用的代码都是模块内函数的顶级调用。什么是顶级调用?看例子:
foo() // 顶级调用
function bar() {
foo() // 函数内调用
}foo() // 顶级调用:这个foo()直接写在文件最外层。模块文件只要被 import,这行就立刻执行一次 —— 不管有没有人用它。就像店铺开门的那一刻,卷帘门“哗”地一声就拉上去了,你没法说“我先不营业”function bar() { foo() } // 函数内调用:这个foo()躲在bar的身体里。只要没人调用bar,它就一次都不会执行。打包工具摇bar的时候,会连它肚子里那句foo()一起摇掉。就像一把收起来的伞,你没打开它,它就一直是收着的,不占地方
把两种情况画成一张对比图:
所以 Vue.js 3 源码里基本上都是给顶级调用加这个注释,负担并不大。
小贴士 1:/*#__PURE__*/ 并不只对函数调用有效,任何语句前面都可以贴 小贴士 2:/*#__PURE__*/ 不只 rollup.js 能识别,webpack 和压缩工具(如 terser)也都能识别,所以写一次,三样工具都买账
2.4 框架应该输出怎样的构建产物
一个框架不只输出一个文件
前面我们提到 Vue.js 会为开发环境和生产环境输出不同的包。但实际上,Vue.js 的构建产物远不止这两个。本节我们就来聊聊这些产物分别是什么、各自适合什么场景。
场景 A:用 <script> 标签直接引入
最朴素的需求:用户希望直接在 HTML 页面里用 <script> 标签引入 Vue.js:
<body>
<script src="/path/to/vue.js"></script>
<script>
const { createApp } = Vue
// ...
</script>
</body><script src="...">:引入外部脚本。src后面跟的是文件地址(/path/to/vue.js这里只是个占位写法)。注意这行的位置:它必须先执行完,下面那行才能拿到Vueconst { createApp } = Vue:这叫解构(destructuring),意思是“从Vue这个大盒子里,只把createApp这一样东西取出来”,起一个同名变量。打个比方:Vue 是个塞满了工具的搬家纸箱,你伸手进去只拿createApp这一件工具,不用把整个箱子抱走// ...:注释,表示“其他代码”
⚠️ 注意这只是“取出引用”,不是复制 ——
createApp这个函数在Vue上依然还在
这种用法有个前提:你得能接受“整个 Vue 全都要”。所以为了支持这种用法,我们需要输出一种叫 IIFE 格式的资源。
IIFE(Immediately Invoked Function Expression):
立即调用的函数表达式。
(function () {
// ...
}())这就是一个立即执行的函数。脚本加载完,这个函数立刻就跑完了,期间可以挂一堆变量到全局
为什么要用 IIFE 这种怪写法? 普通 <script> 里写的代码全都挤在同一个全局作用域里,你写 var count 会跟别人的 count 打架。用立即执行的函数包一层,里面的变量就只在这个函数内部有效,出去了谁都碰不到 —— 就像给临时工发一个只能进出一次的工牌通道,进去了在里面随便贴,出来就作废,不会污染公共走廊
Vue.js 的 vue.global.js 就是 IIFE 格式的,它的代码结构长这样:
var Vue = (function(exports){
// ...
exports.createApp = createApp;
// ...
return exports
}({}))var Vue = (function(exports){:左边声明一个全局变量Vue(var意味着它会被挂到全局,谁都能用)。右边是一个立即执行的函数表达式,这个函数的参数叫exports—— 你可以把它理解成“这里准备一个空盒子,专门用来往外装东西”// ...:Vue 的全部源码都在这个函数里面(源码很多,这里省略)exports.createApp = createApp;:往盒子里装东西。注意写法是“盒子.名字 = 值”,而盒子本身没有名字,所以想拿里面的东西只能用“点”来取。createApp里的create是创建、App是应用return exports:把装满东西的盒子交出去。函数体内的var出了函数就作废了,盒子是唯一能把东西带出去的东西}({})):这里有三个括号,缺一不可。{}是调用时传进去的空盒子(所以盒子一开始是空的);{}右边那个()表示“立刻执行”;最外层)闭合整个表达式- 把它连起来读一遍:
var Vue = (准备盒子 → 往里装东西 → 把盒子交出去)(空盒子)。执行完毕,页面上就有了一个现成可用的全局Vue变量
这样,当我们用 <script> 引入 vue.global.js 后,全局变量 Vue 就立刻可用了。
在 rollup.js 里,我们通过 format: 'iife' 来输出这种资源:
// rollup.config.js
const config = {
input: 'input.js',
output: {
file: 'output.js',
format: 'iife' // 指定模块形式
}
}
export default config// rollup.config.js:注释,这是 rollup 的配置文件的名字。config是“配置”的意思,.js是 JS 文件的后缀const config = { ... }:把整份配置写在一个对象里,起名configinput: 'input.js':input是入口 —— 从哪个文件开始收集代码。键: 值这种写法叫对象的“属性”output: { ... }:输出相关配置。跟输入分开写,是为了让你想只改输出时不用动整份配置file: 'output.js':产物叫什么名字format: 'iife':产物用什么格式。这一行是本节的关键:iife就是 rollup 里“IIFE 格式”的配置值export default config:用 ESM 的写法把这份配置默认导出,rollup 启动时会读取它。(export default的意思是“这个模块对外只提供这一个东西”)
现在记住这条规律,后面的两种格式就都能秒懂了:同一份源码,只要把 format 从 'iife' 改成 'esm' 或 'cjs',产物就换一副面孔。
场景 B:用 <script type="module"> 引入 ESM
现在主流浏览器对原生 ESM 的支持都很好。所以用户除了能用普通 <script> 引 IIFE 外,还能直接用 <script type="module"> 引 ESM 格式的资源:
<script type="module" src="/path/to/vue.esm-browser.js"></script>type="module":告诉浏览器“这个脚本是个 ES 模块”。浏览器对它的处理跟普通<script>有两处关键区别:它天生自带defer效果(自动延后到 HTML 解析完再执行,所以不用你操心执行顺序);而且它有独立的作用域(里面定义的变量不会满世界乱跑)- 代价是:ESM 有一套严格的跨域规则(文件必须由服务器正确提供),所以直接双击打开本地 HTML 文件时
type="module"可能不工作 —— 这也是为什么“打包好的 IIFE 版本”至今仍然有市场
在 rollup.js 里配置 format: 'esm' 即可输出这种格式。
你可能注意到 Vue.js 输出的 ESM 资源里,文件名有 -browser 字样(如 vue.esm-browser.js)。其实还有另一个版本:vue.esm-bundler.js,后缀是 -bundler。它们有什么区别?
-browser vs -bundler:ESM 的两套
无论是 rollup.js 还是 webpack,在寻找资源时,如果 package.json 里存在 module 字段,会优先使用 module 字段指向的资源(而不是 main 字段)。打开 Vue.js 源码的 packages/vue/package.json 看:
{
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js",
}"main": "index.js":正门。给不认识 ESM 的老工具(以及直接require的 Node.js 环境)用"module": "dist/vue.runtime.esm-bundler.js":侧门。打包工具会优先走这里
package.json 与模块字段:
一个库(也就是一个文件夹)在发布时会附一张“说明书”,文件名就叫 package.json。别人说“帮我装一下 vue”,装到硬盘上的其实是这张说明书加上一堆代码文件。说明书里必须写清楚“你到底该怎么找到我的代码”,这就需要下面这几个字段。
main(主入口)- 最通用、也是最老的入口字段。CommonJS 风格的
require('vue')走的就是它 - 打个比方:它是这家店的正门,谁都认
- 最通用、也是最老的入口字段。CommonJS 风格的
module(ESM 入口)- 给 rollup / webpack 这类打包工具走的入口。工具优先认它,而不是
main—— 因为它指向的是 ESM 版本,工具能看清里面的依赖,才能摇树 - 打个比方:它是侧门,只有认得 ESM 的客人走
- 给 rollup / webpack 这类打包工具走的入口。工具优先认它,而不是
exports(出口清单,较新的字段)- 本节用不到它,但你在别的库的说明书里可能见到
- 它能把
dist/里的内部文件统统锁上,只放出你想让人用的那几个入口,属于“明确告诉工具哪些能公开”。知道有这么个东西就够了
一句话记法:main 是“老的、通用的大门”,module 是“给打包工具开的、方便摇树的小门”。
module 字段指向的是 vue.runtime.esm-bundler.js。也就是说:
- 带
-bundler字样的 ESM 资源:给 rollup.js / webpack 等打包工具用 - 带
-browser字样的 ESM 资源:直接给<script type="module">用
它们的区别在于对 __DEV__ 常量的处理方式:
对于 -browser 版本:构建时直接把 __DEV__ 替换为字面量 true 或 false。比如源代码里这行:
if (__DEV__) {
warn(`useCssModule() is not supported in the global build.`)
}if (__DEV__) {:又见__DEV__。注意这里是光杆一个判断 —— 只要是开发环境就报警,不看别的条件warn(...):警告内容说“全局构建(global build)不支持useCssModule()”。useCssModule是 CSS Modules 相关的 API,而全局版是拿整份 CSS 的,没有“模块”的概念,所以用不了
在 -browser 的开发版里会变成:
if (true) {
warn(`useCssModule() is not supported in the global build.`)
}if (true) {:__DEV__被定死成true了,所以这等于“永远报警”。注意这里__DEV__的值在打包那一刻就定死了 —— 这份文件不是给你拿去打包的,是直接扔进浏览器跑的,没法再改
对于 -bundler 版本:不能直接把 __DEV__ 替换成字面量 true/false,因为用户的项目还可能有“开发模式”。所以它会把 __DEV__ 替换为 (process.env.NODE_ENV !== 'production'):
if ((process.env.NODE_ENV !== 'production')) {
warn(`useCssModule() is not supported in the global build.`)
}process.env:Node.js 里的一个全局对象,env是 environment(环境)的缩写,专门存环境变量。它的点号写法意味着“这个对象在打包时会被替换成具体值”,所以在浏览器里它不会真的去读什么NODE_ENV:最常用的那个环境变量,值就是'development'(开发)或'production'(生产)。!==是“不等于”((...)):外面多套了一层括号,只是为了“替换进来的是一个表达式”这件事在语法上更清楚,没有别的意思
⚠️ 为什么要多绕这一圈? 因为
-browser版本是你自己拿去用的,环境在打包 Vue 时就定了;而-bundler版本是要进你自己的项目的 —— 同一个 Vue 文件,你本地开发时要带警告传到线上时又不能带。这个“到底是开发还是生产”的决定权,必须留到用户打包自己项目的那一刻才揭晓
这样做的好处是:用户可以通过 webpack 配置自行决定构建资源的目标环境,但最终效果其实一样 —— 这段代码也只会出现在开发环境中。
场景 C:在 Node.js 里用 require 引入
用户还希望能在 Node.js 里这样用:
const Vue = require('vue')require('vue'):把vue这个包拿进来。require是 CommonJS 特有的写法,作用相当于 ESM 的import
⚠️ 注意引号里只有包名,不带任何路径。因为在 Node.js 里,
require会自动去“node_modules”这个文件夹里找 —— 你不需要写require('vue/dist/vue.cjs.js')这种又长又容易写错的路径。这套“按包名找、按说明书(package.json)定位入口”的机制,就是 Node.js 的模块解析规则
为什么要支持这种?因为服务端渲染(SSR)!做 SSR 时,Vue.js 代码是跑在 Node.js 里的,不是浏览器。在 Node.js 里,模块格式是 CommonJS(简称 cjs)。
服务端渲染(Server-Side Rendering,简称 SSR):
普通的网页是“浏览器渲染”的 —— 服务器只递给你一份基本空白的 HTML,页面长什么样,是浏览器拿到 JS 之后再自己算出来的。
SSR 是反过来的:服务器先用 Node.js 跑一遍你的组件,把算好的 HTML 字符串直接吐出来。
打个比方:普通做法等于给你一套图纸,让你自己盖房子;SSR 是别人先把房子盖好了,你推门就住。
为什么 SSR 一定要用 Vue? 因为组件里有大量逻辑(取数据、拼字符串、算条件),这些逻辑必须在服务器上跑一遍,浏览器拿到的已经是成品 HTML 了。框架要能这么用,就必须能被 Node.js 加载 —— 而 Node.js 认的是 CommonJS。
前面说过,CommonJS 的依赖是运行时才决定的,打包工具很难提前看清谁依赖谁 —— 这也是 Tree-Shaking 更偏向 ESM 的原因之一。
在 rollup.js 里通过 format: 'cjs' 输出:
// rollup.config.js
const config = {
input: 'input.js',
output: {
file: 'output.js',
format: 'cjs' // 指定模块形式
}
}
export default config// rollup.config.js:注释,配置文件名,和场景 A 那份是同一个文件const config = { ... }:整份配置写在一个对象里,起名configinput: 'input.js':入口文件,从哪儿开始收集代码output: { ... }:输出相关配置file: 'output.js':产物叫什么名字format: 'cjs':这一行是本节唯一的重点 —— 产物用什么格式。cjs就是 rollup 里“CommonJS 格式”的配置值,它把产物写成require()/module.exports那副样子,Node.js 才认得export default config:用 ESM 的写法把配置默认导出,rollup 启动时读取- 和场景 A 那份配置逐行对比:
input(入口)、output(输出)、file(文件名)三行一模一样,只有最后那行从format: 'iife'变成了format: 'cjs' - 这正是 2.4 节最想让你记住的一点:同一份源码,产出不同格式,往往只差一个配置值。格式是为需求服务的,不是为炫技服务的
UMD(Universal Module Definition,“通用模块定义”):
本章没提它,但你在老项目里偶尔会遇到 —— 一种“两边都能用”的折中格式:既能当 CommonJS 被 require,也能当普通脚本被 <script> 标签直接引。
看懂本节之后你就能推断出它长什么样了:无非是把“CommonJS 那套写法”和“IIFE 那套写法”用判断语句包在一起而已。提到它是为了让你认得出它,不要以为框架设计者想不到更简单的方案 —— 通用和简单,往往不能兼得。
产物总览
| 资源 | 格式 | 用途 |
|---|---|---|
vue.global.js | IIFE | <script> 直接引入 |
vue.esm-browser.js | ESM | <script type="module"> 直接引入 |
vue.esm-bundler.js、vue.runtime.esm-bundler.js | ESM | 给 rollup/webpack 等打包工具(带 runtime 的是不含模板编译器的运行时版本,完整版才把模板编译器一起打进去) |
vue.cjs.js | CommonJS,简称 cjs | 按需把上面的资源构建成 cjs 格式,给 Node.js / 服务端渲染用 |
*.prod.js | 各种 | 生产环境版(不带警告信息) |
把本节三个场景横向摆在一起,“谁该用哪种格式”和“__DEV__ 归谁定”就一目了然了:
| 格式 | 引入方式 | 谁来加载 | __DEV__ 怎么处理 |
|---|---|---|---|
| IIFE | <script src> | 浏览器 | 构建时定死成 true / false |
ESM(-browser) | <script type="module"> | 浏览器原生 | 构建时定死成 true / false |
ESM(-bundler) | rollup / webpack 等打包工具 | 浏览器(经过打包) | 替换为 (process.env.NODE_ENV !== 'production'),决定权留给用户 |
| CommonJS(cjs) | require('vue') | Node.js | 构建时定死成 true / false |
把“谁该用哪个”变成一张关系图,就更一目了然了:
文件名里的 -browser / -bundler 不是随便取的,它就是在提醒你“我该被谁吃”。
一句话记住这张图:你的项目怎么加载 Vue,就决定了 Vue 该给你哪一套产物
2.5 特性开关
什么是特性开关?
框架一般会给用户提供很多特性(功能)。比如我们提供了 A、B、C 三个特性给用户,同时提供了 a、b、c 三个特性开关,用户可以通过把开关设置为 true 或 false,来开启或关闭对应的特性。
这种方式带来两个巨大的好处:
- 对用户:用户关闭的特性,在打包时可以被 Tree-Shaking 机制自动剔除,最终资源体积更小
- 对框架作者:可以放心大胆地往框架里加新特性(新功能),不用担心资源体积变大。框架升级时也可以用开关来支持遗留 API,新用户选择不用遗留 API 时就能减小体积
特性开关(feature flag,也叫“功能开关”):
框架在代码里预先埋好一个开关名字,用它把某项功能的全部代码包起来。框架发布时这个开关默认是打开的,所以谁都能用;但到了打包那一刻,用不用就由用户说了算。
生活中的比喻:特性开关就像是电器的“开关按钮”。空调的“除湿模式”、微波炉的“烧烤模式”,不用的时候就关掉,省电又省空间。
再打个比方:它就像饮料厂出的“全功能试用装”。出厂时所有功能都开着,你拿去用;但如果你只想要基础款,生产线可以拧一个旋钮,把那几项配料从配方里直接去掉,而不是“生产出来再倒掉” —— 瓶子上就是实打实轻了。
怎么实现特性开关?
实现起来很简单,原理和上一节的 __DEV__ 常量完全一样 —— 本质上还是利用 rollup.js 的预定义常量插件。
拿 Vue.js 3 源码里的一段 rollup 配置来举例:
{
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
}{ ... }:一个对象字面量,里面全是“常量名:常量值”的对应关系。rollup 的预定义常量插件读的就是这张表__FEATURE_OPTIONS_API__:常量的名字,意思是“要不要支持选项 API 这项特性”。名字两侧都加下划线,是为了在源码里一眼认出它是“构建期占位符”,而不是某个真实的变量isBundlerESMBuild ? A : B:这是一个三元表达式(也叫条件运算符),语法是条件 ? 成立时取的值 : 不成立时取的值,相当于一个只有两个分支的 if。isBundlerESMBuild表示“这次是不是在构建给打包工具用的 ESM 资源”`__VUE_OPTIONS_API__`(注意它也是反引号,但不是模板字符串,只是源码作者顺手写成了字符串):条件成立时,替换成__VUE_OPTIONS_API__这个名字: true:条件不成立时(比如在构建vue.global.js这种“没有选择余地”的文件),直接替换成true—— 全带上
⚠️ 这里有个特别值得琢磨的设计,慢一点看:为什么给打包工具的版本,要把
__FEATURE_OPTIONS_API__替换成另一个常量名,而不是直接替换成true或false?
因为开关的开关要留给用户。构建 vue.global.js 时,Vue 作者自己就能拍板“要带就全带”;但构建给用户项目用的 vue.runtime.esm-bundler.js 时,这个决定权不在 Vue 这一边,在用户那边 —— 用户本地开发要开着、传到线上要关掉。所以产物里留下的必须是一个“等用户来填的空格”,而不是已经填死的答案。
__FEATURE_OPTIONS_API__ 跟 __DEV__ 一样是个预定义常量。
在 Vue.js 3 源码里搜索,能找到类似下面的判断分支:
// support for 2.x options
if (__FEATURE_OPTIONS_API__) {
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}// support for 2.x options:注释 —— “兼容 2.x 选项的分支”。整段代码就是“怎么用 Vue 2 那套写法”的说明书if (__FEATURE_OPTIONS_API__) {:开关判断。和__DEV__一模一样的套路,区别只在于这个开关归用户管,而不是归 Vue 作者管currentInstance = instance:先把“当前正在处理的组件实例”记到全局变量currentInstance上。这个全局状态是框架内部用来做“拦截”的(后面章节会展开)—— 设置它的目的,是让applyOptions内部那些处理选项的函数知道“我现在正在处理哪个组件”pauseTracking():暂停依赖收集(tracking)。后面的applyOptions只是把选项一个个搬进组件,它读data、computed时,是框架自己在读,不是用户的计算逻辑,不该把这些值登记成“页面渲染依赖它”。打个比方:清洁工打扫时顺手碰了一下水桶,不能因此把水桶登记成“你家的生活必需品”applyOptions(instance, Component):真正干活的那一步 —— 把 Vue 2 那套data()、computed、methods等选项,一个一个应用到组件实例上。Component是你写的组件对象,instance是它的实例resetTracking():处理完,再把依赖收集恢复打开,好让接下来的用户代码正常收集依赖。(reset是“重置”。)currentInstance = null:把“当前实例”清空。这一步别当成可有可无的收尾:不清空的话,后面处理别的组件时就可能误用上一个组件的状态
当 Vue.js 构建给打包工具用的资源(带 -bundler 字样)时,上面的代码会变成:
// support for 2.x options
if (__VUE_OPTIONS_API__) { // 注意这里
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}逐行对一下,你会发现绝大部分行和上面那个代码块一字不差:
// support for 2.x options:还是同一句注释if (__VUE_OPTIONS_API__) { // 注意这里:只有这一行变了。常量名从__FEATURE_OPTIONS_API__(Vue 自己定的构建期名字)换成了__VUE_OPTIONS_API__(留给用户填的空格)currentInstance/pauseTracking()/applyOptions/resetTracking()/currentInstance = null五行:一个字都没改,作用也完全一样(逐行解释见上面那个代码块)
结论:框架作者只写一份源码,靠“替换常量”长出好几副面孔。 替换的是条件,不是逻辑 —— 这也是这类技巧最优雅的地方。
__VUE_OPTIONS_API__ 是个特性开关,用户可以通过设置这个常量的值来控制要不要保留这段代码。
通常用户可以用 webpack.DefinePlugin 插件来配置:
// webpack.DefinePlugin 插件配置
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true) // 开启特性
})// webpack.DefinePlugin 插件配置:注释。webpack是点号写法的命名空间,DefinePlugin是插件的名字new webpack.DefinePlugin({...}):new表示“创建一个插件实例”。DefinePlugin顾名思义就是“定义常量”的插件 —— 它的作用和 rollup 那边一模一样:在打包时做一次文本替换,把源码里那个谁都没定义的__VUE_OPTIONS_API__换成你在这里写的值__VUE_OPTIONS_API__: JSON.stringify(true):键是常量名,值是“替换成的字面量”// 开启特性:注释。想关掉的话,把true改成false就行;那段选项 API 的代码会变成死代码,被摇掉
⚠️ 那为什么大家都习惯写成
JSON.stringify(true)? 因为布尔值其实两种写法等价 —— webpack 官方文档写的是「如果值是字符串,它会被当作代码片段原样插入;如果值不是字符串,它会被直接转成字符串」,所以true和JSON.stringify(true)效果一样,官方示例里就有BROWSER_SUPPORTS_HTML5: true这种直接写的。
真正必须包 JSON.stringify 的是字符串值。假如你写 SERVICE_URL: 'https://api.example.com',这个字符串会被当代码片段插进去,产物里就变成 fetch(https://api.example.com) —— 而 https 是个裸标识符,运行时会报 ReferenceError。这时才必须写 JSON.stringify('https://api.example.com'),让引号一起插进去。
__VUE_OPTIONS_API__ 这个开关是干嘛的?
在 Vue.js 2 里,我们写组件用的是“选项对象式 API”:
export default {
data() {}, // data 选项
computed: {}, // computed 选项
// 其他选项
}export default { ... }:导出一个组件配置对象data() {}:一个叫data的选项,值是个函数。注意它是函数不是普通值 —— 写成函数,是因为每个组件实例都要有一份自己的数据;如果写成普通对象,多个实例会共用同一份数据,页面之间就会互相串味。这里写成空函数,只是占个位computed: {}:一个叫computed的选项,里面放计算属性(由别的数据推导出来的数据)// 其他选项:还有methods、props、watch等等一长串- “选项对象式”的精髓就在这:一个组件的“数据怎么来、算出来的值怎么给、点了按钮干什么”,全都平铺在一个对象的各个选项里
但在 Vue.js 3 里,推荐用的是“组合式 API(Composition API)”:
export default {
setup() {
const count = ref(0)
const doubleCount = computed(() => count.value * 2) // 相当于 Vue.js 2 中的 computed 选项
}
}setup() { ... }:一个叫setup的选项,里面的代码在一个函数里写。这个函数会执行一次,里面声明的变量、函数都是这个组件“自有的”const count = ref(0):组件自己的数据const doubleCount = computed(() => ...):组件自己的计算属性。computed接一个返回值的函数(也就是所谓的 getter),值变了它就重新算一次- 两套写法的差别,一句话就是:“选项式”是把不同种类的东西分开装进不同的抽屉(
data一个抽屉、computed一个抽屉、methods一个抽屉),读一个功能要跨好几个抽屉;“组合式”是把同一个功能要用到的东西写在同一个函数里 —— 要显示doubleCount的话,count和doubleCount就挨在一起,一眼能看全
为了兼容 Vue.js 2 用户,Vue.js 3 仍然支持选项 API。但是如果你的项目只用组合式 API,就可以通过 __VUE_OPTIONS_API__ 开关把选项 API 的代码关掉。这样打包时,那部分代码会被 Tree-Shaking 剔除,最终资源体积更小。
这里就是特性开关最典型的用武之地:如果只有一套 API,关不关都无所谓;正因为框架出于兼容性同时提供了两套,才需要让用户二选一 —— 新项目选了组合式 API,那套选项 API 的实现代码就一个字节都不该进产物
结论:它让用户能“按需取用”,框架作者能“放心堆功能”,双赢。
2.6 错误处理
为什么错误处理这么重要?
错误处理是框架开发中非常重要的环节。框架的错误处理机制做得怎么样,直接决定了:
- 用户应用的健壮性(遇到错误会不会整个崩掉)
- 用户开发时的心智负担(出了错要写多少 try-catch)
一个工具模块的例子
假设我们开发了一个工具模块:
// utils.js
export default {
foo(fn) {
fn && fn()
}
}// utils.js:注释,这是文件名export default { ... }:把一个对象作为默认导出(default export)。别人写import utils from 'utils.js'就能把这个对象整个拿走 —— 对象里有什么,这个模块就对外提供什么foo(fn) { ... }:对象上的一个属性,值是一个方法。方法名是foo,参数是fn。fn本身还是一个函数,这种“把函数当参数传”的做法在 JS 里叫回调函数(callback)- 生活中的比喻:你在餐厅点菜,把“上菜之后要做的事”写在纸条上交给服务员。服务员(
foo)不关心纸条上写了什么,他只负责照着做。那张纸条就是回调函数
- 生活中的比喻:你在餐厅点菜,把“上菜之后要做的事”写在纸条上交给服务员。服务员(
fn && fn():JS 里的短路写法,等价于if (fn) { fn() }—— 意思是“如果用户真的传了个函数进来,才执行它”。为什么这么写?因为用户完全可能图省事直接utils.foo()不传任何东西,框架不能因此崩掉。undefined在 JS 里属于“假值”,&&一看是假值就直接返回,不往下走- 两个
}:先关掉方法,再关掉整个对象
这个模块导出一个对象,其中 foo 是一个函数,接收一个回调函数 fn 作为参数。调用 foo 时会去执行 fn。
用户侧使用时:
import utils from 'utils.js'
utils.foo(() => {
// ...
})import utils from 'utils.js':把工具模块拿进来,起个本地名字utils() => { ... }:这叫箭头函数(arrow function),是“定义一个函数”的最短写法:() => {}等价于function () {}。这里它是一个“不收参数、不返回值”的函数utils.foo(() => {...}):把这个箭头函数交给foo。注意执行的时机 —— 现在还没跑!要等到foo内部执行fn()时,这段代码才会真正跑一遍。这就叫延迟执行
如果用户传进来的回调函数 fn 在执行的时候出错了,怎么办?
方案 1:让用户自己处理
import utils from 'utils.js'
utils.foo(() => {
try {
// ...
} catch (e) {
// ...
}
})try { ... }:try是 JS 语言自带的错误处理语法,意思是“接下来这块我来盯着,万一炸了我接住”// ...(在try里):真正可能出错的业务代码} catch (e) {:出错了就跳到这里。e是错误对象(error),里面带着出错信息(e.message是错误描述,e.stack是错误发生在哪几行)// ...(在catch里):用户自己想怎么处理 —— 打日志、弹窗上报,还是干脆吞掉(什么都不做)
⚠️ 关键点:
try...catch必须紧贴着用户传进来的那段代码写。也就是说,用户得在每一个回调里手写一遍,而且还得记得别漏掉一个
这样行是行,但如果 utils.js 提供了几十上百个类似的函数(foo、bar、baz……),用户就每个调用都得加 try-catch,太烦了。
顺带说清楚这一节开头提到的两个词:
健壮性(robustness):
一个东西“抗折腾”的能力。
遇到意外不崩、还能继续跑,就叫健壮。天气预报说下雨你家却没带伞,出门被淋湿了 —— 这就不叫健壮。
心智负担(mental load):第 1 章已经讲过——你为了用这个东西,需要在脑子里记住多少东西。
写代码时“每个函数都得记得加 try-catch、还得记得加在正确的位置”,这就是心智负担。好的框架就是要把这类负担从用户身上拿走。
方案 2:框架统一处理错误
更好的办法是:框架代替用户统一处理错误。
// utils.js
export default {
foo(fn) {
try {
fn && fn()
} catch(e) {/* ... */}
},
bar(fn) {
try {
fn && fn()
} catch(e) {/* ... */}
},
}foo(fn) { ... }/bar(fn) { ... }:两个方法名字不同,但结构一模一样。它们各自把用户传的fn包进了try块里 —— 这就是“框架替用户处理错误”的字面意思catch(e) {/* ... */}:注意catch里被故意写得很“空”。因为错误到底该怎么处理,框架自己并不知道 —— 是打日志?上报服务器?还是弹窗?框架只能先把错误接住,具体怎么处置,还得问用户- 盯住这个别扭的地方:同一段
try...catch被复制粘贴了两遍。它能跑,但如果有 50 个方法,就是 50 份一模一样的样板代码,改一次得改 50 处。记住这份别扭 —— 它正是下一段的铺垫
但每个函数都写一遍 try-catch 也太啰嗦。我们可以把它抽成一个统一函数:
// utils.js
export default {
foo(fn) {
callWithErrorHandling(fn)
},
bar(fn) {
callWithErrorHandling(fn)
},
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
console.log(e)
}
}foo(fn) { callWithErrorHandling(fn) }:foo的全部内容就剩一句话 —— 把用户传的fn原样转交给callWithErrorHandling。从这一刻起,foo自己不再碰错误,它对“错误”这件事一无所知bar(fn) { callWithErrorHandling(fn) }:同理。两个方法现在都退化成了“一行转发”function callWithErrorHandling(fn) {:定义一个具名函数(有名字的函数),名字直译过来就是“带着错误处理来调用”。call是“调用”,with error handling是“带错误处理”。它的参数也还是那个回调fn- 为什么这样抽出来是合法的? 因为 JS 的
try...catch只要求一件事:出错的代码在try块内部。至于这个fn到底是谁写的、写在哪个文件里,JS 一概不管。所以我们可以把用户写的代码请进一个统一的沙盒里执行 —— 等于是给所有回调装了一道统一门禁,谁进来都别想崩到外面去- 生活中的比喻:机场安检。每件行李(每个回调)都得过一遍安检,安检流程(
try...catch)是统一的一套,不需要每件行李的主人自己雇个保安贴身跟着
- 生活中的比喻:机场安检。每件行李(每个回调)都得过一遍安检,安检流程(
try {:沙盒的入口,从这里开始“重点盯防”fn && fn():用户传进来的回调在这里被执行 —— 和上一版相比,位置一个字没变,变的只是外面多了层门禁} catch (e) {:万一炸了,错误落到这里,变量名叫econsole.log(e):先在控制台打印一下错误,这是框架自己定的默认行为- 两个
}:先关掉catch块,再关掉整个函数
代码简洁多了 —— 复制粘贴的样板代码从“每个方法一份”变成了“整个模块一份”。但简洁不是目的,这么做真正的好处是,它能让我们为用户提供统一的错误处理接口:
// utils.js
let handleError = null
export default {
foo(fn) {
callWithErrorHandling(fn)
},
// 用户可以调用该函数注册统一的错误处理函数
registerErrorHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
// 将捕获到的错误传递给用户的错误处理程序
handleError(e)
}
}注意这一版和上一版只差了一行 —— console.log(e) 被换成了 handleError(e)。框架作者的全部设计功夫,就压在这一次替换上
let handleError = null:声明一个模块级变量当“插槽”,初始值null表示“目前还没有人接管错误”- 比喻:这就是服务台旁边那个空的意见箱。框架只负责把意见投进去,至于谁来取、怎么处理,框架不插手
- 用
let而不是const,是因为后面要给它重新赋值
⚠️ 注意它写在
export default的外面 —— 这是整个设计的关键。模块里的callWithErrorHandling(读)和对外暴露的registerErrorHandler(写),共享同一个handleError变量:一个往里存,一个往外取
export default { ... }:对外接口foo(fn) { callWithErrorHandling(fn) }:还是一行转发,跟上一版一模一样// 用户可以调用该函数注册统一的错误处理函数:注释 —— 点明registerErrorHandler是专门留给用户调用的入口registerErrorHandler(fn) {:register就是“登记、注册”的意思。用户调用它,等于说“以后出错了请找这个函数”handleError = fn:把用户传进来的处理函数存进那个插槽function callWithErrorHandling(fn) { ... }:还是那个统一入口,一字未改try { fn && fn() }:执行用户回调} catch (e) {:接住错误,e是错误对象// 将捕获到的错误传递给用户的错误处理程序:注释,点明了意图handleError(e):把错误交出去 —— 这是整个设计最关键的一行。框架不再“替用户决定怎么处理”,而是把处理权原样转交。框架不知道、也不关心你要上报、要弹窗还是直接忽略,它只负责两件事:拿到错误、递出去- 两个
}:关掉catch块,关掉函数
⚠️ 注意
handleError = fn只是存起来,不马上执行。区分两个动作很重要:注册(现在做,只是记下联系方式)vs 处理(将来出错时才真的打电话)。这就像你把客服电话抄进通讯录 —— 抄进去的那一刻,没有任何人被打扰
我们提供了 registerErrorHandler 函数,用户可以使用它注册错误处理程序,然后在 callWithErrorHandling 函数内部捕获错误后,把错误传递给用户注册的错误处理程序。这样用户侧的代码就既简洁又健壮:
import utils from 'utils.js'
// 注册错误处理程序
utils.registerErrorHandler((e) => {
console.log(e)
})
utils.foo(() => {/*...*/})
utils.bar(() => {/*...*/})import utils from 'utils.js':拿工具,老规矩utils.registerErrorHandler((e) => {...}):注册一个错误处理程序。这里的e是形参,也就是“等会儿会有人把错误传给我,我给它起个名字叫e”- 注意这个函数整个项目只写了一次。以后不管是
foo出错、bar出错,还是第 501 个方法出错,全都会走它。这就是“统一”两个字的含金量 utils.foo(() => {/*...*/}):调用foo,回调里干干净净,一个try...catch都没有utils.bar(() => {/*...*/}):再调用bar,同样干净
把方案 1 和方案 2 摆一起对比一下,你就明白框架的价值了:
| 用户要写的代码 | 出错时谁处理 | |
|---|---|---|
| 方案 1 | 每个回调里都手写 try...catch | 用户自己,每个调用各写各的 |
| 方案 2 | 整个项目只写一次注册函数 | 用户注册的函数,框架转交 |
错误处理的能力完全由用户控制:
- 想忽略错误?注册一个空函数就行
- 想上报到监控系统?注册一个上报函数就行
- 想弹个提示给用户?注册一个弹窗函数就行
Vue.js 的错误处理
上面就是 Vue.js 错误处理的原理。在 Vue.js 源码里搜 callWithErrorHandling 就能找到这个函数。
实际使用 Vue.js 时,我们也可以注册统一的错误处理函数:
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
// 错误处理程序
}import App from 'App.vue':导入根组件。.vue是 Vue 单文件组件的后缀(single-file component),一个.vue文件里把模板、脚本、样式三部分装在一起const app = createApp(App):创建应用实例(就是 2.1 节那个createApp)app.config:这个应用的“配置项总表”。凡是全局性的设置都挂在这儿,比如全局混入、插件、还有这里要用的errorHandlerapp.config.errorHandler = () => {...}:给这个应用挂一个错误处理函数 —— 等于告诉框架“以后出错了,请交给这个函数”。=是赋值:把右边这个函数,存进左边这个属性里// 错误处理程序:这就是你要填的地方。想上报监控、想弹窗,随便你- 再对照一下前面的
registerErrorHandler:一个是框架内部模块的写法,一个是 Vue 对外暴露给你的写法,背后是同一套设计 —— 框架只负责“拿到错误、递出去”,绝不自己替用户决定
这样框架里所有捕获到的错误,都会走你这个函数来处理。
2.7 良好的 TypeScript 类型支持
TypeScript 是什么?
TypeScript(简称 TS):
由微软开源的编程语言,是 JavaScript 的超集(就是 JS 的“升级版”),能为 JS 提供类型支持。
超集(superset):
“包含”关系的一种说法。超集里有的东西,集合里全都有(JS 的语法 TS 全支持),反过来不一定成立。所以你写的每一行合法 JS,交给 TS 也都是合法的 —— 这也是 TS 能在任何老项目里“混进去”的原因。
生活中的比喻:JS 就像便签纸上的字 —— 写错也看不出来。TS 就像表格 —— 你必须填“姓名”、“电话”,填错了表格直接提示你。
类型(type):
“这个值是哪一类东西”的说明,比如 string(文本)、number(数字)、boolean(是/否)。
类型声明(type declaration / type annotation):
你在代码里亲手写下的那段类型说明,比如 val: any 里的 : any。
比喻:表格上你亲手填进“姓名”那一栏的内容。
类型推导(type inference):
你不写,TS 自己从代码里把类型推出来。
比如 const a = 'str',TS 一眼就看出 a 是字符串,于是你不用写 const a: string = 'str'。比喻:你把一个东西放进“字符串”那个格子里,管理员不问你也知道它是哪一类。
泛型(generic):
类型上的“占位符”。
你写代码时不把类型定死,而是先留一个洞,等真正调用时再用传进来的东西把这个洞填上。比喻:复印店的空白表格模板,姓名栏先空着,谁来填就填谁的名字。
编译时(compile time):第 1 章已经讲过——代码还没运行之前就把工作干完了。在 TS 这里还多一层含义:编辑器边写边检查的那一刻,也算编译时。类型错误在这一阶段就被拦下来,代价极低;等运行时才发现,就得一路调试回去找。
现在越来越多的开发者和团队在项目中使用 TS。TS 的好处很多:
- 代码即文档:类型就是最好的说明
- 编辑器自动提示:写代码时编辑器能给你更智能的补全
- 一定程度上避开低级 bug:把“该传字符串的地方传了数字”这种错误在编译时就拦下来
- 可维护性更强:重构代码时,类型系统会告诉你哪里需要同步修改
因此对 TS 类型的支持是否完善,也成为评价一个框架的重要指标。
一个常见的误区
如何衡量一个框架对 TS 类型支持的水平呢?
这里有个常见的误区:很多同学以为“框架是用 TS 写的”就等于“对 TS 类型支持友好”。
⚠️ 这两件事完全不同!
下面我们用一个简单的例子把这件事说清楚。
一个 TS 函数的例子
先看一个用 TS 写的简单函数:
function foo(val: any) {
return val
}先说清楚这个例子的立场:我们先假设“这个函数是框架作者写的”。作者比谁都清楚 foo 是“原样返回”,也完全有能力把类型写准。但写框架的人替用框架的人写代码时,有个绕不开的冲突 —— 框架作者不可能为每一种调用场景都把类型写死(那等于把框架撑爆),可写得太宽松,类型信息就全丢了。于是“用不用心为类型服务”,就变成了区分“TS 写了个框架”和“对 TS 友好的框架”的分水岭
解释一下:
function foo(val: any):定义函数foo,参数val。: any就是那句类型声明val: any:参数val的类型是any(任意类型),意味着它可以是任何东西return val:返回值就是val本身
所以如果传入 number 类型的参数,返回值也是 number;传入字符串就返回字符串 —— 看起来很合理。
但当我们实际使用这个函数时:
const res = foo('str')const res = ...:把foo('str')的结果存进一个叫res的常量里foo('str'):传了一个字符串进去,原样返回
把鼠标悬浮到 res 上时,会发现它的类型是 any,而不是字符串。这并不是我们想要的结果。

图 2-5 返回值类型丢失
⚠️ 这里的关键是搞清楚“问题出在哪”:函数体
return val本身是如实的 —— 你给数字它返回数字,给字符串返回字符串。问题出在那句声明。因为val: any相当于告诉 TS“我什么类型都不关心”,TS 顺着这句话一推到底:res的类型也只能是“我什么都不知道”。any会把整个类型链“污染”成any—— 比喻:一张表格的“姓名”栏被你填了“随便”,后面所有要抄这份信息的格子,就全都只能写“随便”了
修复方法
只需要对 foo 函数做一点小修改,就能拿到理想的类型推导:
function foo<T extends any>(val: T): T {
return val
}<T extends any>:这是泛型声明。T就是一个类型占位符,T extends any是在说“T可以是任何类型”。关键在于T是一个“洞” —— 后面你传进来什么,它就变成什么- 打个比方:原来那行写的是“收一个随便什么东西,吐回一个随便什么东西”;现在写的是“收一个东西,照着它原样吐回来” —— 收的是苹果就吐苹果,收的是字符串就吐字符串
(val: T):参数val的类型不再是死板的any,而是那个“洞”T: T(冒号在括号外):这是返回值的类型声明,写在参数列表右边,表示“这个函数返回的,也是那个T”- 这三个
T是同一个洞。调用foo('str')时,TS 从实参'str'反推出T应该是字符串,于是val和返回值全都被推导成了字符串 return val:函数体一个字没变,变的只是“说明”。这是 TS 最好玩的地方 —— 你改的全是注释级别的信息,一行运行时逻辑都没碰
你不需要理解这段代码(这是 TS 的泛型),直接看效果:

图 2-6 能够推导出返回值类型
现在 res 的类型是字符字面量 'str',而不是 any。这说明我们的代码生效了。
注意“字符字面量”这个说法:'str' 不只是“一个字符串”,TS 还能精确地指出它具体是 str 这四个字。这种把内容也一起记住的类型信息,TS 全靠泛型才能拿到。
这个例子说明了什么?
通过这个例子,我们认识到:
结论:使用 TS 写代码 ≠ 对 TS 类型支持友好。
想做好类型支持,要付出的努力远超想象。
可以看看 Vue.js 源码里的 runtime-core/src/apiDefineComponent.ts 文件。整个文件真正会在浏览器里运行的代码只有 3 行,但全部代码加起来接近 200 行!也就是说,除了那 3 行,剩下近 200 行全部是在为类型支持服务。
怎么理解这个“3 行 vs 接近 200 行”? 那些多出来的代码一行都不会在浏览器里执行,它们只在编译时被 TS 用来做类型检查。打个比方:货物装箱前的那份报关单(字段写得越细,清关越快),货物本身该多重还是多重。框架作者宁可多写一大半“报关单”,也要让你这个“海关”(TS 编辑器)验货时清清楚楚。
由此可见,一个框架想要做到完善的类型支持,需要付出相当大的努力。甚至有时候,做类型支持的时间比写框架功能本身还多。
除了类型推导之外,还要考虑对 TSX(JSX 的 TypeScript 版)的支持 —— 后续章节会详细讨论这部分内容。
结论:对 TS 友好,不取决于框架“用什么语言写”,而取决于作者愿不愿意为类型多花那接近 200 行。
2.8 总结
本章我们一口气讲了 7 个框架设计的核心要素,现在把它们串起来:
1. 开发体验
开发体验是衡量框架的重要指标。提供友好的警告信息至关重要,能帮开发者快速定位问题(毕竟大多数时候“框架”比开发者更清楚问题出在哪)。
2. 框架体积
警告越详细,框架越大。为了不让线上代码变大,需要用 Tree-Shaking 机制配合 __DEV__ 常量 —— 只在开发环境打印警告,生产环境直接剔除。
3. Tree-Shaking
Tree-Shaking 是排除 dead code 的机制,依赖 ESM 的静态结构。因为 JS 是动态语言,纯静态分析难度大,所以需要用 /*#__PURE__*/ 注释来辅助构建工具进行 Tree-Shaking。
4. 构建产物
不同类型的产物是为了满足不同需求:
- IIFE:给
<script>直接用 - ESM-browser:给
<script type="module">用 - ESM-bundler:给 webpack/rollup 用,
__DEV__替换为(process.env.NODE_ENV !== 'production') - CJS:给 Node.js / SSR 用
5. 特性开关
通过特性开关让用户能“按需取用”。比如 Vue.js 3 中通过 __VUE_OPTIONS_API__ 开关可以选择是否使用 Vue 2 风格的选项 API。
6. 错误处理
框架的错误处理做得怎么样,决定了用户应用的健壮性和开发体验。框架应该提供统一的错误处理接口,让用户能注册自定义的错误处理函数。
7. TypeScript 类型支持
使用 TS 写框架 ≠ 对 TS 类型支持友好。有时候为了让类型支持更完善,要花的精力比写框架功能本身还多。
本章小结
结论:这 7 件事看起来各管各的,但其实是同一件事的不同侧面 —— 让写框架的人少背一点包袱(体积、类型),让用框架的人少操一点心(错误处理、开发体验、构建产物)。
下一步我们就要正式进入“响应系统”,看看 ref(0) 里那个“盒子”到底是怎么造出来的。
