От понимания к глубокому виртуальному DOM и реализации алгоритма сравнения

внешний интерфейс JavaScript DOM
От понимания к глубокому виртуальному DOM и реализации алгоритма сравнения

Виртуальный DOM и алгоритм сравнения

предисловие

虚拟DOMиdiffАлгоритмы, вы часто их иногда слышите, так что же они реализованы?Это то, что Xiaolang я изучаю虚拟DOMиdiffВо время резюме, здесь, чтобы дать вам более глубокое пониманиеvirtual DOMиdiffалгоритм, изsnabbdomОсновное использование , чтобы реализовать упрощенную версию самостоятельноsnabbdom, реализуйте самиh функция(создать виртуальный DOM)patchфункция (обновление представления путем сравнения старого и нового виртуального DOM), здесь я также рисую несколько анимаций, чтобы помочь вам понятьdiffЧетыре стратегии оптимизации, статья немного длинная, надеюсь, вы терпеливо ее читаете, и, наконец, все коды будут опубликованы, вы можете попробовать.

Наконец, я надеюсь, что каждый сможет дать Сяолануотличный

Прошлые основные моменты:

Написание простого ответа vue поможет вам понять принцип ответа.

От использования до самостоятельной реализации простого Vue Router — просто взгляните на это

Необходимые базовые знания о фронтенд-интервью, хотя и немногочисленные, но вы не можете не знать

1. Введение

Virtual DOMкраткое введение

даJavaScriptв соответствии сDOMСтруктура для создания объекта виртуальной древовидной структуры состоит в следующем:DOMабстракция, чемDOMболее легкий

зачем использоватьVirtual DOM

  • Конечно, это фронтенд-оптимизация, избегание частых операций.DOM, частая работаDOMЭто может привести к переформатированию и перерисовке браузера, производительность будет очень низкой, и есть ручное управлениеDOMЭто все еще довольно проблематично, и следует учитывать проблемы совместимости браузеров.jQueryи т.д. библиотека упрощаетDOMОперация, но проект сложный,DOMОперации по-прежнему будут сложными, и операции с данными также станут сложными.
  • Не во всех случаях используются виртуальныеDOMОба улучшают производительность и нацелены на использование в сложных проектах. Если простая операция, используйте виртуальныйDOM, чтобы создать виртуальныйDOMСерия операций, таких как объекты, не так хороша, как обычныеDOMдействовать
  • виртуальныйDOMМожет быть достигнут кросс-платформенный рендеринг, рендеринг сервера, апплеты и собственные приложения используют виртуальныеDOM
  • использовать виртуальныйDOMИзменения текущего состояния не требуют немедленного обновленияDOMИ обновленный контент обновляется, и для неизмененного контента не выполняется никаких операций, и сравнивается разница между двумя до и после.
  • Виртуальный DOM может поддерживать состояние программы и отслеживать последнее состояние.

2. Введение в снаббдом

Сначала давайте представим snabbdom

Если мы хотим понять виртуальный DOM, мы должны сначала понять его предка, то естьsnabbdom

snabbdomэто проект с открытым исходным кодом,Vueвнутри виртуальногоDOMОн был заимствован изsnabbdom, мы можем понять поsnabbdomвиртуальныйDOMпонятьVueвиртуальныйDOM,Vueслишком много исходного кода,snabbdomОн более лаконичен, поэтому используйте его для расширения виртуальногоDOMИсследовательская работа

Установить через нпм

npm install snabbdom

1. Snabbdom прост в использовании

Давайте напишем простой пример, используя snabdom

<body>
  <div id="app"></div>
  <script src="./js/test.js"></script>
</body>

Напишите test.js для использования

/* test.js */

// 导入 snabbdom
import { h, init, thunk } from 'snabbdom'
// init() 方法返回一个 patch 函数 用来比较两个虚拟DOM 的差异 然后更新到真实的DOM里
// 这里暂时传入一个空数组 []
let patch = init([])
// h 方法是用来创建 Virtual DOM
// 第一个参数是 虚拟DOM 标签
// 第二个参数是 虚拟DOM 的数据
// 第三个参数是 虚拟DOM 的子虚拟DOM
// 它有好几种传参方式 h函数做了重载 这里就 用上面的传参
// 而且可以进行嵌套使用
let vnode = h('div#box', '测试', [
  h('ul.list', [
    h('li', '我是一个li'),
    h('li', '我是一个li'),
    h('li', '我是一个li'),
  ]),
])
// 获取到 html 的 div#app
let app = document.querySelector('#app')
// 用来比较两个虚拟DOM 的差异 然后更新到真实的DOM里
let oldNode = patch(app, vnode)
// 再来模拟一个异步请求
setTimeout(() => {
  let vNode = h('div#box', '重新获取了数据', [
    h('ul.list', [
      h('li', '我是一个li'),
      h('li', '通过path判断了差异性'),
      h('li', '更新了数据'),
    ]),
  ])
  // 再来进行比较差异判断是否更新
  patch(oldNode, vNode)
}, 3000)

image-20210726224703891

видно поставить виртуалкуDOMобновлено до реальногоDOM, прямо поставить предыдущуюdiv#appобновлено, чтобы заменить

