📖 前因

今天在写代码的时候用到了 defineModel 宏。只知道他是用来定义双向绑定的Props的,但具体源码怎么实现的、又为什么这么做并不太了解,现在来看看研究研究。

📃 Vue3 官方文档

首先看看官方文档https://cn.vuejs.org/api/sfc-script-setup.html#definemodel

// 声明 "modelValue" prop,由父组件通过 v-model 使用
const model = defineModel()
// 或者:声明带选项的 "modelValue" prop
const model = defineModel({ type: String })

// 在被修改时,触发 "update:modelValue" 事件
model.value = "hello"

// 声明 "count" prop,由父组件通过 v-model:count 使用
const count = defineModel("count")
// 或者:声明带选项的 "count" prop
const count = defineModel("count", { type: Number, default: 0 })

function inc() {
  // 在被修改时,触发 "update:count" 事件
  count.value++
}

可以从中看出来, 父组件通过 Props 进行参数的传递。而子组件使用这个宏所定义的值,在子组件变更它时会触发其附带的事件,能一次性解决需要定义两个宏, definePropsdefineEmits 来监控一个属性的问题。

🔎 源码笔记 - 前置

OK,知道它用来做什么的还不够,先看看它底层怎么实现的:

首先,可以在处理AST树的源码中看出。毕竟宏只能在 setup 语法糖作用域内使用,因此只会在对应作用域中遍历判断。

https://github.com/vuejs/core/blob/main/packages/compiler-sfc/src/compileScript.ts#L518

...
// 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === 'ExpressionStatement') {
      const expr = unwrapTSNode(node.expression)
      // process `defineProps` and `defineEmit(s)` calls
      if (
        processDefineProps(ctx, expr) ||
        processDefineEmits(ctx, expr) ||
        processDefineOptions(ctx, expr) ||
        processDefineSlots(ctx, expr)
      ) {
        ctx.s.remove(node.start! + startOffset, node.end! + startOffset)
      } else if (processDefineExpose(ctx, expr)) {
        // defineExpose({}) -> expose({})
        const callee = (expr as CallExpression).callee
        ctx.s.overwrite(
          callee.start! + startOffset,
          callee.end! + startOffset,
          '__expose',
        )
      } else {
        processDefineModel(ctx, expr)
      }
    }
...

随后先通过 unwrapTSNode 函数将 TypeScript 的类型定义剥离,Vue 只专注处理逻辑而不干预 TypeScript 类型语法。

因为 TypeScript 的语法允许嵌套定义,因此会通过递归获取最里面的定义。

而使用 any 类型这是因为无法动态判断节点中是否存在对应属性。

https://github.com/vuejs/core/blob/main/packages/compiler-core/src/babelUtils.ts#L506

...
export const TS_NODE_TYPES: string[] = [
  'TSAsExpression', // foo as number
  'TSTypeAssertion', // (<number>foo)
  'TSNonNullExpression', // foo!
  'TSInstantiationExpression', // foo<string>
  'TSSatisfiesExpression', // foo satisfies T
]

export function unwrapTSNode(node: Node): Node {
  if (TS_NODE_TYPES.includes(node.type)) {
    return unwrapTSNode((node as any).expression)
  } else {
    return node
  }
}

举个例子:(count as number) + 1; 经过解析后会返回 count


在获得节点后,对其运行一遍 Props、Emits、Options、Slots 宏的相关解析。而因为有 isCallOf 函数的存在,可以使得快速判断。

通过 isCallOf 函数源码,可以看出其首先判断节点是一个函数调用表达式。随后判断调用者必须是标识符,而不是成员表达式。最后判断函数名是否相等。在这个文章中其主要判断的是 defineModel 之类的编译器宏。

使用双重否定 !! 来确保返回的一定是布尔值,而判断调用者这是为了限制使用场景。

https://github.com/vuejs/core/blob/main/packages/compiler-sfc/src/script/utils.ts#L39

...
export function isCallOf(
  node: Node | null | undefined,
  test: string | ((id: string) => boolean) | null | undefined,
): node is CallExpression {
  return !!(
    node &&
    test &&
    node.type === 'CallExpression' &&
    node.callee.type === 'Identifier' &&
    (typeof test === 'string'
      ? node.callee.name === test
      : test(node.callee.name))
  )
}
...

举个例子:

if (isCallOf(node, 'console.log')) {
  // 处理所有console.log调用
}

最后判断到我们这篇文章的重点 defineModel 宏。首先将上下文的 hasDefineModelCall 定义为 true 表示该函数节点已经被接管处理,随后在上下文的 modelDecls 模型声明中查找是否有重名的定义,如有则报错防止重复声明。然后遍历属性,将 getset 属性剥离防止重复。

随后定义上下文的 modelDecls 属性,记录类型、选项等信息,等待后续生成 Props 使用。并且通过 overwrite 方法,将 defineModel 重写成 useModel 实现注入函数,等待后续执行。最后注入其他参数等待后续生成使用。

