《Vue.js 设计与实现》—— 02 框架设计核心要素

news/2024/11/29 7:39:40/

框架设计并非仅仅实现功能那么简单,里面有很多学问。例如:

  • 框架应该给用户提供哪些构建产物?产物的模块格式如何?

  • 当用户没有以预期的方式使用框架时,是否应该打印合适的警告信息从而提供更好的开发体验,让用户快速定位问题?

  • 开发版本和生产版本的构建有何区别?

  • 热更新(hot module replacement,HMR)需要框架层面的支持,是否也应该考虑?

  • 当框架提供了多个功能,而用户只需要其中几个功能时,用户能否选择关闭其他功能从而减少最终资源的打包体积?

1. 提升开发体验

衡量一个框架是否足够优秀的指标之一就是看它的开发体验如何,以 Vue.js 3 为例:

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

当创建一个组件并试图将其挂载到一个不存在的 DOM 节点时,就会收到一条警告信息:

[Vue warn]: Failed to mount app: mount target selector "#not-exist" returned null.

这条信息让我们能够清晰且快速地定位问题。如果 Vue.js 内部不做任何处理,那么很可能得到的是 JavaScript 层面的错误信息,如 Uncaught TypeError: Cannot read property 'xxx' of null,而根据此信息很难知道问题所在。

因此,在框架设计和开发过程中,提供友好的警告信息至关重要。始终提供友好的警告信息不仅能够帮助用户快速定位问题,节省用户的时间,还能够让框架收获良好的口碑,让用户认可框架的专业性。

在 Vue.js 的源码中,经常能够看到 warn 函数的调用,例如:

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

除了提供必要的警告信息外,还有很多其他方面可以作为切入口,进一步提升用户的开发体验。例如,在 Vue.js 3 中,当在控制台打印一个 ref 数据时:

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

打印结果是一个 ref 对象,很不直观,但调用 count.value 后,得到的就是响应式对象的值,变得非常直观。

那么有没有办法在直接打印 count 时让输出的信息更友好呢?当然可以,浏览器允许我们编写自定义的 formatter,从而自定义输出形式。

在 Vue.js 3 的源码中,有一个名为 initCustomFormatter 的函数,用来在开发环境下初始化自定义 formatter。

以 Chrome 为例,打开 DevTools 的设置,然后勾选 “Console” -> “Enable custom formatters” 选项,如下:

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

2. 控制代码体积

框架的大小也是衡量框架的标准之一。在实现同样功能的情况下,代码越少越好,这样体积就会越小,最后浏览器加载资源的时间也就越少。前面说到,框架提供越完善的警告信息越好,但这意味着要编写更多的代码,那么如何在这个基础上实现代码体积的控制呢?

如果去看 Vue.js 3 的源码,就会发现每一个 warn 函数的调用都会配合 __DEV__ 常量的检查,例如:

if (__DEV__ && !res) { // 打印警告信息的前提时 __DEV__ 为 truewarn(`Failed to mount app: mount target selector "${container}" returned null.`)
}

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

Vue.js 在输出资源的时候,会输出两个版本,其中一个用于开发环境,如 vue.global.js,另一个用于生产环境,如 vue.global.prod.js。

当 Vue.js 构建用于开发环境的资源时,会把 __DEV__ 常量设置为 true;当构建生产环境的资源时,会把 __DEV__ 常量设置为 false

因为生产环境下判断条件始终为假,这段永远不会执行的代码称为 dead code,它不会出现在最终产物中,在构建资源时就会被移除。这样就做到了在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积。

3. 良好的 Tree-Shaking

仅仅通过 __dev__ 变量控制代码量是远远不够的。还以 Vue.js 为例,其内建了很多组件,例如 <Transition> 组件,如果项目中没有用到该组件,其对应的代码就不需要也不应该包含在最终的构建资源中。那么如何做到这一点呢?答案就是 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

首先安装 rollup.js:

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) { // bar 函数未被使用obj && obj.bar
}

接着,执行如下命令进行构建:

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

构建后,输出的 bundle.js 的内容为:

export function foo(obj) {obj && obj.foo
}
foo()

这说明 Tree-Shaking 起了作用,我们并没有使用 bar 函数,因此它作为 dead code 被删除了。但是仔细观察会发现,foo 函数的执行也没有什么意义,仅仅是读取了对象的值,所以它的执行似乎没什么必要。既然把这段代码删了也不会对程序产生影响,那为什么 rollup.js 不把这段代码也作为 dead code 移除呢?

这涉及 Tree-Shaking 中的第二个关键点 —— 副作用。如果一个函数调用会产生副作用,那么就不能将其移除

简单地说,副作用就是,当调用函数的时候会对外部产生影响,例如修改了全局变量。

