深入理解 Vue3 组件的实现原理:props 与组件的被动更新

什么是组件的被动更新?

由 props 引起的组件更新为组件的被动更新。组件自己内部状态引起的更新为自更新,props 本质是父组件的数据,因此父组件会先进行自更新。

从源码层面,认识组件的 props

在虚拟 DOM 层面,组件的 props 与普通 HTML 标签的属性差别不大。假设有如下模板:

<MyComponent title="A Big Title" :other="val" />

这段模板对应的虚拟 DOM 是:

const vnode = {
  type: MyComponent,
  props: {
    title: 'A big Title',
    other: this.val
  }
}

可以看到,模板与虚拟 DOM 几乎是“同构”的。另外,在编写组件时,我们需要显式地指定组件会接收哪些 props 数据,如下面的代码所示:

const MyComponent = {
  name: 'MyComponent',
  // 组件接收名为 title 的 props,并且该 props 的类型为 String
  props: {
    title: String
  },
  render() {
    return {
      type: 'div',
      children: `title is: ${this.title}` // 访问 props 数据
    }
  }
}

在初始化组件实例的时候,会从虚拟 DOM 的 type 属性中获取组件的 props 。

// packages/runtime-core/src/renderer.ts
const mountComponent: MountComponentFn = (
  initialVNode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 2.x compat may pre-create the component instance before actually
  // mounting
  const compatMountInstance =
    __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
  // 获取组件实例
  const instance: ComponentInternalInstance =
    compatMountInstance ||
    (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ))
  // 省略其他代码
}

本文中的源码均摘自 Vue.js 3.2.45

createComponentInstance 函数用于创建组件实例

// packages/runtime-core/src/component.ts

let uid = 0

export function createComponentInstance(
  vnode: VNode,
  parent: ComponentInternalInstance | null,
  suspense: SuspenseBoundary | null
) {
  const type = vnode.type as ConcreteComponent
  // inherit parent app context - or - if root, adopt from root vnode
  const appContext =
    (parent ? parent.appContext : vnode.appContext) || emptyAppContext

  const instance: ComponentInternalInstance = {
    uid: uid++,
    // 省略其他代码
    propsOptions: normalizePropsOptions(type, appContext),
  }
  // 返回组件实例
  return instance
}

其中,normalizePropsOptions 函数用于标准化组件的 props

// packages/runtime-core/src/componentProps.ts

export function normalizePropsOptions(
  comp: ConcreteComponent,
  appContext: AppContext,
  asMixin = false
): NormalizedPropsOptions {
  const cache = appContext.propsCache
  const cached = cache.get(comp)
  if (cached) {
    return cached
  }

  const raw = comp.props
  const normalized: NormalizedPropsOptions[0] = {}
  const needCastKeys: NormalizedPropsOptions[1] = []

  // apply mixin/extends props
  let hasExtends = false
  if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
    const extendProps = (raw: ComponentOptions) => {
      if (__COMPAT__ && isFunction(raw)) {
        raw = raw.options
      }
      hasExtends = true
      const [props, keys] = normalizePropsOptions(raw, appContext, true)
      extend(normalized, props)
      if (keys) needCastKeys.push(...keys)
    }
    if (!asMixin && appContext.mixins.length) {
      appContext.mixins.forEach(extendProps)
    }
    if (comp.extends) {
      extendProps(comp.extends)
    }
    if (comp.mixins) {
      comp.mixins.forEach(extendProps)
    }
  }

  if (!raw && !hasExtends) {
    if (isObject(comp)) {
      cache.set(comp, EMPTY_ARR as any)
    }
    return EMPTY_ARR as any
  }

  if (isArray(raw)) {
    for (let i = 0; i < raw.length; i++) {
      if (__DEV__ && !isString(raw[i])) {
        warn(`props must be strings when using array syntax.`, raw[i])
      }
      const normalizedKey = camelize(raw[i])
      if (validatePropName(normalizedKey)) {
        normalized[normalizedKey] = EMPTY_OBJ
      }
    }
  } else if (raw) {
    if (__DEV__ && !isObject(raw)) {
      warn(`invalid props options`, raw)
    }
    for (const key in raw) {
      const normalizedKey = camelize(key)
      if (validatePropName(normalizedKey)) {
        const opt = raw[key]
        const prop: NormalizedProp = (normalized[normalizedKey] =
          isArray(opt) || isFunction(opt) ? { type: opt } : { ...opt })
        if (prop) {
          const booleanIndex = getTypeIndex(Boolean, prop.type)
          const stringIndex = getTypeIndex(String, prop.type)
          prop[BooleanFlags.shouldCast] = booleanIndex > -1
          prop[BooleanFlags.shouldCastTrue] =
            stringIndex < 0 || booleanIndex < stringIndex
          // if the prop needs boolean casting or default value
          if (booleanIndex > -1 || hasOwn(prop, 'default')) {
            needCastKeys.push(normalizedKey)
          }
        }
      }
    }
  }

  const res: NormalizedPropsOptions = [normalized, needCastKeys]
  if (isObject(comp)) {
    cache.set(comp, res)
  }
  return res
}

