zl程序教程

您现在的位置是:首页 >  Javascript

当前栏目

《Vue.js 设计与实现》读书笔记 - 第12章、组件的实现原理

2023-03-14 09:45:43 时间

第12章、组件的实现原理

12.1 渲染组件

在渲染器内部的实现看,一个组件是一个特殊类型的虚拟 DOM 节点。之前在 patch 我们判断了 VNode 的 type 值来处理,现在来处理类型为对象的情况。

// n1 旧node n2 新node container 容器
function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }
  const { type } = n2
  if (typeof type === 'string') {
    // ...
  } else if (type === Text) {
    // ...
  } else if (type === Fragment) {
    // ...
  } else if (typeof type === 'object') {
    // 组件
    if (!n1) {
      // 挂载
      mountComponent(n2, container, anchor)
    } else {
      patchComponent(n1, n2, anchor)
    }
  }
}

其中 mountComponent 就是先通过组件的 render 函数获取对应的 vnode 然后再挂载。

function mountComponent(vnode, container, anchor) {
  const componentOptinos = vnode.type
  const { render } = componentOptinos
  const subTree = render()
  patch(null, subTree, container, anchor)
}

在渲染时使用组件类型:

const MyComponent = {
  name: 'MyComponent',
  render() {
    return {
      type: 'div',
      children: 'Text',
    }
  },
}

const CompVNode = {
  type: MyComponent,
}

renderer.render(CompVNode, document.querySelector('#app'))

12.2 组件状态与自更新

完成了组件的初始渲染,现在开始设计组件自身的状态。

我们在渲染时把组件的状态设置为响应式,并把渲染函数放在 effect 中执行,这样就实现了组件状态改变时重新渲染。同时指定 scheduler 来让渲染队列在一个微任务中执行并进行去重。

function mountComponent(vnode, container, anchor) {
  const componentOptinos = vnode.type
  const { render, data } = componentOptinos
  const state = reactive(data()) // 让组件的数据变成响应式

  // 为了让组件状态发生变化时能自动渲染
  effect(
    () => {
      const subTree = render.call(state, state)
      patch(null, subTree, container, anchor)
    },
    {
      scheduler: queueJob,
    }
  )
}
  
const MyComponent = {
  name: 'MyComponent',
  data() {
    return {
      foo: 'hello world',
    }
  },
  render() {
    return {
      type: 'div',
      children: `foo = ${this.foo}`,
    }
  },
}

12.3 组件实例与组件的生命周期

当状态修改导致组件再次渲染时,patch 不应该还是挂载,所以我们需要维护一个实例,记录组件的状态,是否被挂载和上一次的虚拟 DOM 节点。

同时我们的组件有很多生命周期函数,我们需要在相应的时机调用对应的生命周期函数。

function mountComponent(vnode, container, anchor) {
  const componentOptinos = vnode.type
  const {
    render,
    data,
    beforeCreate,
    created,
    beforeMount,
    mounted,
    beforeUpdate,
    updated,
  } = componentOptinos
  beforeCreate && beforeCreate()
  const state = reactive(data()) // 让组件的数据变成响应式

  const instance = {
    state,
    isMounted: false,
    subTree: null,
  }
  vnode.component = instance

  created && created.call(state)

  // 为了让组件状态发生变化时能自动渲染
  effect(
    () => {
      const subTree = render.call(state, state)
      if (!instance.isMounted) {
        // 检测组件是否已经被挂载
        beforeMount && beforeMount.call(state)
        patch(null, subTree, container, anchor)
        instance.isMounted = true
        mounted && mounted.call(state)
      } else {
        beforeUpdate && beforeUpdate.call(state)
        patch(instance.subTree, subTree, container, anchor)
        updated && updated.call(state)
      }
      instance.subTree = subTree
    },
    {
      scheduler: queueJob,
    }
  )
}

12.4 props 与组件的被动更新

在 Vue3 中要显示指定需要的属性,如果没有指定将会被存储到 attrs 对象中。

function mountComponent(vnode, container, anchor) {
  const componentOptinos = vnode.type
  const {
    render,
    data,
    props: propsOption,
    // ...
  } = componentOptinos
  beforeCreate && beforeCreate()
  const state = reactive(data ? data() : {}) // 让组件的数据变成响应式
  const [props, attrs] = resolveProps(propsOption, vnode.props)
  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  }
  
  // ...
}

function resolveProps(options, propsData) {
  const props = {}
  const attrs = {}
  for (const key in propsData) {
    if (key in options) {
      props[key] = propsData[key]
    } else {
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

当父元素的数据发生变化时,父元素更新,导致子元素更新。在 patch 更新子元素时,由于存在旧节点,会调用 patchComponent 进行更新。在 patchComponent 中我们只需要更新组件属性。

function patchComponent(n1, n2, anchor) {
  const instance = (n2.component = n1.component)
  const { props } = instance
  if (hasPropsChanged(n1.props, n2.props)) {
    const [nextProps] = resolveProps(n2.type.props, n2.props)
    for (const k in nextProps) {
      props[k] = nextProps[k]
    }
    for (const k in props) {
      if (!(k in nextProps)) delete props[k]
    }
  }
}

function hasPropsChanged(prevProps, nextProps) {
  const nextKeys = Object.keys(nextProps)
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    if (nextProps[key] !== prevProps[key]) return true
  }
  return false
}

但是这样仅仅在示例保存了 props 并不能在渲染函数中访问他们,所以需要封装一个渲染上下文对象,生命周期函数和渲染函数都绑定该对象。

function mountComponent(vnode, container, anchor) {
  // ...
  vnode.component = instance

  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      const { state, props } = t
      if (state && k in state) {
        return state[k]
      } else if (k in props) {
        return props[k]
      } else {
        console.error('不存在')
      }
    },
    set(t, k, r) {
      const { state, props } = t
      if (state && k in state) {
        state[k] = v
      } else if (k in props) {
        console.warn('不可以设置props的值')
      } else {
        console.error('不存在')
      }
      return true
    },
  })

  created && created.call(renderContext)

  // 为了让组件状态发生变化时能自动渲染
  effect(() => {
    const subTree = render.call(renderContext, renderContext)
    // ...
  })
}