但是,上面的代码只读取对象的值,怎么会产生副作用呢?其实是有可能的,如果 obj 对象是一个通过 Proxy 创建的代理对象,那么读取对象属性时,就会触发代理对象的 get 夹子(trap),在 get 夹子中是可能产生副作用的。至于到底会不会产生副作用,只有代码真正运行的时候才能知道,JavaScript 本身是动态语言,因此想要静态地分析哪些代码是 dead code 很有难度。

即然静态地分析代码很困难,所以像 rollup.js 这类工具都会提供一个机制,让我们手动明确地告诉 rollup.js 该段代码是一个纯函数,不会产生副作用,可以移除它。具体实现如下:

import {foo} from './utils'/*#__PURE__*/ foo() // 前面的 __PURE__ 告知是一个纯函数,不会产生副作用,可以移除

此时再次执行构建命令并查看 bundle.js 文件,就会发现它的内容是空的。

因此,我们在编写框架的时候需要合理使用 /*#__PURE__* 注释。Vue.js 3 的源码里面大量使用了该注释。

那么,这会不会对编写代码造成很大的心智负担呢?其实不会,因为通常产生副作用的代码都是模块内函数的顶级调用。

什么是顶级调用呢?如下:

foo() // 顶级调用function bar() {foo() // 函数内调用 -- 没有副作用,除非 bar() 顶级调用
}

/*#__PURE__*/ 注释不仅仅作用于函数,它可以应用于任何语句上。该注释也不是只有 rollup.js 才能识别,webpack 以及压缩工具(如 terser)都能识别它。

4. 输出构建产物

前面说到 Vue.js 会为开发环境和生产环境输出不同的包,如 vue.global.js 用于开发环境,它包含必要的警告信息,而 vue.global.prod.js 用于生产环境,不包含警告信息。实际上,Vue.js 的构建产物除了有环境上的区分之外,还会根据使用场景的不同而输出其他形式的产物。

不同类型的产物一定有对应的需求背景,因此需要从需求讲起。首先我们希望用户可以直接在 HTML 页面中使用 <script> 标签引入框架并使用:

<body><script src="/path/to/vue.js"></script><script>const { createApp } = Vue// ...</script>
</body>

为了实现这个需求,需要输出一种叫作 IIFE 格式的资源。IIFE 的全称是 Immediately Invoked Function Expression,即“立即调用的函数表达式”,易于用 JavaScript 来表达:

(function () {// ...
})

如以上代码所示,这是一个立即执行的函数表达式。实际上,vue.global.js 文件就是 IIFE 形式的资源,它的代码结构如下所示:

var Vue = (function(exports){// ...exports.createApp = createApp;// ...return exports
}({}))

这样当我们使用 <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

随着技术的发展和浏览器的支持,现在主流浏览器对原生 ESM 的支持都不错,所以用户除了能够使用 <script> 标签引用 IIFE 格式的资源外,还可以直接引入 ESM 格式的资源,例如 Vue.js 3 还会输出 vue.esm-browser.js 文件,用户可以直接用 <script type="module"> 标签引入:

<script type="module" src="/path/to/vue.esm-browser.js"></script>

为了输出 ESM 格式的资源,rollup.js 的输出格式需要配置为:format: 'esm'

为什么 vue.esm-browser.js 文件中会有 -browser 字样?其实对于 ESM 格式的资源来说,Vue.js 还会输出一个 vue.esm-bundler.js 文件,其中 -browser 变成了 -bundler。为什么这么做呢?我们知道,无论是 rollup.js 还是 webpack,在寻找资源时,如果 package.json 中存在 module 字段,那么会优先使用 module 字段指向的资源来代替 main 字段指向的资源。

可以打开 Vue.js 源码中的 packages/vue/package.json 文件看一下:

{"main": "index.js","module": "dist/vue.runtime.esm-bundler.js",
}

