Vue 核心源码 (上)- 04

131 阅读6分钟

问题 vue 的diff 算法和 react 的 diff 算法有什么区别?

最早的 diff 算法复杂度是O(m^3n^3) -> 2011 年降低到O(n^3) n 代表节点数 -> react O(n) 最好情况;最坏情况O(n^2)

复杂度:最好时间复杂度、最坏时间复杂度、平均时间复杂度、均摊时间复杂度

例子理解,如果遍历列表,长度是 n 假设最好情况:遍历第一个就找到,复杂度就是O(1) 最坏情况:最后一个才找到,复杂度就是O(n) 平均复杂度:总情况数 / 总操作数 情况数: 1 次找到、2 次、... n 次找到 1 + 2 + 3 + ... + n 操作数: n + 1 // 1 - 没找到 平均复杂度 = n(1 + n) / 2 / (n + 1) -> 不看系数 O(n)

均摊复杂度:最坏的情况平均分给每一个 in this case O(1)

  1. 为什么需要diff?
  1. 性能,虚拟DOM 的隔离,减少直接操作DOM
  2. 通过DSL,改变原来的开发模式(直接操作DOM,将数据加入),现在经过响应式方法,改变中间的数据,数据再映射到tree,最终tree 经过diff 再映射到视图。现代的前端 f(state) --> View

数据模型 -> virtual dom -> 视图(DOM)

DSL: { type: 'div', props: { class: '', id: '', children: [{type ...}, {}]}, ... } -> 这个对象来描述 DOM 结构

并不能说 虚拟DOM 比真实DOM 快,某些场景下会

DOM 为什么慢?主要是引起重绘和重排 -> 如何解决?

  1. 切片,渲染1w个,切成10个,并发 1000
  2. DocumentFragment, 不会每次操作DOM,最后一次再操作DOM
  3. 拼字符串
  1. 为什么传统的 diff 算法是O(n^3)?

树需要跨层级比较

计算机比较两颗树:最短编辑距离 假设:'hello' -> 'hallo' e -> a 需要几步?一眼望去就知道答案是一步,这个时候你脑子里的思考过程其实就是编辑距离算法

抽象一点,对字符串的操作不外乎三种,「替换」「插入」「删除」,执行这三种操作后到达目的的最小操作数,就是最短编辑距离,这里的复杂度就是我们需要考虑的

树的最短编辑距离算法复杂度是 O(n^2),其实就是实现的时候,拿 Levenshtein 举例,需要双层 for 循环去计算左,左上,右三个值,这里复杂度就是 O(n^2) 了

如果两个树同一层,type 类型不一致,type 不一样(一个div,一个 p),可以做的操作是删掉div,插入p -> 两个操作 O(n^2)

到了这个节点,然后,因为 diff 还要做一次 patch,(找到差异后还要计算最小转换方式)这个时候还要在之前遍历的基础上再遍历一次,要找到当时删除节点的位置,所以累计起来就是 O(n^3) 了

  1. 为什么react diff 是 O(n)? 不是严格意义上的O(n),而是O(nm) [a, b, c] vs [b, d, e, f] 比较的事实上是:同层比较,最优解是O(n),实际一般是O(mn) [a, b] 比 [b, d] 比 [c, e] 比 [null, f] 比
 for (let i = 0, len = oldNodes.length; i < len; i++) {
  if (oldNodes[i].type !== newNodes[i].type) {
    replace()
  }
  else if (oldNodes[i].children && oldNodes[i].children.length) { // 如果没有这一层,假设 type 全不相同,那么就是 O(n),最坏复杂度 O(nm)
  }
 }