https://github.com/vuejs/core/blob/main/packages/compiler-sfc/src/script/defineModel.ts

...
export function processDefineModel(
  ctx: ScriptCompileContext,
  node: Node,
  declId?: LVal,
): boolean {
  if (!isCallOf(node, DEFINE_MODEL)) {
    return false
  }

  ctx.hasDefineModelCall = true

  const type =
    (node.typeParameters && node.typeParameters.params[0]) || undefined
  let modelName: string
  let options: Node | undefined
  const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
  const hasName = arg0 && arg0.type === 'StringLiteral'
  if (hasName) {
    modelName = arg0.value
    options = node.arguments[1]
  } else {
    modelName = 'modelValue'
    options = arg0
  }

  if (ctx.modelDecls[modelName]) {
    ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node)
  }

  let optionsString = options && ctx.getString(options)
  let optionsRemoved = !options
  const runtimeOptionNodes: Node[] = []

  if (
    options &&
    options.type === 'ObjectExpression' &&
    !options.properties.some(p => p.type === 'SpreadElement' || p.computed)
  ) {
    let removed = 0
    for (let i = options.properties.length - 1; i = 0; i--) {
      const p = options.properties[i]
      const next = options.properties[i + 1]
      const start = p.start!
      const end = next ? next.start! : options.end! - 1
      if (
        (p.type === 'ObjectProperty' || p.type === 'ObjectMethod') &&
        ((p.key.type === 'Identifier' &&
          (p.key.name === 'get' || p.key.name === 'set')) ||
          (p.key.type === 'StringLiteral' &&
            (p.key.value === 'get' || p.key.value === 'set')))
      ) {
        // remove runtime-only options from prop options to avoid duplicates
        optionsString =
          optionsString.slice(0, start - options.start!) +
          optionsString.slice(end - options.start!)
      } else {
        // remove prop options from runtime options
        removed++
        ctx.s.remove(ctx.startOffset! + start, ctx.startOffset! + end)
        // record prop options for invalid scope var reference check
        runtimeOptionNodes.push(p)
      }
    }
    if (removed === options.properties.length) {
      optionsRemoved = true
      ctx.s.remove(
        ctx.startOffset! + (hasName ? arg0.end! : options.start!),
        ctx.startOffset! + options.end!,
      )
    }
  }

  ctx.modelDecls[modelName] = {
    type,
    options: optionsString,
    runtimeOptionNodes,
    identifier:
      declId && declId.type === 'Identifier' ? declId.name : undefined,
  }
  // register binding type
  ctx.bindingMetadata[modelName] = BindingTypes.PROPS

  // defineModel -> useModel
  ctx.s.overwrite(
    ctx.startOffset! + node.callee.start!,
    ctx.startOffset! + node.callee.end!,
    ctx.helper('useModel'),
  )
  // inject arguments
  ctx.s.appendLeft(
    ctx.startOffset! +
      (node.arguments.length ? node.arguments[0].start! : node.end! - 1),
    `__props, ` +
      (hasName
        ? ``
        : `${JSON.stringify(modelName)}${optionsRemoved ? `` : `, `}`),
  )

  return true
}
...

在经过前置处理后,genModelProps 函数会获取节点并开始生成 Props 代码。首先通过判断上下文中的 hasDefineModelCall 属性,确保已经经过前置处理。随后通过判断环境进行新增或减少检查代码,最后通过动态拼接字符串达到生成 Props 代码的目的。

https://github.com/vuejs/core/blob/main/packages/compiler-sfc/src/script/defineModel.ts

...
export function genModelProps(ctx: ScriptCompileContext) {
  if (!ctx.hasDefineModelCall) return

  const isProd = !!ctx.options.isProd
  let modelPropsDecl = ''
  for (const [name, { type, options: runtimeOptions }] of Object.entries(
    ctx.modelDecls,
  )) {
    let skipCheck = false
    let codegenOptions = ``
    let runtimeTypes = type && inferRuntimeType(ctx, type)
    if (runtimeTypes) {
      const hasBoolean = runtimeTypes.includes('Boolean')
      const hasFunction = runtimeTypes.includes('Function')
      const hasUnknownType = runtimeTypes.includes(UNKNOWN_TYPE)

      if (hasUnknownType) {
        if (hasBoolean || hasFunction) {
          runtimeTypes = runtimeTypes.filter(t => t !== UNKNOWN_TYPE)
          skipCheck = true
        } else {
          runtimeTypes = ['null']
        }
      }

      if (!isProd) {
        codegenOptions =
          `type: ${toRuntimeTypeString(runtimeTypes)}` +
          (skipCheck ? ', skipCheck: true' : '')
      } else if (hasBoolean || (runtimeOptions && hasFunction)) {
        // preserve types if contains boolean, or
        // function w/ runtime options that may contain default
        codegenOptions = `type: ${toRuntimeTypeString(runtimeTypes)}`
      } else {
        // able to drop types in production
      }
    }

    let decl: string
    if (codegenOptions && runtimeOptions) {
      decl = ctx.isTS
        ? `{ ${codegenOptions}, ...${runtimeOptions} }`
        : `Object.assign({ ${codegenOptions} }, ${runtimeOptions})`
    } else if (codegenOptions) {
      decl = `{ ${codegenOptions} }`
    } else if (runtimeOptions) {
      decl = runtimeOptions
    } else {
      decl = `{}`
    }
    modelPropsDecl += `\n    ${JSON.stringify(name)}: ${decl},`

    // also generate modifiers prop
    const modifierPropName = JSON.stringify(
      name === 'modelValue' ? `modelModifiers` : `${name}Modifiers`,
    )
    modelPropsDecl += `\n    ${modifierPropName}: {},`
  }
  return `{${modelPropsDecl}\n  }`
}