9

Через 3 секунды сравнить виртуальныйDOMразницу добавить к реальнойDOM, тут поменяли второе и третьеliВизуализировать как виртуальный с помощью функции hDOMиoldNodeНе то же самое, поэтому было сделано обновление сравнения

2. Внедрить модули в снаббдом

Несколько модулей кратко здесь

имя модуля Введение
attributes Пользовательские свойства DOM, включая два логических значенияchecked selected,пройти черезsetAttribute()настраивать
props является атрибутом свойства DOM, черезelement[attr] = valueнастраивать
dataset даdata-Атрибуты, начинающиеся с data-src...
style Встроенные стили
eventListeners Используется для регистрации и удаления событий

С приведенным выше введением давайте использовать его просто

/* module_test.js */

// 第一步当然是先导入 snabbdom 的 init() h()
import { h, init } from 'snabbdom'

// 导入模块
import attr from 'snabbdom/modules/attributes'
import style from 'snabbdom/modules/style'
import eventListeners from 'snabbdom/modules/eventlisteners'

// init()注册模块 返回值是 patch 函数用来比较 两个虚拟DOM 差异 然后添加到 真实DOM
let patch = init([attr, style, eventListeners])

// 使用 h() 渲染一个虚拟DOM
let vnode = h(
  'div#app',
  {
    // 自定义属性
    attrs: {
      myattr: '我是自定义属性',
    },
    // 行内样式
    style: {
      fontSize: '29px',
      color: 'skyblue',
    },
    // 事件绑定
    on: {
      click: clickHandler,
    },
  },
  '我是内容'
)

// 点击处理方法
function clickHandler() {
  // 拿到当前 DOM
  let elm = this.elm
  elm.style.color = 'red'
  elm.textContent = '我被点击了'
}

// 获取到 div#app
let app = document.querySelector('#app')

// patch 比较差异 ,然后添加到真实DOM 中
patch(app, vnode)

после этогоhtmlвведен в

<body>
  <div id="app"></div>
  <script src="./js/module_test.js"></script>
  <script></script>
</body>

увидеть эффект

11

Вы можете видеть, что настраиваемые атрибуты, встроенные стили и события кликов — все этоh()оказанный

Вышеупомянутое использование было кратко передано, поэтому давайте посмотримsnabbdomисходный код в

3. Пример виртуального DOM

сказал так долгоh()функции и виртуальныеDOMЗатем визуализированный виртуальныйDOMна что это похоже

реальная структура DOM

<div class="container">
  <p>哈哈</p>
  <ul class="list">
    <li>1</li>
    <li>2</li>
  </ul>
</div>

Структура после преобразования в виртуальный DOM

{ 
  // 选择器
  "sel": "div",
  // 数据
  "data": {
    "class": { "container": true }
  },
  // DOM
  "elm": undefined,
  // 和 Vue :key 一样是一种优化
  "key": undefined,
  // 子节点
  "children": [
    {
      "elm": undefined,
      "key": undefined,
      "sel": "p",
      "data": { "text": "哈哈" }
    },
    {
      "elm": undefined,
      "key": undefined,
      "sel": "ul",
      "data": {
        "class": { "list": true }
      },
      "children": [
        {
          "elm": undefined,
          "key": undefined,
          "sel": "li",
          "data": {
            "text": "1"
          },
          "children": undefined
        },
        {
          "elm": undefined,
          "key": undefined,
          "sel": "li",
          "data": {
            "text": "1"
          },
          "children": undefined
        }
      ]
    }
  ]
}

упоминалось ранееsnabbdomсерединаpatchметод

верноновый виртуальный DOMистарый виртуальный DOMпровестиdiff(детальное сравнение), выясняем, что наименьшее количество обновлений приходится на виртуальныйDOMсравнивать

невозможно поставить всеDOMУдалите их все и перерисуйте их все

4.h Функция

Выше мы испыталивиртуальный DOMuse , то теперь мы реализуем упрощенную версиюsnabbdom

Вводится функция h

существуетsnabbdomМы также использовали несколькоhФункция, основная функция заключается в создании виртуальных узлов

snabbdomиспользоватьTSнапиши, такhсделано в функцииперегрузка методаГибкость в использовании

Нижеsnabbdomсерединаhфункции, видно, что есть несколько способов параметров

export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;

Реализовать функцию vnode

письмоhРеализовать перед функциейvnodeфункция,vnodeфункция быть вhиспользуется, на самом деле этоvnodeФункция реализации функции очень проста вTSВ нем указано много типов, но я использую его здесь и далее.JSнаписать

/* vnode.js */

/**
 * 把传入的 参数 作为 对象返回
 * @param {string} sel 选择器
 * @param {object} data 数据
 * @param {array} children 子节点
 * @param {string} text 文本
 * @param {dom} elm DOM
 * @returns object
 */
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}

Реализовать простую h-функцию

Написанная здесь функция h реализует только основную функцию, не реализует перегрузку и напрямую реализует функцию h с 3 параметрами.

/* h.js */

// 导入 vnode
import vnode from './vnode'