复杂度就为 O(m * n) m - 节点数 n - 子节点的数目 也可以理解成O(n^2) 极端情况下子节点遍历的数目和当前遍历的数目是一样的

  1. 如何做到 O(n)?
  • react 是怎么设计将复杂度砍下来呢?其实就是在算法复杂度、虚拟 dom 渲染机制、性能中找了一个平衡,react 采用了启发式的算法,做了如下最优假设:
  • a. 如果节点类型相同,那么以该节点为根节点的 tree 结构,大概率是相同的,所以如果类型不同,可以直接「删除」原节点,「插入」新节点
  • b. 跨层级移动子 tree 结构的情况比较少见,或者可以培养用户使用习惯来规避这种情况,遇到这种情况同样是采用先「删除」再「插入」的方式,这样就避免了跨层级移动
  • c. 同一层级的子元素,可以通过 key 来缓存实例,然后根据算法采取「插入」「删除」「移动」的操作,尽量复用,减少性能开销
  • d. 完全相同的节点,其虚拟 dom 也是完全一致的

基于这些假设,可以将 diff 抽象成只需要做同层比较的算法,这样复杂度就直线降低了

面试题:为什么 v-for 要有key? key 的作用是复用,内部有一个映射表,在新旧 nodes 对比时辨识 VNodes

遍历 老的 tree,存了key和下标的映射

let prevMap = {}
let nextMap = {}

// old tree children
for (let i = 0; i < prev.length; i++) {
  let { key = i + '' } = prev[i]
  prevMap[key] = i
}

遍历新的children,取出 key 去老得里面找,如果没有说明是新增的,-> 找出新增的节点位置,mount(mount 方法两种方法,要么是insertBefore,一个是appendChild)

let lastIndex = 0
  for (let n = 0; n < next.length; n++) {
    let { key = n + '' } = next[n]
    let j = prevMap[key]
    let nextChild = next[n]
    nextMap[key] = n
    
    // {b: 0, a: 1}
    // 原children    新 children
    // [b, a]   ->   [c, d, a]  ::[c, b, a] 👉 c
    // [b, a]   ->   [c, d, a]  ::[c, d, b, a] 👉 d
    if (j == null) {
      let refNode = n === 0 ? prev[0].el : next[n - 1].el.nextSibling
      mount(nextChild, parent, refNode)
    }
    else {
      // [b, a] -> [c, d, a]  ::[c, d, a, b] 👉 a
      patch(prev[j], nextChild, parent)
      if (j < lastIndex) {
        let refNode = next[n - 1].el.nextSibling;
        parent.insertBefore(nextChild.el, refNode)
      }
      else {
        lastIndex = j
      }
    }
  }

遍历老 tree,老的有的新的没有,直接把老的 删除了

// [b, a] -> [c, d, a]  ::[c, d, a] 👉 b
  for (let i = 0; i < prev.length; i++) {
    let { key = '' + i } = prev[i]
    if (!nextMap.hasOwnProperty(key)) parent.removeChild(prev[i].el)
  }

举个例子,假设原来有 [1, 2, 3] 三个子节点渲染了,假设我们这么操作了一波,将顺序打乱变成 [3, 1, 2],并且删除了最后一个,变成 [3, 1]

  • 那,最优的 diff 思路应该是复用 3, 1组件,移动一下位置,去掉 2 组件,这样整体是开销最小的,如果有 key 的话,这波操作水到渠成,如果没有 key 的话,那么就要多一些操作了:
  • a. 判断哪些可以复用,有 key 只需要从映射中康康 3, 1在不在,没有 key 的话,可能就执行替换了,肯定比「复用」「移动」开销大了
  • b. 删除了哪一个?新增了哪一个?有 key 的话是不是很好判断嘛,之前的映射没有的 key,比如变成 [3, 1, 4]那这个 4 很容易判断出应该是新建的,删除也同理
  • 但是没有 key 的话就麻烦一些了

追问:为什么不推荐用 下标做key? 我们来分析一下:

  • [1, 2, 3] 这是原来的渲染节点,页面展示出 1, 2, 3,然后我们 splice(0, 1) 删除第一个元素后,理想情况是变成 [2, 3]
  • 但是因为使用了下标为 key,对比前后两次 keys
  • [0, 1, 2] -> [0, 1] 因为 vue 的机制,sameNode 判断一波后,误认为是 2 被删除了...害!
