Skip to content
On this page

总结

  1. 提升用户开发体验,友好的警告信息,有助于开发者快速定位问题
  2. 控制框架代码体积,警告信息越详细代码越大,不需要在生产环境的代码则需要移除
  3. Tree-Shaking 是一种移除 dead code 的机制,基于ESM。且可以利用注释代码 /*#__PURE__#*/ 可以让我们协助它判断此处无副作用。
  4. 使用场景的不同,需求不同,输出不同形式的产物
  5. 用户控制功能开启或关闭的特性开关,更好的灵活性让关闭的功能代码被 Tree-Shaking 移除
  6. 框架的错误处理直接决定了用户应用程序的健壮性,也决定了用户开发应用时处理错误的心智负担。框架提供统一的错误处理接口,用户可以通过注册自定义的错误处理函数来处理全部的框架异常。
  7. 使用TS编写框架 和 框架对TS类型支持友好 不是一回事,后者需要付出巨大的精力去提供友好的类型支持。类型支持不友好的框架在开发过程中会忽略很多低级错误。

1. 提升用户开发体验

  • 友好的警告信息:可以帮助用户快速定位问题,节省用户的时间。
    Warn函数实现上需要依赖组件栈信息。

  • 开发环境自定义输出信息,浏览器允许编写 window.devtoolsFormatters 以实现自定义输出。对于复杂的数据结构,用户可能只关注类似value值,可以通过formatter重新定义输出。Vue3中是通过initCustomFormatter函数实现的

    js
    export 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. 控制框架代码体积

有部分代码是为了在开发环境提高用户的开发体验,生产环境并不需要。可以通过常量,控制代码出现在对应环境,以此来控制框架代码体积。

js
if(__DEV__ && !res) {
  warm(
    `Fail tp ...`
  )
}

Vue3通过 rollup.js 对项目进行构建,输出资源时输出两个版本

  • 开发环境:vue.global.js
  • 生产环境:vue.global.prod.js

在构建开发环境时,__DEV__会设置为true,这样Warn函数在开发环境时是存在的

js
if(true && !res) {
  warm(
    `Fail tp ...`
  )
}

在构建生产环境时,__DEV__会设置为false,因为判断条件始终是假,这段代码永远不会执行,所以在构建时就会被移除

js
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的静态结构。

js
// 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

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 提供给我们一个方法,可以让我们明确地告知它,哪些可能产生副作用的代码实际是不会产生副作用,帮助它判断移除。

js
// input.js
import {foo} from './utils'

/*#__PURE__#*/ foo()

通过注释代码 /*#__PURE__#*/ 告诉rollup.js,foo函数不会产生副作用。这样只要没有实际作用,编译后就不会出现。

/*#__PURE__#*/ 一般在模块的顶级调用中使用。因为函数内调用时使用该注释代码,只要函数没被执行,自然不会产生副作用,意义不大。
Vue3在许多模块的顶级调用函数上都是用了该注释代码。

js
// 某模块内

/*#__PURE__#*/ foo() // 顶级调用

function bar() { // bar不调用 自然不会产生副作用
  /*#__PURE__#*/ foo() // 函数内调用
}

当然 /*#__PURE__#*/ 可以作用在任何语句上,在 webpack 和压缩工具 terser 都是可以被识别的。

4. 框架应该输出怎样的构建产物

根据使用场景的不同,需求不同,输出不同形式的产物。

4.1 可以通过 <script> 引入框架

为了实现这个需要,需要一种IIFE形式的资源。(IIFE:Immediately Invoked Function Expression,立即调用的函数表达式)

js
(function(){
  // ...codes
}())

例如 vue.global.js 文件便是IIFE形式的资源

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

这样 script 标签引用了该文件后,Vue 作为全局变量,可以直接使用。

在 rollup 中,可以配置 format: 'iife' 来指定输出这种形式的资源。

js
// 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"

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

可以配置 format: 'esm' 来指定输出这种形式的资源。

4.3 -browser.js 和 -bundler.js 的区别

packages/vie/package.json

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

package.json 中存在 module 字段时,优先使用 module 来替代 main

  • -browser 的资源是给 <script type="module"> 使用的。
  • -bundler 的资源是给webpack 和 rollup使用的。
js
// -browser
if(__DEV__) {
  warn(`some warn info`)
}

// -bundler
if(process.env.NODE_ENV !== 'production') {
  warn(`some warn info`)
}

他们的区别是,在浏览器中使用,由于没有 process.env.NODE_ENV 只能通过直接设置 __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时,便可以通过特性开关 __VUE_OPTIONS_API__ 关闭该特性,从而减小资源体积。

以下是Vue3源码中的实现

js
// 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 插件

js
new webpack.DefinedPlugin({
  __VUE_OPTIONS_API__: JSON.stringify(true) // 开启
})

6. 错误处理

框架的错误处理直接决定了用户应用程序的健壮性,也决定了用户开发应用时处理错误的心智负担。框架提供统一的错误处理接口,用户可以通过注册自定义的错误处理函数来处理全部的框架异常。
当用户调用框架的方法后,回调了业务函数,业务函数中发生报错,框架需要捕获该错误并进行统一处理。因为如果不捕获,用户就需要在每个业务函数中写 try catch ,负担过大。
Vue3 便是通过 callWithErrorHandling 函数封装错误处理程序,也提供了 app.config.errorHandler 函数供用户注册统一的错误处理函数。

js
// 使用
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
  // 统一的错误处理程序
}
js
// 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行。

疑问

  1. -browser 更改 __DEV__ 是指直接改-browser文件,直接写死在里面的变量赋值吗? 如果不是,那不理解为什么要有-browser.js的存在 -bundler够用了
  2. js
     {
       _FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
     }  
     // 这个源代码不理解`__VUE_OPTIONS_API__`为什么是个变量,不是反引号是文本吗。是webpack.DefinedPlugin插件在编译阶段处理成变量的?
    
  3. 用户的统一错误处理函数,也会通过封装错误处理程序调用,加入用户的统一错误处理程序报错了,怎么回避掉这个循环嵌套的