// 导出 h 方法
// 这里就实现简单3个参数 参数写死
/**
 *
 * @param {string} a sel
 * @param {object} b data
 * @param {any} c 是子节点 可以是文本,数组
 */
export default function h(a, b, c) {
  // 先判断是否有三个参数
  if (arguments.length < 3) throw new Error('请检查参数个数')
  // 第三个参数有不确定性 进行判断
  // 1.第三个参数是文本节点
  if (typeof c === 'string' || typeof c === 'number') {
    // 调用 vnode 这直接传 text 进去
    // 返回值 {sel,data,children,text,elm} 再返回出去
    return vnode(a, b, undefined, c, undefined)
  } // 2.第三个参数是数组 [h(),h()] [h(),text] 这些情况
  else if (Array.isArray(c)) {
    // 然而 数组里必须是 h() 函数
    // children 用收集返回结果
    let children = []
    // 先判断里面是否全是 h()执行完的返回结果 是的话添加到 chilren 里
    for (let i = 0; i < c.length; i++) {
      // h() 的返回结果 是{} 而且 包含 sel
      if (!(typeof c[i] === 'object' && c[i].sel))
        throw new Error('第三个参数为数组时只能传递 h() 函数')
      // 满足条件进行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
      children.push(c[i])
    }
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  } // 3.第三个参数直接就是函数 返回的是 {sel,data,children,text,elm}
  else if (typeof c === 'object' && c.sel) {
    // 这个时候在 使用h()的时候 c = {sel,data,children,text,elm} 直接放入children
    let children = [c]
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  }
}

Разве это не очень просто, он сказал, что это не рекурсивно, как своего рода вложение, постоянно собирая{sel,data,children,text,elm}

chirldrenПоложите это внутрь{sel,data,children,text,elm}

Например

/* index.js */

import h from './my-snabbdom/h'

let vnode = h('div', {}, 
  h('ul', {}, [
    h('li', {}, '我是一个li'),
    h('li', {}, '我是一个li'),
    h('li', {}, '我是一个li'),
  ),
])
console.log(vnode)

<body>
  <div id="container"></div>
  <script src="/virtualdir/bundle.js"></script>
</body>

image-20210727204731661

OK, написаноhС функцией проблем нет, дамми генерируетсяDOMдерево, которое генерирует виртуальный DOM, который мы будем использовать позже

Кратко опишите процесс

все знаютjsВыполнение функции, конечно, сначала выполнить самую внутреннюю функцию

  • 1.h('li', {}, '我是一个li')Первое выполнение возвращает{sel,data,children,text,elm}Три ли подряд это

  • 2. Тогда естьh('ul', {}, [])Введите второе суждение, является ли это массивом, а затем оцените, является ли каждый элемент объектом и имеет ли онselсвойства, затем добавьте вchildrenВнутри и обратно{sel,data,children,text,elm}

  • 3. Третье — реализацияh('div', {},h()), третий параметр напрямуюh()функция ={sel,data,children,text,elm},егоchildrenиспользовать его[ ]заворачивать

    вернуться кvnode

5.функция патча

Введение

существуетsnabbdomсреди нас черезinit()вернулсяpatchфункцию, черезpatchИдите вперед и сравните два виртуальных DOM и добавьте реальный.DOMНа дереве среднее сравнение — это то, о чем мы поговорим позже.diff

Давайте сначала разберемсяpatchчто внутри

image-20210728172052418

В соответствии с описанным выше процессом, давайте напишем простойpatch

1.patch

Сначала напишите тот же Vnode

используется для сравнения двух виртуальныхDOMизkeyиsel

/* sameVnode.js */

/**
 * 判断两个虚拟节点是否是同一节点
 * @param {vnode} vnode1 虚拟节点1
 * @param {vnode} vnode2 虚拟节点2
 * @returns boolean
 */
