# 什么是 Virtual DOM

  1. Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM
  1. let element = document.querySelector('#app')
  2. let s = ''
  3. for (var key in element) {
  4. s += key + ','
  5. }
  6. console.log(s)
  7. // 打印结果 align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,aut ocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,off setTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,onc opy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onc hange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondrag end,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchan ge,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypr ess,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown ,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup, onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,on resize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend ,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongot pointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointe rup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerl eave,onselectstart,onselectionchange,onanimationend,onanimationiteration ,onanimationstart,ontransitionend,dataset,nonce,autofocus,tabIndex,click ,focus,blur,enterKeyHint,onformdata,onpointerrawupdate,attachInternals,n amespaceURI,prefix,localName,tagName,id,className,classList,slot,part,at tributes,shadowRoot,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLef t,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight ,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,eleme ntTiming,previousElementSibling,nextElementSibling,children,firstElement Child,lastElementChild,childElementCount,onfullscreenchange,onfullscreen error,onwebkitfullscreenchange,onwebkitfullscreenerror,setPointerCapture ,releasePointerCapture,hasPointerCapture,hasAttributes,getAttributeNames ,getAttribute,getAttributeNS,setAttribute,setAttributeNS,removeAttribute ,removeAttributeNS,hasAttribute,hasAttributeNS,toggleAttribute,getAttrib uteNode,getAttributeNodeNS,setAttributeNode,setAttributeNodeNS,removeAtt ributeNode,closest,matches,webkitMatchesSelector,attachShadow,getElement sByTagName,getElementsByTagNameNS,getElementsByClassName,insertAdjacentE lement,insertAdjacentText,insertAdjacentHTML,requestPointerLock,getClien tRects,getBoundingClientRect,scrollIntoView,scroll,scrollTo,scrollBy,scr ollIntoViewIfNeeded,animate,computedStyleMap,before,after,replaceWith,re move,prepend,append,querySelector,querySelectorAll,requestFullscreen,web kitRequestFullScreen,webkitRequestFullscreen,createShadowRoot,getDestina tionInsertionPoints,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_ NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMME NT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION _NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMEN T_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAI NED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,nodeType,nodeName,baseU RI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstCh ild,lastChild,previousSibling,nextSibling,nodeValue,textContent,hasChild Nodes,getRootNode,normalize,cloneNode,isEqualNode,isSameNode,compareDocu mentPosition,contains,lookupPrefix,lookupNamespaceURI,isDefaultNamespace ,insertBefore,appendChild,replaceChild,removeChild,addEventListener,remo veEventListener,dispatchEvent
  1. 可以使用 Virtual DOM 来描述真实 DOM
  1. {
  2. sel: "div",
  3. data: {},
  4. children: undefined,
  5. text: "Hello Virtual DOM",
  6. elm: undefined,
  7. key: undefined
  8. }

# 为什么使用 Virtual DOM

  • 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升
  • 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM 出现了
  • Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述DOMVirtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
  • 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
  • 通过比较前后两次状态的差异更新真实 DOM

# 虚拟 DOM 的作用

  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能
  • 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等

前端进阶之路-原理 - 图1

# Virtual DOM 库

# Snabbdom 基本使用

# 创建项目

  1. # 创建项目目录
  2. md snabbdom-demo
  3. # 进入项目目录
  4. cd snabbdom-demo
  5. # 创建 package.json yarn init -y
  6. # 本地安装 parcel
  7. yarn add parcel-bundler

配置 package.jsonscripts

  1. "scripts": {
  2. "dev": "parcel index.html --open", "build": "parcel build index.html"
  3. }

创建目录结构

前端进阶之路-原理 - 图2

  1. yarn add snabbdom
  1. import{init,h,thunk}from'snabbdom'

snabbdom 的核心仅提供最基本的功能,只导出了三个函数 init()h()thunk()

  • init() 是一个高阶函数,返回 patch()
  • h() 返回虚拟节点 VNode,这个函数我们在使用 Vue.js 的时候见过
  1. new Vue({
  2. router,
  3. store,
  4. render: h => h(App)
  5. }).$mount('#app')
  • thunk() 是一种优化策略,可以在处理不可变数据时使用

注意:导入时候不能使用 import snabbdom from 'snabbdom'。原因:node_modules/src/snabbdom.ts 末尾导出使用的语法是 export 导出 API,没有使用 export default 导出默认输出

