Vue3 源码解读-Props 实现原理

233 阅读11分钟

vuejs3.png

💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4)

欢迎关注公众号:《前端 Talkking》

1、前言

在 Vue 中,父组件可以通过 props属性向子组件传递值,子组件接收到这些值后可以实现各种功能。

Vue 内部是如何实现 props 初始化以及更新的呢?接下来,一起来看看 props 的实现原理吧。

2、源码实现

2.1 props 配置的标准化

props支持多种数据类型,比如数组,对象、字符串、数值等类型,因此,需要对 props配置进行标准化处理,源码实现如下:

const instance: ComponentInternalInstance = {
  uid: uid++,
  vnode,

  // 省略部分代码

  propsOptions: normalizePropsOptions(type, appContext),

}

根据以上代码可知,标准化 props是通过 normalizePropsOptions方法完成的,我们来看它的实现:

normalizePropsOptions 源码实现

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
  // 处理extends和mixins这些props
  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
  }
  // 数组形式的props定义
  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])
      // props名称合法
      if (validatePropName(normalizedKey)) {
        // 为标准化后的key对应 的每一个值创建一个空对象
        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 } : extend({}, 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
}

normalizePropsOptions方法有三个参数,其中:

  • comp:表示定义组件的对象;
  • appContext:表示全局上下文;
  • asMixin:表示组件是否处于 mixin的处理环境中。

其处理过程步骤如下:

  1. 处理 mixinsextends这两个特殊属性,因为它们的作用都是扩展组件,因此需要对它们定义中的 props递归执行 normalizePropsOptions

  2. 处理数组形式的 prop:将数组中的字符串都变成驼峰形式 key,校验 key是否符合命名规范,然后给标准化后的 key都创建一个空对象,例如,

    export default {
      props: ["name", "nick-name"],
    };
    

    标准化的 prop后的定义如下:

    export default {
       props: {
         name: {},
         nickName: {},
       },
    };
    
  3. 处理对象形式的 prop:标准化每个 prop 属性定义,把数组或者函数形式的 prop 标准化对象形式。例如:

  export default {
    title: String,
    author: [String, Boolean],
  };

标准化的 prop后的定义如下:

  export default {
    props: {
      title: {
        type: String,
      },
      author: {
        type: [String, Boolean],
      },
    },
  };

4. 缓存并返回标准化后的结果。

标准化 props 配置的目的是为了支持用户各种 props的配置写法,标准化统一的对象格式为了后续统一处理。

2.2 props 值的初始化

在执行 setupComponent函数的时候,会初始化 props

export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  isInSSRComponentSetup = isSSR

  const { props, children } = instance.vnode
  const isStateful = isStatefulComponent(instance)
  // 初始化props
  initProps(instance, props, isStateful, isSSR)
  // 初始化插槽
  initSlots(instance, children)
  // 设置有状态的组件实例(通常,我们写的组件就是一个有状态的组件,所谓有状态,就是组件会在渲染过程中把一些状态挂载到组件实例对应的属性上)
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined
  isInSSRComponentSetup = false
  return setupResult
}

根据以上代码得知,prop的初始化是通过 initProps函数完成的,我们来看它的实现:

initProps 函数源码实现

export function initProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  isStateful: number, // result of bitwise flag comparison
  isSSR = false
) {
  const props: Data = {}
  const attrs: Data = {}
  // props的默认值缓存对象
  def(attrs, InternalObjectKey, 1)
  instance.propsDefaults = Object.create(null)
  // 设置props值
  setFullProps(instance, rawProps, props, attrs)

  // ensure all declared prop keys are present
  for (const key in instance.propsOptions[0]) {
    if (!(key in props)) {
      props[key] = undefined
    }
  }

  // validation
  // 验证props是否合法
  if (__DEV__) {
    validateProps(rawProps || {}, props, instance)
  }

  if (isStateful) {
    // stateful
    // 有状态组件,响应式处理
    instance.props = isSSR ? props : shallowReactive(props)
  } else {
    // 函数式组件处理
    if (!instance.type.props) {
      // functional w/ optional props, props === attrs
      instance.props = attrs
    } else {
      // functional w/ declared props
      instance.props = props
    }
  }
  // 普通属性赋值
  instance.attrs = attrs
}