export default function sameVnode(vnode1, vnode2) {
  return (
    (vnode1.data ? vnode1.data.key : undefined) ===
      (vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
  )
}

Написать базовый патч

/* patch.js */

// 导入 vnode
import vnode from './vnode'


// 导出 patch
/**
 *
 * @param {vnode/DOM} oldVnode
 * @param {vnode} newVnode
 */
export default function patch(oldVnode, newVnode) {
  // 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
  if (!oldVnode.sel) {
    // 转为虚拟DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判断 oldVnode 和 newVnode 是否为同一虚拟节点
  // 通过 key 和 sel 进行判断
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
    ...
  } else {
    // 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
    ...
  }
  newVnode.elm = oldVnode.elm

  // 返回newVnode作为 旧的虚拟节点
  return newVnode
}

/**
 * 转为 虚拟 DOM
 * @param {DOM} elm DOM节点
 * @returns {object}
 */
function emptyNodeAt(elm) {
  // 把 sel 和 elm 传入 vnode 并返回
  // 这里主要选择器给转小写返回vnode
  // 这里功能做的简陋,没有去解析 # .
  // data 也可以传 ID 和 class
  return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}

Теперь нам предстоит разобраться с вопросом, является ли это одним и тем же виртуальным узлом

2.createElm

Сначала разберитесь с другим виртуальным узлом

Чтобы справиться с этим, мы должны написать метод для создания узла здесьcreateElm.jsзавершено в

/* createElm.js */

/**
 * 创建元素
 * @param {vnode} vnode 要创建的节点
 */
export default function createElm(vnode) {
  // 拿出 新创建的 vnode 中的 sel
  let node = document.createElement(vnode.sel)
  // 存在子节点
  // 子节点是文本
  if (
    vnode.text !== '' &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 直接添加文字到 node 中
    node.textContent = vnode.text

    // 子节点是数组
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    let children = vnode.children
    // 遍历数组
    for (let i = 0; i < children.length; i++) {
      // 获取到每一个数组中的 子节点
      let ch = children[i]
      // 递归的方式 创建节点
      let chDom = createElm(ch)
      // 把子节点添加到 自己身上
      node.appendChild(chDom)
    }
  }
  // 更新vnode 中的 elm
  vnode.elm = node
  // 返回 DOM
  return node
}

вышеcreateElmЭто использовать рекурсивный способ создания дочерних узлов, а затем мы переходим к патчу, чтобы вызвать метод создания узлов конкретно

/* patch.js */

// 导入 vnode createELm
import vnode from './vnode'
import createElm from './createElm'


// 导出 patch
/**
 *
 * @param {vnode/DOM} oldVnode
 * @param {vnode} newVnode
 */
export default function patch(oldVnode, newVnode) {
  // 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
  if (!oldVnode.sel) {
    // 转为虚拟DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判断 oldVnode 和 newVnode 是否为同一虚拟节点
  // 通过 key 和 sel 进行判断
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
    ...
  } else {
    // 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
    // 这里通过 createElm 递归 转为 真实的 DOM 节点
    let newNode = createElm(newVnode)
    // 旧节点的父节点
    if (oldVnode.elm.parentNode) {
      let parentNode = oldVnode.elm.parentNode
      // 添加节点到真实的DOM 上
      parentNode.insertBefore(newNode, oldVnode.elm)
      // 删除旧节点
      parentNode.removeChild(oldVnode.elm)
    }
  }
  newVnode.elm = oldVnode.elm
  return newVnode
}
...
}

Рекурсивное добавление дочерних узлов в конце, в котором мы находимся.patchдобавить к реальномуDOM, удалите старый узел перед

Я написал это здесь, чтобы попробовать, действительно ли добавлены разные узлы

/* index.js */

import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'


let app = document.querySelector('#app')

let vnode = h('ul', {}, [
  h('li', {}, '我是一个li'),
  h('li', {}, [
    h('p', {}, '我是一个p'),
    h('p', {}, '我是一个p'),
    h('p', {}, '我是一个p'),
  ]),
  h('li', {}, '我是一个li'),
])


let oldVnode = patch(app, vnode)

<body>
  <div id="app">hellow</div>
  <script src="/virtualdir/bundle.js"></script>
</body>

image-20210728164308771

Пучокdiv#appЗаменено и успешно заменено

3.patchVnode

Давайте теперь реализуем ту же виртуальную обработку DOM

в patchVnode

Все шаги написаны в соответствии с предыдущей блок-схемой, мы сравниваем дватакой жеКод виртуального DOM написан наpatchVnode.jsсередина

Есть несколько случаев при сравнении двух идентичных веток виртуальных узлов

/* patchVnode.js */

// 导入 vnode createELm
import createElm from './createElm'

/**
 *
 * @param {vnode} oldVnode 老的虚拟节点
 * @param {vnode} newVnode 新的虚拟节点
 * @returns
 */
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
  // 1.判断是否相同对象
  console.log('同一个虚拟节点')
  if (oldVnode === newVnode) return
  // 2.判断newVnode上有没有text
  // 这里为啥不考虑 oldVnode呢,因为 newVnode有text说明就没children
  if (newVnode.text && !newVnode.children) {
    // 判断是text否相同
    if (oldVnode.text !== newVnode.text) {
      console.log('文字不相同')
      // 不相同就直接把 newVnode中text 给 elm.textContent
      oldVnode.elm.textContent = newVnode.text
    }
  } else {
    // 3.判断oldVnode有children, 这个时候newVnode 没有text但是有 children
    if (oldVnode.children) {
      ...这里新旧节点都存在children 这里要使用 updateChildren 下面进行实现
    } else {
      console.log('old没有children,new有children')
      // oldVnode没有 children ,newVnode 有children
      // 这个时候oldVnode 只有text 我们把 newVnode 的children拿过来
      // 先清空 oldVnode 中text
      oldVnode.elm.innerHTML = ''
      // 遍历 newVnode 中的 children
      let newChildren = newVnode.children
      for (let i = 0; i < newChildren.length; i++) {
        // 通过递归拿到了 newVnode 子节点
        let node = createElm(newChildren[i])
        // 添加到 oldVnode.elm 中
        oldVnode.elm.appendChild(node)
      }
    }
  }
}

Следуйте блок-схеме для кодирования, теперь для обработкиnewVnodeиoldVnodeоба существуютchildrenситуация

Здесь мы собираемся провести утонченное сравнение, о чем мы часто говоримdiff

4.diff

часто слышалdiff (отличное сравнение), тогда давайте сначала разберемся