function sameVnode (a, b) {
return (
 a.key === b.key &&  // key值
 a.tag === b.tag &&  // 标签名
 a.isComment === b.isComment &&  // 是否为注释节点
 // 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&  
 sameInputType(a, b) // 当标签是<input>的时候,type必须相同
 )
}

这个问题在 react 中表现上不会出现,会把第一个数组删掉,但是会把所有的li重新渲染一遍,性能上损失了

原理:默认删掉第一个之后,对应不上,执行的全部是新建

[a, b, c, d, e] [b, c, d, e]

虚拟 DOM

  1. 什么是 虚拟 DOM 本质 ->
{
  type: 'div'.
  props: {
    children: [

    ]
  },
  el: xxx
} 

嵌套对象 -> 形成一个 Tree

数据结构有了,需要babel中的方法, 将JSX中的关键词提取出并嵌套调用 createElement, 转成 DOM Tree

  1. 怎么创建虚拟 DOM

-> h() createElement...

function h(type, props) {
  return { type, proprs }
}
  1. 使用

以 react 为例 jsx - js 和 html 混用,但是性能上这里比不上vue

<div>
  <ul className='padding-20'>
    <li key='li-01'>this is li 01</li>
  </ul>
</div>

经过 babel 转一下:

createElement('div', {
  children: [
    createElement('ul', {
      { className: 'padding-20' }, createElement('li', {
        key: 'li-01'
      }, 'this is li 01')
    })
  ]
})
  1. 虚拟 DOM 的数据结构有了,那么就是渲染 (mount/render) f(vnode) -> view
f(vnode) {
  // 映射到真实DOM
  document.createElement();
  ...

  parent.insert();
  ...insertBefore()
}

//最终对外暴露一个render 方法
export const render = (vnode, parent) => { }

// 因此都会有个id='#app'
<div id='#app'></div>
  1. diff 相关 (patch) f(oldVnodeTree, newVnodeTree, parent) -> 调度(并不是数据一变更就渲染,每空闲的几毫秒进行渲染)$nextTick -> view
const normalize = (children = []) => children.map(child => typeof child === 'string' ? createText(child) : child)
// step1 定义虚拟DOM 数据结构

const createVnode = (type, props, key, $$) => {
  return {
    type, // div | ComponentA | '' 对应文本
    props,
    key, // 也可以放在props里
    $$ // 内部使用的属性
  }
}

// 特殊情况,创建文本方法
export const NODE_FLAG = {
  EL: 1, //元素 element
  TEXT: 1 << 1 // 位运算
}

// 2 & 1 = 1; 1 & 2 = 0
//  * if (vnode.$$.flag & NODE_FLAG.TEXT)
//  * // true 就是文本节点

const createText = (text) => {
  return {
    type: '',
    props: {
      nodeValue: text + ''
    },
    $$: { flag: NODE_FLAG.TEXT } // 标识虚拟节点为文本节点
  }
}

