【Vue.js设计与实现读书笔记】-- 第一篇:框架设计概览

346 阅读7分钟

1. 权衡

1.1 命令式与声明式

命令式关注过程

const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => { alert('ok') })

描述“做事的过程”

声明式关注结果

<div @click="() => alert('ok')">hello world</div>

用户编写的时候只用关注“结果”,具体的实现过程交给框架去实现


1.2 性能和可维护性

声明式代码的性能不优于命令式代码的性能 以修改#app元素的文本内容为例,命令式代码可以直接修改相应dom元素即可 而框架是声明式的,要修改dom元素内容,首先得找出其声明的代码发生了什么变化,然后再去修改变化的部分

把直接修改的性能消耗定义为A,找出差异的性能消耗定义为B,则有:

  • 命令式代码的更新性能消耗 = A
  • 声明式代码的更新性能消耗 = B + A

声明式代码具有更好的可维护性,而命令式代码具有更好的性能


1.3 虚拟DOM

虚拟DOM是纯JS层面的计算,维护了一个JS的对象,这个对象就是虚拟DOM,该对象能够映射到DOM元素中,当需要修改DOM的时候,只需要修改虚拟DOM,然后渲染的时候找出虚拟DOM中变化的部分(也就是所谓的Diff算法),然后再渲染变化的部分即可

1.3.1 虚拟DOM和innerHtml

  • 虚拟DOM在更新元素的时候,只会进行必要的DOM更新(也就是只更新变化的部分)
  • innerHtml更新元素的话,是会销毁所有旧的DOM,再根据新的innerHtml创建新的DOM
  • 因此采用innerHtml更新元素的性能最低
  • 实际上性能最高的更新DOM元素的方法还是用原生JS,但是这样的坏处就是不好维护,开发者需要自己找出变化的DOM元素,并且定位到要修改的相关DOM,再手动进行插入删除等改动

IMG_20220405_110859.jpg


1.4 运行时和编译时

1.4.1 纯运行时

纯运行时代码示例: 一个Render函数

// Render 渲染函数
function Render(obj, root) {
  const el = document.createElement(obj.tag)
  if (typeof obj.children === 'string') {
    const text = document.createTextNode(obj.children)
    el.appendChild(text)
  } else if (obj.children) {
    // 数组 -- 递归调用 Render 并使用 el 作为 root 参数
    obj.children.forEach((child) => Render(child, el))
  }

  // 将元素添加到 root 中
  root.appendChild(el)
}

const obj = {
  tag: 'div',
  children: [{ tag: 'span', children: 'hello world' }],
}

// 将 obj 这个“虚拟DOM” 渲染到 body 中
Render(obj, document.body)

这就是纯运行时代码,一切都是js,没有涉及别的东西


1.4.2 纯编译时

编译时则是将html标签转成上面的obj,也就是一种描述树形结构的数据对象,如:

const html = `
<div>
	<span>hello world</span>
</div>
`

假设有一个Compiler函数,能够将上面这个html字符串转成obj

function Compiler() {
	...
}
  
const obj = Compiler(html)
console.log(obj)
  
// output
{
  tag: 'div',
  children: [{ tag: 'span', children: 'hello world' }],
}

这样就是编译时的代码,因为并不是纯js运行,而是涉及到了html或者类似html的描述树形结构的方式的语言,将他们转成js的对象,这个过程就是“编译”


1.4.3 运行时 + 编译时

将上面编译时的代码执行后得到的obj对象传给运行时代码完成渲染,这样的组合就是运行时 + 编译时

const obj = Compiler(html)
Render(obj, document.body)

2. 框架设计的核心要素

2.1 直观输出ref响应式数据

直接consolo.log()ref包装的响应式数据时,看到的是下面这样的数据 image.png 很不直观,不能一眼知道这个响应式数据是什么 打开控制台的自定义格式设置工具,可以让ref的数据打印变得直观些 image.png

2.2 Tree-Shaking

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

2.2.1 Tree-Shaking基本理解

比如在utils.js中导出下面两个函数

// utils.js
export function foo(obj) {
  obj && obj.foo
}

export function bar(obj) {
  obj && obj.bar
}

input.js中导入其中的foo函数并调用

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

foo()

再用rollup.js进行打包,这是一个和webpack类似的构建工具

npm install rollup -D
npx rollup input.js -f esm -o bundle.js

输出的bundle.js中内容如下

// bundle.js
function foo(obj) {
  obj && obj.foo;
}