Четыре стратегии оптимизации для diff

Здесь используются 4 указателя, начиная с порядка 1-4, чтобы попасть в стратегию оптимизации, нажать один и переместить указатель.(新前和旧前向下移动,新后和旧后向上移动), никаких хитов, просто используйтеследующая стратегия, если ни одна из четырех стратегий не сработала, вы можете найти ее, только зациклив

попадание: два узлаselиkeyТакой же

  1. новый и старый
  2. новый и старый
  3. новый и старый
  4. до и после

Поговорим о новом

Четыре стратегии выполняются в цикле

while(旧前<=旧后&&新前<=新后){
  ...
}

14

Как можно видетьстарый дочерний узелПосле того, как цикл завершится первым, это означает, что новый дочерний узел должен добавить новый дочерний узел.

новый фронтиновый постУзел — это байт, который нужно добавить

Удалено дело 1

19

Здесь новый дочерний узел завершает цикл первым, указывая на то, что в старом дочернем узле есть узлы, которые необходимо удалить.

Удалено дело 2

Когда мы удаляем несколько, и ни одна из 4 стратегий не удовлетворяется, мы должны пройтиwhileцикл старый дочерний узел найти новый дочерний узел нужно найти узел и пометить какundefinedвиртуальный узелundefinedна самом деле вDOMпереместил его,старый фронтиСтарыйУзел между - это узел, который необходимо удалить.

18

Усложнение 1

Когда срабатывает четвертая стратегия, узел нужно переместить сюда, а узел, на который указывает после старого (отмеченный как виртуальный узел какundefined), Настоящийновый фронтУзел указывает наDOMв движении кперед старым

20

Усложнение 2

Когда срабатывает третья стратегия, узел также необходимо переместить сюда, а узел, на который указывает старый прежний (помеченный как виртуальный узелundefined), Настоящийновый постУзел указывает наDOMв движении кпосле старого

21

Обратите внимание на несколько моментов:

  • h('li',{key:'A'} : "A"})Например, ключ в этом — уникальный идентификатор этого узла.
  • его существование говоритdiff, они одинаковы до и после измененияDOMузел.
  • Толькотот же виртуальный узел,Только сделайте уточненное сравнение, иначе этогрубая сила удалить старую, вставить новый
  • Один и тот же виртуальный узел должен иметь не только один и тот же ключ, но и один и тот же селектор, т.h()В объекте виртуального узла, созданном функциейsel
  • Выполняются сравнения только одного уровня, межуровневые сравнения не выполняются.

5.updateChildren

См. выше дляdiff, я не знаю, четкая ли картинка, которую я нарисовал, и тогда мы продолжаем доделыватьpatchVnode

мы должны написатьupdateChildrenдля подробного сравнения

Этот файлdiffСердце алгоритма, который мы используем для сравненияoldVnodeиnewVnodeоба существуютchildrenСлучай

Здесь немного запутанно, комментарии все написаны, пожалуйста, смотрите с терпением, процесс написания в соответствии с четырьмя стратегиями diff, а также имеет дело с отсутствием совпадений.

/* updateChilren.js */

// 导入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 导出 updateChildren
/**
 *
 * @param {dom} parentElm 父节点
 * @param {array} oldCh 旧子节点
 * @param {array} newCh 新子节点
 */
