第8章、挂载与更新

8.1 挂载子节点和元素的属性

扩展子元素的类型可以为数组,并判断如果是数组的话,就先依次挂载所有的子元素。

同时新增节点属性。属性可以通过 el.setAttribute 添加到 DOM 上,也可以直接在 DOM 对象上设置。

function createRenderer(options) {
  const { createElement, insert, setElementText } = options

  function mountElement(vnode, container) {
    const el = createElement(vnode.type)

    if (typeof vnode.children === 'string') {
      setElementText(el, vnode.children)
    } else if (Array.isArray(vnode.children)) {
      vnode.children.forEach((child) => {
        patch(null, child, el)
      })
    }

    if (vnode.props) {
      for (const key in vnode.props) {
        el.setAttribute(key, vnode.props[key])
        // el[key] = vnode.props[key]
      }
    }
    insert(el, container)
  }
  
  // ... 同上一章 省略
}

const renderer = createRenderer({
  // ...
})

renderer.render(
  {
    type: 'div',
    props: {
      id: 'foo'
    },
    children: [
      {
        type: 'p',
        children: 'hello'
      }
    ],
  },
  document.getElementById('app')
)

8.2 HTML Attributes 与 DOM Properties

  • HTML Attributes:HTML 标签上的属性,比如 <div id="foo"></div> 这里的 id

  • DOM Properties:JavaScript 中 DOM 对象的属性。如下图,DOM 对象上的属性:

    image.png

  • HTML Attributes 和 DOM Properties 的名字不总是相同,并不是所有的 HTML Attributes 都有对应的 DOM Properties,也不是所有的 DOM Properties 都有对应的 HTML Attributes

    • HTML Attributes 中的 class 在 DOM Properties 为 className
    • HTML Attributes 中 <div aria-valuenow="75"></div> 中的 aria-* 类在 DOM Properties 没有对应值。
    • DOM Properties 中的 el.textContent 在 HTML Attributes 也没有对应属性。
  • HTML Attributes 和 DOM Properties 具体相同名称的看作 直接映射。

  • HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。(inputvaluedefaultValue

8.3 正确的设置元素属性

<button disabled>Button</button> 中,对应的 vnode 为

{
  type: 'node',
  props: {
    disabled: ''
  }
}

是在表示禁用。而 <button disabled="false">Button</button> 中,对应的 vnode 为 {disabled: false} 是在表示不禁用。

如果设置 HTML Attributes 的话 el.setAttribute(key, 'false') 会被设置为禁用,设置 DOM Properties 的话 el.disabled = '' 又会把 '' 转为 false 设置成不禁用。我们我们需要对 Boolean 值进行特殊处理。

function mountElement(vnode, container) {
  const el = createElement(vnode.type)
  
  // ...
  
  if (vnode.props) {
    for (const key in vnode.props) {
      if (key in el) {
        // 判断 key 是否存在对应的 DOM Properties
        const type = typeof el[key] // 获取该属性的类型
        const value = vnode.props[key]
        if (type === 'boolean' && value === '') {
          el[key] = true
        } else {
          el[key] = value
        }
      } else {
        // 没有对应的 DOM Properties 
        el.setAttribute(key, vnode.props[key])
      }
    }
  }
  insert(el, container)
}

还有种特殊情况,在 inputform 对应的 DOM Properties 属性是只读的。我们需要先判断是否只读再决定如何修改属性。所以新增函数,用于判断一个属性是否要通过 DOM Properties 来设置。

function shouldSetAsProps(el, key, value) {
  // 特殊处理(可能有很多需要特殊处理的情况 此处只列这一种)
  if (key === 'form' && el.tagName === 'INPUT') return false

  return key in el
}

接下来要把上面的代码中平台相关的部分抽出来。

function mountElement(vnode, container) {
  const el = createElement(vnode.type)

  // ...

  if (vnode.props) {
    for (const key in vnode.props) {
      patchProps(el, key, null, vnode.props[key])
    }
  }
  insert(el, container)
}

const renderer = createRenderer({
  // ...
  patchProps(el, key, prevValue, nextValue) {
    if (shouldSetAsProps(el, key, nextValue)) {
      // 判断 key 是否存在对应的 DOM Properties
      const type = typeof el[key] // 获取该属性的类型
      if (type === 'boolean' && nextValue === '') {
        el[key] = true
      } else {
        el[key] = nextValue
      }
    } else {
      // 没有对应的 DOM Properties
      el.setAttribute(key, nextValue)
    }
  },
})

8.4 class 的处理

在 Vue 中,可以通过字符串,对象,数组的方式来设置类名,所以我们需要对各种方式的书写做处理,处理为统一的字符串格式。这里假设只有字符串形式。

同时在浏览器中使用 el.className 设置类名的效率是最高的,所以这里特殊处理一下使用该方法类设置域名。

patchProps(el, key, prevValue, nextValue) {
  if (key === 'class') {
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) {
  //...
},
  • 为用户提供便利的代价是在底层做统一化的处理,这消耗了更多的性能。(style也是)
  • vnode.props 不是总和 DOM 元素属性保持一致,这取决于上层 API 设计。

8.5 卸载操作

前文通过 container.innerHTML = '' 来清空容器,这样做并不严谨。原因如下:

  1. 需要调用相关生命周期函数,如 beforeUnmount
  2. 需要自定自定义指令的卸载钩子函数
  3. 需要移除绑定的事件函数

我们需要找到关联的 DOM 元素,并使用原生 DOM 操作方法将该 DOM 元素移除。因此,我们需要给 vnode 绑定对应的 DOM 元素。

function mountElement(vnode, container) {
  const el = (vnode.el = createElement(vnode.type))

  // ... 
}

然后修改 render 中的卸载逻辑:

// 传入一个 vnode 卸载与其相关联的 DOM 节点
function unmount(vnode) {
  const parent = vnode.el.parentNode
  if (parent) {
    parent.removeChild(vnode.el)
  }
}

function render(vnode, container) {
  if (vnode) {
    // 如果有新 vnode 就和旧 vnode 一起用 patch 处理
    patch(container._vnode, vnode, container)
  } else {
    if (container._vnode) {
      // 没有新 vnode 但是有旧 vnode 卸载
      unmount(container._vnode)
    }
  }
  // 把旧 vnode 缓存到 container
  container._vnode = vnode
}

8.6 区分 vnode 的类型

当我们传递了新旧两个节点,来使用 patch 打补丁的时候,我们需要在 patch 判断新旧节点的类型,如果类型不同那就先卸载再挂载,节点类型相同时才有打补丁的意义。

// n1 旧node n2 新node container 容器
function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }
  const { type } = n2
  if (typeof type === 'string') {
    if (!n1) {
      // 挂载
      mountElement(n2, container)
    } else {
      // 打补丁
      patchElement(n1, n2)
    }
  } else if (typeof type === 'object') {
    // 组件
  } else if (type == 'xxx') {
    // 处理其他类型的vnode
  }
}