为了提升性能,在正式标准化组件 props 前,会从应用程序上下文(appContext)中获取 props 缓存,并判断是否已经缓存了该组件,如果有,则直接缓存已标准化的 props

const cache = appContext.propsCache
const cached = cache.get(comp)
if (cached) {
  return cached
}

如果缓存中不存在当前组件(comp),则从当前组件(comp)中取得原始的 props

const raw = comp.props
const normalized: NormalizedPropsOptions[0] = {}
const needCastKeys: NormalizedPropsOptions[1] = []

标准化的 props 是一个元组类型(NormalizedPropsOptions)

export interface PropOptions<T = any, D = T> {
  type?: PropType<T> | true | null
  required?: boolean
  default?: D | DefaultFactory<D> | null | undefined | object
  validator?(value: unknown): boolean
}

const enum BooleanFlags {
  shouldCast,
  shouldCastTrue
}

type NormalizedProp =
  | null
  | (PropOptions & {
      [BooleanFlags.shouldCast]?: boolean
      [BooleanFlags.shouldCastTrue]?: boolean
    })

export type NormalizedProps = Record<string, NormalizedProp>
export type NormalizedPropsOptions = [NormalizedProps, string[]] | []

元组类型(NormalizedPropsOptions)第一个元素是一个对象,第二个元素是一个字符串数组。Vue3 会将组件传入的 props 标准化为一个数组,该数组中第一个元素是对象,第二个元素的字符串数组。

TypeScript 中的元组类型用于表示具有固定数量和特定类型的有序元素的数组。元组类型可以确保数组中的每个元素都具有指定的类型,并且元素的顺序与元组类型声明的顺序一致。这样可以在编译阶段捕获潜在的类型错误。

递归地调用 normalizePropsOptions 函数,从 mixinsextends 中标准化 props 。

// apply mixin/extends props
let hasExtends = false
if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
  const extendProps = (raw: ComponentOptions) => {
    if (__COMPAT__ && isFunction(raw)) {
      raw = raw.options
    }
    hasExtends = true
    const [props, keys] = normalizePropsOptions(raw, appContext, true)
    extend(normalized, props)
    if (keys) needCastKeys.push(...keys)
  }
  if (!asMixin && appContext.mixins.length) {
    appContext.mixins.forEach(extendProps)
  }
  if (comp.extends) {
    extendProps(comp.extends)
  }
  if (comp.mixins) {
    comp.mixins.forEach(extendProps)
  }
}
// packages/shared/src/index.ts

export const extend = Object.assign
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'

__FEATURE_OPTIONS_API__,rollup 的环境变量,是否开启了选项式风格的 API

如果组件本身没有 props 同时 mixin 、extends 也没有 props 则该组件没有 props ,则只需返回空对象。