export default function updateChildren(parentElm, oldCh, newCh) {
  // 下面先来定义一下之前讲过的 diff 的几个指针 和 指针指向的 节点
  // 旧前 和 新前
  let oldStartIdx = 0,
    newStartIdx = 0
  let oldEndIdx = oldCh.length - 1 //旧后
  let newEndIdx = newCh.length - 1 //新后
  let oldStartVnode = oldCh[0] //旧前 节点
  let oldEndVnode = oldCh[oldEndIdx] //旧后节点
  let newStartVnode = newCh[0] //新前节点
  let newEndVnode = newCh[newEndIdx] //新后节点
  let keyMap = null //用来做缓存
  // 写循环条件
  while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    console.log('---进入diff---')

    // 下面按照 diff 的4种策略来写 这里面还得调用 pathVnode
    // patchVnode 和 updateChildren 是互相调用的关系,不过这可不是死循环
    // 指针走完后就不调用了

    // 这一段都是为了忽视我们加过 undefined 节点,这些节点实际上已经移动了
    if (oldCh[oldStartIdx] == undefined) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldCh[oldEndIdx] == undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newCh[newStartIdx] == undefined) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newCh[newEndIdx] == undefined) {
      newEndVnode = newCh[--newEndIdx]
    }
    // 忽视了所有的 undefined 我们这里来 判断四种diff优化策略
    // 1.新前 和 旧前
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      console.log('1命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newStartVnode)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } // 2.新后 和 旧后
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      console.log('2命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newEndVnode)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } // 3.新后 和 旧前
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      console.log('3命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newEndVnode)
      // 策略3是需要移动节点的 把旧前节点 移动到 旧后 之后
      // insertBefore 如果参照节点为空,就插入到最后 和 appendChild一样
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    }
    // 4.新前 和 旧后
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      console.log('4命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newStartVnode)
      // 策略4是也需要移动节点的 把旧后节点 移动到 旧前 之前
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      console.log('diff四种优化策略都没命中')
      // 当四种策略都没有命中
      // keyMap 为缓存,这样就不用每次都遍历老对象
      if (!keyMap) {
        // 初始化 keyMap
        keyMap = {}
        // 从oldStartIdx到oldEndIdx进行遍历
        for (let i = oldStartIdx; i < oldEndIdx; i++) {
          // 拿个每个子对象 的 key
          const key = oldCh[i].data.key
          // 如果 key 不为 undefined 添加到缓存中
          if (!key) keyMap[key] = i
        }
      }

      // 判断当前项是否存在 keyMap 中 ,当前项时 新前(newStartVnode)
      let idInOld = keyMap[newStartIdx.data]
        ? keyMap[newStartIdx.data.key]
        : undefined

      // 存在的话就是移动操作
      if (idInOld) {
        console.log('移动节点')
        // 从 老子节点 取出要移动的项
        let moveElm = oldCh[idInOld]
        // 调用 patchVnode 进行对比 修改
        patchVnode(moveElm, newStartVnode)
        // 将这一项设置为 undefined
        oldCh[idInOld] = undefined
        // 移动 节点 ,对于存在的节点使用 insertBefore移动
        // 移动的 旧前 之前 ,因为 旧前 与 旧后 之间的要被删除
        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
      } else {
        console.log('添加新节点')
        // 不存在就是要新增的项
        // 添加的节点还是虚拟节点要通过 createElm 进行创建 DOM
        // 同样添加到 旧前 之前
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
      }

      // 处理完上面的添加和移动 我们要 新前 指针继续向下走
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 我们添加和删除操作还没做呢
  // 首先来完成添加操作 新前 和 新后 中间是否还存在节点
  if (newStartIdx <= newEndIdx) {
    console.log('进入添加剩余节点')
    // 这是一个标识
    // let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
    let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
    // new 里面还有剩余节点 遍历添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh里面的子节点还需要 从虚拟DOM 转为 DOM
      parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('进入删除多余节点')
    // old 里面还有剩余 节点 ,旧前 和 旧后 之间的节点需要删除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      // 删除 剩余节点之前 先判断下是否存在
      if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
    }
  }
}

На данный момент мы в основном завершили написание,hфункция создания виртуальныхDOM , patchСравнить виртуальныеDOMсделать вид обновления

6. Давайте проверим, что мы написали

На самом деле при написании кода происходит его постоянная отладка. . . Протестируйте несколько прямо сейчас

1. Код

html

<body>
  <button class="btn">策略3</button>
  <button class="btn">复杂</button>
  <button class="btn">删除</button>
  <button class="btn">复杂</button>
  <button class="btn">复杂</button>
  <ul id="app">
    hellow
  </ul>

  <script src="/virtualdir/bundle.js"></script>
</body>

index.js

/* index.js */

import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'

let app = document.querySelector('#app')

let vnode = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'E' }, 'E'),
])

let oldVnode = patch(app, vnode)

let vnode2 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'A' }, 'A'),
])
let vnode3 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'K' }, 'K'),
])
let vnode4 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
])
let vnode5 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'V' }, 'V'),
])
let vnode6 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h(
    'li',
    { key: 'E' },
    h('ul', {}, [
      h('li', { key: 'A' }, 'A'),
      h('li', { key: 'B' }, 'B'),
      h('li', { key: 'C' }, 'C'),
      h('li', { key: 'D' }, 'D'),
      h('li', { key: 'E' }, h('div', { key: 'R' }, 'R')),
    ])
  ),
])
let vnodeList = [vnode2, vnode3, vnode4, vnode5, vnode6]
let btn = document.querySelectorAll('.btn')
for (let i = 0; i < btn.length; i++) {
  btn[i].onclick = () => {
    patch(vnode, vnodeList[i])
  }
}

2. Демонстрация

Стратегия 3

22

сложный

23

Удалить

24

сложный

25

Комплекс (здесь просто..)

26

7. Заключение

Я написал все комментарии.Вы можете сравнить картинки, которые я нарисовал выше.Вы можете прочитать их терпеливо и неоднократно.

Если посмотреть, то не почувствуешь, можешь написать сам, весь код выложу ниже

Код также размещен вgithub

Полный код:

h.js

/* h.js */

// 导入 vnode
import vnode from './vnode'

// 导出 h 方法
// 这里就实现简单3个参数 参数写死
/**
 *
 * @param {string} a sel
 * @param {object} b data
 * @param {any} c 是子节点 可以是文本,数组
 */