可以看到 patch 的具体操作还是和 vnode 的类型有关,要看是原始的 HTML 类型还是组件或其他。

8.7 事件的处理

在 vnode 中,我们约定把 on 开头的属性视作事件。然后我们可以通过 addEventListener 函数来绑定事件。

如果之前就有值,我们会自然想到先移除旧事件再绑定新事件,不过还有更优雅的方式,就是我们存储一个事件处理函数,并把真正的事件函数赋值到该函数。

我真的觉得这个处理方式好牛逼啊!!!!!

patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    // evl: vue event invoker
    let invoker = el._vel
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
        invoker = el._vel = (e) => {
          invoker.value(e)
        }
        invoker.value = nextValue
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  }

  // ... 省略之前的代码
},

// 使用
renderer.render(
  {
    type: 'button',
    props: {
      onClick: () => {
        console.log('click!')
      },
    },
    children: 'Button',
  },
  document.getElementById('app')
)

由于上面把所有的时间都通过 invoker 存储,如果有多种事件的话会相互覆盖,所以应该把 invoker 设计为一个对象。同时,同一个事件也可能绑定多个事件函数,我们还需要判断是否为数组。

patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    // evl: vue event invoker
    const invokers = el._vel || (el._vel = {})
    let invoker = invokers[key]
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
        invoker = el._vel[key] = (e) => {
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach((fn) => fn(e))
          } else {
            invoker.value(e)
          }
        }
        invoker.value = nextValue
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  }

  // ...
},

8.8 事件冒泡与更新时机问题

看如下示例(patchElement函数的实现在 8.9 但是下面的示例需要用到。。。),我们定义了两个节点,父节点一开始点击事件为空,点击子节点会切换父元素的点击事件。

const bol = ref(false)

effect(() => {
  const vnode = {
    type: 'div',
    props: bol.value
      ? {
          onClick: () => {
            alert('父元素 clicked')
          },
        }
      : {},
    children: [
      {
        type: 'p',
        props: {
          onClick: () => {
            bol.value = true
          },
        },
        children: 'text',
      },
    ],
  }

  renderer.render(vnode, document.querySelector('#app'))
})

现在我们点击子节点会发现父元素的事件被执行了。原因是子元素点击后副作用函数会被重新执行,我们先执行副作用函数把父元素事件调整了之后,冒泡才到父元素的 DOM 节点,导致事件函数被执行。