其中 module 字段指向的是 vue.runtime.esm-bundler.js 文件,意思是说,如果项目是使用webpack 构建的,那么你使用的 Vue.js 资源就是 vue.runtime.esm-bundler.js,也就是说,带有 -bundler 字样的 ESM 资源是给 rollup.js 或 webpack 等打包工具使用的,而带有 -browser 字样的 ESM 资源是直接给 <script type="module"> 使用的。它们之间有何区别?这就不得不提到上文中的 __DEV__ 常量。当构建用于 <script> 标签的 ESM 资源时,如果是用于开发环境,那么 __DEV__ 会设置为 true;如果是用于生产环境,那么 __DEV__ 常量会设置为 false,从而被 Tree-Shaking 移除。但是当我们构建提供给打包工具的 ESM 格式的资源时,不能直接把 __DEV__ 设置为 truefalse,而要使用(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.js 的代码是在 Node.js 环境中运行的。在 Node.js 环境中,资源的模块格式应该是 CommonJS,简称 cjs。为了能够输出 cjs 模块的资源,可以通过修改 rollup.config.js 的配置 format: 'cjs' 来实现:

5. 特征开关

在设计框架时,框架会给用户提供诸多特性(或功能),例如提供 A、B、C 三个特性给用户,同时还提供了 a、b、c 三个对应的特性开关,用户可以通过设置 a、b、c 为 truefalse 来代表开启或关闭对应的特性,这将会带来很多益处,如:

  • 对于用户关闭的特性,可以利用 Tree-Shaking 机制让其不打包在最终的资源中。
  • 该机制为框架设计带来了灵活性,可以通过特性开关任意为框架添加新的特性,而不用担心资源体积变大。
  • 当框架升级时,也可以通过特性开关来支持遗留 API,这样新用户可以选择不使用遗留 API,从而使最终打包的资源体积最小化。

那如何实现特性开关呢?其原理和前面提到的 __DEV__ 常量一样,本质上是利用 rollup.js 的预定义常量插件来实现。拿 Vue.js 3 源码中的一段 rollup.js 配置来说:

{__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
}

其中 __FEATURE_OPTIONS_API__ 类似于 __DEV__。在 Vue.js 3 的源码中搜索,可以找到很多类似于如下代码的判断分支:

// support for 2.x options
if (__FEATURE_OPTIONS_API__) {currentInstance = instancepauseTracking()applyOptions(instance, Component)resetTracking()currentInstance = null
}

当 Vue.js 构建资源时,如果构建的资源是供打包工具使用的(即带有 -bundler 字样的资源),那么上面的代码在资源中会变成:

// support for 2.x options
if (__VUE_OPTIONS_API__) { // 这里不一样currentInstance = instancepauseTracking()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__ 开关有什么用。在 Vue.js 2 中,我们编写的组件叫作Options API;在 Vue.js 3 中,推荐使用 Composition API 来编写代码。为了兼容 Vue.js 2,在 Vue.js 3 中仍然可以使用 Options API 的方式编写代码。但是如果明确知道自己不会使用选项 API,用户就可以使用 __VUE_OPTIONS_API__ 开关来关闭该特性,这样在打包的时候 Vue.js 的这部分代码就不会包含在最终的资源中,从而减小资源体积。

6. 错误处理

错误处理是框架开发过程中非常重要的环节。框架错误处理机制的好坏直接决定了用户应用程序的健壮性,还决定了用户开发时处理错误的心智负担。

假设我们开发了一个工具模块,代码如下:

// 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 提供了几十上百个类似的函数,那么用户在使用的时候就需要逐一添加错误处理程序。

第二个办法是我们代替用户统一处理错误,如以下代码所示:

// utils.js
export default {foo(fn) {try {fn && fn()} catch(e) {/* ... */}},bar(fn) {try {fn && fn()} catch(e) {/* ... */}},// ...
}

事实上,可以进一步将错误处理程序封装在一个函数上,假设称为 callWithErrorHandling

// utils.js
export default {foo(fn) {callWithErrorHandling(fn)},bar(fn) {callWithErrorHandling(fn)},// ...
}function callWithErrorHandling(fn) {try {fn && fn()} catch(e) {/* ... */} 
}

简洁还不是封装函数的主要目的,我们能为用户提供统一的错误处理接口,如:

// utils.js
let handleError = nullexport default {foo(fn) {callWithErrorHandling(fn)},// 用户可以调用该函数注册统一的错误处理函数registerErrorHandler(fn) {handleError = fn}
}function callWithErrorHandling(fn) {try {fn && fn()} catch (e) {// 将捕获到的错误传递给用户的错误处理程序handleError(e)}
}

这样用户侧的代码就会非常简洁且健壮:

import utils from 'utils.js'// 注册错误处理程序
utils.registerErrorHandler((e) => {console.log(e)
})
utils.foo(() => {/*...*/})
utils.bar(() => {/*...*/})

这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。实际上,这就是 Vue.js 错误处理的原理,可以在源码中搜索到 callWithErrorHandling 函数。另外,在 Vue.js 中,也可以注册统一的错误处理函数:

import App from 'App.vue'const app = createApp(App)
app.config.errorHandler = () => {// 错误处理程序
}

7. 良好的 TS 支持

TypeScript 是由微软开源的编程语言,简称 TS,它是 JavaScript 的超集,能够为 JavaScript 提供类型支持。使用 TS 的好处有很多,如代码即文档、编辑器自动提示、一定程度上能够避免低级 bug、代码的可维护性更强等。因此对 TS 类型的支持是否完善也成为评价一个框架的重要指标。

如何衡量一个框架对 TS 类型支持的水平呢?这里有一个常见的误区,很多人以为只要是使用 TS 编写框架,就等价于对 TS 类型支持友好,其实这两种完全不同。

举例来说。下面是使用 TS 编写的函数:

function foo(val: any) {return val
}

这个函数直接将参数作为返回值,这说明返回值的类型是由参数决定的,如果参数是 number 类型,那么返回值也是 number 类型。但是,假设有下面的代码:

const res = foo('str') // 参数为字符串类型,理论上 res 也为字符串类型,但是却推断成了 any 类型

为了达到理想状态,只需要对 foo 函数做简单的修改即可:

function foo<T extends any>(val: T): T {return val
}const res = foo('str') // 这时就会将 res 推断为 "str" 字符串字面量了

通过这个例子可以认识到,使用 TS 编写代码与对 TS 类型支持友好是两件事。在编写大型框架时,想要做到完善的 TS 类型支持很不容易,可以查看 Vue.js 源码中的 runtime-core/src/apiDefineComponent.ts 文件,整个文件里真正会在浏览器中运行的代码其实只有 3 行,但是全部的代码接近 200 行,其实这些代码都是在为类型支持服务。由此可见,框架想要做到完善的类型支持,需要付出相当大的努力。

更多文章可关注:GopherBlog、GopherBlog副站


http://www.ppmy.cn/news/62958.html

相关文章

惠普暗影精灵5 super 873-068rcn如何重装系统

惠普暗影精灵5 super 873-068rcn是一款家用游戏台式电脑&#xff0c;有时候你可能用久会遇到系统出现故障、中毒、卡顿等问题&#xff0c;或者你想要更换一个新的操作系统&#xff0c;这时候你就需要重装系统。重装系统可以让你的电脑恢复到出厂状态&#xff0c;清除所有的个人…

AI仿写软件-仿写文章生成器

AI仿写软件&#xff1a;高效出色的营销利器 作为互联网时代的营销人员&#xff0c;我们不仅需要品牌意识&#xff0c;还必须深谙营销技巧。万恶的时限压力使得我们不得不在有限的时间内输出更多的文本内容&#xff0c;以便吸引更多的关注。那么&#xff0c;如何解决这个问题呢…

Sass使用

前言&#xff1a; 这份记录&#xff0c;主要是记录学习sass的学习记录&#xff0c;用于记录一些本人认为可能以后会用到的比较常用的一些知识点&#xff0c;更详细的请看sass官网 功能1-嵌套规则 Sass 允许将一套 CSS 样式嵌套进另一套样式中&#xff0c;内层的样式将它外层的…

Python的HTTP库及示例

13.3 HTTP库 HTTP&#xff08;Hyper Text Transfer Protocol&#xff09;是一个客户端和服务器端请求和应答的标准。客户端是终端用户&#xff0c;服务器端是网站。客户端发起一个到服务器上指定端口的HTTP请求&#xff0c;服务器向客户端发回一个状态行和响应的消息。 可以…

华为OD机试 - 第一个错误的版本(Java)

一、题目描述 你是产品经理&#xff0c;目前正在带领一个团队开发新的产品。不幸的是&#xff0c;你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的&#xff0c;所以错误的版本之后的所有版本都是错的。 假设你有 n 个版本 [1, 2, …, n]&#xff…

如何学好单片机C语言并写出高质量代码

单片机C语言的学习需要掌握以下方面&#xff1a; C语言基础&#xff1a;需要学习C语言的基本语法、数据类型、运算符、控制语句等基础知识。 单片机基础&#xff1a;需要掌握单片机的基本结构、寄存器、输入输出等知识。 编程思想&#xff1a;需要掌握编程思想&#xff0c;如…

测试5年从中兴 15K 跳槽去腾讯 32K+16,啃完这份笔记你也可以

粉丝小王转行做测试已经是第5个年头&#xff0c;一直是一个不温不火的小职员&#xff0c;本本分分做着自己的事情&#xff0c;觉得自己的工作已经遇到了瓶颈&#xff0c;一个偶然的机会&#xff0c;获得了一份软件测试全栈知识点学习笔记&#xff0c;通过几个月的学习&#xff…

【递推专题】常见的递推“模型”总结

目录 1.斐波那契数列分析&#xff1a;代码&#xff1a; 2.平面分割问题分析&#xff1a; 3.汉诺塔问题分析&#xff1a; 4.卡特兰数分析&#xff1a; 5.第二类斯特林数总结&#xff1a; 1.斐波那契数列 分析&#xff1a; 斐波那契数列又称兔子数列&#xff0c;其原理来源于兔子…