if (!raw && !hasExtends) {
  if (isObject(comp)) {
    cache.set(comp, EMPTY_ARR as any)
  }
  return EMPTY_ARR as any
}
export const EMPTY_ARR = __DEV__ ? Object.freeze([]) : []

Object.freeze() 是 JavaScript 中一个用于冻结对象的方法。当一个对象被冻结后,无法再添加、修改或删除该对象的属性和方法,使其变为不可变的。通过使用 Object.freeze() 方法,可以确保对象的属性不被意外修改,提高代码的安全性和可靠性。

可以使用数组的方式声明 props ,也可以使用对象的方式声明 props

<script setup>
const props = defineProps(['foo'])

console.log(props.foo)
</script>
// 使用 <script setup>
defineProps({
  title: String,
  likes: Number
})
if (isArray(raw)) {
  // 用户以数组的方式声明 props
  for (let i = 0; i < raw.length; i++) {
    if (__DEV__ && !isString(raw[i])) {
      warn(`props must be strings when using array syntax.`, raw[i])
    }
    const normalizedKey = camelize(raw[i])
    if (validatePropName(normalizedKey)) {
      normalized[normalizedKey] = EMPTY_OBJ
    }
  }
} else if (raw) {
  if (__DEV__ && !isObject(raw)) {
    warn(`invalid props options`, raw)
  }
  // 用户以对象的方式声明 props
  for (const key in raw) {
    const normalizedKey = camelize(key)
    if (validatePropName(normalizedKey)) {
      const opt = raw[key]
      const prop: NormalizedProp = (normalized[normalizedKey] =
        isArray(opt) || isFunction(opt) ? { type: opt } : { ...opt })
      if (prop) {
        const booleanIndex = getTypeIndex(Boolean, prop.type)
        const stringIndex = getTypeIndex(String, prop.type)
        prop[BooleanFlags.shouldCast] = booleanIndex > -1
        prop[BooleanFlags.shouldCastTrue] =
          stringIndex < 0 || booleanIndex < stringIndex
        // if the prop needs boolean casting or default value
        if (booleanIndex > -1 || hasOwn(prop, 'default')) {
          needCastKeys.push(normalizedKey)
        }
      }
    }
  }
}

Vue 会将用户传入的 props 中的 key 转换为驼峰的形式,比如,lang-content 会被转换为 langContent

const camelizeRE = /-(\w)/g

const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null)
  return ((str: string) => {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }) as T
}

export const camelize = cacheStringFunction((str: string): string => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})

将 props 中的 key 转换为驼峰的形式的函数是 camelize 。该函数利用闭包来做缓存,提升了函数的性能。在平时的开发中,我们也可以借鉴这种方式,来提升自己编写函数的性能。

正则表达式 /-(\w)/g 会匹配中划线(-)和单个字符(字母、数字或者下划线)。

validatePropName 函数用于判断用户传入的 props 中的 key 是否合法

// packages/runtime-core/src/componentProps.ts

function validatePropName(key: string) {
  if (key[0] !== '$') {
    return true
  } else if (__DEV__) {
    warn(`Invalid prop name: "${key}" is a reserved property.`)
  }
  return false
}

可以从源码中发现,props 中的 key 不能以 $ 符号开头,$ 符号开头的 key 都为 Vue 内部保留的 key 。

Vue 会将需要进行布尔转换(boolean casting)和计算默认值的 prop 存入 needCastKeys 数组。

for (const key in raw) {
  // 省略其他代码
  if (prop) {
    const booleanIndex = getTypeIndex(Boolean, prop.type)
    const stringIndex = getTypeIndex(String, prop.type)
    prop[BooleanFlags.shouldCast] = booleanIndex > -1
    prop[BooleanFlags.shouldCastTrue] =
      stringIndex < 0 || booleanIndex < stringIndex

    // if the prop needs boolean casting or default value
    if (booleanIndex > -1 || hasOwn(prop, 'default')) {
      needCastKeys.push(normalizedKey)
    }
  }
}
  • booleanIndex 如果大于等于 0 (即大于 -1),则传入的 prop 为 boolean 类型;否则,传入的 prop 非 boolean 类型。因此,当 booleanIndex > -1 时,需要将 shouldCast 设置为 true

  • stringIndex 如果大于等于 0(即大于 -1),则传入的 prop 为 string 类型;否则,传入的 prop 非 string 类型。如果 prop 非 string 类型(即 stringIndex < 0)或者是 string 类型但是非 boolean 类型(即 booleanIndex < stringIndex)则需要将 shouldCastTrue 设置为 true 。