// step2 定义生成虚拟DOM对象的方法
// h('div', { className: 'padding20' }, 'hello world!')
export const h = (type, props, ...kids) => {
  props = props || {}
  let key = props.key || void 0
  kids = normalize(props.children || kids)

  // props.children: 3 种情况
  // void 0  没有
  // { type: 'div', ... } 对象
  // [{xx}, {xxx}] 数组中有很多对象
  if (kids.length) props.children = kids.length === 1 ? kids[0] : kids

  const $$ = {}
  $$.el = null
  $$.flag = type === '' ? NODE_FLAG.TEXT : NODE_FLAG.EL

  return createVnode(type, props, key, $$)

// step3 渲染 f(vnode, parent)
export const render = (vnode, parent) => {
  // parent 上已经有 vnode虚拟节点
  let prev = parent._vnode

  if (!prev) {
    mount(vnode, parent)
    parent._vnode = vnode
  } else {
    if (vnode) { // 新旧两个 vnodeTree 都存在
      patch(prev, vnode, parent)
      parent._vnode = vnode
    }
    else {
      parent.removeChild(prev.$$.el)
    }
  }
}
// mount
export const mount = (vnode, parent, refNode) => {
  if (!parent) throw new Error('你可能忘了点啥')
  const $$ = vnode.$$

  // if ($$.flag === NODE_FLAG.TEXT)
  if ($$.flag & NODE_FLAG.TEXT) {
    const el = document.createTextNode(vnode.props.nodeValue)
    vnode.el = el
    parent.appendChild(el)
  }
  else if ($$.flag & NODE_FLAG.EL) {
    const { type, props } = vnode
    // 先不考虑 type 是一个组件的情况 ⚠️
    const el = document.createElement(type)
    vnode.el = el

    const { children, ...rest } = props
    if (Object.keys(rest).length) {
      for (let key of Object.keys(rest)) {
        patchProps(key, null, rest[key], el)
      }
    }

    if (children) {
      const __children = Array.isArray(children) ? children : [children]
      for (let child of __children) {
        mount(child, el)
      }
    }

    refNode ? parent.insertBefore(el, refNode) : parent.appendChild(el)
  }
}
// patch
const patchChildren = (prev, next, parent) => {
  // diff 比较耗性能,可以前置做一些处理,提升效率
  if (!prev) {
    if (!next) {
      // do nothing
    }
    else {
      next = Array.isArray(next) ? next : [next]

      for (const c of next) {
        mount(c, parent)
      }
    }
  }

  else if (prev && !Array.isArray(prev)) { // 只有一个 children
    if (!next) parent.removeChild(prev.el)
    else if (next && !Array.isArray(next)) {
      patch(prev, next, parent)
    }
    else {
      parent.removeChild(prev.el)
      for (const c of next) {
        mount(c, parent)
      } 
    }
  }

  else odiff(prev, next, parent)
}

export const patch = (prev, next, parent) => {

  // type: 'div' -> type: 'p'
  // 同层比较
  if (prev.type !== next.type) {
    parent.removeChild(prev.el)
    mount(next, parent)
    return
  }

  // type 一样,diff props(先不看 children)
  const { props: { children: prevChildren, ...prevProps } } = prev
  const { props: { children: nextChildren, ...nextProps } } = next
  // patchProps
  const el = (next.el = prev.el)
  for (let key of Object.keys(nextProps)) {
    let prev = prevProps[key],
      next = nextProps[key]
      patchProps(key, prev, next, el)
  }

  for (let key of Object.keys(prevProps)) {
    if (!nextProps.hasOwnProperty(key)) patchProps(key, prevProps[key], null, el)
  }

  // patch children ⚠️
  patchChildren(
    prevChildren,
    nextChildren,
    el
  )
}

export const patchProps = (key, prev, next, el) => {

  // style
  if (key === 'style') {

    // { style: { margin: '0px', padding: '10px' }}
    if (next)
      for (let k in next) {
        el.style[k] = next[k]
      }

    // { style: { padding: '0px', color: 'red' } }
    if (prev)
      for (let k in prev) {
        if (!next.hasOwnProperty(k)) {
          el.style[k] = ''
        }
      }
  }

  // class
  else if (key === 'className') {
    if (!el.classList.contains(next)) {
      el.classList.add(next)
    }
  }

  // events
  else if (key[0] === 'o' && key[1] === 'n') {
    prev && el.removeEventListener(key.slice(2).toLowerCase(), prev)
    next && el.addEventListener(key.slice(2).toLowerCase(), next)
  }

  else if (/\[A-Z]|^(?:value|checked|selected|muted)$/.test(key)) {
    el[key] = next
  }

  else {
    el.setAttribute && el.setAttribute(key, next)
  }
}

diff

import { mount } from './mount.js'
import { patch } from './patch.js'

export const diff = (prev, next, parent) => {
  let prevMap = {}
  let nextMap = {}

  // old tree children
  for (let i = 0; i < prev.length; i++) {
    let { key = i + '' } = prev[i]
    prevMap[key] = i
  }

  let lastIndex = 0
  for (let n = 0; n < next.length; n++) {
    let { key = n + '' } = next[n]
    let j = prevMap[key]
    let nextChild = next[n]
    nextMap[key] = n
    
    // {b: 0, a: 1}
    // 原children    新 children
    // [b, a]   ->   [c, d, a]  ::[c, b, a] 👉 c
    // [b, a]   ->   [c, d, a]  ::[c, d, b, a] 👉 d
    if (j == null) {
      let refNode = n === 0 ? prev[0].el : next[n - 1].el.nextSibling
      mount(nextChild, parent, refNode)
    }
    else {
      // [b, a] -> [c, d, a]  ::[c, d, a, b] 👉 a
      patch(prev[j], nextChild, parent)
      if (j < lastIndex) {
        let refNode = next[n - 1].el.nextSibling;
        parent.insertBefore(nextChild.el, refNode)
      }
      else {
        lastIndex = j
      }
    }
  }

  // [b, a] -> [c, d, a]  ::[c, d, a] 👉 b
  for (let i = 0; i < prev.length; i++) {
    let { key = '' + i } = prev[i]
    if (!nextMap.hasOwnProperty(key)) parent.removeChild(prev[i].el)
  }
}

Vue3 中的核心diff 算法

// [a, b, c, d] // [d, a, b, c]

  1. 最快的办法是将 b 移动到第一个,abc 不要动 //最长上升子序列
  2. 不动的是 d,移动,a,移动b,移动c

双指针算法 解决对比后只需要增加或减少的情况 // [a, b, c, d] // [a, b, c, d, e]

export const odiff = (prevChildren, nextChildren, parent) => {
  // 前指针
  let j = 0

  // 后指针
  let prevEnd = prevChildren.length - 1
  let nextEnd = nextChildren.length - 1

  let prevNode = prevChildren[j]
  let nextNode = nextChildren[j]

  // [a, b, c, d]   [a, b, c, d, e]
  //  j        👆    j           👆
  // 前置优化部分,假设上述情况
  // 👆:结束指针
  // 双指针前面4个都相同,因此都不需要diff,只关注最后的e
  outer: {
    while(prevNode.key === nextNode.key) {
      patch(prevNode, nextNode, parent)
      j++
      if (j > prevEnd || j > nextEnd) break outer
      prevNode = prevChildren[j]
      nextNode = nextChildren[j]
    }

    prevNode = prevChildren[prevEnd]
    nextNode = nextChildren[nextEnd]

    while (prevNode.key === nextNode.key) {
      patch(prevNode, nextNode, parent)
      prevEnd--
      nextEnd--
      if (j > prevEnd || j > nextEnd) break outer
      prevNode = prevChildren[prevEnd]
      nextNode = nextChildren[nextEnd]
    }
  }

  // [a, b, c, h, d]   [a, b, c, f, m, k, h, d]
  //        👆 j                 j     👆
  // 前指针大于老tree的后指针 && 前指针小于等于新tree的后指针,前指针和新tree的后指针之间正好是老tree没有的!!!直接插入既可
  if (j > prevEnd && j <= nextEnd) {
    let nextPos = nextEnd + 1
    let refNode = nextPos >= nextChildren.length
      ? null
      : nextChildren[nextPos].el
    while (j <= nextEnd) {
      mount(nextChildren[j++], parent, refNode)
    }
    return
  }

  // [a, b, c, f, m, k, h, d]  [a, b, c, h, d]   
  //           j     👆               👆  j
  // 同理,前长后短,只需要删掉老tree的前后指针间的
  else if (j > nextEnd) {
    while (j <= prevEnd) {
      parent.removeChild(prevChildren[j++].el)
    }
    return
  }

  // [a, b, c, d]  [c, a, d, b]
  //  j        👆   j        👆
  // 移动的例子
  let nextStart = j,
    prevStart = j,
    nextLeft = nextEnd - j + 1,
    nextIndexMap = {},
    source = new Array(nextLeft).fill(-1),
    patched = 0,
    lastIndex = 0,
    move = false

  // { 'c': 0, 'a': 1, 'd': 2, 'b': 3 }
  for (let i = nextStart; i <= nextEnd; i++) {
    let key = nextChildren[i].key || i
    nextIndexMap[key] = i
  }

  for (let i = prevStart; i <= prevEnd; i++) {
    let prevChild = prevChildren[i],
      prevKey = prevChild.key || i,
      nextIndex = nextIndexMap[prevKey]

    // [a, b, f, m, c]  [c, a, d, b]
    //  a                                nextLeft = 4; patched = 1; nextIndex = 1; nextStart = 0; source = [-1, 0, -1, -1]; lastIndex = 1
    //     b                             nextLeft = 4; patched = 2; nextIndex = 3; nextStart = 0; source = [-1, 0, -1, 1]; lastIndex = 3
    //        f                          nextLeft = 4; patched = 2; 
    //           m                       nextLeft = 4; patched = 2; 
    //              c                    nextLeft = 4; patched = 3; nextIndex = 0; nextStart = 0; source = [4, 0, -1, 1]; lastIndex = 3; move = true
    if (patched >= nextLeft || nextIndex === undefined) {
      parent.removeChild(prevChild.el)
      continue
    }
    patched++
    let nextChild = nextChildren[nextIndex]
    patch(prevChild, nextChild, parent)

    source[nextIndex - nextStart] = i

    if (nextIndex < lastIndex) {
      move = true
    } else {
      lastIndex = nextIndex
    }
  }

  if (move) {
    const seq = lis(source); // seq = [1, 3]
    let j = seq.length - 1;
    for (let i = nextLeft - 1; i >= 0; i--) {
      let pos = nextStart + i,
        nextPos = pos + 1,
        nextChild = nextChildren[pos],
        refNode = nextPos >= nextLeft ? null : nextChildren[nextPos].el
      // [4, 0, -1, 1]
      if (source[i] === -1) {
        mount(nextChild, parent, refNode)
      } else if (i !== seq[j]) {
        parent.insertBefore(nextChild.el, refNode)
      } else {
        j--
      }
    }
  } else {
    // no move
    for (let i = nextLeft - 1; i >= 0; i--) {
      if (source[i] === -1) {
        let pos = nextStart + i,
          nextPos = pos + 1,
          nextChild = nextChildren[pos],
          refNode = nextPos >= nextLeft ? null : nextChildren[nextPos].el
      
        mount(nextChild, parent, refNode)
      }
    }
  }
}

// 最长上升子序列算法: 就是在一个序列中,求长度最长且顺序是升序的子序列
// 1, 5, 2, 4, 6, 0, 7 -> 1, 2, 4, 6, 7

// 0 8 4 12 2 10 6 4 1 9 5 13
// 0
// 0 8
// 0 8 4 ❌
// 0 8 12 
// 0 8 12 2 ❌
// ...
// 0 8 12 13 | 0 4 9 13 | ... 
// 选出一个最长的序列
// 如果长度一样,选一个就可以

// 回到 [a, b, c, d]
//     [d, a, b, c]
// 最长上升子序列 a b c ,保持不动 -> 将 d 移动到最前

function lis(arr) {
  let len = arr.length,
    result = [],
    dp = new Array(len).fill(1);

  for (let i = 0; i < len; i++) {
    result.push([i])
  }

  for (let i = len - 1; i >= 0; i--) {
    let cur = arr[i], nextIndex = undefined
    if (cur === -1) continue

    for (let j = i + 1; j < len; j++) {
      let next = arr[j]
      if (cur < next) {
        let max = dp[j] + 1
        if (max > dp[i]) {
          nextIndex = j
          dp[i] = max
        }
      }
    }
    if (nextIndex !== undefined) result[i] = [...result[i], ...result[nextIndex]]
  }
  let index = dp.reduce((prev, cur, i, arr) => cur > arr[prev] ? i : prev, dp.length - 1)
  return result[index]
}