根据上面代码可知,该函数主要做了以下几件事情:

  1. 调用 setFullProps设置 props的值;
  2. 调用 validateProps验证 props是否合法;
  3. props变成响应式;
  4. props添加到实例 instance.props上;
  5. 普通属性添加到 instance.attrs上。

接下来我们来看 setFullPropsvalidateProps是如何实现的。

2.2.1 设置 props

接下来我们看下 setFullProps方法的实现:

setFullProps 方法源码实现

function setFullProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  props: Data,
  attrs: Data
) {
  // 获取标准化props的配置
  const [options, needCastKeys] = instance.propsOptions
  // 判断普通属性是否改变了标志位
  let hasAttrsChanged = false
  let rawCastValues: Data | undefined
  if (rawProps) {
    for (let key in rawProps) {
      // key, ref are reserved and never passed down
      // 一些保留的prop比如ref、key是不会传递的
      if (isReservedProp(key)) {
        continue
      }

      if (__COMPAT__) {
        if (key.startsWith('onHook:')) {
          softAssertCompatEnabled(
            DeprecationTypes.INSTANCE_EVENT_HOOKS,
            instance,
            key.slice(2).toLowerCase()
          )
        }
        if (key === 'inline-template') {
          continue
        }
      }

      const value = rawProps[key]
      // prop option names are camelized during normalization, so to support
      // kebab -> camel conversion here we need to camelize the key.
      // 连字符形式的props也转成驼峰形式
      let camelKey
      if (options && hasOwn(options, (camelKey = camelize(key)))) {
        if (!needCastKeys || !needCastKeys.includes(camelKey)) {
          props[camelKey] = value
        } else {
          ;(rawCastValues || (rawCastValues = {}))[camelKey] = value
        }
      } else if (!isEmitListener(instance.emitsOptions, key)) {
        // Any non-declared (either as a prop or an emitted event) props are put
        // into a separate `attrs` object for spreading. Make sure to preserve
        // original key casing
        if (__COMPAT__) {
          if (isOn(key) && key.endsWith('Native')) {
            key = key.slice(0, -6) // remove Native postfix
          } else if (shouldSkipAttr(key, instance)) {
            continue
          }
        }
        if (!(key in attrs) || value !== attrs[key]) {
          attrs[key] = value
          hasAttrsChanged = true
        }
      }
    }
  }
  // 对需要转换的props求值
  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)
      )
    }
  }

  return hasAttrsChanged
}

根据以上代码可知,可以拆解两个步骤:

  1. 遍历 rawProps,拿到每一个 key。由于我们在标准化 props 配置过程中已经把 props 定义的 key 转成了驼峰形式,所以也需要把 rawProps 的 key 转成驼峰形式,然后对比看 prop 是否在配置中定义。如果 rawProps 中的 prop 在配置中定义了,那么把它的值赋值到 props 对象中,如果不是,那么判断这个 key 是否为非事件派发相关,如果是那么则把它的值赋值到 attrs 对象中。另外,在遍历的过程中,遇到 key、ref 这种 key,则直接跳过。遍历 rawProps

  2. 对需要转换的 props 求值:props 求值

    resolvePropValue 主要就是针对两种情况的转换,第一种是默认值的情况,即我们在 prop 配置中定义了默认值,并且父组件没有传递数据的情况,这里 prop 对应的值就取默认值。

    第二种是布尔类型的值,前面我们在 normalizePropsOptions 的时候已经给 prop 的定义添加了两个特殊的 key,所以 opt[0] 为 true 表示这是一个含有 Boolean 类型的 prop,然后判断是否有传对应的值,如果不是且没有默认值的话,就直接转成 false,举个例子:

    export default {
      props: {
        author: Boolean,
      },
    };
    

