框架设计的核心要素

框架设计远比想象复杂,并不是把功能开发完就算大功告成。

还需要考虑框架应该给用户提供哪些构建产物?产物的模块格式如何?用户以错误的方式使用框架,如何打印合适的警告信息,让用户快速定位问题?开发版本的构建和生产版本构建有何区别?热更新需要框架层面的支持,我们是否应该支持?你的框架提供多个功能,用户只需要其中几个功能,用户能否可以走到按需使用,从而减少资源打包体积?这都应该是我们在设计框架的过程中应该考虑的。

提升用户开发体验

框架设计和开发过程中,提供友好的警告信息至关重要。
友好的警告信息不仅能够帮助用户快速定位问题,还能够让框架收获良好的口碑,让用户任何框架的专业性。

vue.js 源码 warn 函数

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

warn 函数,最终调用了 console.warn 函数。

除了提供必要警告信息,还有很多其他方面可以作为切入口,进一步提升用户开发体验。

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

直接打印 count,我们会看到一个对象,而不是 count.value 的值。

vue.js 3 的源码中,可以搜到 initCustomFormatter 的函数,该函数用来在开发环境初始化自定义 formatter。

浏览器允许我们编写自定义的 formatter,以 Chrome 为例,我们可以打开 DevTools,勾选 Console => Enable custom formatters 选项。刷新浏览器再查看,就可以直接看到 count.value 的值。

控制代码体积

框架的大小也是衡量框架的标准之一。
实现同样功能的前提下,编写的代码越少越好,这样体积就会越小,浏览器加载资源的时间也越少。

vue.js 源码,每一个 warn 函数的调用都会配置 _DEV_ 常量的检查:

if (__DEV__ && !res) { warn( 'Failed to moune app: mount target selector "${ container }" returned null.' ) }

vue.js 使用 rollup.js 对项目进行构建,这里的 _DEV_ 是通过 rollup.js 的插件配置来预定义的,功能类似于 webpack 中的 DefinePlugin 插件。生产环境中,这段代码不会出现在最终产物中,在构建资源的时候就会被移除。

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

良好的 Tree-Shaking

vue.js 内置了很多组件,我们的项目并没有使用这么多组件,还有前面提到的变量的打印,生产环境不需要这些代码出现。

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

要想实现 Tree-Shaking,必须满足一个条件,模块必须要 ESM(ES Module),Tree-Shaking 依赖 ESM 的静态结果。

以 rollup.js 为例

|- demo | - package.json | - input.js | - utils.js
yarn add rollup -D
// 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 中导出了两个函数,在 input.js 中我们只是用到 foo 函数。

npx rollup input.js -f esm -o dist/bundle.js // rollup 构建
// dist/bundle.js function foo (obj) { obj && obj.foo; } foo();

编译后代码不包括 bar 函数,Tree-Shaking 起到了作用。但是还存在问题,我们只是读取了 foo 函数,并没有调用,为什么 rollup.js 为什么不把 obj && obj.foo 作为 dead code 移除?

这其实是 Tree-Shaking 的另一个关键点 - 副作用。

副作用就是当调用函数时会对外部产生影响。你可能会说,上面代码只是读取值,怎么会产生副作用?其实有这样一种场景,如果 obj 对象是一个通过 Proxy 创建的代理对象,当我们读取对象的值,就会触发代理对象的 get trap,get trap 中可能存在副作用。

JavaScript 作为一门动态预览,想要静态分析代码很困难,所以 rollup.js 这类工具都会提供一种机制,你可以很明确地告诉 rollup.js,这段代码不会产生副作用,可以移除它。

// input.js import { foo } from './utils.js'; /*#__PURE__*/ foo();

如果你搜索 vue.js 3 的源码,会发现它大量使用了该注释。

export const isHtmlTag = /*#__PURE__*/ makeMap(HTML_TAGS);

通常产生副作用的代码都是模块内函数的顶级调用。

foo(); // 顶级调用 function bar () { foo(); // 函数内调用 }

对于顶级调用,可能会产生副作用;对于函数调用来说,只要函数 bar 没有被调用,那么就不会产生副作用。

在 vue.js 3 的源码中,基本都是在一些顶级调用的函数中使用 /*__PURE__*/ 注释。该注释不仅可以作用于函数,还可以作用于任何语句。该注释也可以被 webpack 及 terser 识别。

如何输出构建产物

vue.js 会为开发环境和生产环境输出不同的包。
vue.global.js 用于开发环境,它包含必要的警告信息;vue.gobal.prod.js 用于生产环境,不包含警告信息。
vue.js 构建产物不仅仅存在环境上的区分,还会根据使用场景的不同输出其他形式的产物。

不同类型的产物一定有对应的需求背景。

当用户希望可以直接在 HTML 页面中使用 <script> 标签引入框架并使用。
我们需要输出一种 IIFE 格式的资源。IIFE 的全称是 Immediately Invoked Function Expression ,即 “立即调用函数”。

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

vue.global.js 文件就是 IIFE 形式的资源。

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

在 rollup.js 中,我们可以配置 format: 'iife' 来输出这种形式的资源。

// rollup.config.js const config = { input: 'input.js', output: { file: 'output.js', format: 'iife' } }; export default config;

目前主流浏览器基础都支持 ESM,所以用户还可以引入 ESM 格式的资源。

vue.js 3 的 vue.esm-browser.js 文件,可以使用 <script type="module"> 标签引入。

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

除了 vue.esm-browser.js 文件,vue.js 还会输出 vue.esm-bundler.js 文件。

vue.esm-bundler.js 是提供给 rollup.js 或 webpack 等打包工具使用的,通常配置在 package.json 中的 module 字段。

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

它们之间有何区别?

