📖 前因
今天在写代码的时候用到了 defineModel 宏。只知道他是用来定义双向绑定的Props的,但具体源码怎么实现的、又为什么这么做并不太了解,现在来看看研究研究。
📃 Vue3 官方文档
首先看看官方文档
// 声明 "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 进行参数的传递。而子组件使用这个宏所定义的值,在子组件变更它时会触发其附带的事件,能一次性解决需要定义两个宏, defineProps 和 defineEmits 来监控一个属性的问题。
🔎 源码笔记 - 前置
OK,知道它用来做什么的还不够,先看看它底层怎么实现的:
首先,可以在处理AST树的源码中看出。毕竟宏只能在 setup 语法糖作用域内使用,因此只会在对应作用域中遍历判断。
...
// 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 类型这是因为无法动态判断节点中是否存在对应属性。
...
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 之类的编译器宏。
使用双重否定 !!
来确保返回的一定是布尔值,而判断调用者这是为了限制使用场景。
...
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 模型声明中查找是否有重名的定义,如有则报错防止重复声明。然后遍历属性,将 get 和 set 属性剥离防止重复。
随后定义上下文的 modelDecls 属性,记录类型、选项等信息,等待后续生成 Props 使用。并且通过 overwrite 方法,将 defineModel 重写成 useModel 实现注入函数,等待后续执行。最后注入其他参数等待后续生成使用。
...
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 代码的目的。
...
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 。随后使用 camelize 和 hyphenate 函数对命名规范化,使用 getModelModifiers 函数获取修饰符。最后则是核心部分,通过 customRef 函数创建一个双向数据代理。
...
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 函数自动注入修饰符对象。
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 框架的基底就是响应式套响应式(这个结论估计是不对的)至少到现在能明白它这个宏,大致上是怎么做到的了。当然前提是修完计算机专业的主修课程,不然也是看的一知半解。
评论区