Virtual DOM的实现原理总结超详细版(一)
Virtual DOM的实现原理 made by Q7Long
目标
- 了解什么是虚拟
DOM
,以及虚拟DOM
的作用 Snabbdom
的基本使用(Vue
内部的虚拟DOM
是改造了开源库Snabbdom
)Snabbdom
的源码解析
1、什么是Virtual DOM
Virtual Dom(虚拟DOM)
,是由普通的 JS
对象来描述 DOM
对象,因为不是真实的 DOM
对象,所以叫做 Virtual DOM
。
我们为什么用虚拟 DOM
来模拟真实的 DOM
呢?
因为我们知道一个 DOM
对象中的成员是非常多。所以创建 DOM
对象的成本非常高。
如果使用虚拟 DOM
来描述真实 DOM
,就会发现创建的成员少,成本也就低了。
2、为什么使用Virtual DOM
- 手动操作
DOM
比较麻烦,还需要考虑浏览器兼容性问题,虽然有Jquery
等库简化DOM
操作,但是随着项目的复杂度越来越高,DOM
操作复杂提升,既要考虑DOM
操作,还要考虑数据的操作。 - 为了简化
DOM
的复杂操作于是出现了各种的MVVM
框架,MVVM
框架解决了视图和状态的同步问题,也就是当数据发生变化,更新视图,当视图发生变化更新数据
。 - 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题(当数据发生了变化后,无法获取上一次的状态,只有将页面上的元素删除,然后在重新创建,这时页面有刷新的问题,同时频繁操作
DOM
,性能也会非常低),于是Virtual Dom
出现了。 Virtual Dom
的好处就是当状态改变时不需要立即更新DOM
,只需要创建一个虚拟树来描述DOM
,Virtual Dom
内部将弄清楚如何有效(diff
)的更新DOM
。(例如:向用户添加列表中添加一个用户,只添加新的内容,原有的结构会被重用)- 虚拟
DOM
就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有 10 次更新DOM
的动作,虚拟DOM
不会立即操作DOM
,而是将这 10 次更新的diff
内容保存到本地一个JS
对象中,最终将这个JS
对象一次性attch
到DOM
树上,再进行后续操作,避免大量无谓的计算量。所以,用JS
对象模拟DOM
节点的好处是,页面的更新可以先全部反映在JS
对象(虚拟DOM
)上,操作内存中的JS
对象的速度显然要更快,等更新完成后,再将最终的JS
对象映射成真实的DOM
,交由浏览器去绘制。 - 下面,我们看一段代码,该代码是使用
jquery
来实现的数据展示与排序,是纯DOM
操作的方式
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8">
<script type="text/javascript" src="https://code.jquery.com/jquery-1.11.3.js"></script>
</head>
<body>
<div id="app">
</div>
<div id="sort" style="margin-top: 20px;">按年纪排序</div>
<script type="text/javascript">
var datas = [
{ 'name': 'james', 'age': 32 },
{ 'name': 'Q7Long', 'age': 23 },
{ 'name': 'kobe', 'age': 31 },
{ 'name': 'wade', 'age': 30 }
];
var render = function() {
var html = '';
datas.forEach(function(item, index) {
html += `<li>
<div class="u-cls">
<span class="name">姓名:${item.name}</span>
<span class="age" style="margin-left:20px;">年龄:${item.age}</span>
<span class="closed">x</span>
</div>
</li>`;
});
return html;
};
$("#app").html(render());
$('#sort').on('click', function() {
datas = datas.sort(function(a, b) {
return a.age - b.age;
});
$('#app').html(render());
})
</script>
</body>
</html>
你用传统的原生api
或jQuery
去操作DOM
时,浏览器会从构建DOM
树开始从头到尾执行一遍流程
比如当我们在一次操作时,需要更新10个DOM
节点,浏览器没这么智能,收到第一个更新DOM
请求后,并不知道后续还有9次更新操作,因此会马上执行流程,最终执行10次流程
而通过VNode
,同样更新10个DOM
节点,虚拟DOM
不会立即操作DOM
,而是将这10次更新的diff
内容保存到本地的一个js
对象中,最终将这个js
对象一次性attach
到DOM
树上,避免大量的无谓计算
如上 demo
排序,虽然在使用 jquery
时代这种方式是可行的,我们点击按钮,它就可以从小到大的排序,但是它比较暴力,它会将之前的 demo
全部删除,然后重新渲染新的 demo
节点,我们知道,操作 DOM
会影响页面的性能,并且有时候数据根本就没有发生改变,我们希望未更改的数据不需要重新渲染操作。
因此虚拟 DOM
的思想就出来了,虚拟 DOM
的思想是先控制数据再到视图,但是数据状态是通过 diff
比对,它会比对新旧虚拟 DOM
节点,然后找出两者之前的不同,然后再把不同的节点再发生渲染操作。
很多人认为虚拟 DOM 最大的优势是 diff 算法,减少 JavaScript 操作真实 DOM 的带来的性能消耗。虽然这一个虚拟 DOM 带来的一个优势,但并不是全部。虚拟 DOM 最大的优势在于抽象了原本的渲染过程,实现了跨平台的能力,而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是近期很火热的小程序,也可以是各种GUI
如下图所示:
-
总结:
虚拟
DOM
可以维护程序的状态,跟踪上一次的状态通过比较前后两次状态的差异来更新真实
DOM
3、虚拟DOM的作用
维护视图和状态的关系(虚拟 DOM
会记录状态的变化,只需要更新状态变化的内容就可以了)
复杂视图情况下提升渲染性能
。
下面的一个案例,该案例的功能比较简单,单击按钮后,更新 div
中的内容。
let div=document.querySelector('#app')
let btn=document.querySelector('#btn')
btn.onclick=function(){
div.textContent='Hello World'
}
以上代码非常简单,而且是使用 DOM
操作的方式来实现的。
如果上面的案例,我们使用虚拟 DOM
来实现,应该怎样处理呢?首先,我们要创建一个虚拟 DOM
的对象,
虚拟 DOM
对象就是一个普通的 JS
对象。当单击按钮的时候,需要对比两次状态的差异。所以说,仅仅是该案例,
我们使用虚拟 DOM
的方式来实现,要比使用纯 DOM
的方式来实现,性能要低。
所以说, 并不是所有的情况下使用虚拟DOM都会提升性能的
。只有在视图比较复杂的情况下使用虚拟 DOM 才会提升渲染的性能
。
虚拟 DOM
除了渲染 DOM
以外,还可以实现渲染到其它的平台 虚拟DOM有跨平台特性
,例如可以实现服务端渲染( ssr
),原生应用( React Native
),小程序( uni-app
等)。以上列举的案例中,内部都使用了虚拟 DOM
.
Vue
中虚拟 DOM
生成真实 DOM
的过程
下面就是一个开源的虚拟 DOM
库--- Snabbdom
。
从 Vue2.x
开始内部使用的虚拟 DOM
,就是改造的 Snabbdom
。源码大约200行作用,源码使用 TypeScript开发
。
4、Snabbdom基本使用
4.1 创建项目
Snabbdom
的基本使用之前,我们先来创建一个项目。
打包工具为了方便使用,使用了 parcel
,也可以使用 webpack
。
下面创建项目,并安装 parcel
//创建项目目录
md snabbdom-demo
// 进入项目目录
cd snabbdom-demo
// 创建package.json
npm init -y
//本地安装parcel
npm install parcel-bundler
配置 package.json
中的 scripts
"srcipts":{
"test": "echo "Error: no test specified" && exit 1",
"dev":"parcel index.html --open" , //open打开浏览器
"build":"parcel build index.html"
}
创建目录结构
index.html
package.json
---js
basicusage.js
4.2 导入Snabbdom
方法文档:
https://github.com/snabbdom/snabbdom
下面先安装 Snabbdom
。(这里最新版本有问题,可以先安装0.7.4)
npm install snabbdom@0.7.4
在项目的 js
文件夹下的 01-basicusage.js
文件中,添加如下代码:
import snabbdom from "snabbdom";
console.log(snabbdom);
以上代码的意思就是导入 snabbdom
这个包,然后打印其内容。
项目的启动
npm run dev
这时,开启的端口号为 1234
http://localhost:1234
在打开的浏览器中,查看控制台的输出,发现输出的内容为 undefined
为什么输出的是 undefined
呢?这里我们需要查看对应的源码
我们在学习一个函数时,可以重点了解该函数的“入参”和“出参”,大致就能判断该函数的作用。
在 node_modules/snabbdom/snabbdom.js
文件中,
我们可以看到在整个文件中的最下面,并没有使用 export default
的方式进行导出,所以就不能使用 import snabbdom
这种方式进行导入。同时在,源码中,我们可以看到导出了三项内容分别为 h
, thunk
, init
.
分别在源码的第29行,第31行,第311行,进行了对这三项内容函数的单独导出,并没有采用 export default
的形式进行整体导出
exports.h = h_1.h; //29
exports.thunk = thunk_1.thunk; //31
exports.init = init; //311
所以我们就不能使用 import snabbdom
这种方式进行导入,而是应该先对其进行一个解构。导入的代码修改成如下的形式
import { h, thunk, init } from "snabbdom";
console.log(h, thunk, init);
Snabbdom
的核心仅提供最基本的功能,只导出了三个函数 init()
, h()
, thunk()
-
init()
函数是高阶函数里面是有很多的函数,结果是返回了一个patch()
。init()
函数被定义在package/init.ts
文件中:init()
函数接收一个模块数组modules
和可选的domApi
对象作为参数,返回一个函数,即patch()
函数。domApi
对象的接口包含了很多DOM
操作的方法。下面的内容也将对modules
参数做重点介绍。
高阶函数英文叫Higher-order function。
JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。
-
h()
函数返回虚拟节点VNode
,这个函数我们在使用Vue.js
的时候见过。h()
函数被定义在package/h.ts
文件中:
2.1 h()
函数用于创建虚拟 DOM
,在 Snabbdom
中用 VNode
描述虚拟节点,也就是虚拟 DOM
。
new Vue({
router,
// h()函数在 Vue.js 中的使用,h() 函数的作用就是创建虚拟 DOM,我们在整个 Snabbdom 里面就是使用 h() 函数返回的 VNode 来描述虚拟DOM
render:h=>h(App)
}).$mount('#app')
2.2 VNode
是什么?这里简单解释下:
VNode,该对象用于描述节点的信息,它的全称是虚拟节点(virtual node)。与 “虚拟节点” 相关联的另一个概念是 “虚拟 DOM”,它是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。“虚拟 DOM” 由 VNode 组成的。
—— 全栈修仙之路 《Vue 3.0 进阶之 VNode 探秘》
其实 VNode
就是一个 JS
对象,在 Snabbdom
中是这么定义 VNode
的类型:
/*
这里举一个 h() 函数传递传递三个参数的例子,方便下面的理解
let vnode = h("div#container", {
// 第二个参数这里写模块所需要的数据
style: { backgroundColor: "skyblue" },
// 注册一个事件
on: { click: eventHandler }
}, [h("h1", "Hello Q7Long"), h("p", "这是一个p标签")]
)
*/
export interface VNode {
// sel选择器,就是我们在调用 h() 函数的时候,传递的第一个参数。比如上面的 "div#container"
sel: string | undefined;
// data节点的数据,里面就包括了 属性/样式/事件等
data: VNodeData | undefined;
// children子节点:VNode是用来描述真实DOM的,如果描述真实DOM中有子节点,我们可以通过 children来表示这些子节点
children: Array<VNode | string> | undefined;
// 记录的是VNode对应的真实的DOM,也就是将VNode虚拟节点,转成真实DOM之后,这个真实DOM就会被会存储到elm中
elm: Node | undefined;
// 表示的就是节点当中的内容
text: string | undefined;
// 优化的作用
key: Key | undefined;
}
export interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
hero?: Hero;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string; // for SVGs
fn?: () => VNode; // for thunks
args?: Array<any>; // for thunks
[key: string]: any; // for any other 3rd party module
}
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
let key = data === undefined ? undefined : data.key;
// 最后返回了一个JS对象,这个其实就是 虚拟DOM
return {sel, data, children, text, elm, key};
}
export default vnode;
在 VNode 对象中含描述节点选择器 sel
字段、节点数据 data
字段、节点所包含的子节点 children
字段等。
在这个 demo
中,我们似乎并没有看到模块系统相关的代码
thunk
函数是一种优化策略,可以在处理不可变数据时使用(用于优化复杂的视图)。
4.3 Snabbdom的基本使用
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snabbdom的基本使用</title>
</head>
<body>
<div id="app">Hello World</div>
<script src="./js/01-basicusage.js"></script>
</body>
</html>
下面我们来看一下 Snabbdom
的基本使用,在 01- basicusage.js
文件中编写如下代码
import { h, thunk, init } from "snabbdom";
// init方法返回值为patch函数,patch函数作用是对比两个vndoe的差异并更新到真实的DOM中。init函数的参数是一个数组,数组中的内容是模块,关于模块内容后面还会讲解
let patch = init([]);
//创建虚拟DOM
// 第一个参数:标签+选择器(id选择器或者是类选择器)
// 第二个参数:如果是字符串的话就是标签中的内容
let vnode = h("div#container.cls", "Hello Q7Long");
//我们这里需要将创建的虚拟dom,最终要渲染到`index.html`中`app`这个div中,所以这里需要获取一下该div
let app = document.querySelector("#app");
//要想将虚拟DOM渲染到`app`中,需要用到patch函数。
// 我们知道patch函数的作用是对比两个vnode的差异来更新到真实的`DOM`中。
//但是我们目前没有两个虚拟DOM.那么patch方法的第一个参数也可以是真实的DOM.patch方法会将真实的DOM转换成VNode.
// 第二个参数:为VNode
//返回值为VNode
let oldNode = patch(app, vnode);
运行上面的代码,可以在浏览器中看到 Hello Q7Long
.
可以查看对应生成的元素。
下面我们再来看一个问题,假如在某个时刻需要重新获取服务端的数据,并且将获取到的数据重新渲染到该 div
中( id='container
的 div
)。
我们这里,就需要重新创建一个 VNode
, 然后传递给 patch
,让 patch
比较一下新的 VNode
与原有的 VNode
之间的差异。
补充后的代码如下:
import { h, thunk, init } from "snabbdom";
// console.log(h, thunk, init);
/* snabbdom 的基本使用,调用init函数,需要的参数是一个数组,
数组中的内容是模块,这个地方没有用到先写一个空数组 */
let patch = init([]) // 返回的是patch函数,作用:对比两个 vNode 的差异并更新到真实的DOM中
/* 创建虚拟DOM,用到h()函数帮助我们创建虚拟DOM
1. 第一个参数:标签+选择器 加上 .cls 是类选择器 前面加 # 就是 id 选择器
2. 第二个参数:如果写一个字符串就是标签中的内容
h()函数返回的就是一个vNode
*/
let vnode = h("div#container.cls", "Hello Q7Long")
// 我们这里需要将创建的虚拟dom,最终要渲染到index.html中id="app"这个div中,所以这里需要获取一下该div
let app = document.querySelector("#app")
/* 将虚拟 DOM 渲染到 app 的 div 中的话,需要用到 patch() 函数
patch()函数的作用就是对比两个 vnode 的差异,来更新真实的DOM,我们这里只是创建了一个虚拟DOM
那么我们第一个参数就可以写成一个真实的DOM,path()会将真实的DOM转换成 vnode
第二个参数是一个vnode,
path()函数的返回结果也是一个vnode
*/
let oldNode = patch(app, vnode)
// 我们在这里创建一个新的vNode
vnode = h("div", "Hello Vue")
//patch()的两个参数都是vNode 对比新旧两个vNode的差异,新vNode替换旧vNode
patch(oldNode, vnode)
在上面的代码中,我们又创建了一个虚拟 DOM
, vnode
。然后把这个 vnode
与 oldNode
进行对比,最后渲染到页面中。
patch( )
函数的两个参数都是 vNode
对比新旧两个 vNode
的差异,新vNode
替换 旧vNode
注意启动项目是在 snabbdom-test
文件夹下使用 npm run dev
结果如下:
在 js
目录下面在创建一个文件: 02-basicusage.js
实现的代码如下:
// 本案例实现的要求是:在div中设置子元素h1,p
import { h, init } from "snabbdom";
let patch = init([]);
// h函数的第二个参数可以是一个数组,在该数组中添加所要创建的子元素。
let vnode = h("div#container", [
h("h1", "Q7Long"),
h("p", "这是一个p标签"),
]);
let app = document.querySelector("#app");
patch(app, vnode);
同时还需要修改 index.html
文件中的引入。
<body>
<div id="app"></div>
<script src="./js/02-basicusage.js"></script>
</body>
这时,可以在浏览器中查看更新后的内容。
下面我们再来看另外一个问题,就是模拟从服务器获取数据,然后更新页面中的内容。
在上面的代码中,再增加如下的内容:
// 本案例实现的要求是:在div中设置子元素h1,p
import { h, init } from "snabbdom";
let patch = init([]);
// h函数的第二个参数可以是一个数组,在该数组中添加所要创建的子元素。
let vnode = h("div#container", [
h("h1", "Q7Long"),
h("p", "这是一个p标签"),
]);
let app = document.querySelector("#app");
// 记录更新后的VNode
let oldVnode = patch(app, vnode);
// 在两秒钟之后进行页面的更新
setTimeout(() => {
vnode = h("div#container", [h("h1", "Hello Q7Long"), h("p", "Hello p标签")]);
patch(oldVnode, vnode);
}, 2000);
在上面的代码中,首先记录第一次 patch
方法更新后的 vnode
,同时在2秒钟以后,通过 h
函数重新创建了一个虚拟 DOM
,并且通过 patch
函数与原有的虚拟 DOM
进行比较,然后重新更新页面内容。
2秒钟以后清空节点内容
let a = patch(oldVnode, vnode);
patch(a, h("!"));//第二个参数,表示创建一个注释节点。
02-basicusage.js
/* 02-basicusage.js */
import { h, init } from "snabbdom";
let patch = init([]);
// h函数的第二个参数可以是一个数组,在该数组中添加所要创建的子元素。
let vnode = h("div#container", [
h("h1", "Q7Long"),
h("p", "这是一个p标签"),
]);
let app = document.querySelector("#app");
// 记录更新后的VNode
let oldVnode = patch(app, vnode);
// 两秒后清除内容,创建一个注释节点,替换名为 a 的 vNode
setTimeout(() => {
let a = patch(oldVnode, vnode);
patch(a, h("!"));//第二个参数,表示创建一个注释节点。
}, 2000);
页面效果:
4.4 模块
Snabbdom
的核心库并不能处理元素的属性/样式/事件等,如果需要处理,可以使用模块。
Snabbdom
模块系统是 Snabbdom
提供的一套 **可拓展**、**可灵活组合**
的模块系统,用来为 Snabbdom
提供操作 VNode
时的各种模块支持,如我们组建需要设置 DOM
元素的属性则引入对应的 attributesModule
,需要处理事件,则引入 eventListenersModule
,支持按需引入。
Snabbdom
模块系统的特点可以概括为:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。
常用模块
官方提供了6个模块
模块名称 | 模块功能 |
---|---|
attributesModule | 设置 DOM 元素的属性,内部使用 setAttribute()来设置属性, ,处理布尔类型的属性(可以对布尔类型的属性作相应的判断处理,布尔类型的属性,我们比较熟悉的有 selected , checked 等)。 |
propsModule | 和 attributes 模块类似,设置 DOM 元素的属性 element[attr]=value ,不处理布尔类型的属性。 |
classModule | 切换样式类,注意:给元素设置类样式是通过 sel 选择器。 |
datasetModule | 设置 HTML5 中的以 (data- *) 开头的自定义属性,然后可以使用 HTMLElement.dataset 属性访问它们。 |
eventListenersModule | 注册和移除事件,为 DOM 元素绑定事件监听器。 |
styleModule | 设置行内样式,支持动画(内部创建 transitionend 事件),会增加额外的属性:delayed / remove / destory |
使用模块的步骤:
第一步:导入需要的模块
第二步:在 init()
中注册模块
第三步:使用 h
函数创建 VNode
的时候,可以把第二个参数设置为对象(对象中是模块需要的数据,可以设置行内样式、事件等),其它参数往后移。
下面我们要实现的案例,就是给 div
添加一个背景,同时为其添加一个单击事件,当然在 div
中还要创建两个元素分别是 h1
与 p
。
具体实现的代码如下:
// 模块的基本使用
import { h, init } from 'snabbdom'
// 导入模块
import style from 'snabbdom/modules/style'
import eventListeners from 'snabbdom/modules/eventlisteners'
// 调用 init 方法注册模块
let patch = init([style, eventListeners])
// 使用h()函数创建虚拟DOM的时候,第二个参数是需要传入模块所需要的数据,这些数据需要放进对象中
let vnode = h("div", {
// 第二个参数这里写模块所需要的数据
style: { backgroundColor: "skyblue" },
// 注册一个事件
on: { click: eventHandler }
}, [h("h1", "Hello Q7Long"), h("p", "这是一个p标签")]
)
function eventHandler () {
console.log("点击了我一下")
}
let app = document.querySelector("#app")
patch(app, vnode)
注意:在index.html
文件中要引入以上代码所在的js
文件,如下所示:
<body>
<div id="app"></div>
<script src="./js/03-modules.js"></script>
</body>
5、Snabbdom源码解读
Snabbdom
的核心:
- 使用
h( )
函数创建JavaScript
对象 (VNode
) 描述真实DOM
。 init( )
函数设置模块,创建patch( )
函数。patch( )
函数比较新旧两个VNode
- 把变化的内容更新到真实
DOM
树上
5.1 h函数
h
函数介绍
在使用 Vue
的时候加过 h( )
函数
new Vue({
router,
render:h=>h(App)
}).$mount('#app')
render
函数的参数就是 Snabbdom
中的 h
函数,当然在 Vue
中将 h
函数做了一定的修改,可以用来支持组件,原有的 Snabbdom
中的 h
函数不支持组件的内容。在 Snabbdom
中 h( )
函数的作用就是用来创建 VNode
.
在看源码之前,先来了解一个概念:函数重载
。
因为在源码中用到了 函数重载
。
所谓的函数重载: **参数个数** 或 **类型 **
不同的函数,称之为函数重载
但是在 JavaScript
中没有重载的概念,在 TypeScript
中是有重载的。
我们来看一段重载的示例代码
function add(a,b){
return a+b
}
function add(a,b,c){
console.log(a+b+c)
}
add(1,2) // NaN
add(1,2,3) // 6
以上我们是通过参数个数的形式,展示了一段函数重载的代码。
下面我们来看一下 h
函数的源码,h()函数返回的是VNode。
源码位置:node_modules/snabbdom/src/h.ts
// h函数的重载
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
// h函数重载的具体实现
// h函数可以接收三个参数,?表示该参数可以不传递
export function h(sel: any, b?: any, c?: any): VNode {
// 定义变量
var data: VNodeData = {}, children: any, text: any, i: number;
// 处理参数,实现重载的机制
// 如果c这个参数的值不等于undefined,说明是传递了c这个参数。表示传递了三个参数
if (c !== undefined) {
// 如果该条件成立,表示处理的就是有三个参数的情况
// 参数b中存储的就是模块处理的时候,需要的数据,在modules.js中写过例如:行内样式(style: { backgroundColor: "skyblue" },),事件(on: { click: eventHandler })等,关于这块在前面的案例中也写过,在这里将b参数的值赋给了data这个变量
data = b;
// 下面是对参数c进行了判断。
// 关于参数c有三种情况,第一种情况为数组,第二种情况为字符串或者是数字,第三种情况为VNode.
// 首先判断参数c是否为数组,如果是数组,赋值给了children这个变量,表明c是子元素。
// 例如前面我们在使用模块的案例中,给h函数指定的第三个参数就为数组:[h("h1", "Hello Vue"), h("p", "这是p标签")]
if (is.array(c)) { children = c; } // c 是数组
// 如果 c 参数是字符串或者是数字,将参数c赋值给了text变量,表明传递过来的内容其实就是标签中的文本内容
else if (is.primitive(c)) { text = c; } // c 参数是字符串或者是数字
// 如果有sel属性,表明 c 是vnode,在这里需要转换成数组的形式,然后再赋值给children这个变量
else if (c && c.sel) { children = [c]; } // c 参数是vnode
} else if (b !== undefined) {
// 如果该条件成立,表明处理的是两个参数的情况 在 02-basicusage.js 中写过
// 如果b是一个数组,赋值给chilren这个变量:vnode = h("div#container", [h("h1", "Hello Vue"), h("p", "Hello p")]);
if (is.array(b)) { children = b; }
// 如果b是字符串或者数字:h("div", "Hello Vue");
else if (is.primitive(b)) { text = b; }
// 如果b是Vnode的情况
else if (b && b.sel) { children = [b]; }
else { data = b; }
}
// 判断children中有值 比如:[h("h1", "Hello Vue"), h("p", "Hello p")]
if (children !== undefined) {
// 对chilren进行遍历
for (i = 0; i < children.length; ++i) {
// 判断从chilren中取出来的内容是否为:string/number,如果是使用 vnode 创建文本的虚拟节点。
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
// 如果是svg,添加命名空间
addNS(data, children, sel);
}
// 最后返回的是整个 VNode 所以 h 函数的核心就是调用 vnode方法,来返回一个虚拟节点
return vnode(sel, data, children, text, undefined);
};
// 导出h函数
export default h;
addNs
方法的实现如下:
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
data.ns = 'http://www.w3.org/2000/svg';
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
let childData = children[i].data;
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
}
}
}
}
addNs
方法中就是给 data
添加了命名空间,然后通过递归的方式给 chilren
中的所有子元素都添加了命名空间。
在看 VNode
这个方法的源码前,了解一下看源码必备的快捷键。
- 光标移动到某个变量处,按
F12
快速定位到该变量的定义位置。 ALT
+ 左方向键,回到上次的代码位置Ctrl
+ 单击,跳转到某个变量的定义处- 选中某个变量或方法名,按
F12
显示出该变量或方法的具体代码
5.2 VNode函数
在 h
函数的最后调用了 VNode
函数创建了一个虚拟节点,并返回。下面看一下 VNode
函数内部实现。
VNode
函数的代码在 vnode.ts
文件中
import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'
export type Key = string | number;
/*
这里举一个 h() 函数传递传递三个参数的例子,方便下面的理解
let vnode = h("div#container", {
// 第二个参数这里写模块所需要的数据
style: { backgroundColor: "skyblue" },
// 注册一个事件
on: { click: eventHandler }
}, [h("h1", "Hello Q7Long"), h("p", "这是一个p标签")]
)
*/
export interface VNode {
// sel选择器,就是我们在调用 h() 函数的时候,传递的第一个参数。比如上面的 "div#container"
sel: string | undefined;
// data节点的数据,里面就包括了 属性/样式/事件等
data: VNodeData | undefined;
// children子节点:VNode是用来描述真实DOM的,如果描述真实DOM中有子节点,我们可以通过 children来表示这些子节点
children: Array<VNode | string> | undefined;
// 记录的是VNode对应的真实的DOM,也就是将VNode虚拟节点,转成真实DOM之后,这个真实DOM就会被会存储到elm中
elm: Node | undefined;
// 表示的就是节点当中的内容
text: string | undefined;
// 优化的作用
key: Key | undefined;
}
export interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
hero?: Hero;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string; // for SVGs
fn?: () => VNode; // for thunks
args?: Array<any>; // for thunks
[key: string]: any; // for any other 3rd party module
}
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
let key = data === undefined ? undefined : data.key;
// 最后返回了一个JS对象,这个其实就是 虚拟DOM
return {sel, data, children, text, elm, key};
}
export default vnode;
在上面的代码中,我们首先关注的就是接口VNode
,该接口中定义了很多的属性,而最终 vnode
这个函数返回的 VNode
对象必须都要实现该接口中的这些属性。
下面可以看一下这些属性的含义
export interface VNode {
//选择器,也就是调用h函数的时候传递的第一个参数
sel: string | undefined;
// 节点数据:属性/样式/事件等。
data: VNodeData | undefined;
//子节点,和text互斥 VNode是描述真实DOM的,如果所描述的真实DOM中有子节点,通过children来表示这些子节点
children: Array<VNode | string> | undefined;
// 记录vnode对应的真是DOM,将Vnode转换成真实DOM以后,会存储到elm这个属性中。关于这一点可以在将VNode转换成真实DOM的时候看到。
elm: Node | undefined;
// 节点中的内容,和children只能互斥
text: string | undefined;
//优化,关于这个属性可以在将VNode转换成真实DOM的时候看到。
key: Key | undefined;
}
最后看一下 vnode
这个函数,返回的就是一个 js
对象,该对象中包含了 VNode
这个接口中的属性,而这个 js
对象就行虚拟节点。而这个虚拟的节点是怎样转换成真实的 DOM
?后面会重点讲解这块内容。
5.3 复习h函数与Vnode函数应用
下面我们在通过一个案例,复习一下 h
函数与 vnode
函数。
假如,我们创建了如下的一个虚拟 DOM
test.js
// 构造一个虚拟dom
var vnode = h('div#app',
{style: {color: '#000'}},
[
h('span', {style: {fontWeight: 'bold'}}, "my name is Q7Long"),
' and xxxx',
h('a', {props: {href: '/foo'}}, '我是张祺龙')
]
);
下面看一下上面的代码的执行流程。
注意:这边先执行的是先内部的调用,然后再依次往外执行调用。
因此首先调用和执行的代码是:
第一步先在 test.js
文件中执行: h('span', {style: {fontWeight: 'bold'}}, "my name is Q7Long")
,第二步把参数传递到 h.ts
文件中,进入 h
函数之后:
sel参数的值是: 'span'
,
b参数的值是 {style: {fontWeight: 'bold'}}
,
c参数的值是 "my name is Q7Long"
;
首先判断 if (c !== undefined) {}
代码,然后进入if语句内部代码,如下:
if (c !== undefined) {
data = b; // 这里将b的值 {style: {fontWeight: 'bold'}} 给了 data
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; } //这里将c的值"my name is Q7Long"给了text
}
因此data = {style: {fontWeight: 'bold'}}
; 然后判断 c
是否是一个数组,可以看到 c
是一个字符串,不是一个数组,因此进入 else if
语句,因此text = "my name is Q7Long"
; 从代码中可以看到,这个个条件判断 c
是一个字符串成立,那么就有返回值了,就直接跳过所有的代码了,最后执行 return VNode(sel, data, children, text, undefined);
了,因此会调用 VNode函数
进入文件中 snabbdom/vnode.js
代码如下:
/*
* VNode函数如下:主要的功能是构造VNode, 把输入的参数转化为Vnode
* @param {sel} 'span'
* @param {data} {style: {fontWeight: 'bold'}}
* @param {children} undefined
* @param {text} "my name is Q7Long"
* @param {elm} undefined
*/
module.exports = function(sel, data, children, text, elm) {
var key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
};
因此这里 data
中是没有名字为 key
的属性 var key = data.key = undefined;
最后返回值 key
的值也是一个 undefined
如下:
{
sel: 'span',
data: {style: {fontWeight: 'bold'}},
children: undefined,
text: "my name is Q7Long",
elm: undefined,
key: undefined
}
第二步:调用h('a', {props: {href: '/foo'}}, '我是张祺龙');
代码
同理:sel = 'a'; b = {props: {href: '/foo'}}, c = '我是张祺龙';
然后执行如下代码:
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
}
因此 data = {props: {href: '/foo'}}; text = '我是张祺龙'; children = undefined;
最后也一样执行返回:
return VNode(sel, data, children, text, undefined);
因此又调用snabbdom/vnode.js
代码如下:
/*
* VNode函数如下:主要的功能是构造VNode, 把输入的参数转化为Vnode
* @param {sel} 'a'
* @param {data} {props: {href: '/foo'}}
* @param {children} undefined
* @param {text} "我是张祺龙"
* @param {elm} undefined
*/
module.exports = function(sel, data, children, text, elm) {
var key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
};
因此执行代码:var key = data.key = undefined;
最后返回值如下:
{
sel: 'a',
data: {props: {href: '/foo'}},
children: undefined,
text: "我是张祺龙",
elm: undefined,
key: undefined
}
第三步调用外层的代码,把参数传递进去,因此代码初始化变成如下,因为实现执行里面的内容,所以说里面的内容 h('span', {style: {fontWeight: 'bold'}}, "my name is Q7Long")
,和 h('a', {props: {href: '/foo'}}, '我是张祺龙');
就会改变了,这时候就会去执行外面的 h()
函数,里面的参数情况分析如下:
var vnode = h('div#app',
{style: {color: '#000'}},
[
{
sel: 'span',
data: {style: {fontWeight: 'bold'}},
children: undefined,
text: "my name is Q7Long",
elm: undefined,
key: undefined
},
' and xxxx',
{
sel: 'a',
data: {props: {href: '/foo'}},
children: undefined,
text: "我是张祺龙",
elm: undefined,
key: undefined
}
]
);
继续把参数传递进去,因此 sel = 'div#app'; b = {style: {color: '#000'}}; c
的值变为如下:
c = [
{
sel: 'span',
data: {style: {fontWeight: 'bold'}},
children: undefined,
text: "my name is Q7Long",
elm: undefined,
key: undefined
},
' and xxxx',
{
sel: 'a',
data: {props: {href: '/foo'}},
children: undefined,
text: "我是张祺龙",
elm: undefined,
key: undefined
}
];
首先看 if
判断语句,if (c !== undefined) {};
因此会进入if语句内部代码;
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
}
因此 data = {style: {color: '#000'}}; c
是数组的话,就把c赋值给 children
; 因此 children
值为如下
children = [
{
sel: 'span',
data: {style: {fontWeight: 'bold'}},
children: undefined,
text: "my name is Q7Long",
elm: undefined,
key: undefined
},
' and xxxx',
{
sel: 'a',
data: {props: {href: '/foo'}},
children: undefined,
text: "我是张祺龙",
elm: undefined,
key: undefined
}
];
我们下面接着看 如下代码:
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]);
}
}
如上代码,判断如果 children
是一个数组的话,就循环该数组 children
; 从上面我们知道 children
长度为3,因此会循环3次。进入 for
循环内部。判断其中一项是否是数字和字符串类型,因此只有 ' and xxxx'
符合要求,这里由于是第二个参数,那么这里的 i 就等于 1 了
,因此 children[1] = VNode(undefined, undefined, undefined, ' and xxxx',undefined,undefined);
最后会调用 snabbdom/vnode.js
代码如下:
module.exports = function(sel, data, children, text, elm) {
var key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
};
通过上面的代码可知,我们最后返回的是如下:
children[1] = {
sel: undefined,
data: undefined,
children: undefined,
text: ' and xxxx',
elm: undefined,
key: undefined
};
执行完成后,我们最后返回代码:return VNode(sel, data, children, text, undefined);
因此会继续调用snabbdom/vnode.js
代码如下:
/*
@param {sel} 'div#app'
@param {data} {style: {color: '#000'}}
@param {children} 值变为如下:
children = [
{
sel: 'span',
data: {style: {fontWeight: 'bold'}},
children: undefined,
text: "my name is Q7Long",
elm: undefined,
key: undefined
},
{
sel: undefined,
data: undefined,
children: undefined,
text: ' and xxxx',
elm: undefined,
key: undefined
},
{
sel: 'a',
data: {props: {href: '/foo'}},
children: undefined,
text: "我是张祺龙",
elm: undefined,
key: undefined
}
];
@param {text} undefined
@param {elm} undefined
*/
module.exports = function(sel, data, children, text, elm) {
var key = data === undefined ? undefined : data.key;
return {sel: sel, data: data, children: children,
text: text, elm: elm, key: key};
};
因此继续执行内部代码:var key = undefined;
最后返回代码:
return {
sel: sel,
data: data,
children: children,
text: text,
elm: elm,
key: key
};
因此最后构造一个虚拟 dom
返回的值为如下:
vnode = {
sel: 'div#app',
data: {style: {color: '#000'}},
children: [
{
sel: 'span',
data: {style: {fontWeight: 'bold'}},
children: undefined,
text: "my name is Q7Long",
elm: undefined,
key: undefined
},
{
sel: undefined,
data: undefined,
children: undefined,
text: ' and xxxx',
elm: undefined,
key: undefined
},
{
sel: 'a',
data: {props: {href: '/foo'}},
children: undefined,
text: "我是张祺龙",
elm: undefined,
key: undefined
}
],
text: undefined,
elm: undefined,
key: undefined
}
接着往下执行如下代码:
// 初始化容器
var app = document.getElementById('app');
//这里将虚拟DOM转成真实DOM之后,然后调用patch()函数将app的DOM做一个替换,将vnode patch 到 app 中,但是我们这里只是说了虚拟DOM产生的过程,暂时并没有将虚拟DOM转成真实DOM,所以目前步骤并不完整
patch(app, vnode);
以上就是生成整个虚拟 DOM
的过程。
5.3 patch函数的执行过程
将虚拟 DOM
渲染成真实的 DOM
,最主要的就是 patch()
函数
patch()
函数的作用就是将新旧节点进行比较,当我们使用 patch()
函数的时候,会传入两个参数(两个虚拟节点),然后 patch()
函数就会对两个节点进行比较,把节点中变化的内容渲染到真实的 DOM
中,如果没有两个虚拟节点的话,也可以传入一个真实的 DOM
,它会自动将真实 DOM
转换成一个虚拟节点
patchTest.js
patch(oldVnode, newVnode)
patch()
函数的执行过程:
patch()
作用:把新节点中的内容渲染到真实的 DOM
中,返回最后的新节点,新节点也是一个 Vnode
,新节点也会作为下次比较的旧节点
对比新旧两个 Vnode
是否是相同节点(根据节点的 key
和节点的 sel
, sel
是选择器, key
是节点的唯一值,对两块内容进行比较如果一样就是相同节点)
如果不是相同节点,将原来的内容删除掉,重新渲染新的内容
如果是相同节点的话,再比较新的 Vnode
是否有 text
,如果有并且和 oldVnode
中的 text
不同的话,直接更新文本内容
如果新的 Vnode
有 children
,紧接着判断子节点是否有变化,判断子节点的过程就是 diff
算法
diff
算法比较的过程只是在同层级进行比较。
如何获取到的 patch()
函数的,是通过调用 init()
函数之后的返回值是 patch()
函数
let patch = init([])
所以我们这里先看一下 init()
函数
5.4 init()函数源码查看
源码位置:node_modules/snabbdom/src/snabbdom.ts
48行
//4. hooks就是一个数组,数组里面有一些内容,这里面都是模块中的钩子函数,hooks里面存储的就是所有模块中钩子函数的名称
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
export {h} from './h';
export {thunk} from './thunk';
// 当我们调用函数的时候,我们会传过来一个数组,
// 第二个参数有个? 代表是可有可无的参数
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number, j: number, cbs = ({} as ModuleHooks);
//1. 如果不传入第二个参数,那么这里 domApi 就是 undefined 条件成立,拿到的结果是 htmlDomApi,ctrl进入htmlDomApi
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
//3. 这里是对hooks进行遍历 ctrl找到hooks
/*4.
hooks = ['create', 'activate', 'update', 'remove', 'destroy']
遍历这些钩子,然后从 modules 的各个模块中找到相应的方法,比如:directives 中的 create、update、destroy 方法
让这些方法放到 cb[hook] = [hook 方法] 中,比如: cb.create = [fn1, fn2, ...]
然后在合适的时间调用相应的钩子方法完成对应的操作
*/
for (i = 0; i < hooks.length; ++i) {
//7.比如 cbs.create=[],cbs.update=[]
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
//8. 每循环一次将模块中的钩子函数拿出来放入 hook 里面
const hook = modules[j][hooks[i]];
//9. 这里判断 hooks 不等 undefined 的话,直接取出来放入cbd对应的钩子函数名称里面的数组中
if (hook !== undefined) {
//5. 这里说明 钩子函数的名称对应的是数组,值就是为钩子函数添加的一个具体的函数,比如 fn1 fn2,遍历各个 modules,找出各个 module 中的 create 方法,然后添加到 cbs.create 数组
(cbs[hooks[i]] as Array<any>).push(hook);
//10. cbs最后的格式就是 cbs={create:[fn1,fn2],update:[fn]}
}
}
}
// 11.一些辅助函数
function emptyNodeAt(elm: Element) {
...
}
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
...
}
function addVnodes(parentElm: Node,) {
...
}
function removeVnodes(parentElm: Node,): void {
...
}
function updateChildren(parentElm: Node) {
...
}
function patchVnode(oldVnode: VNode, vnode: VNode) {
...
}
//12. 返回一个 patch 函数,如果一个函数返回一个函数,那么这个init()函数就被称为高阶函数
/*13. 使用高阶函数的优点,patch函数里面传入了 oldVNode 和 newVNode 并且这里会用到一些模块,DOM中的Api
当我们调用init()函数的时候,DOM中的Api和模块,当我们调用init()高阶函数的时候,这两个参数就已经传入进来了
由于我们patch()函数在用到这两个参数的时候,就不需要再次传入了,直接拿来使用即可
*/
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 14. 调用了DOM中的Api
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
}
源码位置:node_modules/snabbdom/src/htmlDomApi.ts
80行
// 2. 这里面都是对DOM操作的一些方法,虚拟DOM最终都是通过htmlDomApi中提供的方法,将虚拟DOM转成了真实的DOM
export const htmlDomApi = {
createElement,
createElementNS,
createTextNode,
createComment,
insertBefore,
removeChild,
appendChild,
parentNode,
nextSibling,
tagName,
setTextContent,
getTextContent,
isElement,
isText,
isComment,
} as DOMAPI;
export default htmlDomApi;
源码位置:node_modules/snabbdom/src/modules/style.ts
96行
// 这里拿一个styleModule举例,说明模块中存在钩子函数
export const styleModule = {
pre: forceReflow,
// 6. 这里说明模块里面确实有钩子函数
create: updateStyle,
update: updateStyle,
destroy: applyDestroyStyle,
remove: applyRemoveStyle
} as Module;
export default styleModule;
5.5 patch()函数源码查看
在 snabbdom
中我们 通过 init()
返回了一个 patch()
函数,通过 patch()
进行吧比较两个 虚拟DOM
然后添加的 真实的DOM
树上,中间比较就是我们等下要说的diff
当创建 Vue
实例的时候,会执行以下代码:
updateComponent = () => {
const vnode = vm._render();
vm._update(vnode)
}
vm._watcher = new Watcher(vm, updateComponent, noop)
例如当 data
中定义了一个变量 a
,并且模板中也使用了它,那么这里生成的 Watcher
就会加入到 a
的订阅者列表中。当 a
发生改变时,对应的订阅者收到变动信息,这时候就会触发 Watcher
的 update
方法,实际 update
最后调用的就是在这里声明的 updateComponent
。 当数据发生改变时会触发回调函数 updateComponent
,updateComponent
是对patch
过程的封装。patch
的本质是将新旧 vnode
进行比较,创建、删除或者更新 DOM
节点/组件实例。
源码位置:node_modules/snabbdom/src/snabbdom.ts
280行左右
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 定义一个数组,这其实是个队列,保存了新插入节点的队列,为了触发以后的钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 循环执行了模块中的 pre 钩子函数,一般在pre完成一些预处理操作,模块中有pre钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// oldVnode 有两种类型,一种是Vnode,一种是Element节点DOM元素
if (!isVnode(oldVnode)) {
// 如果传入的不是虚拟节点Vnode,就会转换成真实的Vnode,比如之前我们传入的
// let oldNode = patch(app,vnode) 这个app就是真实DOM,会被转换成VNode
oldVnode = emptyNodeAt(oldVnode);
}
// 判断 oldVnode 和 newVnode 是否为同一虚拟节点,通过 key 和 sel 进行判断
if (sameVnode(oldVnode, vnode)) {
// 是同一个虚拟节点 调用 patchVnode 方法,找到节点中不同的地方,更新DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果新旧节点不是同一个节点,那么会将新节点渲染成DOM插入到文档中,并且将老节点从文档中移除
// 不是同一虚拟个节点 直接暴力拆掉老节点,新节点渲染成DOM插入文档中,老节点不要了
// oldVnode.elm 获取DOM元素
elm = oldVnode.elm as Node;
// elm就是oldVnode的DOM节点,parentNode方法获取当前elm的父节点
parent = api.parentNode(elm);
// 把Vnode转换成真实的DOM元素
createElm(vnode, insertedVnodeQueue);
// 根据oldVnode的父节点不为空
if (parent !== null) {
// 调用了DOM中的Api
// 把创建好的真实的DOM渲染到parent中,具体渲染到时elm元素的后面
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
// 然后将老节点移除了,即将oldVNode移除
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
// 返回vnode作为 旧的虚拟节点
return vnode;
};
// 64行
function emptyNodeAt(elm: Element) {
// 传递过来的DOM元素,首先获取DOM元素的id,如果有id的话,给id前面加入一个 # 比如传入的一个id="app"->#app
const id = elm.id ? '#' + elm.id : '';
// 如果有类选择器,那么在前面加上一个点. 然后按照空格进行分割,分割之后呢,加入点.进行拼接转换成字符串
const c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
// 最后就转换成了vnode,回去标签上的名字,比如div,连接上id选择器以及类选择器
// 这里与 let vnode = h("div#container.cls","Hello World")类似,传入到vnode中
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
}
调试 patch
函数,看清 patch
内部执行过程:
01-basicusage.js
import { h, thunk, init } from "snabbdom";
let patch = init([])
let vnode = h("div#container.cls", "Hello World")
let app = document.querySelector("#app")
let oldNode = patch(app, vnode)
vnode = h("div", "Hello Vue")
patch(oldNode, vnode)
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">Hello World</div>
<script src="./js/01-basicusage.js"></script>
</body>
</html>
// 看源码快捷键操作: F10逐行执行,F11进入函数内部
// 1.进入snabbdom-test文件夹,npm run dev启动项目
// 2. F12,Sources-js-01-basicusage.js
// 3. 在let oldNode = patch(app, vnode)处打一个断点,然后刷新页面
F11进入源码内部: 可以看到传入patch的第一个参数是 id="app" 的 div 第二个参数是我们自己创建的虚拟节点Vnode
按下F10,进行下一步,发现for循环里面,没有定义pre这个钩子函数,所以这个for循环不执行,F10进入下一步,进入isVNode方法,对oldVnode进行判断,看是不是虚拟节点Vnode,如果不是这里需要创建虚拟节点Vnode
按下F11进入 isVnode函数,我们发现这里传入的DOM元素中,并没有sel属性,那么就说明并不是一个Vnode,而是一个DOM元素,这里使用了 return vnode.sel !== undefined 判断了vnode.sel是否是undefined,即传过来的vnode参数中是否含有sel属性,来判断这是不是一个Vnode
// 这里的返回值结果是false
function a () {
return 1 !== 2
}
console.log(a()) // true
判断传入的是否为Vnode之后,if(!isVnode(oldVnode)),条件成立,那么就执行
oldVnode = emptyNodeAt(oldVnode)将真实的 DOM 转换成 Vnode,按下F11进入emptyNodeAt函数
进入 emptyNodeAt() 函数之后,这里将真实的 DOM 作为 elm 参数传入,这里的elm就是 "div#app",可以发现这里的 elm 只有 id 选择器,没有类选择器,所以 emptyNodeAt()函数里面的 id在elm.id前面加"#"之后就变成了,id="#app",而 c=" ",然后进入vnode函数
这里进入 vnode 方法,先执行的是 api.tagName(elm).toLowerCase() 之后,api.tagName(elm)拿到的就是标签的名字,这里的标签名字就是 DIV
然后进入 vnode 方法,将真实的 DOM 转成真实的 vnode
// 如果是真实DOM,就需要转为虚拟DOM。
// 如何比较? 需要知道真实DOM和虚拟DOM有何区别,真实DOM中有很多属性,但是肯定没有sel属性,而虚拟DOM有sel属性,根据这个属性来判断是否为虚拟节点
所以这里生成的 oldVnode 是含有 sel 属性的,说明这里将真实的 DOM 转成了真实的 vnode,并且这里也将以前的真实DOM,存入了 oldVnode 的 elm 属性中
生成oldVnode(真实的vnode)之后,下一步按F11进入 sameVnode 方法,判断生成的 oldVnode 和 vnode 是否为同一节点
// 只有是同一个虚拟节点,才进行精细化比较,否则直接删除旧节点,插入新节点
// 判断两个节点是否为同一个,是根据比较选择器,也就是 sel 的值和 key 的值是否都相同,都相等则判断为同一个虚拟节点
// vnode1对应oldVnode vnode2对应vnode
可以发现这里的 vnode1.key = undefined vnode2.key = undefined
vnode1.sel = "div#app" vnode2.sel="div#container.cls" 两个sel不相等说明不是同一节点
vnode 对应的节点是"div#container.cls",而oldVnode是 "#app" 对应的节点,很明显不是同一个
如果不是同一节点,不执行 patchVnode,而是执行 else 操作,首先取到 oldVnode 的 elm 属性赋值给 elm,然后通过 api.parentNode(elm) 获取 elm即 "div#app" 的父节点
调用api.parentNode(elm),获取 elm 的父节点,从index.html中可以看出"div#app" 的父节点是body
所以调用 parentNode() 方法将 body 赋值给了 parent
执行createElm方法,将我们传入的 vnode,转换成了真实的 DOM,这里暂时不看createElm,按下F10
这里是将 vnode = h("div#container.cls","hello world")在"div#app"之后,插入到 parent 中,parent 其实就是 body 元素,当执行完这一行,我们可以在页面中看到效果
// 在执行 removeVnodes 之前,我们可以进入 Elements 里面查看元素情况。可以发现,我们在 body里面 <div id="app"></div> 元素的后面插入了,vnode 转成真实 DOM 后的 <div id="container" class="cls">Hello World</div>
执行 removeVnodes(parent, [oldVnode], 0, 0); 将 oldVnode 从 body 中移除,并且查看Elements,可以看到效果
5.6 createElm()函数源码查看
源码位置:node_modules/snabbdom/src/snabbdom.ts
80行左右
// createElm,将 vnode 转换为 真实的 dom 节点
1. 判断当前的 vnode tag 标签是否是存在的
2. 如果存在,创建对应的节点,然后 设置 样式的 作用域
3. 遍历子元素,并插入节点之中
4. 触发 create 钩子函数
5. 如果tag 标签不存在,判断是否是 注释节点,然后创建
6. 如果tag 标签不存在,且不是 注释节点,直接创建文本节点
/* 仅仅把虚拟节点Vnode转换成真实的DOM元素,并没有渲染到页面中调用 insertBefore函数的时候,才会插入
完成三项功能:
1. 执行用户设置的 init 钩子函数
2. 把Vnode转换成真实的DOM元素,但是并没有渲染到页面中
3. 把新创建的DOM元素给返回
*/
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any, data = vnode.data;
// data 变量中存储的是vnode中的data,实际上就是使用h()函数的第二个参数模块中的数据,比如一些样式
if (data !== undefined) {
// 执行用户设置的 init 钩子函数 hook 和 init 都是用户传递过来的内容
if (isDef(i = data.hook) && isDef(i = i.init)) {
// 执行了 init 钩子函数
i(vnode);
// 对 data 重新赋值,为什么?考虑到执行完 init 钩子函数,有可能对vnode中data进行修改,重新赋值
data = vnode.data;
}
}
// 把Vnode转换成真实的DOM对象 star
// children表示vnode中的子节点
let children = vnode.children, sel = vnode.sel;
// sel表示选择器 如果 sel === ! 表示创建的是一个注释节点
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = '';
}
// 调用createComment 这个 api 创建注释节点
vnode.elm = api.createComment(vnode.text as string);
// 如果 sel !== undefined 创建新的 DOM 元素
} else if (sel !== undefined) {
// Parse selector(解析选择器) 对选择器进行处理 下面5行代码
// 比如有可能创建的是 div#container.cls 的形式
// 那么首先就需要 获取 # 的位置 获取.的位置,获取div标签
const hashIdx = sel.indexOf('#');
const dotIdx = sel.indexOf('.', hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
//获取到标签之后,调用createElement()方法,创建对应的 DOM 元素,并且赋值给了 vnode.elm 属性
const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
// 把 Vnode 转换成真实的 DOM 对象 end
// 给 DOM 元素设置 id 属性和 class 属性
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/./g, ' '));
// 取出来 cbs 里面的 create 钩子函数执行
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
// 如果 vnode中有子节点,前面已经获取children
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
// 将子节点转成 DOM 元素,并且追加到 elm中
// 如果 ch != null 子节点不为空的话,再次调用自己 createElm 形成递归调用,把每一个子节点转换成DOM元素,追加到elm上,形成DOM树
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
// 如果vnode.text的值是 String或者是Number,直接调用createTextNode创建文本节点,并且追加到 elm 的DOM数上
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
// 获取hook,hook里面存储的是钩子函数
i = (vnode.data as VNodeData).hook; // Reuse variable
if (isDef(i)) {
// 调用用户传入的 create 钩子函数
if (i.create) i.create(emptyNode, vnode);
// i.insert 如果hook中有 insert钩子函数,那么将vnode添加到队列中,为后续执行 insert 钩子函数做准备
if (i.insert) insertedVnodeQueue.push(vnode);
}
// 对应的是 sel === undefined,选择器为空,那么创建的就是一个文本节点
} else {
vnode.elm = api.createTextNode(vnode.text as string);
}
// 把vnode转化成DOM元素,将vnode的elm中存储的DOM元素返回
return vnode.elm;
}
5.7 removeVnodes()函数源码查看
源码位置:node_modules/snabbdom/src/snabbdom.ts
底部调用 160行左右使用
// 理解一个函数就要先从他的参数开始理解 removeVnodes(parent, [oldVnode], 0, 0)
parentElm:要删除的元素所在的父元素
vnodes: 这个参数是一个数组,存储的时候要删除的DOM元素对应的老节点VNode
startIdx: 数组中要删除的节点的起始位置,循环起始点
endIdx: 数组中要删除的节点的结束位置,循环终止点
function removeVnodes(parentElm: Node,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number): void {
// 这里是根据用户传入的参数,对要截取的点 这里目前是只循环一次
for (; startIdx <= endIdx; ++startIdx) {
// const ch = vnodes[startIdx] 找到要删除的元素节点
let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
// 是否有要被删除的元素节点,说明获取到了要删除的节点
if (ch != null) {
// 如果有要被删除的元素节点,那么紧接着
// 根据元素的sel属性判断是否元素节点 or 文本节点
// 如果 ch.sel !== undefined 元素节点 ch.sel === undefined 文本节点
if (isDef(ch.sel)) {
// 执行所有子节点的 destroy 的钩子函数
invokeDestroyHook(ch);
// 记录模块中 remove钩子函数的个数,作用防止重复调用删除节点的方法(createRmCb)
// 获取cbs.remove的钩子函数 并 +1,这个变量的作用是防止变量重复删除DOM元素
listeners = cbs.remove.length + 1;
// createRmCb是个高阶函数,他是一个返回真正删除DOM元素的函数
// ch.elm: 要被删除的元素 listeners: 防止变量重复删除DOM元素
rm = createRmCb(ch.elm as Node, listeners);
// 遍历remove方法,然后传入ch和rm(也就是createRmCb返回的函数)
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 这步主要是执行用户传入的remove()钩子函数,如果有传入remove钩子函数返回该函数,没有调用的就执行createRmCb()
if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
i(ch, rm);
} else {
// 如果没有传入 remove 调用的就是createRmCb
rm();
}
} else { // Text node 如果被删除的节点是文本节点的话
// 直接调用 api.removeChild(parentElm, ch.elm!) 删除父元素中的elm属性
api.removeChild(parentElm, ch.elm as Node);
}
}
}
}
// 上面调用了 createRmCb() 方法
function createRmCb(childElm: Node, listeners: number) {
return function rmCb() {
if (--listeners === 0) {
// 根据传递过来要删除的结点,找到它的父节点
const parent = api.parentNode(childElm);
// 在父节点中删除传进来的结点
api.removeChild(parent, childElm);
}
};
}
5.8 addVnodes()函数源码查看
parentElm: 父元素
before: 参考节点,插入到 before 之前
vnodes:添加的节点
startIdx、endIdx: 开始结束节点 // 循环起始值 终止值
insertedVnodeQueue: 存储刚刚插入的具有inserted钩子函数的节点
通过createElm(ch, insertedVnodeQueue)创建DOM元素,插入到DOM树种
function addVnodes(parentElm: Node,
before: Node | null,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue) {
for (; startIdx <= endIdx; ++startIdx) {
// 每一次都从vnodes里面取出来一个节点
const ch = vnodes[startIdx];
if (ch != null) {
// 如果取出来的不等于 null 那么就调用 insertBefore 先调用createElm将vnode转成真实的DOM
// 再将DOM元素插入到before之前
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
}
}
}
5.9 patchVnodes()函数源码查看
源码位置:node_modules/snabbdom/src/snabbdom.ts
底部340行左右调用 290行使用
//1. 作用
比较新旧节点,更新两个节点之间的差异,在patch节点中才比较了新旧两个节点之间的差异
//2. 参数
oldVnode: 旧节点
vnode: 新节点
insertedVnodeQueue: 收集具有inserted钩子函数的节点
//3. 整体流程 在对比中分了很多情况
1. 先执行prepatch()钩子函数和update()钩子函数,然后在整个节点对比完之后也会触发一个钩子函数,也会触发一个postpatch钩子函数,中间内容就是新旧两个节点的对比。
2. 首先会判断新节点中是否有 text 属性,如果有并且不等于旧节点中的 text 属性,这个时候会更新DOM节点中的文本内容,并且这个更新的过程也会比较老节点是否有children属性,如果有的话,也会移除老节点中children属性中的DOM元素,并且会设置新节点中DOM元素的textContent属性(内容)
3. 如果新旧节点中都有children,并且不相等,这个时候就会调用updateChildren()来对比新老节点中的children也就是它们的子节点,找出子节点中的差异,并且更新这些差异
4. 如果只有新节点中有children属性,老节点中没有children属性,那么就会去查找老节点中是否有text属性,那么就会清空text属性中的内容,也就是去清空DOM元素中的textContent,清空文本内容了之后,然后添加所有的子节点
5. 如果只有老节点中有children属性,更新的话就需要将老节点中所有的children都给移除
6. 如果只有老节点中有text属性,新节点中没有text属性,那么就会清空DOM元素中的textContent属性,就是清空所有的文本内容。
// 使用 patchVnode 方法前情提要
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 定义一个数组,这其实是个队列,保存了新插入节点的队列,为了触发以后的钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 循环执行了模块中的 pre 钩子函数,一般在pre完成一些预处理操作,模块中有pre钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// oldVnode 有两种类型,一种是Vnode,一种是Element节点DOM元素
if (!isVnode(oldVnode)) {
// 如果传入的不是虚拟节点Vnode,就会转换成真实的Vnode,比如之前我们传入的
// let oldNode = patch(app,vnode) 这个app就是真实DOM,会被转换成VNode
oldVnode = emptyNodeAt(oldVnode);
}
// 判断 oldVnode 和 newVnode 是否为同一虚拟节点,通过 key 和 sel 进行判断
if (sameVnode(oldVnode, vnode)) {
// 是同一个虚拟节点 调用 patchVnode 方法,找到节点中不同的地方,更新DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
.....
}
}
// 如果发现比较的新旧节点是同一节点的话,那么调用patchVnode方法,里面涉及到 diff 算法
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
// 判断用户是否设置了 patchVnode这个钩子函数,如果设置了,那么会立即执行这个钩子函数
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
// 获取到DOM元素,并将其赋值给 elm 常量中 elm常量中保存了老节点中的DOM元素
const elm = vnode.elm = (oldVnode.elm as Node);
// 获取老节点中所有的子节点
let oldCh = oldVnode.children;
// 获取新节点中所有的子节点
let ch = vnode.children;
// 判断新旧子节点是否相同节点,比较的是内存地址是否相同,如果相同那么是没有发生任何变化
// 如果相等那么就说明没有问题,直接返回
if (oldVnode === vnode) return;
//新旧子节点不是相同节点时候,判断vnode里面的data是否有值,执行update钩子函数
if (vnode.data !== undefined) {
// 先执行模块中的update钩子函数,也会执行用户设置的update钩子函数
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
//核心代码 真正对比新旧 vnode 差异的地方,当找到差异之后会立即更新真实DOM
//vnode就是新节点 如果新节点是没有有 text 属性
if (isUndef(vnode.text)) {
// oldCh ch 在上面是新旧节点的children属性。
//1. 判断新旧节点是否都有子节点
if (isDef(oldCh) && isDef(ch)) {
// 判断新老节点的子节点是否相同,如果不相同,调用updateChildren()函数
// 这个函数做了什么? 完成新老节点中的children属性的对比,通过diff算法进行对比
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
//2. 判断新节点中有children,老节点中没有children ch代表新节点中的children
} else if (isDef(ch)) {
// 紧跟着判断老节点中是否有 text 属性,如果有就调用setTextContent方法,设置文本内容,但是内容是空字符串,意思就是清空DOM元素的内容
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
// 批量添加子节点到页面中,对新节点中的子节点进行一个批量添加的操作,页面中就有了新节点中子节点的内容
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
//3. 只有老节点中才有子节点
} else if (isDef(oldCh)) {
//调用removeVnodes()方法,删除Vnode,批量将子节点从DOM中删除,老节点中的children全部remove删除
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
//4. 只有老节点中有text属性
} else if (isDef(oldVnode.text)) {
// 把老节点中的内容清空 elm里面是存的老节点
api.setTextContent(elm, '');
}
// 如果新节点有 text 属性,比较新旧节点的text属性是否不相同,相同则不进行任何操作
// 新老节点中的text属性不相等的话,那么就会更新文本的操作
} else if (oldVnode.text !== vnode.text) {
// 判断老节点是否有子节点,有的话调用removeVnodes移除老节点中的children,把老节点中的DOM全部移除
if (isDef(oldCh)) {
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
}
// 将老节点中的DOM元素移除之后,再调用setTextContent()方法,更新文本内容
api.setTextContent(elm, vnode.text as string);
}
// 触发 postpatch 钩子函数
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
// patchVnode中有一个关键的方法是 updateChildren(),里面涉及到了diff算法,下一篇中讲解
Vue虚拟DOM(一) Over made by Q7Long 2022-10-28 20:26
转载自:https://juejin.cn/post/7159550477598720036