解决方法:记录事件触发的时间和事件绑定的时间,只有触发时间在绑定时间之后才会执行。

但这里书中的处理方法我认为有问题,不确定,给老师提了意见,再看看。

8.9 更新子节点

元素的子节点分为三种类型

  • 没有子节点 vnode.children = null
  • 文本子节点 typeof vnode.children = string
  • 其他情况,单个元素或多个子节点,此时用数组表示

我们在新旧子节点切换的时候,理论上是这三种的互相切换,就是有 9 种可能。现在实现之前没有实现的 patchElement 函数(就是之前新旧节点都存在时,patch 中用于处理的函数)。

// n1 旧node n2 新node
function patchElement(n1, n2) {
  const el = (n2.el = n1.el)
  const oldProps = n1.props
  const newProps = n2.props

  for (const key in newProps) {
    if (newProps[key] !== oldProps[key]) {
      patchProps(el, key, oldProps[key], newProps[key])
    }
  }
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProps(el, key, oldProps[key], null)
    }
  }
  // 更新children
  patchChildren(n1, n2, el)
}

function patchChildren(n1, n2, container) {
  // 如果新节点是字符串类型
  if (typeof n2.children === 'string') {
    // 新节点只有在为一组节点的时候需要卸载处理 其他情况不需要任何操作
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c))
    }
    // 设置新内容
    setElementText(container, n2.children)
  } else if (Array.isArray(n2.children)) {
    // 如果新子元素是一组节点
    if (Array.isArray(n1.children)) {
      // 如果旧子节点也是一组节点 后续使用核心的diff算法
      // 暂时先全部卸载再重新添加
      n1.children.forEach((c) => unmount(c))
      n2.children.forEach((c) => patch(null, c, container))
    } else {
      // 否则旧节点不存在或者是字符串 只需要清空容器然后添加新节点就可以
      setElementText(container, '')
      n2.children.forEach((c) => patch(null, c, container))
    }
  } else {
    // 新子节点不存在
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c))
    } else if (typeof n1.children === 'string') {
      setElementText(container, '')
    }
  }
}

patchChildren 分别对 9 种情况讨论处理。其中 diff 会在后面章节实现。

8.10 文本节点和注释节点

我们使用 type: 'div' 这种形式表示一个 HTML 中的普通标签,但是对于注释节点和文本节点是没有标签的。所以我们使用 Symbol 来表示,如下:

const Text = Symbol()
const newVnode = {
  type: Text,
  children: '我是文本内容'
}

const Comment = Symbol()
const newVnode = {
  type: Comment,
  children: '我是注释内容'
}

调整之前的 patch 函数,新增文本类型的判断。

function createRenderer(options) {
  const { createElement, insert, setElementText, patchProps, createText, setText } = options
  // ...

  // n1 旧node n2 新node container 容器
  function patch(n1, n2, container) {
    if (n1 && n1.type !== n2.type) {
      unmount(n1)
      n1 = null
    }
    const { type } = n2
    if (typeof type === 'string') {
      // ...
    } else if (type === Text) {
      // 如果新节点是文本类型
      if (!n1) {
        const el = (n2.el = createText(n2.children))
        insert(el, container)
      } else {
        const el = (n2.el = n1.el)
        if (n2.children !== n1.children) {
          // 更新文本节点内容
          setText(el, n2.children)
        }
      }
    } else if (typeof type === 'object') {
      // 组件
    } else if (type == 'xxx') {
      // 处理其他类型的vnode
    }
  }
  // ...
}

const renderer = createRenderer({
  // ...
  createText(text) {
    return document.createTextNode(text)
  },
  setText(el, text) {
    el.nodeValue = text
  },
  patchProps(el, key, prevValue, nextValue) {
    // ...
  }
})

注释节点和文本节点逻辑类似,不过需要使用 document.createComment 来创建节点。

8.11 Fragment

在 Vue3 中允许多根节点模板,实际上是通过 Fragment 来实现的。Fragment 没有标签名,也通过 Symbol 作为唯一表标识。

const Fragment = Symbol()

// ...

// 在 patch 中 Fragment 的更新逻辑
else if (type === Fragment) {
  if (!n1) {
    // 如果之前不存在 需要把节点一次挂载
    n2.children.forEach((c) => patch(null, c, container))
  } else {
    // 之前存在只需要更新子节点即可
    patchChildren(n1, n2, children)
  }
}

// unmount 中添加 Fragment 的逻辑,因为 Fragment 没有实际节点 只需要卸载子节点
function unmount(vnode) {
  if (vnode.type === Fragment) {
    vnode.children.forEach(c => unmount(c))
    return
  }
  const parent = vnode.el.parentNode
  if (parent) {
    parent.removeChild(vnode.el)
  }
}