阅读 271

前端笔记:Vue2.x的虚拟dom及diff

本内容笔记来源于【尚硅谷】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的资源管理器内直接输入相关包名会直接定位到当前包的位置

image.png

内容不一致时

当我们的包版本号不同的时候,里面的内容会发生变化。所以当你看到网上某些文章的源码跟你的不一致的时候,就要关注是否包的版本号不同,建议使用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'
  }
}
复制代码

创造出如下目录结构

image.png

/** 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"
  }
}

复制代码

判定搭建成功

(ps:需要把click后面跟的函数改为() => {})

讲解乞丐版虚拟dom和diff中每个函数的作用

为什么说是乞丐版:

  1. h函数固定了参数个数,以及使用的方式
  2. patchVnode不考虑节点前存在字符串或数字的情况
  3. 组件没考虑在内

综上所述,还有很多特殊情况尚未考虑,只考虑正常情况下的核心功能和流程

虚拟dom相关

1. h函数

作用是把传入的参数变为vnone

2. vnode函数

把传入的参数变为固定格式的对象

diff相关

1. patch函数

patch.png

2. createElement函数

真正创建节点 将 vnode 创建为 DOM

3. patchVnode函数

是上图中的精细化比较部分

patchVnode.png

4. updateChildren

diff算法

  • 四种命中查找:
    ① 新前与旧前
    ② 新后与旧后
    ③ 新后与旧前(此种命中,涉及移动节点,那么旧前指向的节点,移动到旧后之后)
    ④ 新前与旧后(此种命中,涉及移动节点,那么旧后指向的节点,移动到旧前之前)
  • 如果都没有命中,就需要用循环来寻找了

image.png

// 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结束

  1. newStartIdx <= newEndIdx 还符合就说明旧节点需要新增
  2. oldStartIdx <= oldEndIdx 还符合就说明旧节点需要删除

image.png

校验是否是移动了dom

打开F12在console旁边的Elements里,改变dom的内容,再点击按钮更改, 若内容一致则是移动。若内容回到最初则是新增。

源码地址

github.com/introvert-y…

最后

看源码才能理解,为什么key在for循环里不可缺少以及不能误用,让我们能从底层的逻辑开始做些性能优化,以及减少bug的产生。转载请标明出处,谢谢。

文章分类
前端
文章标签