前端进阶之路-原理 - 图3

# 基本使用

例子1

  1. import { h, init } from 'snabbdom'
  2. // 1. hello world
  3. // 参数:数组,模块
  4. // 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
  5. let patch = init([])
  6. // 第一个参数:标签+选择器
  7. // 第二个参数:如果是字符串的话就是标签中的内容
  8. let vnode = h('div#container.cls', {
  9. hook: {
  10. init (vnode) {
  11. console.log(vnode.elm)
  12. },
  13. create (emptyVnode, vnode) {
  14. console.log(vnode.elm)
  15. }
  16. }
  17. }, 'Hello World')
  18. let app = document.querySelector('#app')
  19. // 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
  20. // 第二个参数:VNode
  21. // 返回值:VNde
  22. let oldVnode = patch(app, vnode)
  23. // 假设的时刻
  24. vnode = h('div', 'Hello Snabbdom')
  25. patch(oldVnode, vnode)

例子2

  1. // 2. div中放置子元素 h1,p
  2. import { h, init } from 'snabbdom'
  3. let patch = init([])
  4. let vnode = h('div#container', [
  5. h('h1', 'Hello Snabbdom'),
  6. h('p', '这是一个p标签')
  7. ])
  8. let app = document.querySelector('#app')
  9. let oldVnode = patch(app, vnode)
  10. setTimeout(() => {
  11. vnode = h('div#container', [
  12. h('h1', 'Hello World'),
  13. h('p', 'Hello P')
  14. ])
  15. patch(oldVnode, vnode)
  16. // 清空页面元素 -- 错误
  17. // patch(oldVnode, null)
  18. patch(oldVnode, h('!'))
  19. }, 2000);

例子3 debug-patchVnode

  1. import { h, init } from 'snabbdom'
  2. let patch = init([])
  3. // 首次渲染
  4. let vnode = h('div', 'Hello World')
  5. let app = document.querySelector('#app')
  6. let oldVnode = patch(app, vnode)
  7. // patchVnode 的执行过程
  8. vnode = h('div', 'Hello Snabbdom')
  9. patch(oldVnode, vnode)

例子4 debug-updateChildren

  1. import { h, init } from 'snabbdom'
  2. let patch = init([])
  3. // 首次渲染
  4. let vnode = h('ul', [
  5. h('li', '首页'),
  6. h('li', '视频'),
  7. h('li', '微博')
  8. ])
  9. let app = document.querySelector('#app')
  10. let oldVnode = patch(app, vnode)
  11. // updateChildren 的执行过程
  12. vnode = h('ul', [
  13. h('li', '首页'),
  14. h('li', '微博'),
  15. h('li', '视频')
  16. ])
  17. patch(oldVnode, vnode)

例子5 debug-updateChildren-key

  1. import { h, init } from 'snabbdom'
  2. let patch = init([])
  3. // 首次渲染
  4. let vnode = h('ul', [
  5. h('li', { key: 'a' }, '首页'),
  6. h('li', { key: 'b' }, '视频'),
  7. h('li', { key: 'c' }, '微博')
  8. ])
  9. let app = document.querySelector('#app')
  10. let oldVnode = patch(app, vnode)
  11. // updateChildren 的执行过程
  12. vnode = h('ul', [
  13. h('li', { key: 'a' }, '首页'),
  14. h('li', { key: 'c' }, '微博'),
  15. h('li', { key: 'b' }, '视频')
  16. ])
  17. patch(oldVnode, vnode)

# 模块

Snabbdom 的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块

常用模块

官方提供了 6 个模块

  • attributes

    • 设置 DOM 元素的属性,使用 setAttribute ()
    • 处理布尔类型的属性
  • props

    • attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
    • 不处理布尔类型的属性
  • class

    • 切换类样式
    • 注意:给元素设置类样式是通过 sel 选择器
  • dataset

    • 设置 data-* 的自定义属性 eventlisteners
    • 注册和移除事件
  • style

    • 设置行内样式,支持动画
    • delayed/remove/destroy

模块使用

