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

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

提升用户的开发体验

友好的警告信息

js 复制代码
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 数据时:

js 复制代码
01 const count = ref(0) 02 console.log(count)

打开控制台查看输出:

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

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

控制框架代码的体积

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

js 复制代码
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,这时上面那段输出警告信息的代码就等价于:

js 复制代码
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 如何工作,其目录结构如下:

tree 复制代码
├── demo 
│ └── package.json  
│ └── input.js  
│ └── utils.js

下面是 input.js 和 utils.js 文件的内容:

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 进行构建:

bash 复制代码
npx rollup input.js -f esm -o bundle.js

这句命令的意思是,以 input.js 文件为入口,输出 ESM,输出的文件叫作 bundle.js。命令执行成功后,我们打开 bundle.js 来查看一下它的内容:

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 文件:

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__ 常量替换为字面量 truefalse,后者则将 __DEV__ 常量替换为 process.env.NODE_ENV !== 'production' 语句。

特性开关

框架会提供多种能力或功能。有时出于灵活性和兼容性的考虑,对于同样的任务,框架提供了两种解决方案,例如 Vue.js 中的选项对象式 API 和组合式 API 都能用来完成页面的开发,两者虽然不互斥,但从框架设计的角度看,这完全是基于兼容性考虑的。有时用户明确知道自己仅会使用组合式 API,而不会使用选项对象式 API,这时用户可以通过特性开关关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被 Tree-Shaking 机制排除。

错误处理⭐

假设我们有一个工具函数:

js 复制代码
// utils.js
export default {
  foo(fn) {
    fn && fn()
  }
}

该模块导出一个对象,其中 foo 属性是一个函数,接收一个回调函数作为参数,调用 foo 函数时会执行该回调函数,在用户侧使用时:

js 复制代码
import utils from 'utils.js'
utils.foo(() => {
 // ...
})

大家思考一下,如果用户提供的回调函数在执行的时候出错了,怎么办?此时有两个办法,第一个办法是让用户自行处理,这需要用户自己执行 try...catch

js 复制代码
import utils from 'utils.js'
utils.foo(() => {
  try {
    // ...
  } catch (e) {
    // ...
  }
})

但是这会增加用户的负担。试想一下,如果 utils.js 不是仅仅提供了一个 foo 函数,而是提供了几十上百个类似的函数,那么用户在使用的时候就需要逐一添加错误处理程序。

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

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

在每个函数内都增加 try...catch 代码块,实际上,我们可以进一步将错误处理程序封装为一个函数,假设叫它 callWithErrorHandling

js 复制代码
// utils.js
export default {
  foo(fn) {
    callWithErrorHandling(fn)
  },
  bar(fn) {
    callWithErrorHandling(fn)
  },
}
function callWithErrorHandling(fn) {
  try {
    fn && fn()
  } catch (e) {
    console.log(e)
  }
}

可以看到,代码变得简洁多了。但简洁不是目的,这么做真正的好处是,我们能为用户提供统一的错误处理接口,如以下代码所示:

js 复制代码
// 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 函数内部捕获错误后,把错误传递给用户注册的错误处理程序。

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

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

这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。

良好的 TypeScript 类型支持

"使用 TS 编写框架和框架对 TS 类型支持友好是两件完全不同的事"

考虑到有的读者可能没有接触过 TS,书中没有做深入讨论,只举了一个简单的例子。下面是使用 TS 编写的函数:

ts 复制代码
function foo(val: any) {
  return val
}

这个函数很简单,它接收参数 val 并且该参数可以是任意类型(any),该函数直接将参数作为返回值,这说明返回值的类型是由参数决定的,如果参数是 number 类型,那么返回值也是 number 类型。然后我们尝试使用一下这个函数,如图所示。

图 2-5 返回值类型丢失

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

ts 复制代码
function foo<T extends any>(val: T): T {
  return val
}

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

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

相关推荐
一斤代码2 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子2 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年2 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子3 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina3 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路4 小时前
React--Fiber 架构
前端·react.js·架构
伍哥的传说4 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js
qq_424409194 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app
我在北京coding4 小时前
element el-table渲染二维对象数组
前端·javascript·vue.js
布兰妮甜4 小时前
Vue+ElementUI聊天室开发指南
前端·javascript·vue.js·elementui