🤯vue3核心源码剖析(九)

772 阅读8分钟

Vue3 Element 元素的props和children更新逻辑

之前我们实现了 element 元素的挂载,还没有实现当元素内的数据发生改变的时候,要对元素进行更新操作。

为此我准备了一个小 demo,期望用户在点击 button 按钮的时候能让count加一:

import { ref, h } from '../../lib/guide-toy-vue3.esm.js'
export const App = {
  name: 'app',
  setup() {
    const count = ref(0)
    const click = () => {
      count.value++
    }
    return {
      count,
      click,
    }
  },
  render() {
    return h('div', {}, [
      h('p', {}, `count: ${this.count}`),
      h('button', { onClick: this.click }, '点击更新'),
    ])
  },
}

XwtJqU.md.png

可以看到 this.count 并没有打印出数值而是一个[Object object],为什么会这样呢?我们再进一步打印this.count是什么。

Xwtosf.md.png

他是一个 ref 对象,我们想要的值在_value里面,那要怎么去除这个值呢?我们之前是不是实现了proxyRefs,我当时说过他是用来在 h 函数里面自动解包 ref 的。

生怕你们忘了我在这里再贴一次这段代码的实现吧。

// 通常用在vue3 template里面ref取值,在template里面不需要.value就可以拿到ref的值
export function proxyRefs<T extends object>(obj: T) {
  return isReactive(obj)
    ? obj
    : new Proxy<any>(obj, {
        get(target, key) {
          // unref已经处理了是否ref的情况所以我们不需要自己if处理,如果是,返回.value,如果不是,直接返回值
          return unref(Reflect.get(target, key))
        },
        set(target, key, value) {
          // 因为value为普通值类型的情况特殊,要把value赋值给ref的.value
          if (isRef(target[key]) && !isRef(value)) {
            target[key].value = value
            return true
          } else {
            return Reflect.set(target, key, value)
          }
        },
      })
}

我们只需要对 setup 的返回值进行解包就好了。

在 runtime-core 里面的 component.ts 里面添加 proxyRefs 的调用操作

function handleSetupResult(instance: any, setupResult: any) {
  // TODO handle function
  if (isFunction(setupResult)) {
    instance.render = setupResult
  } else if (isObject(setupResult)) {
    // 把setup返回的对象挂载到setupState上  proxyRefs对setupResult解包
    instance.setupState = proxyRefs(setupResult) // 新增
  }
}

这样 ref 的 value 就能正常显示出来了。

XwUu3n.md.png

下一步我们来完成元素的更新

页面上的元素都是虚拟 dom 对象

{
  type: 'div',
  props: {},
  children:{},
  el: {},
  shapeFlag: 2
  ...
}

元素更新的时候需要生成新的 vnode 对象,然后通过旧的 vnode 和新的 vnode 进行对比,从而找出要更新的地方。如果是文本更新,那我们可以用 dom.textContext 属性更新。 XgflVI.md.png

前提是我们需要获取旧的 vnode 和新的 vnode。新的 vnode 怎么获取?重新执行一次render函数就可以了啊,那什么时候应该重新执行一次 render 函数呢?上面的 count 值是一个 ref 响应式对象,只要 count 发生改变,视图中的 text 就应该发生更新啊,render 函数就应该重新执行一次生成一个 vnode 对象。

之前的章节我们实现了effect,它的主要作用是响应式对象的值发生改变的时候,会执行一次对该响应式对象相关联的effect(触发依赖)。

我们只需要在 render 函数被执行的函数里面,用 effect 包裹起来。等到 count 发生改变的时候,就会重新执行一次 render 函数了。

// In renderer.ts
function setupRenderEffect(instance: any, vnode: any, container: any) {
  effect(() => {
    const subTree = instance.render.call(instance.proxy)
    console.log('subTree===>', subTree)
    patch(subTree, container, instance)
    vnode.el = subTree.el
    instance.isMounted = true
  })
}

依赖收集

render() {
  // 触发了ref的get操作(依赖收集)
    return h('div', {}, [
      h('p', {}, `count: ${this.count}`),
      h('button', { onClick: this.click }, '点击更新'),
    ])
  },

触发依赖

setup() {
    const count = ref(0)
    const click = () => {
      // 触发了ref的set操作(执行effect回调函数)
      count.value++
    }
    return {
      count,
      click,
    }
  },