模块使用步骤:

  • 导入需要的模块
  • init() 中注册模块
  • 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
  1. import { init, h } from 'snabbdom'
  2. // 1. 导入模块
  3. import style from 'snabbdom/modules/style'
  4. import eventlisteners from 'snabbdom/modules/eventlisteners'
  5. // 2. 注册模块
  6. let patch = init([
  7. style,
  8. eventlisteners
  9. ])
  10. // 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
  11. let vnode = h('div', {
  12. style: {
  13. backgroundColor: 'red'
  14. },
  15. on: {
  16. click: eventHandler
  17. }
  18. }, [
  19. h('h1', 'Hello Snabbdom'),
  20. h('p', '这是p标签')
  21. ])
  22. function eventHandler () {
  23. console.log('点击我了')
  24. }
  25. let app = document.querySelector('#app')
  26. let oldVnode = patch(app, vnode)
  27. vnode = h('div', 'hello')
  28. patch(oldVnode, vnode)

# Snabbdom 源码解析

# Snabbdom 的核心

  • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
  • init() 设置模块,创建 patch()
  • patch() 比较新旧两个 VNode
  • 把变化的内容更新到真实 DOM 树上

Snabbdom 源码

源码地址: https://github.com/snabbdom/snabbdom

src 目录结构

前端进阶之路-原理 - 图4

# h 函数

  • h() 函数介绍: 在使用 Vue 的时候见过 h() 函数
  1. new Vue({
  2. router,
  3. store,
  4. render: h => h(App)
  5. }).$mount('#app')
  • h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本
  • Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode

函数重载

  • 概念
  • 参数个数或类型不同的函数
  • JavaScript 中没有重载的概念
  • TypeScript 中有重载,不过重载的实现还是通过代码调整参数
  • 重载的示意
  1. function add (a, b) {
  2. console.log(a + b)
  3. }
  4. function add (a, b, c) {
  5. console.log(a + b + c)
  6. }
  7. add(1, 2)
  8. add(1, 2, 3)