12.5 setup 函数的作用与实现

setup 的返回值有两种情况

  1. 返回一个函数 作为组件的 render 函数
  2. 返回一个对象,该对象中包含的数据将暴露给模板使用

setup 函数接受两个参数,第一个参数是 props 数据对象,第二个参数是 setupContext 对象。

const Comp = {
  props: {
    foo: String,
  },
  setup(props, setupContext) {
    props.foo // 访问 props 属性值
    // expose 用于显式地对外暴露组件数据
    const { slots, emit, attrs, expose } = setupContext
  },
}

接下来在 mountComponent 中实现 setup

function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  let {
    render,
    data,
    setup,
    // ...
  } = componentOptions
  // ...
  // 暂时只有 attrs
  const setupContext = { attrs }
  const setupResult = setup(shallowReactive(instance.props), setupContext)
  let setupState = null
  if (typeof setupResult === 'function') {
    if (render) {
      console.warn('setup 返回渲染函数,render选项将被忽略')
    }
    render = setupResult
  } else {
    setupState = setupResult
  }
  vnode.component = instance

  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      const { state, props } = t
      if (state && k in state) {
        return state[k]
      } else if (k in props) {
        return props[k]
      } else if (setupState && k in setupState) {
        return setupState[k]
      } else {
        console.error('不存在')
      }
    },
    set(t, k, r) {
      const { state, props } = t
      if (state && k in state) {
        state[k] = v
      } else if (k in props) {
        console.warn('不可以设置props的值')
      } else if (setupState && k in setupState) {
        setupState[k] = v
      } else {
        console.error('不存在')
      }
      return true
    },
  })

  // ...
}

可以看到我们执行了 setup 并把结果放入了渲染上下文。

12.6 组件事件与 emit 的实现

emit 用来发射组件的自定义事件,本质上就是根据时间名称去 props 数据对象中寻找对用的事件处理函数并执行。

function mountComponent(vnode, container, anchor) {
  // ...
  function emit(event, ...payload) {
    const eventName = `on${event[0].toUpperCase() + event.slice(1)}`

    const handler = instance.props[eventName]
    if (handler) {
      handler(...payload)
    } else {
      console.warn(`${event} 事件不存在`)
    }
  }

  const setupContext = { attrs, emit }
  // ...
}

function resolveProps(options, propsData) {
  const props = {}
  const attrs = {}
  for (const key in propsData) {
    if (key in options || key.startsWith('on')) { // 事件不需要显示声明
      props[key] = propsData[key]
    } else {
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

12.7 插槽的工作原理

在 vnode 中,插槽会被编译为渲染函数,如下:

// 子组件
const MyComponent = {
  name: 'MyComponent',
  render() {
    return {
      type: Fragment,
      children: [
        {
          type: 'header',
          children: [this.$slots.header()],
        },
      ]
    }
  },
}
// 父组件
const vnode = {
  type: MyComponent,
  children: {
    header() {
      return {
        type: 'h1',
        children: '我是标题',
      }
    },
  },
}

具体实现就是在子元素的渲染函数中,当他获取 $slots 的值,就把父元素传入的 children 返回。

function mountComponent(vnode, container, anchor) {
  // ...
  const slots = vnode.children || {}

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    slots,
  }
  // ...
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      const { state, props, slots } = t

      if (k === '$slots') return slots
      // ...
    },
    // ...
  })

  // ...
}

12.8 注册生命周期

setup 中通过 onMounted 等钩子函数可以注册该组件的生命周期函数。但是 onMounted 是如何知道是哪个组件的生命周期?

原理也很简单,和开始的收集依赖有点像,就是在全局保存当前正在执行 setup 的组件实例。

// 全局变量 保存当前在执行 setup 的实例
let currentInstance = null
function setCurrentInstance(instance) {
  currentInstance = instance
}
// 以 onMounted 举例 会把函数添加到组件的 mounted 属性内
function onMounted(fn) {
  if (currentInstance) {
    currentInstance.mounted.push(fn)
  } else {
    console.error('onMounted 函数只能在 setup 中调用')
  }
}

function mountComponent(vnode, container, anchor) {
  // ...
  const instance = {
    // ...
    // 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期函数
    mounted: [],
  }
  // ...
  let setupState = null
  if (setup) {
    const setupContext = { attrs, emit, slots }
    setCurrentInstance(instance)
    const setupResult = setup(shallowReactive(instance.props), setupContext)
    setCurrentInstance(null)
    // ...
  }
  // ...
  effect(
    () => {
      const subTree = render.call(renderContext, renderContext)
      if (!instance.isMounted) {
        // ...
        // 挂载时执行 instance.mounted 中添加的钩子函数
        instance.mounted &&
          instance.mounted.forEach((hook) => hook.call(renderContext))
      } else {
        // ...
      }
      instance.subTree = subTree
    },
    {
      scheduler: queueJob,
    }
  )
}