前端框架核心学习(三)

“前端框架核心”的学习一共三篇,前两篇分别给大家介绍了前端框架核心中的响应式实现的基本原理,以及关于模板编译的原理以及双向绑定的实现等知识点,今天是最后一篇,给大家分享虚拟DOM相关的内容。

还没有阅读的朋友可以先学习一下之前的内容:

虚拟 DOM

我们来看现代框架中另一个重头戏——虚拟 DOM。

虚拟 DOM 这个概念其实并没有那么新,甚至在前端三大框架问世之前,虚拟 DOM 就已经存在了,只不过 React 创造性的应用了虚拟 DOM,为前端发展带来了变革。Vue 2.0 也很快跟进,使得虚拟 DOM 彻底成为现代框架的重要基因。

简单来说,虚拟 DOM 就是用数据结构表示 DOM 结构,它并没有真实 append 到 DOM 上,因此称之为「虚拟」。

应用虚拟 DOM 的收益也很直观:操作数据结构远比和浏览器交互去操作 DOM 快很多。

请读者准确理解这句话:操作数据结构是指改变对象(虚拟 DOM),这个过程比修改真实 DOM 快很多。

但虚拟 DOM 也最终是要挂载到浏览器上成为真实 DOM 节点,因此使用虚拟 DOM 并不能使得操作 DOM 的数量减少,但能够精确地获取最小的、最必要的操作 DOM 的集合。

这样一来,我们抽象表示 DOM,每次通过 DOM diff 计算出视图前后更新的最小差异,再去把最小差异应用到真实 DOM 上的做法,无疑更为可靠,性能更有保障。

那我们该如何表示虚拟 DOM 呢?又该如何产出虚拟 DOM 呢?

直观上我们看这样一段 DOM 结构:

<ul id="chapterList">
  <li class="chapter">chapter1</li>
  <li class="chapter">chapter2</li>
  <li class="chapter">chapter3</li>
</ul>

如果用 JavaScript 来表示,我们采用对象结构:

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'] },
   ]
 }

很好理解:tagName 表示虚拟 DOM 对应的真实 DOM 标签类型;attributes 是一个对象,表示真实 DOM 节点上所有的属性;children 对应真实 DOM 的 childNodes,其中 childNodes 每一项又是类似的结构。

我们来实现一个虚拟 DOM 生成类,用于生产虚拟 DOM:

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

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

上述虚拟 DOM 就可以这样生成:

const chapterListVirtualDom = element('ul', { id'list' }, [
   element('li', { class'chapter' }, ['chapter1']),
   element('li', { class'chapter' }, ['chapter2']),
   element('li', { class'chapter' }, ['chapter3'])
 ])

是不是很简单?我们继续完成虚拟 DOM 向真实 DOM 节点的生成。首先实现一个 setAttribute 方法,后续的代码都将使用 setAttribute 方法来对 DOM 节点进行属性设置。

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
   }
 }

Element 类中加入 render 原型方法,该方法的目的是根据虚拟 DOM 生成真实 DOM 片段:

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

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

     let children = this.children

     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)
 }

实现也不困难,我们借助工具方法:setAttribute 进行属性的创建;对 children 每一项类型进行判断,如果是 Element 实例,进行递归调用 child 的 render 方法;直到遇见文本节点类型,进行内容渲染。

有了真实的 DOM 节点片段,我们趁热打铁,将真实的 DOM 节点渲染到浏览器上,实现 renderDOM 方法:

const renderDom = (element, target) => {
   target.appendChild(element)
 }
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

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

     let children = this.children

     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 chapterListVirtualDom = element('ul', { id'list' }, [
   element('li', { class'chapter' }, ['chapter1']),
   element('li', { class'chapter' }, ['chapter2']),
   element('li', { class'chapter' }, ['chapter3'])
 ])

 const dom = chapterListVirtualDom.render()

 renderDom(dom, document.body)

虚拟 DOM diff

有了上述基础,我们可以产出一份虚拟 DOM,并渲染在浏览器中。当用户在特定操作后,会产出新的一份虚拟 DOM,如何得出前后两份虚拟 DOM 的差异,并交给浏览器需要更新的结果呢?这就涉及到 DOM diff 的过程。

直观上,因为虚拟 DOM 是个树形结构,所以我们需要对两份虚拟 DOM 进行递归比较,将变化存储在一个变量 patches 中:

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

     // 递归树,比较后的结果放到 patches
     walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)

      // 返回 diff 结果
     return patches
 }

walkToDiff 前两个参数是两个需要比较的虚拟 DOM 对象;第三个参数记录 nodeIndex,在删除节点时使用,初始为 0;第四个参数是一个闭包变量,记录 diff 结果:

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 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

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

     let children = this.children

     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(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 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 patch = (node, patches) => {
   let walker = { index0 }
   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 !== 1return
             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
     }
   })
 }

先执行

var element = chapterListVirtualDom.render()
renderDom(element, document.body)

再执行

const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)
patch(element, patches)

生成结果符合预期。

短短不到两百行代码,就实现了虚拟 DOM 思想的全部流程。当然其中还有一些优化手段,一些边界情况并没有进行特别处理,但是我们去翻看一些著名的虚拟 DOM 库:snabbdom、etch 等,其实现思想和上述教例完全一致。

最后

《前端面试题宝典》经过近一年的迭代,现已推出 小程序 和 电脑版刷题网站 (https://fe.ecool.fun/),欢迎大家使用~

同时,我们还推出了面试辅导的增值服务,可以为大家提供 “简历指导” 和 “模拟面试” 服务,现在参与还有额外优惠,感兴趣的同学可以联系小助手(微信号:interview-fe)进行体验哦~