源码位置:src/h.ts

  1. // h函数的重载
  2. export function h (sel: string): VNode
  3. export function h (sel: string, data: VNodeData | null): VNode
  4. export function h (sel: string, children: VNodeChildren): VNode
  5. export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
  6. export function h (sel: any, b?: any, c?: any): VNode {
  7. let data: VNodeData = {}
  8. let children: any
  9. let text: any
  10. let i: number
  11. // 处理参数,实现重载的机制
  12. if (c !== undefined) {
  13. // 处理三个参数的情况
  14. // sel、data、children/text
  15. if (b !== null) {
  16. data = b
  17. }
  18. if (is.array(c)) {
  19. children = c
  20. // 如果 c 是字符串或者数字
  21. } else if (is.primitive(c)) {
  22. text = c
  23. } else if (c && c.sel) {
  24. children = [c]
  25. }
  26. } else if (b !== undefined && b !== null) {
  27. // 处理两个参数的情况
  28. if (is.array(b)) {
  29. children = b
  30. // 如果 b 是字符串或者数字
  31. } else if (is.primitive(b)) {
  32. text = b
  33. // 如果 b 是 VNode
  34. } else if (b && b.sel) {
  35. children = [b]
  36. } else { data = b }
  37. }
  38. if (children !== undefined) {
  39. // 处理 children 中的原始值(string/number)
  40. for (i = 0; i < children.length; ++i) {
  41. // 如果 child 是 string/number,创建文本节点
  42. if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
  43. }
  44. }
  45. if (
  46. sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
  47. (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  48. ) {
  49. // 如果是 svg,添加命名空间
  50. addNS(data, children, sel)
  51. }
  52. // 返回 VNode
  53. return vnode(sel, data, children, text, undefined)
  54. };
  55. // 导出模块
  56. export default h;

# VNode

一个 VNode 就是一个虚拟节点用来描述一个 DOM 元素,如果这个 VNodechildren 就是 Virtual DOM

源码位置: src/vnode.ts

  1. export interface VNodeData {
  2. props?: Props
  3. attrs?: Attrs
  4. class?: Classes
  5. style?: VNodeStyle
  6. dataset?: Dataset
  7. on?: On
  8. hero?: Hero
  9. attachData?: AttachData
  10. hook?: Hooks
  11. key?: Key
  12. ns?: string // for SVGs
  13. fn?: () => VNode // for thunks
  14. args?: any[] // for thunks
  15. is?: string // for custom elements v1
  16. [key: string]: any // for any other 3rd party module
  17. }
  18. export interface VNode {
  19. // 选择器
  20. sel: string | undefined
  21. // 节点数据:属性/样式/事件等
  22. data: VNodeData | undefined
  23. // 子节点,和 text 只能互斥
  24. children: Array<VNode | string> | undefined
  25. // 记录 vnode 对应的真实 DOM
  26. elm: Node | undefined
  27. // 节点中的内容,和 children 只能互斥
  28. text: string | undefined
  29. // 优化用
  30. key: Key | undefined
  31. }
  32. export function vnode (sel: string | undefined,
  33. data: any | undefined,
  34. children: Array<VNode | string> | undefined,
  35. text: string | undefined,
  36. elm: Element | Text | undefined): VNode {
  37. const key = data === undefined ? undefined : data.key
  38. return { sel, data, children, text, elm, key }
  39. }

# snabbdom

  • patch(oldVnode, newVnode)
  • 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
  • 对比新旧 VNode 是否相同节点(节点的 keysel 相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnodetext 不同,直接更 新文本内容
  • 如果新的 VNodechildren,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
  • diff 过程只进行同层级比较

前端进阶之路-原理 - 图5

# init

  • 功能: init(modules, domApi),返回 patch()函数(高阶函数)
  • 为什么要使用高阶函数?

    • 因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如: modules/domApi/cbs
    • 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而 不需要重新创建
  • init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中

源码位置:src/init.ts

  1. export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  2. let i: number
  3. let j: number
  4. const cbs: ModuleHooks = {
  5. create: [],
  6. update: [],
  7. remove: [],
  8. destroy: [],
  9. pre: [],
  10. post: []
  11. }
  12. const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
  13. for (i = 0; i < hooks.length; ++i) {
  14. cbs[hooks[i]] = []
  15. for (j = 0; j < modules.length; ++j) {
  16. const hook = modules[j][hooks[i]]
  17. if (hook !== undefined) {
  18. (cbs[hooks[i]] as any[]).push(hook)
  19. }
  20. }
  21. }
  22. ...
  23. return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  24. let i: number, elm: Node, parent: Node
  25. const insertedVnodeQueue: VNodeQueue = []
  26. for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
  27. if (!isVnode(oldVnode)) {
  28. oldVnode = emptyNodeAt(oldVnode)
  29. }
  30. if (sameVnode(oldVnode, vnode)) {
  31. patchVnode(oldVnode, vnode, insertedVnodeQueue)
  32. } else {
  33. elm = oldVnode.elm!
  34. parent = api.parentNode(elm) as Node
  35. createElm(vnode, insertedVnodeQueue)
  36. if (parent !== null) {
  37. api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
  38. removeVnodes(parent, [oldVnode], 0, 0)
  39. }
  40. }
  41. for (i = 0; i < insertedVnodeQueue.length; ++i) {
  42. insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
  43. }
  44. for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
  45. return vnode
  46. }
  47. }

# patch

  • 功能:

    • 传入新旧 VNode,对比差异,把差异渲染到 DOM
    • 返回新的 VNode,作为下一次 patch()oldVnode
  • 执行过程:

    • 首先执行模块中的钩子函数 pre
    • 如果 oldVnodevnode 相同(keysel 相同)

      • 调用 patchVnode(),找节点的差异并更新 DOM
    • 如果 oldVnodeDOM 元素

      • DOM 元素转换成 oldVnode
      • 调用 createElm()vnode 转换为真实 DOM,记录到 vnode.elm
      • 把刚创建的 DOM 元素插入到 parent
      • 移除老节点
      • 触发用户设置的 create 钩子函数

源码位置:src/snabbdom.ts

  1. return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  2. let i: number, elm: Node, parent: Node
  3. // 保存新插入节点的队列,为了触发钩子函数
  4. const insertedVnodeQueue: VNodeQueue = []
  5. // 执行模块的 pre 钩子函数
  6. for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
  7. // 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
  8. if (!isVnode(oldVnode)) {
  9. // 把 DOM 元素转换成空的 VNode
  10. oldVnode = emptyNodeAt(oldVnode)
  11. }
  12. // 如果新旧节点是相同节点(key 和 sel 相同)
  13. if (sameVnode(oldVnode, vnode)) {
  14. // 找节点的差异并更新 DOM
  15. patchVnode(oldVnode, vnode, insertedVnodeQueue)
  16. } else {
  17. // 如果新旧节点不同,vnode 创建对应的 DOM
  18. // 获取当前的 DOM 元素
  19. elm = oldVnode.elm!
  20. parent = api.parentNode(elm) as Node
  21. // 触发 init/create 钩子函数,创建 DOM
  22. createElm(vnode, insertedVnodeQueue)
  23. if (parent !== null) {
  24. // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
  25. api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
  26. // 移除老节点
  27. removeVnodes(parent, [oldVnode], 0, 0)
  28. }
  29. }
  30. // 执行用户设置的 insert 钩子函数
  31. for (i = 0; i < insertedVnodeQueue.length; ++i) {
  32. insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
  33. }
  34. // 执行模块的 post 钩子函数
  35. for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
  36. // 返回 vnode
  37. return vnode
  38. }

