2.框架设计的核心要素

2024/8/20 Vue

# 提升用户的开发体验

# 必要的警告信息

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

const mount = function (el) {
  document.querySelector(el).appendChild(document.createTextNode('Hello world.'))
}

mount('#not-exists') // Uncaught TypeError: Cannot read properties of null (reading 'appendChild')
1
2
3
4
5

根据此信息,我们可能很难去定位问题出在哪里。所以在设计框架时,需要提供更为有用的信息来帮助用户定位问题。

const mount = function (el) {
  const container = document.querySelector(el)

  if (!container) {
    throw new Error(
      `Target selector "${el}" returned null.`
    )
  }
  container.appendChild(document.createTextNode('Hello world.'))
}

mount('#not-exists')
1
2
3
4
5
6
7
8
9
10
11
12

# 直观的输出内容

在 Vue.js 3 中,当我们在控制台打印一个 Ref 数据时:

const count = Vue.ref(0)
    console.log(count) // RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: 0, _value: 0}
1
2

这样的数据阅读起来是不友好的。

所以在 Vue.js 3 的源码中提供了 initCustomFormatter 的函数用于在开发环境初始化自定义的 formatter。

在 Chrome 浏览器中可以通过 设置 -> 控制台 -> 启用自定义格式设置工具 来开启。

const count = Vue.ref(0)
console.log(count) // Ref<0>
1
2

# 控制框架代码的体积

​ Vue.js 在输出资源的时候,会为开发环境和生产环境输出不同的包,例如vue.global.js 用于开发环境,它包含必要的警告信息,而 vue.global.prod.js 用于生产环境,不包含警告信息。

​ 在 Vue.js 3 的源码中,通过一些环境常量来决定某些代码只会在特定的环境中生效:

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

以上代码中, __DEV__ 常量实际上是通过 rollup.js 的插件配置来预定义的。

  • 当 Vue.js 构建用于开发环境的资源时,会把 __DEV__ 常量设置为 true,所以,这段代码在开发环境中是肯定存在的。
  • 当 Vue.js 用于构建生产环境的资源时,会把 __DEV__ 常量设置为 false,所以,这段代码永远不会执行(称为 dead code),在构建资源时会被移除。

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

# 做到良好的 Tree-Shaking

仅仅通过环境常量的形式来排除 dead code 是不够的,我们还需要 Tree-Shaking。

// utils.js
export function foo (obj) {
  obj && obj.foo
}
export function bar () {
  obj && obj.bar
}

// input.js
import { foo } from 'utils'
foo()
1
2
3
4
5
6
7
8
9
10
11

像上面的这种情况,bar() 并未被使用到,那么它就不应该出现在打包后的代码中。要做到这一点,我们就需要 Tree-Shaking。

​ Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,现在无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。

​ 想要实现 Tree-Shaking,必须满足一个条件,即模块必须是 ESM(ESModule),因为 Tree-Shaking 依赖 ESM 的静态结构。

如果一个函数调用会产生副作用(即调用时会对外部产生影响,例如修改了全局变量),那么就不能将其移除。会不会产生副作用,我们只有在代码运行的时候才会知道。JavaScript 本身是动态语言,因此想要静态地分析哪些代码是 dead code 是非常困难的。因此,像 rollup.js 这类工具提供了一个机制,即通过 /*#__PURE__*/ 注释,让我们开发明确地告诉构建工具某些代码是不会产生副作用,你可以放心地移除它。

import { foo } from './utils'

/*#__PURE__*/ foo()
1
2
3

在编写框架的时候需要合理使用 /*#__PURE__*/注释。注意注释代码 /*#__PURE__*/,其作用就是告诉 rollup.js,对于该代码的调用不会产生副作用,你可以放心地对其进行 Tree-Shaking

通常产生副作用的代码都是模块内函数的顶级调用,在Vue.js 3 的源码中,基本都是在一些顶级调用的函数上使用 /*#__PURE__*/ 注释。

# 输出怎样的构建产物

​ Vue.js 的构建产物除了有环境上的区分之外,还会根据使用场景的不同而输出其他形式的产物。我们需要针对不同的运行环境提供不同的构建产物,通过在 rollup.config.js 中配置。