XgH4PA.md.png

根据现在的逻辑,可以看到多次点击 button 之后,虽然 count 更新了,但是重复的元素也被 mount 进 dom 树上面。原因是执行 render 的函数的时候它并不知道组件是否已经被挂载。

所以需要在组件实例上新增一个属性isMounted,该属性值是 boolean 类型,表示组件是否已经被挂载。

export function createComponentInstance(vnode: any, parentComponent: any) {
  console.log('createComponentInstance', parentComponent)
  const type = vnode.type
  const instance = {
    vnode,
    type,
    render: null,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
    isMounted: false, // 新增
    provides: parentComponent
      ? parentComponent.provides
      : ({} as Record<string, any>),
    parent: parentComponent,
  }
  instance.emit = emit.bind(null, instance) as any
  return instance
}

更改 setupRenderEffect 内部逻辑

function setupRenderEffect(instance: any, vnode: any, container: any) {
  effect(() => {
    if (!instance.isMounted) {
      // 这里处理第一次被挂载的逻辑
      const subTree = instance.render.call(instance.proxy)
      instance.subTree = subTree
      patch(null, subTree, container, instance)
      vnode.el = subTree.el
      instance.isMounted = true
    } else {
      // 这里处理更新的逻辑
      console.log('update')
    }
  })
}

注意:第一次被挂载的逻辑完成之后,还需要把 isMounted 设置为 true,这样下次更新的时候,就不会执行第一次的逻辑了。

我们把注意力聚焦到更新的逻辑。

我们需要两个 vnode,新的和旧的,所以我们同样要执行一遍 render 函数,为了能在更新的时候拿到老的 vnode,我们还需要在上一次 render 函数执行的时候得到的 vnode 赋值给一个新的属性subTree,这个属性同样是在组件实例 instance 上。

这就很好解释了为什么我在第一次挂载的逻辑中加入了instance.subTree = subTree这个表达式。

export function createComponentInstance(vnode: any, parentComponent: any) {
  console.log('createComponentInstance', parentComponent)
  const type = vnode.type
  const instance = {
    vnode,
    type,
    render: null,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
    isMounted: false,
    subTree: {}, // 新增
    provides: parentComponent
      ? parentComponent.provides
      : ({} as Record<string, any>),
    parent: parentComponent, // 父组件的组件实例
  }
  instance.emit = emit.bind(null, instance) as any
  return instance
}

function setupRenderEffect(instance: any, vnode: any, container: any) {
  effect(() => {
    if (!instance.isMounted) {
      // 这里处理第一次被挂载的逻辑
      const subTree = instance.render.call(instance.proxy)
      instance.subTree = subTree
      patch(null, subTree, container, instance)
      vnode.el = subTree.el
      instance.isMounted = true
    } else {
      // 这里处理更新的逻辑
      // 新的vnode
      const subTree = instance.render.call(instance.proxy)
      // 老的vnode
      const prevSubTree = instance.subTree
      // 存储这一次的vnode,下一次更新逻辑作为老的vnode
      instance.subTree = subTree
      patch(prevSubTree, subTree, container, instance)
    }
  })
}

element 的 props 更新

我们讲一下元素的属性的更新。

有三种情况需要我们处理:

  1. 新的 props 和旧的 props 不同(新增,修改)
  2. 新的 props 被赋值为 null 或者 undefined(删除)
  3. 旧的 props 有些属性在新的 props 中没有(删除)

X2VJHg.png

目前我们只处理元素的情况,所以我们在patch中只修改processElement内部的逻辑。

给 patch 传入n1n2,其中 n1 代表旧的 vnode,n2 代表新的 vnode。

function patch(n1: any, n2: any, container: any, parentComponent: any) {
  // 检查是什么类型的vnode
  const { type } = n2
  switch (type) {
    case Fragment:
      processFragment(n2, container, parentComponent)
      break
    case Text:
      processText(n2, container)
      break
    default: {
      if (n2.shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, parentComponent)
      } else if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        // 是一个组件?处理vnode是组件的情况
        processComponent(n2, container, parentComponent)
      }
      break
    }
  }
}

当 n2 的 shapeFlag 表示元素的时候,我们同样把 n1 和 n2 传入 processElement 函数。