# createElm

  • 功能:

    • createElm(vnode, insertedVnodeQueue),返回创建的 DOM 元素
    • 创建 vnode 对应的 DOM 元素
  • 执行过程:

  • 首先触发用户设置的 init 钩子函数

  • 如果选择器是!,创建评论节点

  • 如果选择器为空,创建文本节点

  • 如果选择器不为空

    • 解析选择器,设置标签的 idclass 属性
    • 执行模块的 create 钩子函数
    • 如果 vnodechildren,创建子 vnode 对应的 DOM,追加到 DOM
    • 如果 vnodetext 值是 string/number,创建文本节点并追击到 DOM
    • 执行用户设置的 create 钩子函数
    • 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
  1. function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  2. let i: any
  3. let data = vnode.data
  4. if (data !== undefined) {
  5. // 执行用户设置的 init 钩子函数
  6. const init = data.hook?.init
  7. if (isDef(init)) {
  8. init(vnode)
  9. data = vnode.data
  10. }
  11. }
  12. const children = vnode.children
  13. const sel = vnode.sel
  14. if (sel === '!') {
  15. // 如果选择器是!,创建评论节点
  16. if (isUndef(vnode.text)) {
  17. vnode.text = ''
  18. }
  19. vnode.elm = api.createComment(vnode.text!)
  20. } else if (sel !== undefined) {
  21. // Parse selector
  22. // 如果选择器不为空
  23. // 解析选择器
  24. // Parse selector
  25. const hashIdx = sel.indexOf('#')
  26. const dotIdx = sel.indexOf('.', hashIdx)
  27. const hash = hashIdx > 0 ? hashIdx : sel.length
  28. const dot = dotIdx > 0 ? dotIdx : sel.length
  29. const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
  30. const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
  31. ? api.createElementNS(i, tag, data)
  32. : api.createElement(tag, data)
  33. if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
  34. if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
  35. // 执行模块的 create 钩子函数
  36. for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
  37. // 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素并追加到 DOM 树上
  38. if (is.array(children)) {
  39. for (i = 0; i < children.length; ++i) {
  40. const ch = children[i]
  41. if (ch != null) {
  42. api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
  43. }
  44. }
  45. } else if (is.primitive(vnode.text)) {
  46. // 如果 vnode 的 text 值是 string/number,创建文本节点并追加到 DOM 树
  47. api.appendChild(elm, api.createTextNode(vnode.text))
  48. }
  49. const hook = vnode.data!.hook
  50. if (isDef(hook)) {
  51. // 执行用户传入的钩子 create
  52. hook.create?.(emptyNode, vnode)
  53. if (hook.insert) {
  54. // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
  55. insertedVnodeQueue.push(vnode)
  56. }
  57. }
  58. } else {
  59. // 如果选择器为空,创建文本节点
  60. vnode.elm = api.createTextNode(vnode.text!)
  61. }
  62. // 返回新创建的 DOM
  63. return vnode.elm
  64. }