这主要用于处理 prop 为 boolean 类型,但是用户却省略了传值的情况,详情见这个 issue: Boolean props conversions don’t work,如下面的情况:

<script src="../../dist/vue.global.js"></script>
<!-- 子组件 -->
<script type="text/x-template" id="grid-demo">
  <div>
    hello {{ langContent }}
  </div>
</script>
<script>
const Demo = {
  template: '#grid-demo',
  props: {
    'lang-content': {
      type: Boolean,
    },       
  }
}
</script>

<!-- 父组件 -->
<div id="demo">
  <demo lang-content />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  }    
}).mount('#demo')
</script>
// packages/runtime-core/src/componentProps.ts

const enum BooleanFlags {
  shouldCast, // 需要转换为布尔值(boolean)的标识
  shouldCastTrue // 需要转换为 true 的标识
}

getType 函数的作用是获取给定参数的构造函数名。正则表达式 /^\s*function (\w+)/ 可用于匹配函数名。

深入理解 Vue3 组件的实现原理:props 与组件的被动更新

// packages/runtime-core/src/componentProps.ts

function getType(ctor: Prop<any>): string {
  const match = ctor && ctor.toString().match(/^\s*function (\w+)/)
  return match ? match[1] : ctor === null ? 'null' : ''
}

function isSameType(a: Prop<any>, b: Prop<any>): boolean {
  return getType(a) === getType(b)
}

function getTypeIndex(
  type: Prop<any>,
  expectedTypes: PropType<any> | void | null | true
): number {
  if (isArray(expectedTypes)) {
    return expectedTypes.findIndex(t => isSameType(t, type))
  } else if (isFunction(expectedTypes)) {
    return isSameType(expectedTypes, type) ? 0 : -1
  }
  return -1
}

isArray 函数用于判断是否为数组。

isFunction 函数用于判断是否为函数

export const isArray = Array.isArray
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'

normalizePropsOptions 函数收集到 needCastKeys 后,会在 setFullProps 函数中使用 resolvePropValue 函数对 needCastKeys 中的 key 进行转换为布尔值(boolean)或者求取默认值。

// packages/runtime-core/src/componentProps.ts

function setFullProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  props: Data,
  attrs: Data
) {

  // 省略其他代码
  if (needCastKeys) {
    const rawCurrentProps = toRaw(props)
    const castValues = rawCastValues || EMPTY_OBJ
    for (let i = 0; i < needCastKeys.length; i++) {
      const key = needCastKeys[i]
      props[key] = resolvePropValue(
        options!,
        rawCurrentProps,
        key,
        castValues[key],
        instance,
        !hasOwn(castValues, key)
      )
    }
  }
}

resolvePropValue 函数各入参的含义

  • options,组件的标准化的 props

  • props,组件接收到的 props

  • key,当前 prop 的 key 值

  • value,当前 prop 的值

  • instance,见名思意,组件的实例对象

  • isAbsent,当前 prop 是否缺失