📑 源码笔记 - 核心

看完从哪开始调用的,为什么会调用到这里。接下来就是看看它是怎么实现双向数据绑定的了。

首先获取组件上下文,并确保不会使用到未初始化的 Props 。随后使用 camelizehyphenate 函数对命名规范化,使用 getModelModifiers 函数获取修饰符。最后则是核心部分,通过 customRef 函数创建一个双向数据代理。

https://github.com/vuejs/core/blob/main/packages/runtime-core/src/helpers/useModel.ts

...
export function useModel(
  props: Record<string, any>,
  name: string,
  options: DefineModelOptions = EMPTY_OBJ,
): Ref {
  const i = getCurrentInstance()!
  if (__DEV__ && !i) {
    warn(`useModel() called without active instance.`)
    return ref() as any
  }

  const camelizedName = camelize(name)
  if (__DEV__ && !(i.propsOptions[0] as NormalizedProps)[camelizedName]) {
    warn(`useModel() called with prop "${name}" which is not declared.`)
    return ref() as any
  }

  const hyphenatedName = hyphenate(name)
  const modifiers = getModelModifiers(props, camelizedName)

  const res = customRef((track, trigger) => {
    let localValue: any
    let prevSetValue: any = EMPTY_OBJ
    let prevEmittedValue: any

    watchSyncEffect(() => {
      const propValue = props[camelizedName]
      if (hasChanged(localValue, propValue)) {
        localValue = propValue
        trigger()
      }
    })

    return {
      get() {
        track()
        return options.get ? options.get(localValue) : localValue
      },

      set(value) {
        const emittedValue = options.set ? options.set(value) : value
        if (
          !hasChanged(emittedValue, localValue) &&
          !(prevSetValue !== EMPTY_OBJ && hasChanged(value, prevSetValue))
        ) {
          return
        }
        const rawProps = i.vnode!.props
        if (
          !(
            rawProps &&
            // check if parent has passed v-model
            (name in rawProps ||
              camelizedName in rawProps ||
              hyphenatedName in rawProps) &&
            (`onUpdate:${name}` in rawProps ||
              `onUpdate:${camelizedName}` in rawProps ||
              `onUpdate:${hyphenatedName}` in rawProps)
          )
        ) {
          // no v-model, local update
          localValue = value
          trigger()
        }

        i.emit(`update:${name}`, emittedValue)
        // #10279: if the local value is converted via a setter but the value
        // emitted to parent was the same, the parent will not trigger any
        // updates and there will be no prop sync. However the local input state
        // may be out of sync, so we need to force an update here.
        if (
          hasChanged(value, emittedValue) &&
          hasChanged(value, prevSetValue) &&
          !hasChanged(emittedValue, prevEmittedValue)
        ) {
          trigger()
        }
        prevSetValue = value
        prevEmittedValue = emittedValue
      },
    }
  })

  // @ts-expect-error
  res[Symbol.iterator] = () => {
    let i = 0
    return {
      next() {
        if (i < 2) {
          return { value: i++ ? modifiers || EMPTY_OBJ : res, done: false }
        } else {
          return { done: true }
        }
      },
    }
  }

  return res
}
...

通过 watchSyncEffect 函数监听父组件 Props 变化确保能立即同步。并且通过 hasChanged 防止出现不必要的触发。

而最后通过 getModelModifiers 函数自动注入修饰符对象。

https://github.com/vuejs/core/blob/main/packages/runtime-core/src/helpers/useModel.ts

export const getModelModifiers = (
  props: Record<string, any>,
  modelName: string,
): Record<string, boolean> | undefined => {
  return modelName === 'modelValue' || modelName === 'model-value'
    ? props.modelModifiers
    : props[`${modelName}Modifiers`] ||
        props[`${camelize(modelName)}Modifiers`] ||
        props[`${hyphenate(modelName)}Modifiers`]
}

📌 总结

看完以上源码,感觉又学到了很多东西。能发自内心的确认 Vue3 框架的基底就是响应式套响应式(这个结论估计是不对的)至少到现在能明白它这个宏,大致上是怎么做到的了。当然前提是修完计算机专业的主修课程,不然也是看的一知半解。