# patchVnode

  • 功能:

    • patchVnode(oldVnode, vnode, insertedVnodeQueue)
    • 对比 oldVnodevnode 的差异,把差异渲染到 DOM
  • 执行过程:

    • 首先执行用户设置的 prepatch 钩子函数
    • 执行 create 钩子函数

      • 首先执行模块的 create钩子函数
      • 然后执行用户设置的 create 钩子函数
    • 如果 vnode.text 未定义

      • 如果 oldVnode.childrenvnode.children 都有值

        • 调用 updateChildren()
        • 使用 diff 算法对比子节点,更新子节点
      • 如果 vnode.children 有值, oldVnode.children 无值

        • 清空 DOM 元素
        • 调用 addVnodes() ,批量添加子节点
      • 如果 oldVnode.children 有值, vnode.children 无值

        • 调用 removeVnodes() ,批量移除子节点
      • 如果 oldVnode.text 有值

        • 清空 DOM 元素的内容
    • 如果设置了 vnode.text 并且和和 oldVnode.text 不等

      • 如果老节点有子节点,全部移除
      • 设置 DOM 元素的 textContentvnode.text
    • 最后执行用户设置的 postpatch 钩子函数
  1. function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  2. const hook = vnode.data?.hook
  3. // 首先执行用户设置的 prepatch 钩子函数
  4. hook?.prepatch?.(oldVnode, vnode)
  5. const elm = vnode.elm = oldVnode.elm!
  6. const oldCh = oldVnode.children as VNode[]
  7. const ch = vnode.children as VNode[]
  8. // 如果新老 vnode 相同返回
  9. if (oldVnode === vnode) return
  10. if (vnode.data !== undefined) {
  11. // 执行模块的 update 钩子函数
  12. for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
  13. // 执行用户设置的 update 钩子函数
  14. vnode.data.hook?.update?.(oldVnode, vnode)
  15. }
  16. // 如果 vnode.text 未定义
  17. if (isUndef(vnode.text)) {
  18. // 如果新老节点都有 children
  19. if (isDef(oldCh) && isDef(ch)) {
  20. // 使用 diff 算法对比子节点,更新子节点
  21. if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
  22. } else if (isDef(ch)) {
  23. // 如果新节点有 children,老节点没有 children
  24. // 如果老节点有text,清空dom 元素的内容
  25. if (isDef(oldVnode.text)) api.setTextContent(elm, '')
  26. // 批量添加子节点
  27. addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  28. } else if (isDef(oldCh)) {
  29. // 如果老节点有children,新节点没有children
  30. // 批量移除子节点
  31. removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  32. } else if (isDef(oldVnode.text)) {
  33. // 如果老节点有 text,清空 DOM 元素
  34. api.setTextContent(elm, '')
  35. }
  36. } else if (oldVnode.text !== vnode.text) {
  37. // 如果没有设置 vnode.text
  38. if (isDef(oldCh)) {
  39. // 如果老节点有 children,移除
  40. removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  41. }
  42. // 设置 DOM 元素的 textContent 为 vnode.text
  43. api.setTextContent(elm, vnode.text!)
  44. }
  45. // 最后执行用户设置的 postpatch 钩子函数
  46. hook?.postpatch?.(oldVnode, vnode)
  47. }

# updateChildren

  • 功能:

    • diff 算法的核心,对比新旧节点的 children,更新 DOM
  • 执行过程:
  • 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比 较,但是这样的时间复杂度为 O(n^3)
  • DOM 操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点
  • 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复 杂度为 O(n)

前端进阶之路-原理 - 图6

  • 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍 历的过程中移动索引
  • 在对开始和结束节点比较的时候,总共有四种情况

    • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
    • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
    • oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
    • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)

前端进阶之路-原理 - 图7

  • 开始节点和结束节点比较,这两种情况类似

    • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
    • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
  • 如果 oldStartVnodenewStartVnodesameVnode (keysel 相同)

    • 调用 patchVnode() 对比和更新节点
    • 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++

前端进阶之路-原理 - 图8

  • oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同

    • 调用 patchVnode() 对比和更新节点
    • oldStartVnode 对应的 DOM 元素,移动到右边

      • 更新索引

前端进阶之路-原理 - 图9

  • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同

    • 调用 patchVnode() 对比和更新节点
    • oldEndVnode 对应的 DOM 元素,移动到左边
    • 更新索引

前端进阶之路-原理 - 图10

  • 如果不是以上四种情况

    • 遍历新节点,使用 newStartNodekey 在老节点数组中找相同节点
    • 如果没有找到,说明 newStartNode 是新节点

      • 创建新节点对应的 DOM 元素,插入到 DOM 树中
    • 如果找到了

      • 判断新节点和找到的老节点的 sel 选择器是否相同
      • 如果不相同,说明节点被修改了

        • 重新创建对应的 DOM 元素,插入到 DOM 树中
      • 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边

前端进阶之路-原理 - 图11

  • 循环结束

    • 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
    • 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
  • 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边

前端进阶之路-原理 - 图12

  • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批 量删除

前端进阶之路-原理 - 图13