本文是根据尚硅谷前端系列对DOM以及diff算法的讲解而做的笔记,中间也参考了其他的博客
文章目录
一、简介
1、什么是虚拟Dom
就是用javaScript对象描述DOM的层次结构,DOM中的一切属性都在虚拟DOM中有对应的属性。
{ "sel": "a", "data": { props: { href: 'http://www.baidu.com' } }, "text": "百度" }
2、什么是真实Dom
<a href="http://www.baidu.com">百度</a>
3、什么是diff
diff主要作用域虚拟DOM,当要进行更新的时候,新旧DOM会进行比较,这个时候就用到了diff算法,当两个DOM的标签名不一样的时候,diff会暴力替换。但是当DOM标签名相同的时候,这个时候就需要精细化比较,算出如何最小量更新,最后反应到真正的DOM上去。
二、安装snabbdom
snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码就是借鉴了 snabbdom
安装步骤:
- 新建文件夹
- npm init(创建用户标签)
- npm i -S snabbdom
- npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3。snabbdom库是DOM库,当然不能在node.js环境运行,所以需要搭建webpack和webpack-dev-server开发环境。还有
必须安装webpack@5
(这点很重要,不然代码跑不起来。) - 创建webpack.config.js文件,里面的具体代码,可以到webpack首页进行复制粘贴。webpack官网
const path = require('path'); module.exports = { // 入口文件 entry: './src/index.js', // 出口文件 output: { // 虚拟打包路径,就是说文件夹不会真正生成,而是会在8080端口虚拟生成 publicPath: "xixi", // 打包出来的文件名 filename: 'bundle.js' }, devServer: { // 端口号 port: 8080, // 静态资源文件夹 contentBase: 'www' } };
- 创建文件夹www,并在其中创建index.html文件,这里要创建一个容器,以便后面可以借助这个容器渲染节点。
- 将github上的
snabbdom官方index.js例子复制下来,放到src/index.js中。
这里要把代码里面用到click函数的地方,用空函数替换一下。因为此时www/index.html页面并没有定义这些方法。 - package.json 文件中新增命令:
{ "scripts": { "dev": "webpack-dev-server", } }
- 终端运行
npm run dev
- 此时页面会显示出来一串英文。表示运行成功。
三、手写h函数
1、概念
h函数用来产生虚拟节点(vnode),h函数接收的第一个参数是标签名。
比如说这样调用h函数:
h('a', { props: { href: 'https://www.baidu.com' } }, '百度');
得到的是这样的虚拟节点:
{sel: 'a', data: {props:{href:'https://baidu.com'}}, children: undefined, text: '百度', elm: undefined}
虚拟节点的属性有:
{
children:undefined//孩子节点
data:{}//节点上带的属性
elm:undefined// 表示这个虚拟结点还没有上树,这个一般会放DOM元素
key:undefined //唯一标识符
sel:"div"//标签属性
text:"我是一个盒子"//文本信息
}
2、真实的h函数在流程中的所处位置
在手写h函数之前呢,先看一下它在整体的流程中所处的位置。下面是一个小的Demo
,写在了src/index.js
里面。
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
// 创建虚拟节点
var myVnode1 = h('a', { props: { href: 'https://www.baidu.com' } }, '百度');
// const myVnode2 = h('div', {}, '我是一个盒子');
// const myVnode3 = h('ul', [
// h('li', "西瓜"),
// h('li', "香蕉"),
// h('li', "火龙果"),
// h('li', "桃子")
// ])
//让虚拟节点上树
console.log(myVnode1)
const container = document.getElementById('container');
patch(container, myVnode1);
上树经历了一下几步:
- 创建patch函数
- 可以从代码里面看出,第二步用到了h函数创建虚拟节点。
- 获得页面上的容器,这里用到的是getElementById方法。
- 然后就是用patch将新建的节点加入进去。
3、手写一个简单的h函数
在src中创建一个文件夹,在此文件夹内创建h.js文件。这里编写的是一个低配版的h函数。这个函数必须要接收3
个参数。与真正的h函数相比,只是重载能力弱而已,真正的h函数会考虑多种情况。
- 形态1:h(‘div’,{},‘文字’)
- 形态2:h(‘div’,{},[])
- 形态3:h(‘div’,{},h())
代码如下:
import vnode from './vnode'
export default function (sel, data, c) {
// vnode(sel, data, c)
// 检查参数的个数
if (arguments.length != 3)
throw new Error('对不起,h函数必须传入3个参数');
// 检查参数c的类型
if (typeof c == 'string' || typeof c == 'number') {
// 说明现在调用h函数是形态1
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明现在调用h函数是形态2
let children = [];
// for循环值是收集一下获得c的子类就可以了
for (let i = 0; i < c.length; i++) {
// 检查c[i]必须是一个对象,如果不满足
if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
throw new Error('传入的数组参数中有项不是h函数');
// 此时只需要收集好就可以了
children.push(c[i])
}
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
// 说明现在调用h函数的是形态3
// 此时说明就包含了一个子节点,因为不是数组形式
let children = [];
children.push(c)
return vnode(sel, data, children, undefined, undefined);
} else {
throw new Error("传入错误")
}
}
聪明你的你一定能看懂这个代码的!
vnode.js代码如下:
export default function (sel, data, children, text, elm) {
return {
sel, data, children, text, elm
};
}
vnode返回的的样子是:
{sel: 'a', data: {props:{href:'https://baidu.com'}}, children: undefined, text: '百度', elm: undefined}
这里h.js函数则是考虑到了更多的情况发生,但是返回的数据仍然是这个样子的。
在src/index.js中进行调用:
import h from './mysnabbdom/h.js'
var myVode1 = h('div', {}, [
h('p', {}, '哈哈'),
h('p', {}, '嘿嘿'),
h('p', {}, '嘻嘻'),
h('p', {}, '吼吼'),
]);
console.log(myVode1);
前台页面输出结果:
四、手写一个简单的patch函数,这里便用到了diff算法
这里要补充
虚拟节点的属性里面有key这个值,这个值太重要了。key
是节点的唯一标识符,告诉diff算法,在更改前后他们是否是同一个DOM节点。
举例说明:在src/index.js中写如下代码
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');
// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
const vnode1 = h('ul', {}, [
h('li', { }, 'A'),
h('li', { }, 'B'),
h('li', { }, 'C'),
h('li', { }, 'D'),
])
patch(container, vnode1)
const vnode2 = h('ul', {}, [
h('li', { }, 'A'),
h('li', { }, 'B'),
h('li', { }, 'C'),
h('li', { }, 'D'),
h('li', { }, 'E'),
])
// 点击按钮,将vnode1变成vnode2
btn.onclick = function () {
patch(vnode1, vnode2);
}
最后一个例子的代码进行了一个小改动,就是吧vnode2中的E提到了A的前面。
当给每个li都添加key属性会怎么样呢?
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
])
const vnode2 = h('ul', {}, [
h('li', { key: 'E' }, 'E'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
])
- 只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除旧的、插入新的。延伸问题:如何定义是同一个虚拟节点?
答:选择器相同且Key相同。
- 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,精细化比较不diff。而是暴力删除旧的,插入新的。(跨层是个咋跨层?我尝试在children中添加子节点,但是之前的子节点还是会保存下来,不会重复更新。)
1、diff处理新旧节点不是同一个节点的时候
2、创建patch.js文件
首先要现在src/index.js中调用patch.js
import h from './mysnabbdom/h.js';
import patch from './mysnabbdom/patch'
// const myVnode1 = h('h1', {}, '你好');
const myVnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
]);
const container = document.getElementById('container');
// 上树,如何上树呢,将在patch.js中进行编写
patch(container, myVnode1);
在src下创建文件夹,在文件夹下创建patch.js
。下面是他的代码:
import vnode from './vnode.js'
import createElement from './createElement'
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数,是DOM节点还是虚拟节点?
if (oldVnode.sel == '' || oldVnode.sel == undefined) {
// 传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
// 判断oldVnode和newVnode是不是同一个节点,是同一个节点
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
// 那么就是精细比较
} else {
// 如果说oldVnode和newVnode不是同一个节点
let newVnodeDOM = createElement(newVnode)
// 在老节点的父节点的子节点里面插入新节点
if (oldVnode.elm.parentNode != undefined && newVnodeDOM != undefined) {
oldVnode.elm.parentNode.insertBefore(newVnodeDOM, oldVnode.elm)
}
console.log(oldVnode);
// 在老节点的父节点的子节点里面删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
}
-
判断是DOM节点还是虚拟节点,要是DOM就要用到vnode了,包装它
-
判断新旧节点是不是同一个节点,刚才说到的判断是否是同一个节点,要比较俩值。
-
是就进行精细比较
-
不是就是粗暴的删除,添加
-
对新节点创建真实DOM节点,放到elm属性里面去。这一步在createElement.js中完成。
// 真正创建节点,将vnode创建为DOM,插入到pivot这个元素之前 export default function createElement(vnode) { console.log('目的是把虚拟节点', vnode, '真正变为DOM'); let domNode = document.createElement(vnode.sel); // 是有子节点还是文本? if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) { // 内部是文字 domNode.innerText = vnode.text; // 将节点上树,因为是同一个节点嘛,所以只需要插入到标杆节点上就可以了 } else if (Array.isArray(vnode.children) && vnode.children.length > 0) { // 它内部是子节点,就要递归创建节点 for (let i = 0; i < vnode.children.length; i++) { let ch = vnode.children[i]; let chDOM = createElement(ch); domNode.appendChild(chDOM); } } // 补充elm属性 vnode.elm = domNode; // 返回elm,elm属性时一个纯DOM对象 return vnode.elm; }
这一步用到了递归,但是也是for循环遍历递归,就递归了一层,不算难理解。
-
返回创建好的vnode虚拟节点,然后再老节点的父元素的中插入新的节点。
-
删除旧的节点。
-
页面就可以展示出来啦。