export default function h(a, b, c) {
  // 先判断是否有三个参数
  if (arguments.length < 3) throw new Error('请检查参数个数')
  // 第三个参数有不确定性 进行判断
  // 1.第三个参数是文本节点
  if (typeof c === 'string' || typeof c === 'number') {
    // 调用 vnode 这直接传 text 进去
    // 返回值 {sel,data,children,text,elm} 再返回出去
    return vnode(a, b, undefined, c, undefined)
  } // 2.第三个参数是数组 [h(),h()] [h(),text] 这些情况
  else if (Array.isArray(c)) {
    // 然而 数组里必须是 h() 函数
    // children 用收集返回结果
    let children = []
    // 先判断里面是否全是 h()执行完的返回结果 是的话添加到 chilren 里
    for (let i = 0; i < c.length; i++) {
      // h() 的返回结果 是{} 而且 包含 sel
      if (!(typeof c[i] === 'object' && c[i].sel))
        throw new Error('第三个参数为数组时只能传递 h() 函数')
      // 满足条件进行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
      children.push(c[i])
    }
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  } // 3.第三个参数直接就是函数 返回的是 {sel,data,children,text,elm}
  else if (typeof c === 'object' && c.sel) {
    // 这个时候在 使用h()的时候 c = {sel,data,children,text,elm} 直接放入children
    let children = [c]
    // 调用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  }
}

patch.js

/* patch.js */

// 导入 vnode createELm patchVnode sameVnode.js
import vnode from './vnode'
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 导出 patch
/**
 *
 * @param {vnode/DOM} oldVnode
 * @param {vnode} newVnode
 */
export default function patch(oldVnode, newVnode) {
  // 1.判断oldVnode 是否为虚拟 DOM 这里判断是否有 sel
  if (!oldVnode.sel) {
    // 转为虚拟DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判断 oldVnode 和 newVnode 是否为同一虚拟节点
  // 通过 key 和 sel 进行判断
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一个虚拟节点 调用我们写的 patchVnode.js 中的方法
    patchVnode(oldVnode, newVnode)
  } else {
    // 不是同一虚拟个节点 直接暴力拆掉老节点,换上新的节点
    // 这里通过 createElm 递归 转为 真实的 DOM 节点
    let newNode = createElm(newVnode)
    // 旧节点的父节点
    if (oldVnode.elm.parentNode) {
      let parentNode = oldVnode.elm.parentNode
      // 添加节点到真实的DOM 上
      parentNode.insertBefore(newNode, oldVnode.elm)
      // 删除旧节点
      parentNode.removeChild(oldVnode.elm)
    }
  }
  newVnode.elm = oldVnode.elm
  // console.log(newVnode.elm)

  // 返回newVnode作为 旧的虚拟节点
  return newVnode
}

/**
 * 转为 虚拟 DOM
 * @param {DOM} elm DOM节点
 * @returns {object}
 */
function emptyNodeAt(elm) {
  // 把 sel 和 elm 传入 vnode 并返回
  // 这里主要选择器给转小写返回vnode
  // 这里功能做的简陋,没有去解析 # .
  // data 也可以传 ID 和 class
  return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}

createElm.js

/* createElm.js */

/**
 * 创建元素
 * @param {vnode} vnode 要创建的节点
 */
export default function createElm(vnode) {
  // 拿出 新创建的 vnode 中的 sel
  let node = document.createElement(vnode.sel)
  // 存在子节点
  // 子节点是文本
  if (
    vnode.text !== '' &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 直接添加文字到 node 中
    node.textContent = vnode.text
    // 子节点是数组
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    let children = vnode.children
    // 遍历数组
    for (let i = 0; i < children.length; i++) {
      // 获取到每一个数组中的 子节点
      let ch = children[i]
      // 递归的方式 创建节点
      let chDom = createElm(ch)
      // 把子节点添加到 自己身上
      node.appendChild(chDom)
    }
  }
  // 更新vnode 中的 elm
  vnode.elm = node
  // 返回 DOM
  return node
}

vnode.js

/* vnode.js */

/**
 * 把传入的 参数 作为 对象返回
 * @param {string} sel 选择器
 * @param {object} data 数据
 * @param {array} children 子节点
 * @param {string} text 文本
 * @param {dom} elm DOM
 * @returns
 */
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}

patchVnode.js

/* patchVnode.js */

// 导入 vnode createELm patchVnode updateChildren
import createElm from './createElm'
import updateChildren from './updateChildren'
/**
 *
 * @param {vnode} oldVnode 老的虚拟节点
 * @param {vnode} newVnode 新的虚拟节点
 * @returns
 */
// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
  // 1.判断是否相同对象
  // console.log('同一个虚拟节点')
  if (oldVnode === newVnode) return
  // 2.判断newVnode上有没有text
  // 这里为啥不考虑 oldVnode呢,因为 newVnode有text说明就没children
  if (newVnode.text && !newVnode.children) {
    // 判断是text否相同
    if (oldVnode.text !== newVnode.text) {
      console.log('文字不相同')
      // 不相同就直接把 newVnode中text 给 elm.textContent
      oldVnode.elm.textContent = newVnode.text
    }
  } else {
    // 3.判断oldVnode有children, 这个时候newVnode 没有text但是有 children
    if (oldVnode.children) {
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
    } else {
      console.log('old没有children,new有children')
      // oldVnode没有 children ,newVnode 有children
      // 这个时候oldVnode 只有text 我们把 newVnode 的children拿过来
      // 先清空 oldVnode 中text
      oldVnode.elm.innerHTML = ''
      // 遍历 newVnode 中的 children
      let newChildren = newVnode.children
      for (let i = 0; i < newChildren.length; i++) {
        // 通过递归拿到了 newVnode 子节点
        let node = createElm(newChildren[i])
        // 添加到 oldVnode.elm 中
        oldVnode.elm.appendChild(node)
      }
    }
  }
}

