qiuhongbingo / blog

Writing something in Issues.
https://github.com/qiuhongbingo/blog/issues
3 stars 0 forks source link

虚拟 DOM 实现 #20

Open qiuhongbingo opened 4 years ago

qiuhongbingo commented 4 years ago
/**
 * 真实 DOM
 */
// <ul id="chapterList">
//   <li class="chapter">chapter1</li>
//   <li class="chapter">chapter2</li>
//   <li class="chapter">chapter3</li>
// </ul>

/**
 * 虚拟 DOM
 */
// const chapterListVirtualDom = {
//   tagName: 'ul',
//   attributes: {
//     id: 'chapterList'
//   },
//   children: [
//     { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter1'] },
//     { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter2'] },
//     { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter3'] }
//   ]
// }

/**
 * 实现思路
 * 通过 Element class 生成了虚拟 DOM
 * 通过 diff 方法对任意两个虚拟 DOM 进行比对,得到差异
 * 将得到的差异通过 patch 更新到现有的 DOM 节点
 * 在 patch 方法内部,我们调用了 walk 进行自身递归
 * walk 对于当前节点的差异调用 doPatch 方法进行更新
 * doPatch 对四种类型的 diff 进行处理
 */
const setAttribute = (node, key, value) => {
  switch (key) {
    case 'style':
      node.style.cssText = value
      break
    case 'value':
      let tagName = node.tagName || ''
      tagName = tagName.toLowerCase()
      if (tagName === 'input' || tagName === 'textarea') {
        node.value = value
      } else {
        // 如果节点不是 input 或者 textarea, 则使用 setAttribute 去设置属性
        node.setAttribute(key, value)
      }
      break
    default:
      node.setAttribute(key, value)
      break
  }
}

class Element {
  constructor(tagName, attributes = {}, children = []) {
    this.tagName = tagName
    this.attributes = attributes
    this.children = children
  }

  render() {
    let element = document.createElement(this.tagName)
    let attributes = this.attributes
    let children = this.children

    for (let key in attributes) {
      setAttribute(element, key, attributes[key])
    }

    children.forEach(child => {
      let childElement =
        child instanceof Element
          ? child.render() // 若 child 也是虚拟节点,递归进行
          : document.createTextNode(child) // 若是字符串,直接创建文本节点
      element.appendChild(childElement)
    })

    return element
  }
}

function element(tagName, attributes, children) {
  return new Element(tagName, attributes, children)
}

const renderDom = (element, target) => {
  target.appendChild(element)
}

const diff = (oldVirtualDom, newVirtualDom) => {
  let patches = {}

  // 递归树比较后的结果放到 patches
  // walkToDiff 前两个参数是两个需要比较的虚拟 DOM 对象
  // 第三个参数记录 nodeIndex,在删除节点时使用,初始为 0
  // 第四个参数是一个闭包变量,记录 diff 结果
  walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)

  return patches
}

let initialIndex = 0

const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
  let diffResult = []

  // 如果 newVirtualDom 不存在,说明该节点被移除,我们将 type 为 REMOVE 的对象推进 diffResult 变量,并记录 index
  if (!newVirtualDom) {
    diffResult.push({
      type: 'REMOVE',
      index
    })
  }
  // 如果新旧节点都是文本节点,是字符串
  else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
    // 比较文本是否相同,如果不同则记录新的结果
    if (oldVirtualDom !== newVirtualDom) {
      diffResult.push({
        type: 'MODIFY_TEXT',
        data: newVirtualDom,
        index
      })
    }
  }
  // 如果新旧节点类型相同
  else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
    // 比较属性是否相同
    let diffAttributeResult = {}

    for (let key in oldVirtualDom) {
      if (oldVirtualDom[key] !== newVirtualDom[key]) {
        diffAttributeResult[key] = newVirtualDom[key]
      }
    }

    for (let key in newVirtualDom) {
      // 旧节点不存在的新属性
      if (!oldVirtualDom.hasOwnProperty(key)) {
        diffAttributeResult[key] = newVirtualDom[key]
      }
    }

    if (Object.keys(diffAttributeResult).length > 0) {
      diffResult.push({
        type: 'MODIFY_ATTRIBUTES',
        diffAttributeResult
      })
    }

    // 如果有子节点,遍历子节点
    oldVirtualDom.children.forEach((child, index) => {
      walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
    })
  }
  // else 说明节点类型不同,被直接替换了,我们直接将新的结果 push
  else {
    diffResult.push({
      type: 'REPLACE',
      newVirtualDom
    })
  }

  if (!oldVirtualDom) {
    diffResult.push({
      type: 'REPLACE',
      newVirtualDom
    })
  }

  if (diffResult.length) {
    patches[index] = diffResult
  }
}

const patch = (node, patches) => {
  let walker = { index: 0 }
  walk(node, walker, patches)
}

const walk = (node, walker, patches) => {
  let currentPatch = patches[walker.index]
  let childNodes = node.childNodes

  childNodes.forEach(child => {
    walker.index++
    walk(child, walker, patches)
  })

  if (currentPatch) {
    doPatch(node, currentPatch)
  }
}

const doPatch = (node, patches) => {
  patches.forEach(patch => {
    switch (patch.type) {
      case 'MODIFY_ATTRIBUTES':
        const attributes = patch.diffAttributeResult.attributes
        for (let key in attributes) {
          if (node.nodeType !== 1) return
          const value = attributes[key]
          if (value) {
            setAttribute(node, key, value)
          } else {
            node.removeAttribute(key)
          }
        }
        break
      case 'MODIFY_TEXT':
        node.textContent = patch.data
        break
      case 'REPLACE':
        let newNode = patch.newNode instanceof Element ? render(patch.newNode) : document.createTextNode(patch.newNode)
        node.parentNode.replaceChild(newNode, node)
        break
      case 'REMOVE':
        node.parentNode.removeChild(node)
        break
      default:
        break
    }
  })
}

// call
const element = chapterListVirtualDom.render()
renderDom(element, document.body)

const chapterListVirtualDom = element('ul', { id: 'list' }, [element('li', { class: 'chapter' }, ['chapter1']), element('li', { class: 'chapter' }, ['chapter2']), element('li', { class: 'chapter' }, ['chapter3'])])
const chapterListVirtualDom1 = element('ul', { id: 'list2' }, [element('li', { class: 'chapter2' }, ['chapter4']), element('li', { class: 'chapter2' }, ['chapter5']), element('li', { class: 'chapter2' }, ['chapter6'])])
const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)
patch(element, patches)