如果父组件调用子组件的时候没有给 author 这个 prop 传值,那么它转换后的值就是 false。

接着看 opt[1] 为 true,并且 props 传值是空字符串或者是 key 字符串的情况,命中这个逻辑表示这是一个含有 Boolean 和 String 类型的 prop,且 Boolean 在 String 前面,例如:

export default {
  props: {
    author: [Boolean, String],
  },
};

这种时候如果传递的 prop 值是空字符串,或者是 author 字符串,则 prop 的值会被转换成 true。

至此,props 的转换求值结束,整个 setFullProps 函数逻辑也结束了,回顾它的整个流程,我们可以发现 它的主要目的就是对 props 求值,然后把求得的值赋值给 props 对象和 attrs 对象中 。

2.2.2 验证 props

validateProps验证过程是在非生产环境执行的,我们来看下它的实现:

function validateProps(
  rawProps: Data,
  props: Data,
  instance: ComponentInternalInstance
) {
  const resolvedValues = toRaw(props)
  const options = instance.propsOptions[0]
  for (const key in options) {
    let opt = options[key]
    if (opt == null) continue
    validateProp(
      key,
      resolvedValues[key],
      opt,
      !hasOwn(rawProps, key) && !hasOwn(rawProps, hyphenate(key))
    )
  }
}

function validateProp(
  name: string,
  value: unknown,
  prop: PropOptions,
  isAbsent: boolean
) {
  const { type, required, validator, skipCheck } = prop
  // required!
  if (required && isAbsent) {
    warn('Missing required prop: "' + name + '"')
    return
  }
  // missing but optional
  if (value == null && !required) {
    return
  }
  // type check
  if (type != null && type !== true && !skipCheck) {
    let isValid = false
    const types = isArray(type) ? type : [type]
    const expectedTypes = []
    // value is valid as long as one of the specified types match
    for (let i = 0; i < types.length && !isValid; i++) {
      const { valid, expectedType } = assertType(value, types[i])
      expectedTypes.push(expectedType || '')
      isValid = valid
    }
    if (!isValid) {
      warn(getInvalidTypeMessage(name, value, expectedTypes))
      return
    }
  }
  // custom validator
  if (validator && !validator(value)) {
    warn('Invalid prop: custom validator check failed for prop "' + name + '".')
  }
}

validateProps方法对标准化后的 prop配置对象进行遍历,调用 validateProp进行验证。它校验了一下内容:

  1. 必填校验:如果 prop配置了 required为 true,那么必须给它传值,否则会发出警告;
  2. 类型校验:如果 prop配置的是数组类型,那么 prop的值只要匹配到了数组中的其中一个类型就是合法的,否则会发出警告;
  3. 自定义校验器 validator校验:如果配置了自定义校验器 validator,那么 prop的值必须满足自定义校验器的规则,否则会报警告。

2.3 props 的更新

props更新时,它的直接反应是会触发子组件的重新渲染。那么,它是如何触发组件的渲染呢?

2.3.1 触发子组件重新更新

组件的重新渲染会触发 patch流程,然后遍历子节点递归 patch,如果遇到组件,它会执行 updateComponent方法:

updateComponent 函数源码实现

 const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
    const instance = (n2.component = n1.component)!
    // 根据新旧子组件vnode判断是否需要更新子组件
    if (shouldUpdateComponent(n1, n2, optimized)) {
      if (
        __FEATURE_SUSPENSE__ &&
        instance.asyncDep &&
        !instance.asyncResolved
      ) {

        updateComponentPreRender(instance, n2, optimized)

        return
      } else {
        // normal update
        // 新的子组件vnode赋值给instance.next
        instance.next = n2
        // 子组件也可能因为数据变化被添加到更新队列里了,移除它们防止对一个子组件重复更新
        invalidateJob(instance.update)
        // instance.update is the reactive effect.
        // 执行子组件的副作用渲染函数
        instance.update()
      }
    } else {
      // 不需要更新,只赋值属性
      n2.el = n1.el
      instance.vnode = n2
    }
  }