// 处理元素的情况
function processElement(
  n1: any,
  n2: any,
  container: any,
  parentComponent: any
) {
  if (!n1) {
    mountElement(n2, container, parentComponent)
  } else {
    patchElement(n1, n2, container)
  }
}

其中需要处理 n1 为 null 的情况,即第一次挂载到 dom 上的情况,否则对 n1 和 n2 做对比 patch。我把对比的操作抽离成了一个函数 patchElement

patchElement 的具体实现如下:

function patchElement(n1: any, n2: any, container: any) {
  // 这里的EMPTY_OBJ是一个空对象,在shareFlags.ts中定义:const EMPTY_OBJ = {}
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  const el = (n2.el = n1.el)
  patchProps(el, oldProps, newProps)
}

patchProps()内部遍历 n2(新的 vnode)的 props,看看 n1 的 props 和 n2 的 props 是否相同。

// newProps就是n2.props
// oldProps就是n1.props
for (let key in newProps) {
  const newProp = newProps[key]
  const oldProp = oldProps[key]
  if (newProp !== oldProp) {
    patchProp(el, key, oldProp, newProp)
  }
}

patchProp()在之前的篇章已经出现过了,它是通过 DOM API 将 h 函数的 prop(h 函数的第二个参数)赋值给真实的 dom 元素。这里我可以重新贴一下具体的代码实现。

export function patchProp(el: any, key: string, oldValue: any, newValue: any) {
  // 这里处理属性值有多个的情况,比如class="flex flex-column flex-grow-1"
  if (Array.isArray(newValue)) {
    el.setAttribute(key, newValue.join(' '))
  } else if (isOn(key) && isFunction(newValue)) {
    el.addEventListener(key.slice(2).toLowerCase(), newValue)
  } else {
    el.setAttribute(key, newValue)
  }
}

其实到这里后已经实现了三种情况的第一种了(新增和修改)

当 n2 的 props 有属性值是 null 或者 undefined 的时候,我们也应该移除这个属性。此时我们可以修改patchProp

export function patchProp(el: any, key: string, oldValue: any, newValue: any) {
  if (Array.isArray(newValue)) {
    el.setAttribute(key, newValue.join(' '))
  } else if (isOn(key) && isFunction(newValue)) {
    // 添加事件
    el.addEventListener(key.slice(2).toLowerCase(), newValue)
  } else {
    // props属性的属性值是undefined或者null,删除该属性
    if (newValue === null || newValue === undefined) {
      el.removeAttribute(key)
    } else {
      el.setAttribute(key, newValue)
    }
  }
}

这样就是实现了第二种情况了

接着因为需要检查 n1 的 props 是不是在 n2 的 props 中被删除了,所以我们需要遍历 n1 的 props,看看是不是在 n2 的 props 中不复存在。

for (let key in oldProps) {
  // 新的props没有该属性
  if (!(key in newProps)) {
    patchProp(el, key, oldProps[key], null)
  }
}

要想删除属性,只需要给 patchProp 传入 null 即可,在内部会调用el.removeAttribute

好了,三种情况都已经解决了。

element 的 children 更新

element 的 children 更新涉及到以下四种情况:

  1. 旧的子节点是 array 类型 ==> 新的子节点是 string 类型
  2. 旧的子节点是 string 类型 ==> 新的子节点是 string 类型
  3. 旧的子节点是 string 类型 ==> 新的子节点是 array 类型
  4. 旧的子节点是 array 类型 ==> 新的子节点是 array 类型

当然其他情况比如旧的子节点是空的,新的子节点可能是空的。

第一种情况:子节点从数组变为字符串

XOMoz4.png

其中shapeFlag是区分 vnode 的一个标识

以下为用于解决这种元素 children 更新的测试 demo

export const App = {
  name: 'app',
  setup() {},
  render() {
    // this.count进行依赖收集,
    return h('div', {}, [
      h('p', {}, '主页'),
      // 旧的是数组 新的是文本
      h(arrayToText),
    ])
  },
}
export default {
  name: 'arrayToText',
  setup() {
    const isChange = ref(false)
    // 把ref对象挂载在window是为了方便在外部改变ref的值
    window.isChange = isChange
    return {
      isChange,
    }
  },
  render() {
    return this.isChange
      ? h('div', {}, 'new text')
      : h('div', {}, [h('div', {}, 'A'), h('div', {}, 'B')])
  },
}