例如上文中提到的 __DEV__ 常量,当构建用于 <script> ESM 资源时,开发环境 __DEV__ 为 true,生产环境 __DEV__ 为 false。

当打包提供给打包工具的 ESM 资源时,需要使用 (process.env.NODE_ENV !== 'production') 替换 __DEV__ 常量。

// browser.js if (__DEV__) { warn('useCssModule() is not supported in the global build.'); }
// -bundler.js if ((process.env.NODE_ENV !== 'production')) { warn('useCssModule() is not supported in the global build.'); }

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

用户可能还希望在 Node.js 中通过 require 语句引用资源。

const Vue = require('vue');

当进行服务端渲染时,vue.js 的代码是在 Node.js 环境中运行的。这时就需要输出 CommonJS 的资源格式,简称 cjs。

// rollup.config.js const config = { input: 'input.js', output: { file: 'output.js', format: 'cjs' } }; export default config;

特性开关

设计框架时,框架还会提供诸多特性(功能),假设我们提供 A、B、C 三个特性给用户,同时还提供 a、b、c 三个对应的特性开关。

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

特性开关和上文提供的 __DEV__ 常量一样,可以利用 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 = instance; pauseTracking(); applyOptions(instance, Component); resetTreacking(); currentInstance = null; }

vue.js 构建资源时,如果构建服务于打包工具的资源(带有 _bundler 字样的资源),上述代码就会变成:

// support for 2.x options if (__VUE_OPTIONS_API__) { currentInstance = instance; pauseTracking(); applyOptions(instance, Component); resetTreacking(); currentInstance = null; }

__VUE_OPTIONS_API__ 就是一个特性开关,用户可以通过设置 __VUE_OPTIONS_API__ 预定义常量的值控制是否包含这段代码。

// webpack.DefinePlugin 插件配置 new webpack.DefinePlugin({ __VUE_OPTIONS_API__: JSON.stringify(true) // 开启特性 })

我们可以通过配置 __VUE_OPTIONS_API__ 特性开关决定是否可以使用选项 API 的方式编写代码。如果明确知道自己不会使用选项 API,可以关闭此特性,这样在打包的时候 vue.js 的这部分代码就不会包含在最终资源中,减小打包体积。

错误处理

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

// utils.js export default { foo (fn) { fn && fn(); } }

该模块导出一个对象。如果用户在使用 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 (error) { } }, bar (fn) { try { barfn && bar(); } catch (error) { } } }

每个函数都增加 try…catch,我们还可以优化下。

export default { foo (fn) { callWithErrorHandling(fn); }, bar (fn) { callWithErrorHandling(fn); } } function callWithErrorHandling (fn) {[ try { fn && fn(); } catch (error) { console.log(error); } ]}

代码变的简洁很多。但简洁不是目的,这样做的好处是我们可以为用户提供统一的错误处理接口。

let handleError = null; export default { foo (fn) { callWithErrorHandling(fn); }, bar (fn) { callWithErrorHandling(fn); }, // 用户注册错误处理函数 registerErrorHandler (fn) { handleError = fn; } } function callWithErrorHandling (fn) {[ try { fn && fn(); } catch (error) { // 抛出错误 handleError && handleError(error); } ]}

我们提供 registerErrorHandler 函数,用户可以使用它注册错误处理程序。这时错误处理的能力交由用户控制,即可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。

其实这就是 vue.js 错误处理的原理,你可以在源码中搜索到 callWithErrorHandling 函数。
另外,在 vue.js 中,我们也可以注册统一的错误处理函数。

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

typescript 支持

ts,它是 js 的超集,能够为 js 提供类型支持。使用 ts 的好处有很多,如编译器自动提示、一定程度避免低级 bug、代码的可维护强。因此对 ts 的支持是否完善也是评价一个框架的重要指标。

如何衡量一个框架对 ts 类型的支持水平?并不是只要是使用 ts 编写框架,就等价于对 ts 类型支持友好。

function foo (val: any) { return val; }

这个函数很简单,接收参数 val 并返回该参数,返回值的类型是由参数决定的。上述代码显然不能满足我们的要求,正确的做法如下。

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

编写大型框架时,想要做到完善的 ts 类型支持很不容易,vue.js 源码中的 runtime-core/src/apiDefineComponent.ts文件,整个文件真正会在浏览器中中运行的代码其实只有 3 行,但是全部代码接近 200 行,这些代码都是在为类型支持服务。

总结

框架设计中开发体验时衡量一个框架的重要指标之一。提供友好的警告信息又处于开发者快速定位维提,大多数情况下 “框架” 要比开发者更清楚问题出在哪里,因此在框架层面抛出有意义的警告信息是非常有必要的。

为了框架体积不受警告信息的影响(提供警告信息越详细,框架体积越大),我们需要利用 Tree-Shaking 机制,配置构建工具预定义变量的能力,从而实现仅在开发环境中打印警告信息,生产环境中则不包含这些用于提升开发体验的代码,实现线上代码的可控性。

框架不同类型的输出产物用于满足不同需求。我们需要结合实际使用情况,可以针对性输出构建产物。

框架会提供多种能力。有时会出于兼容性和灵活性考虑,对于同样的任务,框架会提供多种解决方案。vue.js 中就可以使用选项对象式 API 和组合式 API两种方法完成页面开发。从框架设计来看,这完全是基于兼容性考虑的。如果用户只想使用组合式 API,这时就可以通过特性开关关闭对应的特性。

框架的错误处理决定了用户应用程序的健壮性,也决定了用户开发应用时处理错误的负担。框架需要为用户提供统一的错误处理接口,用户可以通过注册自定义的错误处理函数来处理全部的框架异常。

框架对于 ts 的支持程序也是考量框架的重要指标。
有时候为了让框架提供更加友好的类型支持,甚至会花费比实现框架更多的时间和精力。