首先,内部对比 props来判断是否需要更新子组件,如果需要更新,则将新的子组件 vnode赋值给 instance.next,然后执行 instance.update()触发子组件的重新渲染。

但是,虽然子组件重新渲染了,但是子组件实例 instance.props的数据需要更新才行,不然还是渲染之前的数据,那么是如何更新 instance.props 的呢?

2.3.2 更新 instance.props

执行子组件的 instance.update()函数,实际上执行的是 componentUpdateFn组件副作用渲染函数,

const setupRenderEffect = (
  instance,
  initialVNode,
  container,
  anchor,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 创建响应式的副作用渲染函数
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      // 渲染组件
    } else {
      // 更新组件
      let { next, vnode } = instance;
      // next 表示新的组件 vnode
      if (next) {
        // 更新组件 vnode 节点信息
        updateComponentPreRender(instance, next, optimized);
      } else {
        next = vnode;
      }
      // 渲染新的子树 vnode
      const nextTree = renderComponentRoot(instance);
      // 缓存旧的子树 vnode
      const prevTree = instance.subTree;
      // 更新子树 vnode
      instance.subTree = nextTree;
      // 组件更新核心逻辑,根据新旧子树 vnode 做 patch
      patch(
        prevTree,
        nextTree,
        // 如果在 teleport 组件中父节点可能已经改变,所以容器直接找旧树 DOM 元素的父节点
        hostParentNode(prevTree.el),
        // 参考节点在 fragment 的情况可能改变,所以直接找旧树 DOM 元素的下一个节点
        getNextHostNode(prevTree),
        instance,
        parentSuspense,
        isSVG
      );
      // 缓存更新后的 DOM 节点
      next.el = nextTree.el;
    }
  }, prodEffectOptions);
};

在更新组件的时候,会判断是否有 instance.next,它代表新的组件 vnode,根据前面的逻辑 next 不为空,所以会执行 updateComponentPreRender 更新组件 vnode 节点信息,我们来看一下它的实现:

const updateComponentPreRender = (instance, nextVNode, optimized) => {
  nextVNode.component = instance;
  const prevProps = instance.vnode.props;
  instance.vnode = nextVNode;
  instance.next = null;
  updateProps(instance, nextVNode.props, prevProps, optimized);
  updateSlots(instance, nextVNode.children);
};

上面源码中,updateProps 的主要目标是把父组件渲染时求得的 props 新值,更新到子组件的 instance.props 中。

到这里我们搞清楚了了子组件的 props 是如何更新的,接下来,看为什么需要把 instance.props 变成响应式?

2.3.3 把 instance.props 变成响应式

举一个 🌰:

import { ref, h, defineComponent, watchEffect } from "vue";
const count = ref(0);
let dummy;
const Parent = {
  render: () => h(Child, { count: count.value }),
};
const Child = defineComponent({
  props: { count: Number },
  setup(props) {
    watchEffect(() => {
      dummy = props.count;
    });
    return () => h("div", props.count);
  },
});
count.value++;

这里,我们定义了父组件 Parent 和子组件 Child,子组件 Child 中定义了 prop count,除了在渲染模板中引用了 count,我们在 setup 函数中通过了 watchEffect 注册了一个回调函数,内部依赖了 props.count,当修改 count.value 的时候,我们希望这个回调函数也能执行,所以这个 prop 的值需要是响应式的,由于 setup 函数的第一个参数是 props 变量,其实就是组件实例 instance.props,所以也就是要求 instance.props 是响应式的。

3、总结

本文分析了 props 的实现原理,总结如下:

  • props提供了父子组件数据传递的方式,它允许组件的使用者在外部传递 props,这样组件内部就可以根据这些 props实现各种业务功能了;
  • props允许用户传入各种类型的数据类型,因此,底层需要对这些 props做标准化处理;
  • props的初始化包括 props的求值、验证、响应式处理,当传入的 props数据发生变化时,会触发子组件的重新更新。

4、参考资料

[1]vue官网

[2]vuejs设计与实现

[3]vue3源码