预期需求:

当我在 console 中输入isChange.value = true,然后在浏览器中查看结果,会发现子节点从显示'A'和'B'更新为'new text'。这样就能达到数据驱动视图的效果。

由于之前已经实现了setupRenderEffect()内部的逻辑,可以让 ref 更新的时候,重新执行 patch 函数。我们只需要在调用patchProps()的地方上新增实现patchChildren函数

patchChildren 内部逻辑是怎样的呢?

当子节点的 shapeFlag 是数组的时候,遍历这个数组,并依次 removeChild 做卸载,之后通过 textContent 把文本更新上去。

function patchChildren(n1: any, n2: any, container: any, parentComponent: any) {
  const prevShapeFlag = n1.shapeFlag
  const newShapeFlag = n2.shapeFlag
  const c1 = n1.children
  const c2 = n2.children
  if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 卸载旧的子节点
      unmountChildren(container)
      // 设置元素文本
      setElementText(container, c2)
    }
  }
}
function unmountChildren(child: any) {
  for (let i = 0; i < child.length; i++) {
    remove(child[i])
  }
}
export function remove(child: HTMLElement) {
  // 这里为什么不直接用child.remove()呢?可以用,不过这样写兼容性更好而已
  const parent = child.parentNode
  if (parent) {
    parent.removeChild(child)
  }
}
// 设置元素文本
export function setElementText(el: HTMLElement, text: string) {
  el.textContent = text
}

第二种情况:子节点从字符串变为数组

XOYrKU.png

以下为用于解决这种元素 children 更新的测试 demo

export const App = {
  name: 'app',
  setup() {},
  render() {
    return h('div', {}, [
      h('p', {}, '主页'),
      // 旧的是文本 新的是数组
      h(textToArray),
    ])
  },
}
export default {
  name: 'textToArray',
  setup() {
    const isChange = ref(false)
    window.isChange = isChange
    return {
      isChange,
    }
  },
  render() {
    return this.isChange
      ? h('div', {}, [h('p', {}, 'p标签'), h('span', {}, 'span标签')])
      : h('div', {}, 'old text')
  },
}

预期需求:

跟情况一正好相反,当我在 console 中输入isChange.value = true,然后在浏览器中查看结果,会发现子节点从显示'old text'更新为'A'和'B'。这样就能达到数据驱动视图的效果。

实现思路:

先把子节点重新通过 textContext 设置为空字符串,再通过已经实现的 mountChildren 函数,把新的子节点挂载上去。不过在此之前还是要通过 shapeFlag 判断一下 vnode 的类型。

// n1是旧节点  n2是新节点
function patchChildren(n1: any, n2: any, container: any, parentComponent: any) {
  const prevShapeFlag = n1.shapeFlag
  const newShapeFlag = n2.shapeFlag
  const c1 = n1.children
  const c2 = n2.children
  // 判断新节点的shapeFlag是否是文本
  if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 判断旧节点的shapeFlag是否是数组
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // array to text
      unmountChildren(container)
      setElementText(container, c2)
    }
  } else {
    // text to array
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      setElementText(container, '')
      // 挂载新的子节点
      mountChildren(c2, container, parentComponent)
    }
  }
}

第三种情况:子节点从旧的文本更新为新的文本

这种情况相对简单,只需要判断 n1.children 和 n2.children 是不是相等的,如果不是则利用 textContext 更新文本即可。相等就没必要往下了,节省性能。

XONPOO.png 同时针对上面的 patchChildren 我们可以优化以下逻辑:

// n1是旧节点 n2是新节点
function patchChildren(n1: any, n2: any, container: any, parentComponent: any) {
  const prevShapeFlag = n1.shapeFlag
  const newShapeFlag = n2.shapeFlag
  const c1 = n1.children
  const c2 = n2.children
  if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(container)
    }
    // 判断是否相等,单独把`setElementText`拿出来,兼容了第一种和第三种情况
    if (c1 !== c2) {
      setElementText(container, c2)
    }
  } else {
    // text to array
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      setElementText(container, '')
      mountChildren(c2, container, parentComponent)
    }
  }
}

第四种情况旧节点是数组,新节点是数组,patch 的时候相对复杂,涉及到 vue 的 diff 算法。我将在下一篇带来diff算法的分享。

最后肝血阅读,栓 Q