foo();

可以看到并没有把utils.js中的bar函数也打包进来,这就是Tree-Shaking发挥的作用,只打包用到的代码,没有打包没用到的代码(dead code)


2.2.2 Tree-Shaking的副作用

如果一个函数的调用会产生副作用,那么就不会将它移除 副作用是调用函数时会对外部产生影响

obj对象是一个通过Proxy创建的代理对象,当读取对象属性时,就会触发代理对象的get夹子(trap),在get夹子中是可能产生副作用的

比如在get夹子中修改了某个全局变量,而到底会不会产生副作用,只有代码运行时才知道,因为js是动态语言,想要静态分析哪些是dead code是很困难的

为此,rollup.jswebpack等构建工具提供了一种机制

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

/*#__PURE__*/ foo()

/*#__PURE__*/的作用是告诉构建工具对于foo函数的调用不会产生副作用,可以放心进行Tree-Shaking这时候再构建一次看看会发现,bundle.js中是空的


2.3 构建产物的类型

vue中会为开发环境和生产环境输出不同的包,如vue.global.js用于开发环境,vue.global.prod.js用于生产环境,不包含警告信息 除了环境上的区分外,还会根据使用场景的不同而输出其他形式的产物

2.3.1

如果要构建出能够直接在<script>中引入使用的产物,需要输出一种叫做IIFE(Immediately Invoked Function Expression),中文名叫“立即调用的函数表达式”,引入这种形式的代码后,就可以直接使用了

// IIFE 的形式
(function () {
	// ...
}())

比如上面的input.js和utils.js构建成IIFE形式后代码如下

(function () {
  'use strict';

  function foo(obj) {
    obj && obj.foo;
  }

  foo();

})();

2.3.2 -bundler-browser

如果package.json中存在module字段,那么会优先使用module字段指向的资源来代替main字段指向的资源

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

-bundler字样的ESM资源是给rollup.jswebpack等打包工具使用的 -browser字样的ESM资源是给浏览器中<script type="module">使用的 这两种的区别在哪? 参考vue3源码中的部分代码

if (__DEV__) {
	warn(`useCssModule() is not supported in the global build.`)
}

-browser资源中,__DEV__会被打包工具替换成:

if (false) {
	warn(`useCssModule() is not supported in the global build.`)
}

从而将它变成dead code,由Tree-Shaking优化,不加入到最终构建结果中

-bundler资源中,__DEV__会被打包工具替换成:

if ((process.env.NODE_ENV !== 'production') {
	warn(`useCssModule() is not supported in the global build.`)
}

这样的话在生产环境下打包,Tree-Shaking就会将其忽略

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

2.3.3 服务端渲染

如果希望在Node.js中使用我们的代码的构建结果,由于node中是CommonJS的模块格式,因此只能用require的方式引入,这时候就需要修改构建配置,将输出格式改为cjs


2.4 __VUE_OPTIONS_API__特性开关

用户可以通过使用webpack.DefinePlugin插件实现是否要启用Options API特性,因为vue3仍然是兼容vue2的options api的,但是如果我们明确只用Composition API而不用Options API时,就可以通过配置项将其关闭,减小打包后结果的体积

// webpack.DefinePlugin 插件配置
new webpack.DefinePlugin({
	__VUE_OPTIONS_API__: JSON.stringify(false) // 关闭特性
})

2.5 错误处理

尽可能在自己的代码中处理好异常和错误,避免将错误传递给用户

// utils.js

// 默认的错误处理 -- 直接打印错误
let handleError = (error) => {
  console.log(error)
}

export default {
  foo(fn) {
    callWithErrorHandling(fn)
  },
  // 用户可以调用该函数注册统一的错误处理函数
  registerErrorHandler(fn) {
    handleError = fn
  },
}

function callWithErrorHandling(fn) {
  try {
    fn && fn()
  } catch (e) {
    // 将捕获到的错误传递给用户的错误处理程序
    handleError(e)
  }
}
// index.js
import utils from './utils'

// 注册错误处理程序
utils.registerErrorHandler((e) => {
  console.log('自定义的错误处理程序', e)
})

const errorFn = () => {
  throw new Error('出错啦!')
}

utils.foo(errorFn)

通过注册的思想,让用户可以自己决定如何处理错误,这样用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统,事实上,Vue.js中就是这样的原理,源码中也有callWithErrorHandling函数,可以自行注册统一的错误处理函数

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