function resolvePropValue(
  options: NormalizedProps,
  props: Data,
  key: string,
  value: unknown,
  instance: ComponentInternalInstance,
  isAbsent: boolean
) {
  const opt = options[key]
  if (opt != null) {
    const hasDefault = hasOwn(opt, 'default')
    // 求取 prop 的默认值
    if (hasDefault && value === undefined) {
      const defaultValue = opt.default
      if (opt.type !== Function && isFunction(defaultValue)) {
        const { propsDefaults } = instance
        if (key in propsDefaults) {
          // 读取已经缓存的默认值
          value = propsDefaults[key]
        } else {
          setCurrentInstance(instance)
          // 如果 prop 的默认值为函数类型,
          // 调用该函数求取 prop 的默认值,
          // 将求取到的默认值缓存到 propsDefaults
          value = propsDefaults[key] = defaultValue.call(
            __COMPAT__ &&
              isCompatEnabled(DeprecationTypes.PROPS_DEFAULT_THIS, instance)
              ? createPropsDefaultThis(instance, props, key)
              : null,
            props
          )
          unsetCurrentInstance()
        }
      } else {
        value = defaultValue
      }
    }
    // 对 prop 进行布尔转换
    if (opt[BooleanFlags.shouldCast]) {
      if (isAbsent && !hasDefault) {
        // shouldCast 为 true ,当前 prop 缺失并且没有默认值,
        // 则将 prop 值转换为 false
        value = false
      } else if (
        opt[BooleanFlags.shouldCastTrue] &&
        (value === '' || value === hyphenate(key))
      ) {
        // shouldCastTrue 为 true ,prop 值为空字符串
        // 或 key 转换为连字符分隔的字符串后与 value 相同
        // 则将 prop 转换为 true
        value = true
      }
    }
  }
  return value
}

\B,匹配一个非单词边界。具体可见 正则表达式 – JavaScript | MDN

([A-Z]),表示匹配一个大写字母,并将其捕获为一个分组

g,表示全局匹配

这个正则表达式会匹配所有非单词边界前的大写字母,并将其作为分组进行匹配。在实际使用中,我们可以通过替换操作将匹配到的大写字母替换为连字符加小写字母的形式,从而实现驼峰式命名到连字符分隔的转换。

// packages/shared/src/index.ts

const hyphenateRE = /\B([A-Z])/g

export const hyphenate = cacheStringFunction((str: string) =>
  str.replace(hyphenateRE, '-$1').toLowerCase()
)

const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null)
  return ((str: string) => {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }) as T
}

hyphenate 函数的作用就是将驼峰式命名转换为连字符分隔命名

const str = "myPropertyName"
const result = hyphenate(str)
console.log(result) // 输出:"my-property-name"

综合上面的分析,可以看到 Vue 为了完善 props 机制,编写了大量边界代码。但本质上来说,其原理都是根据组件的 props 选项定义以及为组件传递的 props 数据来处理的。

组件的 props 数据先会被标准化(normalizePropsOptions 函数),标准化过程中会将 prop 的键值(key)转换为驼峰式命名风格,同时收集需要布尔化或求取默认值的 prop ,然后标准化后的 prop 会被存入组件实例对象的 propsOptions 属性中。

当组件的 props 变更后,会在 updateComponent 函数中完成组件的更新。

// packages/runtime-core/src/renderer.ts

const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
  const instance = (n2.component = n1.component)!
  if (shouldUpdateComponent(n1, n2, optimized)) {
    // 省略其他代码
  } else {
    // no update needed. just copy over properties
    n2.el = n1.el
    instance.vnode = n2
  }
}

shouldUpdateComponent 函数用于检测子组件是否真的需要更新,因为子组件的 props 可能是不变的。

// packages/runtime-core/src/componentRenderUtils.ts
export function shouldUpdateComponent(
  prevVNode: VNode,
  nextVNode: VNode,
  optimized?: boolean
): boolean {
  const { props: prevProps, children: prevChildren, component } = prevVNode
  const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
  // 省略其他代码
  if (optimized && patchFlag >= 0) {
    // 省略其他代码
    if (patchFlag & PatchFlags.FULL_PROPS) {
      if (!prevProps) {
        return !!nextProps
      }
      // presence of this flag indicates props are always non-null
      return hasPropsChanged(prevProps, nextProps!, emits)
    } else if (patchFlag & PatchFlags.PROPS) {
      const dynamicProps = nextVNode.dynamicProps!
      for (let i = 0; i < dynamicProps.length; i++) {
        const key = dynamicProps[i]
        // 对比 nextProps 与 prevProps
        if (
          nextProps![key] !== prevProps![key] &&
          !isEmitListener(emits, key)
        ) {
          return true
        }
      }
    }    
  }
  return false
}

