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,再手动进行插入删除等改动
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包装的响应式数据时,看到的是下面这样的数据
很不直观,不能一眼知道这个响应式数据是什么
打开控制台的
自定义格式设置工具,可以让ref的数据打印变得直观些
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.js或webpack等构建工具提供了一种机制
// 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.js或webpack等打包工具使用的
-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 = () => {
// 错误处理程序
}