本内容笔记来源于【尚硅谷】Vue源码解析之虚拟DOM和diff算法的视频讲解,借鉴了优秀同学的笔记blog.csdn.net/wanghuan102…
snabbdom
简介
- snabbdom 是瑞典语单词,单词原意为“速度”,
- snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码就是借鉴了 snabbdom
- 官方 Git:github.com/snabbdom/sn…
安装
- 在 git 上的 snabbdom 源码是用 TypeScript 写的,git 上并不提供编译好的 JavaScript 版本
- 如果要直接使用 build 出来的 JavaScript 版的 snabbdom 库,可以从 npm 上下载:npm i -S snabbdom
- 学习库底层时,建议大家阅读原汁原味的代码,最好带有库作者原注释。这样对你的源码阅读能力会有很大的提升。
- 当前使用的版本为3.0.1与视频内不同,若想保持与文章作者相同依赖,即使用yarn安装依赖
看源码的小技巧
定位文件夹
在vsCode的资源管理器内直接输入相关包名会直接定位到当前包的位置
内容不一致时
当我们的包版本号不同的时候,里面的内容会发生变化。所以当你看到网上某些文章的源码跟你的不一致的时候,就要关注是否包的版本号不同,建议使用yarn来安装相关依赖,这样能保持依赖一致
测试环境搭建
首先新建个空的文件夹
执行 npm init -y 创建出个 package.json 文件
在根目录下新建文件webpack.config.js
// https://webpack.docschina.org/
const path = require('path')
module.exports = {
// 入口
entry: './src/index.js',
// 出口
output: {
// 虚拟打包路径,就是说文件夹不会真正生成,而是在 8080 端口虚拟生成,不会真正的物理生成
publicPath: 'xuni',
// 打包出来的文件名
filename: 'bundle.js'
},
devServer: {
// 端口号
port: 8080,
// 静态资源文件夹
contentBase: 'www'
}
}
复制代码
创造出如下目录结构
/** src/index.js */
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule, // attaches event listeners
]);
const container = document.getElementById("container");
const vnode = h("div#container.two.classes", { on: { click: () => {} } }, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{ on: { click: () => {} } },
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
复制代码
<!-- www/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">按我改变dom</button>
<div id="container"></div> // 方便patch上树
<script src="xuni/bundle.js"></script>
</body>
</html>
</html>
复制代码
snabbdom 库是 DOM 库,当然不能在 nodejs 环境运行,所以我们需要搭建 webpack 和 webpack-dev-server 开发环境
package.json
// package.json
{
"name": "vue_test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "webpack-dev-server"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "^5.36.2",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"
},
"dependencies": {
"snabbdom": "^3.0.1"
}
}
复制代码
判定搭建成功
- 终端运行 npm run dev
- 访问:http://localhost:8080/ 和 http://127.0.0.1:8080/xuni/bundle.js, 可以看到 www/index.html 和 xuni/bundle.js 文件的内容
- 在src/index.js跑通 snabbdom 官方 git 首页的 demo 程序,即证明调试环境已经搭建成功
(ps:需要把click后面跟的函数改为() => {})
讲解乞丐版虚拟dom和diff中每个函数的作用
为什么说是乞丐版:
- h函数固定了参数个数,以及使用的方式
- patchVnode不考虑节点前存在字符串或数字的情况
- 组件没考虑在内
综上所述,还有很多特殊情况尚未考虑,只考虑正常情况下的核心功能和流程
虚拟dom相关
1. h函数
作用是把传入的参数变为vnone
2. vnode函数
把传入的参数变为固定格式的对象
diff相关
1. patch函数
2. createElement函数
真正创建节点 将 vnode 创建为 DOM
3. patchVnode函数
是上图中的精细化比较部分
4. updateChildren
diff算法
- 四种命中查找:
① 新前与旧前
② 新后与旧后
③ 新后与旧前(此种命中,涉及移动节点,那么旧前指向的节点,移动到旧后之后)
④ 新前与旧后(此种命中,涉及移动节点,那么旧后指向的节点,移动到旧前之前) - 如果都没有命中,就需要用循环来寻找了
// updateChildren.js
import createElement from './createElement'
import patchVnode from './patchVnode'
// 判断是否是同一个虚拟节点
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key
}
export default function updateChildren(parentElm, oldCh, newCh) {
// console.log('我是updateChildren')
// console.log(oldCh, newCh)
let oldStartIdx = 0 // 旧前
let newStartIdx = 0 // 新前
let oldEndIdx = oldCh.length - 1 // 旧后
let newEndIdx = newCh.length - 1 // 新后
let oldStartVnode = oldCh[oldStartIdx] // 旧前节点
let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
let newStartVnode = newCh[newStartIdx] // 新前节点
let newEndVnode = newCh[newEndIdx] // 新后节点
let keyMap = null
// console.log(oldStartIdx, newEndIdx)
// 开始循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// console.log('☆')
// 首先不是判断命中,而是要掠过已经加undefined标记的东西
if (oldStartVnode === null || oldCh[oldStartIdx] === undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode === null || oldCh[oldEndIdx] === undefined) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode === null || newCh[newStartIdx] === undefined) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode === null || newCh[newEndIdx] === undefined) {
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
// 新前和旧前
console.log('① 新前和旧前命中')
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
// 新后和旧后
console.log('② 新后和旧后命中')
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
// 新后和旧前
console.log('③ 新后和旧前命中')
patchVnode(oldStartVnode, newEndVnode)
// 当③新后与旧前命中的时候,此时要移动节点。移动新后指向的这个节点到老节点旧后的后面
// 如何移动节点?只要你插入一个已经在DOM树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
// 新前和旧后
console.log('④ 新前和旧后命中')
patchVnode(oldEndVnode, newStartVnode)
// 当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 四种命中都没有找到
// 制作keyMap,缓存
if (!keyMap) {
keyMap = {}
// 从 oldStartIdx 开始,到oldEndIdx结束,创建keyMap映射对象
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key !== undefined) {
keyMap[key] = i
}
}
}
// console.log(keyMap)
// 寻找当前这项 newStartIdx 这项在 keyMap 中映射的序号
const idxInOld = keyMap[newStartVnode.key]
if (idxInOld === undefined) {
// 判断,如果idxInOld是undefined 表示它是全新的项
// 被加入的项(就是newStartVnode这项)现在不是真实的DOM
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 判断,如果idxInOld不是undefined 表示它不是全新的项,需要移动
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
// 把这项设置为undefined,表示已经处理完了
oldCh[idxInOld] = undefined
// 移动,调用insertBefore
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx]
}
}
// 继续看看有没有剩余的 循环结束了 newStartIdx 还是比 newEndIdx 小
if (newStartIdx <= newEndIdx) {
// new这里还有剩余节点没有处理
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore 可以自动识别 null,如果是 null 就会自动排到队尾去。和appendChild是一致的
// newCh[i] 还不是真正的DOM,所以需要此处需要调用createElement
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if (oldStartIdx <= oldEndIdx) {
// old这里还有剩余节点没有处理
// 批量删除oldStartIdx~oldEndIdx之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}
复制代码
while内,都没有命中就用循环来找
为什么只移动newStartIdx?
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx]
复制代码
为什么是插入oldStartVnode.elm之前?
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
复制代码
这两个问题的答案是一致的,因为本质是把旧节点变为新结点
while后的逻辑
需要判断是哪种情况导致的while结束
- newStartIdx <= newEndIdx 还符合就说明旧节点需要新增
- oldStartIdx <= oldEndIdx 还符合就说明旧节点需要删除
校验是否是移动了dom
打开F12在console旁边的Elements里,改变dom的内容,再点击按钮更改, 若内容一致则是移动。若内容回到最初则是新增。
源码地址
最后
看源码才能理解,为什么key在for循环里不可缺少以及不能误用,让我们能从底层的逻辑开始做些性能优化,以及减少bug的产生。转载请标明出处,谢谢。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END