likes
comments
collection
share

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素

作者站长头像
站长
· 阅读数 13

框架设计要比想象得复杂,并不是说只把功能开发完成,能用就算大功告成了,这里面还有很多学问。比如,我们的框架应该给用户提供哪些构建产物?产物的模块格式如何?当用户没有以预期的方式使用框架时,是否应该打印合适的警告信息从而提供更好的开发体验,让用户快速定位问题?开发版本的构建和生产版本的构建有何区别?热更新(hot module replacement,HMR)需要框架层面的支持,我们是否也应该考虑?另外,当你的框架提供了多个功能,而用户只需要其中几个功能时,用户能否选择关闭其他功能从而减少最终资源的打包体积?上述问题是我们在设计框架的过程中应该考虑的。

提升用户的开发体验

友好的警告信息

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

当我们创建一个 Vue.js 应用并试图将其挂载到一个不存在的 DOM 节点时,就会收到一条警告信息。

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素

这条信息告诉我们挂载失败了,并说明了失败的原因: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)

打开控制台查看输出:

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素

可以发现,打印的数据非常不直观。当然,我们可以选择直接打印 count.value 的值,这样就只会输出 0,非常直观。那么有没有办法在打印 count 的时候让输出的信息更友好呢?

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素

我们可以打开 DevTools 的设置,然后勾选“Console”→“Enable custom formatters”选项(如果你的控制台语言是中文,这里就是”控制台“→”启用自定义格式化程序“),再打印一次试试看:

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素

控制框架代码的体积

如果我们去看 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 类型。然后我们尝试使用一下这个函数,如图所示。

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素 图 2-5 返回值类型丢失

  在调用 foo 函数时,我们传递了一个字符串类型的参数 'str',按照之前的分析,得到的结果 res 的类型应该也是字符串类型,然而当我们把鼠标指针悬浮到 res 常量上时,可以看到其类型是 any,这并不是我们想要的结果。为了达到理想状态,我们只需要对 foo 函数做简单的修改即可:

function foo<T extends any>(val: T): T {
  return val
}

  大家不需要理解这段代码,我们直接来看现在的表现:

潜心修炼之精读《Vue.js设计与实现》第2️⃣章 框架设计的核心要素

  可以看到,res 的类型是字符字面量 'str' 而不是 any 了,这说明我们的代码生效了。这个例子旨在说明,不是使用 TS 编写的代码就一定对 TS 类型支持友好。