总结
- 提升用户开发体验,友好的警告信息,有助于开发者快速定位问题
- 控制框架代码体积,警告信息越详细代码越大,不需要在生产环境的代码则需要移除
Tree-Shaking
是一种移除dead code
的机制,基于ESM。且可以利用注释代码/*#__PURE__#*/
可以让我们协助它判断此处无副作用。- 使用场景的不同,需求不同,输出不同形式的产物
- 用户控制功能开启或关闭的特性开关,更好的灵活性让关闭的功能代码被
Tree-Shaking
移除 - 框架的错误处理直接决定了用户应用程序的健壮性,也决定了用户开发应用时处理错误的心智负担。框架提供统一的错误处理接口,用户可以通过注册自定义的错误处理函数来处理全部的框架异常。
- 使用TS编写框架 和 框架对TS类型支持友好 不是一回事,后者需要付出巨大的精力去提供友好的类型支持。类型支持不友好的框架在开发过程中会忽略很多低级错误。
1. 提升用户开发体验
友好的警告信息:可以帮助用户快速定位问题,节省用户的时间。
Warn函数实现上需要依赖组件栈信息。开发环境自定义输出信息,浏览器允许编写
window.devtoolsFormatters
以实现自定义输出。对于复杂的数据结构,用户可能只关注类似value值,可以通过formatter重新定义输出。Vue3中是通过initCustomFormatter
函数实现的jsexport function initCustomFormatter() { // https://www.mattzeunert.com/2016/02/19/custom-chrome-devtools-object-formatters.html const formatter = { header(obj: unknown) { // codes }, hasBody(obj: unknown) { // codes }, body(obj: unknown) { // codes } } if ((window as any).devtoolsFormatters) { ;(window as any).devtoolsFormatters.push(formatter) } else { ;(window as any).devtoolsFormatters = [formatter] } }
2. 控制框架代码体积
有部分代码是为了在开发环境提高用户的开发体验,生产环境并不需要。可以通过常量,控制代码出现在对应环境,以此来控制框架代码体积。
if(__DEV__ && !res) {
warm(
`Fail tp ...`
)
}
Vue3通过 rollup.js 对项目进行构建,输出资源时输出两个版本
- 开发环境:vue.global.js
- 生产环境:vue.global.prod.js
在构建开发环境时,__DEV__
会设置为true
,这样Warn函数在开发环境时是存在的
if(true && !res) {
warm(
`Fail tp ...`
)
}
在构建生产环境时,__DEV__会设置为false,因为判断条件始终是假,这段代码永远不会执行,所以在构建时就会被移除
if(false && !res) {
warm(
`Fail tp ...`
)
}
这样可以做到,在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积。
3. 框架要做良好的Tree-Shaking
3.1 Tree-Shaking
Tree-Shaking 是因 rollup.js 而普及。目的是为了消除永远不会执行的代码。
webpack 和 rollup.js 都支持Tree-Shaking。
实现Tree-Shaking的前提:模块为ESM(ES Module),依赖ESM的静态结构。
// input.js
import { foo } from './utils.js'
foo()
// utils.js
export function foo(obj) {
obj && obj.foo
}
export function bar(obj) {
obj && obj.foo
}
当安装完rollup依赖后,执行npx rollup input.js -f esm -o bundle.js
,将会以input.js为入口,esm格式读取,输出到bundle.js
// bundle.js
function foo(obj) {
obj && obj.foo
}
foo()
可见bar函数因为是永远不会调用的代码——dead code,被移除了。
虽然foo函数内部艾玛本身没任何行为,带因为代码可能产生副作用,所以Tree-Shaing没去除foo函数。要静态分析哪些代码是dead code的难度十分高。
例如Proxy代理obj的foo属性,foo函数读取了该属性可能触发其它代码
3.2 /*#__PURE__#*/ 明确不产生副作用
rollup.js 提供给我们一个方法,可以让我们明确地告知它,哪些可能产生副作用的代码实际是不会产生副作用,帮助它判断移除。
// input.js
import {foo} from './utils'
/*#__PURE__#*/ foo()
通过注释代码 /*#__PURE__#*/
告诉rollup.js,foo函数不会产生副作用。这样只要没有实际作用,编译后就不会出现。
/*#__PURE__#*/
一般在模块的顶级调用中使用。因为函数内调用时使用该注释代码,只要函数没被执行,自然不会产生副作用,意义不大。
Vue3在许多模块的顶级调用函数上都是用了该注释代码。
// 某模块内
/*#__PURE__#*/ foo() // 顶级调用
function bar() { // bar不调用 自然不会产生副作用
/*#__PURE__#*/ foo() // 函数内调用
}
当然
/*#__PURE__#*/
可以作用在任何语句上,在 webpack 和压缩工具terser
都是可以被识别的。
4. 框架应该输出怎样的构建产物
根据使用场景的不同,需求不同,输出不同形式的产物。
4.1 可以通过 <script>
引入框架
为了实现这个需要,需要一种IIFE形式的资源。(IIFE:Immediately Invoked Function Expression,立即调用的函数表达式)
(function(){
// ...codes
}())
例如 vue.global.js
文件便是IIFE形式的资源
var Vue = (function (exports) {
// ...
exports.createApp = createApp
// ...
return exports
}({}))
这样 script 标签引用了该文件后,Vue 作为全局变量,可以直接使用。
在 rollup 中,可以配置 format: 'iife'
来指定输出这种形式的资源。
// rollup.config.js
const config = {
input: 'input.js', // 输入
output: { // 输出
file: 'output.js', // 输出文件名
format: 'iife' // 指定模块形式 还有esm cjs
}
}
4.2 可以通过 <script type="module">
引入框架
IIFE形式的资源浏览器都支持,随着技术发展,现在主流的流程也都支持 ESM
形式的资源,使用type="module"
。
<script type="module" src="/path/to/vue.esm-browser.js"></script>
可以配置 format: 'esm'
来指定输出这种形式的资源。
4.3 -browser.js 和 -bundler.js 的区别
在 packages/vie/package.json
中
{
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js"
}
当 package.json
中存在 module
字段时,优先使用 module
来替代 main
。
- 带
-browser
的资源是给<script type="module">
使用的。 - 带
-bundler
的资源是给webpack 和 rollup使用的。
// -browser
if(__DEV__) {
warn(`some warn info`)
}
// -bundler
if(process.env.NODE_ENV !== 'production') {
warn(`some warn info`)
}
他们的区别是,在浏览器中使用,由于没有 p
只能通过直接设置 __DEV__
变量来确定使用开发或生产环境。
使用构建工具时,可以使用webpack的配置,通过配置来决定构建资源的目标环境。这样更便捷和灵活,虽然最终效果都一样。
4.2 可以通过Node.js的 require
语句引入框架
抛开Webpack,服务端渲染时,Vue.js是在Node环境中运行的,我们会用Node.js启动一个服务器,当有用户访问时,返回对应页面,服务器渲染整个页面这个过程是在Node环境,所以在代码中需要 const Vue = require('vue')
去引入Vue。
可以配置 format: 'cjs'
来指定输出这种形式的资源。
当然在工程化里,也可以用成熟的项目 Vue 的 SSR 项目,因为有webpack对应的loader解析,也可以引入ESM模块。
5 特性开关
让用户控制功能开启或关闭的配置,称为特性开关。
特性开关优点:
- 用户关闭的特性,Tree-Shaking 可以彻底移除它
- 为框架设计提供灵活性,可以任意为框架添加功能,只要不默认开启,就不会影响资源体积。也可以用来控制是否支持遗留API,当用户明确不会使用遗留API,便可以关闭遗留特性,从而使最终打包的资源体积最小化。
例如 Vue2 中,编写组件是选项API,配置项。在 Vue3 中,是 Composition API,函数调用,一切皆函数。在用户明确不会使用选项API时,便可以通过特性开关 _
关闭该特性,从而减小资源体积。
以下是Vue3源码中的实现
// rollup.config.js
const outputConfigs = { // 输出配置
'esm-bundler': {
file: resolve(`dist/${name}.esm-bundler.js`),
format: `es`
},
'esm-browser': {
file: resolve(`dist/${name}.esm-browser.js`),
format: `es`
},
// ...some codes
}
// 外部传参
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats
// 不同参数对应不同包配置
const packageConfigs = process.env.PROD_ONLY
? []
: packageFormats.map(format => createConfig(format, outputConfigs[format]))
// 创建配置
function createConfig(format, output, plugins = []) {
// ...some codes
// 根据不同场景的传参,赋值变量
const isBundlerESMBuild = /esm-bundler/.test(format)
const isBrowserESMBuild = /esm-browser/.test(format)
// ...some codes
return {
// ...some codes
plugins: [
json({
namedExports: false
}),
tsPlugin,
createReplacePlugin(
isProductionBuild,
isBundlerESMBuild,
isBrowserESMBuild,
// isBrowserBuild?
(isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
!packageOptions.enableNonBrowserBranches,
isGlobalBuild,
isNodeBuild,
isCompatBuild,
isServerRenderer
),
...nodePlugins,
...plugins
],
// ...some codes
}
}
function createReplacePlugin(
isProduction,
isBundlerESMBuild,
isBrowserESMBuild,
isBrowserBuild,
isGlobalBuild,
isNodeBuild,
isCompatBuild,
isServerRenderer
) {
const replacements = {
// ...some codes
// 设置特性开关
__BROWSER__: isBrowserBuild,
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__ ` : true,
// ...some codes
}
return replace({
// @ts-ignore
values: replacements,
preventAssignment: true
})
}
mixin(mixin: ComponentOptions) {
// 特性开关在功能上的应用
if (__FEATURE_OPTIONS_API__) { // 如果该特性开启 则可以使用
if (!context.mixins.includes(mixin)) {
context.mixins.push(mixin)
} else if (__DEV__) {
warn(
'Mixin has already been applied to target app' +
(mixin.name ? `: ${mixin.name}` : '')
)
}
} else if (__DEV__) {
warn('Mixins are only available in builds supporting Options API')
}
return app
},
5.1 预定义常量
如何给设置特性开关,使用 webpack.DefinePlugin
插件
new webpack.DefinedPlugin({
__VUE_OPTIONS_API__ : JSON.stringify(true) // 开启
})
6. 错误处理
框架的错误处理直接决定了用户应用程序的健壮性,也决定了用户开发应用时处理错误的心智负担。框架提供统一的错误处理接口,用户可以通过注册自定义的错误处理函数来处理全部的框架异常。
当用户调用框架的方法后,回调了业务函数,业务函数中发生报错,框架需要捕获该错误并进行统一处理。因为如果不捕获,用户就需要在每个业务函数中写 try catch
,负担过大。
Vue3 便是通过 callWithErrorHandling
函数封装错误处理程序,也提供了 app.config.errorHandler
函数供用户注册统一的错误处理函数。
// 使用
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
// 统一的错误处理程序
}
// Vue3源码
// packages\runtime-core\src\errorHandling.ts
/* 封装错误处理程序 */
export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
) {
let res
try {
res = args ? fn(...args) : fn()
} catch (err) {
// 调用统一的错误处理函数
handleError(err, instance, type)
}
return res
}
/* 统一的错误处理函数 */
export function handleError(
err: unknown,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
throwInDev = true
) {
if (instance) {
// codes
// 这里便是app.config.errorHandler
const appErrorHandler = instance.appContext.config.errorHandler
if (appErrorHandler) {
// 用户的统一错误处理函数 也会通过封装错误处理程序调用
callWithErrorHandling(
appErrorHandler,
null,
ErrorCodes.APP_ERROR_HANDLER,
[err, exposedInstance, errorInfo]
)
return
}
}
logError(err, type, contextVNode, throwInDev)
}
7. 良好的 TypeScript 类型支持
TS 可以提供类型支持,在开发时能一定程度避免低级bug,且代码的可维护性更强。
使用TS编写框架,和做到完善的TS类型支持完全不一样,后者很难。packages\runtime-core\src\apiDefineComponent.ts
191行代码都是在为类型支持提供服务,真正在浏览器运行的只有3行。
疑问
- -browser 更改
__DEV__
是指直接改-browser文件,直接写死在里面的变量赋值吗? 如果不是,那不理解为什么要有-browser.js的存在 -bundler够用了 - js
{ _FEATURE_OPTIONS_API__: isBundlerESMBuild ? `_
_VUE_OPTIONS_API__ ` : true, } // 这个源代码不理解`__VUE_OPTIONS_API__`为什么是个变量,不是反引号是文本吗。是webpack.DefinedPlugin插件在编译阶段处理成变量的? - 用户的统一错误处理函数,也会通过封装错误处理程序调用,加入用户的统一错误处理程序报错了,怎么回避掉这个循环嵌套的