潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素
框架设计要比想象得复杂,并不是说只把功能开发完成,能用就算大功告成了,这里面还有很多学问。比如,我们的框架应该给用户提供哪些构建产物?产物的模块格式如何?当用户没有以预期的方式使用框架时,是否应该打印合适的警告信息从而提供更好的开发体验,让用户快速定位问题?开发版本的构建和生产版本的构建有何区别?热更新(hot module replacement,HMR)需要框架层面的支持,我们是否也应该考虑?另外,当你的框架提供了多个功能,而用户只需要其中几个功能时,用户能否选择关闭其他功能从而减少最终资源的打包体积?上述问题是我们在设计框架的过程中应该考虑的。
提升用户的开发体验
友好的警告信息
createApp(App).mount('#not-exist')
当我们创建一个 Vue.js 应用并试图将其挂载到一个不存在的 DOM 节点时,就会收到一条警告信息。
这条信息告诉我们挂载失败了,并说明了失败的原因:Vue.js 根据我们提供的选择器无法找到相应的 DOM 元素(返回 null
)。这条信息让我们能够清晰且快速地定位问题。试想一下,如果 Vue.js 内部不做任何处理,那么我们很可能得到的是 JavaScript 层面的错误信息,例如 Uncaught TypeError: Cannot read property 'xxx' of null
,而根据此信息我们很难知道问题出在哪里。
启用自定义格式化程序⭐
除了提供必要的警告信息外,还有很多其他方面可以作为切入口,进一步提升用户的开发体验。例如,在 Vue.js 3 中,当我们在控制台打印一个 ref
数据时:
01 const count = ref(0) 02 console.log(count)
打开控制台查看输出:
可以发现,打印的数据非常不直观。当然,我们可以选择直接打印 count.value
的值,这样就只会输出 0
,非常直观。那么有没有办法在打印 count
的时候让输出的信息更友好呢?