sameVnode.js

/* sameVnode.js */

/**
 * 判断两个虚拟节点是否是同一节点
 * @param {vnode} vnode1 虚拟节点1
 * @param {vnode} vnode2 虚拟节点2
 * @returns boolean
 */
export default function sameVnode(vnode1, vnode2) {
  return (
    (vnode1.data ? vnode1.data.key : undefined) ===
      (vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
  )
}

updateChildren.js

/* updateChilren.js */

// 导入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 导出 updateChildren
/**
 *
 * @param {dom} parentElm 父节点
 * @param {array} oldCh 旧子节点
 * @param {array} newCh 新子节点
 */
export default function updateChildren(parentElm, oldCh, newCh) {
  // 下面先来定义一下之前讲过的 diff 的几个指针 和 指针指向的 节点
  // 旧前 和 新前
  let oldStartIdx = 0,
    newStartIdx = 0
  let oldEndIdx = oldCh.length - 1 //旧后
  let newEndIdx = newCh.length - 1 //新后
  let oldStartVnode = oldCh[0] //旧前 节点
  let oldEndVnode = oldCh[oldEndIdx] //旧后节点
  let newStartVnode = newCh[0] //新前节点
  let newEndVnode = newCh[newEndIdx] //新后节点
  let keyMap = null //用来做缓存
  // 写循环条件
  while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    console.log('---进入diff---')

    // 下面按照 diff 的4种策略来写 这里面还得调用 pathVnode
    // patchVnode 和 updateChildren 是互相调用的关系,不过这可不是死循环
    // 指针走完后就不调用了

    // 这一段都是为了忽视我们加过 undefined 节点,这些节点实际上已经移动了
    if (oldCh[oldStartIdx] == undefined) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldCh[oldEndIdx] == undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newCh[newStartIdx] == undefined) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newCh[newEndIdx] == undefined) {
      newEndVnode = newCh[--newEndIdx]
    }
    // 忽视了所有的 undefined 我们这里来 判断四种diff优化策略
    // 1.新前 和 旧前
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      console.log('1命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newStartVnode)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } // 2.新后 和 旧后
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      console.log('2命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newEndVnode)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } // 3.新后 和 旧前
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      console.log('3命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldStartVnode, newEndVnode)
      // 策略3是需要移动节点的 把旧前节点 移动到 旧后 之后
      // insertBefore 如果参照节点为空,就插入到最后 和 appendChild一样
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      // 指针移动
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    }
    // 4.新前 和 旧后
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      console.log('4命中')
      // 调用 patchVnode 对比两个节点的 对象 文本 children
      patchVnode(oldEndVnode, newStartVnode)
      // 策略4是也需要移动节点的 把旧后节点 移动到 旧前 之前
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      // 指针移动
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      console.log('diff四种优化策略都没命中')
      // 当四种策略都没有命中
      // keyMap 为缓存,这样就不用每次都遍历老对象
      if (!keyMap) {
        // 初始化 keyMap
        keyMap = {}
        // 从oldStartIdx到oldEndIdx进行遍历
        for (let i = oldStartIdx; i < oldEndIdx; i++) {
          // 拿个每个子对象 的 key
          const key = oldCh[i].data.key
          // 如果 key 不为 undefined 添加到缓存中
          if (!key) keyMap[key] = i
        }
      }

      // 判断当前项是否存在 keyMap 中 ,当前项时 新前(newStartVnode)
      let idInOld = keyMap[newStartIdx.data]
        ? keyMap[newStartIdx.data.key]
        : undefined

      // 存在的话就是移动操作
      if (idInOld) {
        console.log('移动节点')
        // 从 老子节点 取出要移动的项
        let moveElm = oldCh[idInOld]
        // 调用 patchVnode 进行对比 修改
        patchVnode(moveElm, newStartVnode)
        // 将这一项设置为 undefined
        oldCh[idInOld] = undefined
        // 移动 节点 ,对于存在的节点使用 insertBefore移动
        // 移动的 旧前 之前 ,因为 旧前 与 旧后 之间的要被删除
        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
      } else {
        console.log('添加新节点')
        // 不存在就是要新增的项
        // 添加的节点还是虚拟节点要通过 createElm 进行创建 DOM
        // 同样添加到 旧前 之前
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
      }

      // 处理完上面的添加和移动 我们要 新前 指针继续向下走
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 我们添加和删除操作还没做呢
  // 首先来完成添加操作 新前 和 新后 中间是否还存在节点
  if (newStartIdx <= newEndIdx) {
    console.log('进入添加剩余节点')
    // 这是一个标识
    // let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
    let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
    // new 里面还有剩余节点 遍历添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh里面的子节点还需要 从虚拟DOM 转为 DOM
      parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('进入删除多余节点')
    // old 里面还有剩余 节点 ,旧前 和 旧后 之间的节点需要删除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      // 删除 剩余节点之前 先判断下是否存在
      if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
    }
  }
}