isEmitListener 函数用于判断传入的 prop 是否属于 emit 事件的监听器,如果为 emit 事件的监听器则忽略该 prop 的更新。

// packages/runtime-core/src/componentEmits.ts

export function isEmitListener(
  options: ObjectEmitsOptions | null,
  key: string
): boolean {
  if (!options || !isOn(key)) {
    return false
  }

  if (__COMPAT__ && key.startsWith(compatModelEventPrefix)) {
    return true
  }

  key = key.slice(2).replace(/Once$/, '')
  return (
    hasOwn(options, key[0].toLowerCase() + key.slice(1)) ||
    hasOwn(options, hyphenate(key)) ||
    hasOwn(options, key)
  )
}

hasPropsChanged 函数则用于判断组件 props 是否有更新

// packages/runtime-core/src/componentRenderUtils.ts

function hasPropsChanged(
  prevProps: Data,
  nextProps: Data,
  emitsOptions: ComponentInternalInstance['emitsOptions']
): boolean {
  // 获取 nextProps 的所有 key
  const nextKeys = Object.keys(nextProps)
  // 如果 nextProps 的 key 数量与 prevProps 的 key 数量不相等,
  // 说明 props 有更新,则不需要进一步比较
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  // 逐一比较 prevProps 和 nextProps 中的值,
  // 同时借助 isEmitListener 函数过滤掉 emit 事件的监听器,
  // 如果存在不相同的 prop 值,则说明 props 有更新
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    if (
      nextProps[key] !== prevProps[key] &&
      !isEmitListener(emitsOptions, key)
    ) {
      return true
    }
  }
  // 代码运行到这,props 没有更新,返回 false
  return false
}

当确认 props 更新后,会调用 updateProps 函数更新 props

// packages/runtime-core/src/componentProps.ts

export function updateProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  rawPrevProps: Data | null,
  optimized: boolean
) {
  const {
    props,
    attrs,
    vnode: { patchFlag }
  } = instance
  // 省略其他代码
  if (patchFlag & PatchFlags.PROPS) {
    const propsToUpdate = instance.vnode.dynamicProps!
    for (let i = 0; i < propsToUpdate.length; i++) {
      let key = propsToUpdate[i]
      if (isEmitListener(instance.emitsOptions, key)) {
        continue
      }
      const value = rawProps![key]
      if (options) {
        // 省略其他代码
        const camelizedKey = camelize(key)
        // 更新 prop
        props[camelizedKey] = resolvePropValue(
          options,
          rawCurrentProps,
          camelizedKey,
          value,
          instance,
          false /* isAbsent */
        )        
      }
    }
  }
}

总结

由组件 props 变更引起的更新为组件的被动更新,props 本质上是父组件的数据,因此父组件会产生自更新。组件自身内部状态变更引起的更新为自更新。

组件的 props 数据先会被标准化(normalizePropsOptions 函数),标准化过程中会将 prop 的键值(key)转换为驼峰式命名风格,同时收集需要布尔化或求取默认值的 prop ,然后标准化后的 prop 会被存入组件实例对象的 propsOptions 属性中。

当组件的 props 变更后,会在 updateComponent 函数中完成组件的更新。在组件更新前会调用 shouldUpdateComponent 函数判断组件是否真的需要更新,因为 props 可能没有变化,同时要过滤掉 emit 事件监听器。当最后确认 props 发生了变更后,会调用 updateProps 函数更新 prop 值。

参考

《Vue.js 设计与实现》霍春阳·著

原文链接:https://juejin.cn/post/7337896254544953398 作者:云浪

(0)
上一篇 2024年2月22日 上午11:03
下一篇 2024年2月22日 上午11:13

相关推荐

发表回复

登录后才能评论