我们可以打开 DevTools 的设置,然后勾选“Console”→“Enable custom formatters”选项(如果你的控制台语言是中文,这里就是”控制台“→”启用自定义格式化程序“),再打印一次试试看:
控制框架代码的体积
如果我们去看 Vue.js 3 的源码,就会发现每一个 warn
函数的调用都会配合 __DEV__
常量的检查,例如
if (__DEV__ && !res) {
warn(`Failed to mount app: mount target selector "${container}" returned null.`)
}
Vue.js 在输出资源的时候,会输出两个版本,其中一个用于开发环境,如 vue.global.js,另一个用于生产环境,如 vue.global.prod.js,通过文件名我们也能够区分。
当 Vue.js 用于构建生产环境的资源时,会把 __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
什么是 Tree-Shaking 呢?在前端领域,这个概念因 rollup.js 而普及。简单地说,Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,现在无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。
想要实现 Tree-Shaking,必须满足一个条件,即模块必须是 ESM(ES Module),因为 Tree-Shaking 依赖 ESM 的静态结构。我们以 rollup.js 为例看看 Tree-Shaking 如何工作,其目录结构如下:
├── demo
│ └── package.json
│ └── input.js
│ └── utils.js
下面是 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.js 不把这段代码也作为 dead code 移除呢?
这就涉及 Tree-Shaking 中的第二个关键点——副作用。如果一个函数调用会产生副作用,那么就不能将其移除。如果 obj
对象是一个通过 Proxy
创建的代理对象,那么当我们读取对象属性时,就会触发代理对象的 get
夹子(trap
),在 get
夹子中是可能产生副作用的,例如我们在 get
夹子中修改了某个全局变量。而到底会不会产生副作用,只有代码真正运行的时候才能知道,JavaScript 本身是动态语言,因此想要静态地分析哪些代码是 dead code 很有难度,上面只是举了一个简单的例子。
因为静态地分析 JavaScript 代码很困难,所以像 rollup.js 这类工具都会提供一个机制,让我们能明确地告诉 rollup.js:“放心吧,这段代码不会产生副作用,你可以移除它。”具体怎么做呢?如以下代码所示,我们修改 input.js 文件:
import {foo} from './utils'
/*#__PURE__*/ foo()
注意注释代码 /*#__PURE__*/
,其作用就是告诉 rollup.js,对于 foo
函数的调用不会产生副作用,你可以放心地对其进行 Tree-Shaking,此时再次执行构建命令并查看 bundle.js 文件,就会发现它的内容是空的,这说明 Tree-Shaking 生效了。
框架应该输出怎样的构建产物
为了让用户能够通过 <script>
标签直接引用并使用,我们需要输出 IIFE 格式的资源,即立即调用的函数表达式。为了让用户能够通过 <script type="module">
引用并使用,我们需要输出 ESM 格式的资源。这里需要注意的是,ESM 格式的资源有两种:用于浏览器的 esm-browser.js 和用于打包工具的 esm-bundler.js。它们的区别在于对预定义常量 __DEV__
的处理,前者直接将 __DEV__
常量替换为字面量 true
或 false
,后者则将 __DEV__
常量替换为 process.env.NODE_ENV !== 'production'
语句。
特性开关
框架会提供多种能力或功能。有时出于灵活性和兼容性的考虑,对于同样的任务,框架提供了两种解决方案,例如 Vue.js 中的选项对象式 API 和组合式 API 都能用来完成页面的开发,两者虽然不互斥,但从框架设计的角度看,这完全是基于兼容性考虑的。有时用户明确知道自己仅会使用组合式 API,而不会使用选项对象式 API,这时用户可以通过特性开关关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被 Tree-Shaking 机制排除。
错误处理⭐
假设我们有一个工具函数:
// 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(() => {
try {
// ...
} catch (e) {
// ...
}
})
但是这会增加用户的负担。试想一下,如果 utils.js 不是仅仅提供了一个 foo
函数,而是提供了几十上百个类似的函数,那么用户在使用的时候就需要逐一添加错误处理程序。
第二个办法是我们代替用户统一处理错误,如以下代码所示:
// utils.js
export default {
foo(fn) {
try {
fn && fn()
} catch(e) {/* ... */}
},
bar(fn) {
try {
fn && fn()
} catch(e) {/* ... */}
},
}
在每个函数内都增加 try...catch
代码块,实际上,我们可以进一步将错误处理程序封装为一个函数,假设叫它 callWithErrorHandling
:
// utils.js
export default {
foo(fn) {
callWithErrorHandling(fn)
},
bar(fn) {
callWithErrorHandling(fn)
},
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
console.log(e)
}
}
可以看到,代码变得简洁多了。但简洁不是目的,这么做真正的好处是,我们能为用户提供统一的错误处理接口,如以下代码所示:
// 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)
}
}
我们提供了 registerErrorHandler
函数,用户可以使用它注册错误处理程序,然后在 callWithErrorHandling
函数内部捕获错误后,把错误传递给用户注册的错误处理程序。
这样用户侧的代码就会非常简洁且健壮:
import utils from 'utils.js'
// 注册错误处理程序
utils.registerErrorHandler((e) => {
console.log(e)
})
utils.foo(() => {/*...*/})
utils.bar(() => {/*...*/})
这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。
良好的 TypeScript 类型支持
“使用 TS 编写框架和框架对 TS 类型支持友好是两件完全不同的事”
考虑到有的读者可能没有接触过 TS,书中没有做深入讨论,只举了一个简单的例子。下面是使用 TS 编写的函数:
function foo(val: any) {
return val
}
这个函数很简单,它接收参数 val
并且该参数可以是任意类型(any
),该函数直接将参数作为返回值,这说明返回值的类型是由参数决定的,如果参数是 number
类型,那么返回值也是 number
类型。然后我们尝试使用一下这个函数,如图所示。
图 2-5 返回值类型丢失
在调用 foo
函数时,我们传递了一个字符串类型的参数 'str'
,按照之前的分析,得到的结果 res
的类型应该也是字符串类型,然而当我们把鼠标指针悬浮到 res
常量上时,可以看到其类型是 any
,这并不是我们想要的结果。为了达到理想状态,我们只需要对 foo
函数做简单的修改即可:
function foo<T extends any>(val: T): T {
return val
}
大家不需要理解这段代码,我们直接来看现在的表现:
可以看到,res
的类型是字符字面量 'str'
而不是 any
了,这说明我们的代码生效了。这个例子旨在说明,不是使用 TS 编写的代码就一定对 TS 类型支持友好。
转载自:https://juejin.cn/post/7278245966599454761