类型 格式 设置
用于<script>标签直接引用 IIFE、ESM(-browser format: 'iife'
<script type="module">
提供给打包工具
ESM(-bundler format: 'esm'
用于Node.js中 CommonJS format: 'cjs'

# 用于<script>标签的 ESM 资源

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

 <body>
   <script src="/path/to/vue.js"></script>
   <script>
   const { createApp } = Vue
   // ...
   </script>
 </body>
1
2
3
4
5
6
7

​ 为了实现这个需求,我们需要输出一种叫作 IIFE(Immediately Invoked Function Expression) 格式的资源,即“立即调用的函数表达式”:

 (function () {
   // ...
 }())
1
2
3

​ 实际上,vue.global.js 文件就是 IIFE 形式的资源,它的代码结构如下所示:

 var Vue = (function(exports){
   // ...
   exports.createApp = createApp;
   // ...
   return exports
 }({}))
1
2
3
4
5
6

​ 这样当我们使用 <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
1
2
3
4
5
6
7
8
9
10

当构建用于 <script> 标签的 ESM 资源时,如果是用于开发环境,那么 __DEV__ 会设置为 true;如果是用于生产环境,那么 __DEV__ 常量会设置为 false,从而被 Tree-Shaking 移除。

# 提供给打包工具的 ESM 资源

​ 现在主流浏览器对原生 ESM 的支持都不错,所以用户不仅可以使用 <script> 标签引用 IIFE 格式的资源,还可以直接引入 ESM 格式的资源。为了输出 ESM 格式的资源,rollup.js 的输出格式需要配置为:format: 'esm'

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

无论是 rollup.js 还是 webpack,在寻找资源时,如果 package.json 中存在 module 字段,那么会优先使用 module 字段指向的资源来代替 main 字段指向的资源。

// 如果项目是使用 webpack 构建的,那么你使用的 Vue.js 资源就是 vue.runtime.esm-bundler.js
{
   "main": "index.js",
   "module": "dist/vue.runtime.esm-bundler.js",
}
1
2
3
4
5

也就是说,

带有 -bundler 字样的 ESM 资源是给 rollup.js 或webpack 等打包工具使用的,

带有 -browser 字样的 ESM 资源是直接给<script type="module"> 使用的。

当我们构建提供给打包工具的 ESM格式的资源时,不能直接把 __DEV__ 设置为 true 或 false,而要使用(process.env.NODE_ENV !== 'production') 替换 __DEV__ 常量。例如下面的源码:

 if (__DEV__) {
   warn(`useCssModule() is not supported in the global build.`)
 }
1
2
3
  • 在带有 -bundler 字样的资源中会变成:
 if ((process.env.NODE_ENV !== 'production')) {
   warn(`useCssModule() is not supported in the global build.`)
 }
1
2
3

好处:用户可以通过 webpack 配置自行决定构建资源的目标环境,但是最终效果其实一样,这段代码也只会出现在开发环境中。

# 用于Node中通过require引用的资源

用户除了可以直接使用 `<script>` 标签引入资源外,我们还希望用户可以在Node.js 中通过 require 语句引用资源,例如:	
 const Vue = require('vue')
1

​ 为什么会有这种需求呢?答案是“服务端渲染”。当进行服务端渲染时,Vue.js的代码是在 Node.js 环境中运行的,而非浏览器环境。在 Node.js 环境中,资源的模块格式应该是 CommonJS,简称 cjs。为了能够输出 cjs 模块的资源,我们可以通过修改 rollup.config.js 的配置 format: 'cjs' 来实现。

# 特性开关

一个特性对应一个开关,通过开关的形式来决定是否需要某些代码,从而减小资源的体积。

  • 框架会给用户提供诸多特性(或功能),并提供对应的开关,用户可以通过设置 true 或 false 来代表开启或关闭对应的特性(或功能)
  • 对于用户关闭的特性,我们可以利用 Tree-Shaking 机制让其不包含在最终的资源中。
  • 可以通过特性开关任意为框架添加新的特性,而不用担心资源体积变大。当框架升级时,也可以通过特性开关来支持遗留 API,这样,新用户可以选择不使用遗留 API,从而使最终打包的资源体积最小化。

​ 那怎么实现特性开关呢?原理和上文提到的 __DEV__ 常量一样,本质上是利用 rollup.js 的预定义常量插件来实现。例如:

 // support for 2.x options
 if (__FEATURE_OPTIONS_API__) {
   currentInstance = instance
   pauseTracking()
   applyOptions(instance, Component)
   resetTracking()
   currentInstance = null
 }
1
2
3
4
5
6
7
8

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

 // support for 2.x options
 if (__VUE_OPTIONS_API__) { // 注意这里
   currentInstance = instance
   pauseTracking()
   applyOptions(instance, Component)
   resetTracking()
   currentInstance = null
 }
1
2
3
4
5
6
7
8

​ 其中 __VUE_OPTIONS_API__ 是一个特性开关(是否使用选项 API),用户可以通过设置__VUE_OPTIONS_API__ 预定义常量的值来控制是否要包含这段代码。通常用户可以使用 webpack.DefinePlugin 插件来实现:

 // webpack.DefinePlugin 插件配置
 new webpack.DefinePlugin({
   __VUE_OPTIONS_API__: JSON.stringify(true) // 开启特性,为了兼容Vue.js2,在Vue.js3中仍然可以使用选项API的方式编写代码
 })
1
2
3
4

# 错误处理

提供统一的错误处理接口,并且让用户可以自行注册错误处理函数来处理错误。

​ 提供了 registerErrorHandler 函数,用户可以使用它注册错误处理程序,然后在 callWithErrorHandling 函数内部捕获错误后,把错误传递给用户注册的错误处理程序。

 // 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)
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这样用户侧的代码就会非常简洁且健壮,这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。

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

实际上,这就是 Vue.js 错误处理的原理,我们可以在源码中搜索到callWithErrorHandling 函数。在 Vue.js 中,我们也可以注册统一的错误处理函数:

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

# 良好的 TypeScript 类型支持

​ TypeScript 是 JavaScript 的超集,使用 TS 的好处有很多,如代码即文档、编辑器自动提示、一定程度上能够避免低级 bug、代码的可维护性更强等。

​ 使用 TS 编写代码与对 TS 类型支持友好是两码事。并不是说使用 TS 编写框架,就等价于对 TS 类型支持友好。

  • 使用 TS 编写的函数:推导不出,依然是 any

使用TS编写的函数

  • TS 类型支持友好:能够推导出返回值类型

TS类型支持友好

# 总结

  • 提供友好的警告信息至关重要,这有助于开发者快速定位问题,因为大多数情况下“框架”要比开发者更清楚问题出在哪里,因此在框架层面抛出有意义的警告信息是非常必要的。

  • 但提供的警告信息越详细,就意味着框架体积越大。我们需要利用 Tree-Shaking 机制,配合构建工具预定义常量的能力,例如预定义 __DEV__ 常量,从而实现仅在开发环境中打印警告信息,而生产环境中则不包含这些用于提升开发体验的代码,从而实现线上代码体积的可控性。

  • 对于用户可能用不到的能力,我们可以利用 Tree-Shaking 机制使最终打包的代码体积最小化。但Tree-Shaking 本身基于 ESM,并且 JavaScript 是一门动态语言,通过纯静态分析的手段进行 Tree-Shaking 难度较大,因此大部分工具能够识别/*#__PURE__*/ 注释,通过可以利用它来辅助构建工具进行 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' 语句。

    ③为了支持服务端渲染,我们需要输出 CommonJS 格式的资源。

  • 框架会提供多种能力或功能。有时出于灵活性和兼容性的考虑,对于同样的任务,框架提供了两种解决方案,用户可以通过特性开关关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被 Tree-Shaking 机制排除。

  • 框架需要为用户提供统一的错误处理接口,这样用户可以通过注册自定义的错误处理函数来处理全部的框架异常。

  • 使用 TS 编写框架和框架对 TS 类型支持友好是两件完全不同的事。有时候为了让框架提供更加友好的类型支持,甚至要花费比实现框架功能本身更多的时间和精力。

上次更新: 2024/9/10 02:14:23