框架设计基础
1.框架设计基础
2.虚拟Dom本身的性能
虚拟dom与innerHTML
这里需要纠正一个常见的误区:虚拟Dom的性能比原生Dom更好。但其实本质上虚拟Dom只是实际Dom的一个映射形式,理论上虚拟Dom的性能就不可能比原生操作Dom更高了。其实通常说的虚拟Dom性能更好是指基于虚拟Dom以及diff算法的渲染模式比直接重新渲染整个区域的性能更好。对于声明式代码,更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,那么虚拟Dom的存在,是服务于降低性能差异而存在的。
虚拟Dom和直接用innerHTML的差异如下:
可以看出,虚拟Dom最大的优势在于让更新过程从“销毁所有旧Dom,再新建所有新Dom”变成“进行必要的Dom更新”,这是一个质的飞跃,因为浏览器渲染Dom元素本身的开销是巨大的。
一点题外话:
其实自己手动渲染也是可以的,像笔者曾经在一个练手项目中,如果某个频繁切换的视图使用直接修改innerHTML这个方法的话,每次切换这个视图都会陷入白屏一段时间,这种其实就是暴力地插入innerHTML后,由于视图需要整个重新渲染带来的白屏。那么当时每一次视图切换的时候,innerHTML改变的只有部分的Text和部分元素的属性,那么我其实可以用
document.createRange().createContextualFragment(innerHtmlToChange)
来生成一个新的视图Dom,然后进行新老的比对
const newElement = Array.from(newDom.querySelectorAll('*'));
const curElement = Array.from(this._parentElement.querySelectorAll('*'));
newElement.forEach((newEl,index) => {
const curEl = curElement[index];
// 当不同的节点为文本节点
if(!newEl.isEqualNode(curEl) &&
newEl.firstChild?.nodeValue.trim()!='') {
curEl.textContent = newEl.textContent;
}
// 当不同节点的差异发生在属性上
if(!newEl.isEqualNode(curEl)) {
Array.from(newEl.attributes).forEach(attr => {
curEl.setAttribute(attr.name,attr.value);
})
}
})
3.运行时与编译时框架
当我在纯运行时框架中,我将虚拟Dom写成以下形式,并假定渲染函数只支持这一种形式:
01 const obj = {
02 tag: 'div',
03 children: [
04 { tag: 'span', children: 'hello world' }
05 ]
06 }
那么这种树形结构其实很多时候并不利于实际开发,那么如果用户可以书写类似HTML的标签形语言,再转成虚拟Dom,框架本身会友好很多,这其实就是编译时框架:
vue3本身是运行时+编译时的框架,它既可以直接使用模板语言去渲染组件,也可以直接写虚拟Dom,直接交由Render函数去渲染。
2.框架设计的核心要素
对于一个框架来说,功能的实现并不是终点,实际上,框架设计的时候需要考虑很多问题,诸如:我们的框架应该给用户提供哪些构建产物?产物的模块格式如何?当用户没有以预期的方式使用框架时,是否应该打印合适的警告信息从而提供更好的开发体验,让用户快速定位问题?开发版本的构建和生产版本的构建有何区别?热更新(hot module replacement,HMR)需要框架层面的支持,我们是否也应该考虑?另外,当你的框架提供了多个功能,而用户只需要其中几个功能时,用户能否选择关闭其他功能从而减少最终资源的打包体积?
1.正确的打印信息
(1)假如我挂在一个不存在的节点:
createApp(App).mount('#not-exist')
若是原生的JavaScript提示信息,大概率会得到一个不清晰的提示信息,例如 Uncaught TypeError: Cannot read property ‘xxx’ of null,而根据此信息我们很难知道问题出在哪里。
而通过vue.js内部的处理,输出信息会比较明确:
友好的警告信息对于开发过程中是至关重要的,警告信息应该尽可能地帮助开发者定位到发生错误的地方,在vue.js内部会做很多复杂的处理,但本质还是一系列console.warn()的输出。
(2)当我在控制台打印一个ref输出的时候:
01 const count = ref(0)
02 console.log(count)
可能得到的结果并不直观:
在 Vue.js 3 的源码中,你可以搜索到名为 initCustomFormatter 的函数,该函数就是用来在开发环境下初始化自定义 formatter 的。以 Chrome 为例,我们可以打开 DevTools 的设置,然后勾选“Console”→“Enable custom formatters”选项
那么现在输出会变得非常直观:
2.尽可能地减少体积
在vue中,每一个warn信息都会配合__DEV__
常量 :
01 if (__DEV__ && !res) {
02 warn(
03 `Failed to mount app: mount target selector "${container}" returned null.`
04 )
05 }
Vue.js 使用 rollup.js 对项目进行构建,这里的 __DEV__
常量实际上是通过rollup.js 的插件配置来预定义的,其功能类似于 webpack 中的 DefinePlugin 插件。那么vue输出资源时,会输出两个版本,一个用于开发环境,如vue.global.js,另一个用于生产环境,如 vue.global.prod.js。
那么上边的代码在构建成生产环境的时候必然永远都不会执行,这个部分被称为dead code,在生产环境的包中,这个代码是永远不会出现的。
3.tree shaking
所谓tree shaking,是指消除掉那些永远不会删除的代码,也就是上边的dead code。想要实现tree-shaking,模块必须是ESM,因为tree-shaking依赖于ESM的静态结构。
tree-shaking的第二个关键点就是,若一个函数会产生“副作用”,那么不能将这个函数移除。副作用函数指的是调用函数会对外部产生影响的函数,例如Array.prototype.sort就是一个典型的副作用函数。有时候副作用是难以静态判断的:例如当我们读一个对象的值,看起来不会产生什么影响,但是当我对对象用Proxy进行拦截get夹子,那么get中可能存在副作用。
那么如何删除dead code呢? rollup.js会提供一个机制:特定的魔法注释——当在函数前面打上/*#__PURE__*/
,指明这一个函数是没有副作用的函数。一般来讲,副作用函数都是在模块内函数的顶级调用。
顶级调用的形式如下:
01 foo() // 顶级调用
02
03 function bar() {
04 foo() // 函数内调用
05 }
当然,这个注释不仅仅适用于函数,也适用于任何语句。
4.框架应该怎么构建输出的产物?
vue本身会为生产环境/开发环境输出不一样的包,当然在不同的使用场景下,也会输出不一样的产物。
以在HTML中引入vue为例:
01 <body>
02 <script src="/path/to/vue.js"></script>
03 <script>
04 const { createApp } = Vue
05 // ...
06 </script>
07 </body>
要实现这种场景,vue会构建成IIFE输出,即可以暴露出全局的vue对象:
01 var Vue = (function(exports){
02 // ...
03 exports.createApp = createApp;
04 // ...
05 return exports
06 }({}))
在 rollup.js 中,我们可以通过配置 format: ‘iife’ 来输出这种形式的资源:
01 // rollup.config.js
02 const config = {
03 input: 'input.js',
04 output: {
05 file: 'output.js',
06 format: 'iife' // 指定模块形式
07 }
08 }
09
10 export default config
5.特性开关
假设我们给用户A、B、C三个特性,并给他们A、B、C三个变量以通过真值来控制特性是否使用,那么这三个变量我们称为特性开关。
特性开关和__DEV__
一样,本质上是利用 rollup.js 的预定义常量插件来实现。
3.vue的一些设计思路
1.初识渲染器
渲染器就是将我们写的虚拟dom给渲染成真实dom的一个中间层:
假设有如下的虚拟dom:
01 const vnode = {
02 tag: 'div',
03 props: {
04 onClick: () => alert('hello')
05 },
06 children: 'click me'
07 }
那么渲染器可以为:
01 function renderer(vnode, container) {
02 // 使用 vnode.tag 作为标签名称创建 DOM 元素
03 const el = document.createElement(vnode.tag)
04 // 遍历 vnode.props,将属性、事件添加到 DOM 元素
05 for (const key in vnode.props) {
06 if (/^on/.test(key)) {
07 // 如果 key 以 on 开头,说明它是事件
08 el.addEventListener(
09 key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
10 vnode.props[key] // 事件处理函数
11 )
12 }
13 }
14
15 // 处理 children
16 if (typeof vnode.children === 'string') {
17 // 如果 children 是字符串,说明它是元素的文本子节点
18 el.appendChild(document.createTextNode(vnode.children))
19 } else if (Array.isArray(vnode.children)) {
20 // 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
21 vnode.children.forEach(child => renderer(child, el))
22 }
23
24 // 将元素添加到挂载点下
25 container.appendChild(el)
26 }
其中vnode是输入的虚拟dom对象,而container为挂在虚拟dom的父元素。
2.组件的本质
本质上:组件就是一组DOM元素的封装。
这里是一个组件的例子:
01 const MyComponent = function () {
02 return {
03 tag: 'div',
04 props: {
05 onClick: () => alert('hello')
06 },
07 children: 'click me'
08 }
09 }
可以看到,组件返回的也是一组虚拟dom,那么虚拟dom在描述组件时,可以让虚拟dom的tag来存储组件函数:
01 const vnode = {
02 tag: MyComponent
03 }
那么这个时候我们需要修改一下上边的Renderer函数,我们需要根据tag的类型去分别做处理:
01 function renderer(vnode, container) {
02 if (typeof vnode.tag === 'string') {
03 // 说明 vnode 描述的是标签元素
04 mountElement(vnode, container)
05 } else if (typeof vnode.tag === 'function') {
06 // 说明 vnode 描述的是组件
07 mountComponent(vnode, container)
08 }
09 }
那么mountComponent也是需要借助renderer函数来进行实现:
01 function mountComponent(vnode, container) {
02 // 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
03 const subtree = vnode.tag()
04 // 递归地调用 renderer 渲染 subtree
05 renderer(subtree, container)
3.模板的工作原理
上面提到的renderer函数本身是面向虚拟dom这一js对象的,vue的声明式UI中还有一个更常用的:模板,模板本身更接近与前端开发常写的html。那么想要让模板编程真实dom,必然中间需要将模板转换为虚拟dom,才能进行渲染。