Vue 3.4 特性:自定义双向绑定的语法糖,defineModel
defineModel
🐱🐱🐱随着 Vue 3.4 的更新,defineModel
API 也正式加入了。它可以简化组件间双向绑定的操作,在自定义表单类组件中非常有用。
defineModel 以前的自定义双向绑定
defineModel
可以看成是通过修改props
、emits
、事件监听或者watch
实现自定义v-model
双向绑定的语法糖。以前没有defineModel
的时候,我们需要这样子:
ts- // child
- <script setup lang="ts">
- import { ref, watch } from 'vue';
- const props = defineProps({
- modelValue: {
- default: 0
- }
- })
- const emits = defineEmits(['update:modelValue'])
- const modelValue = ref(props.modelValue)
- watch(() => props.modelValue, (val) => {
- modelValue.value = val
- })
- watch(modelValue, (val) => {
- emits('update:modelValue', val)
- })
- </script>
- <template>
- <div>
- <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
- </div>
- </template>
引用子组件,使用v-model
进行双向绑定。
ts- // parent
- <script setup lang="ts">
- import { ref } from 'vue'
- import Child from './child.vue';
- const count = ref(0)
- </script>
- <template>
- <button @click="count++">count</button>
- <Child v-model="count"></Child>
- </template>
defineModel 自定义双向绑定
在defineModel
下,我们在子组件自定义双向绑定只需要这样子:
ts- <script setup lang="ts">
- const modelValue = defineModel({
- default: 0
- })
- </script>
- <template>
- <div>
- <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
- </div>
- </template>
而且defineModel
还支持v-model
添加修饰符:
ts- // child
- <script setup lang="ts">
- const [modelValue, modifiers] = defineModel({
- default: 0,
- set (value) {
- // 如果有 v-model.notLessThan0 则...
- if (modifiers.notLessThan0) {
- return Math.max(value, 0)
- }
- // 返回原来的值
- return value
- }
- })
- </script>
- <template>
- <div>
- <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
- </div>
- </template>
modifiers
是v-model
接受的修饰符,它是这样子的数据结构:{ 修饰符名: true }
,配合set
选项,可以根据修饰符来对来自亲组件的赋值进行调整。
ts- // parent
- <script setup lang="ts">
- import { ref } from 'vue'
- import Child from './child.vue';
- const count = ref(0)
- </script>
- <template>
- <button @click="count++">count</button>
- <Child v-model.notLessThan0="count"></Child>
- </template>
这里给子组件的v-model
设置了notLessThan0
修饰符,进入上面子组件defineModel
的set
选项逻辑。
defineModel 原理
defineXxx
系列的函数,本质上是在<script setup>
中,Vue 的宏,要看原理,那先看它被编译成了什么。举个栗子🌰:
ts- <script setup lang="ts">
- const modelValue = defineModel({
- default: 0
- })
- </script>
- <template>
- <div>
- <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
- </div>
- </template>
编译的结果:
ts- const _sfc_main$2 = /* @__PURE__ */ defineComponent({
- __name: "child",
- props: {
- "modelValue": {
- default: 0
- },
- "modelModifiers": {}
- },
- emits: ["update:modelValue"],
- setup(__props) {
- const modelValue = useModel(__props, "modelValue");
- return (_ctx, _cache) => {
- return openBlock(), createElementBlock("div", null, [
- createBaseVNode("button", {
- type: "button",
- onClick: _cache[0] || (_cache[0] = ($event) => modelValue.value++)
- }, "count is " + toDisplayString(modelValue.value), 1)
- ]);
- };
- }
- });
- const _export_sfc = (sfc, props) => {
- const target = sfc.__vccOpts || sfc;
- for (const [key, val] of props) {
- target[key] = val;
- }
- return target;
- };
- const Child = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-bb686a29"]]);
_sfc_main$2
中,自动添加了双向绑定的props
、emits
,以及调用了useModel
函数。modelModifiers
,其实就是往v-model
命令中添加的修饰符,例如v-model.trim
,此外,如果双向绑定的变量叫其他名字,例如v-model:test
,对应地,修饰符的props
属性名变成testModifiers
。
useModel
defineModel
被编译成useModel
,下面看一下useModel
的逻辑。
ts- export function useModel(
- props: Record<string, any>,
- name: string,
- options: DefineModelOptions = EMPTY_OBJ,
- ): Ref {
- const i = getCurrentInstance()!
- const camelizedName = camelize(name)
- const hyphenatedName = hyphenate(name)
- const res = customRef((track, trigger) => {
- let localValue: any
- watchSyncEffect(() => {
- const propValue = props[name]
- if (hasChanged(localValue, propValue)) {
- localValue = propValue
- trigger()
- }
- })
- return {
- get() {
- track()
- return options.get ? options.get(localValue) : localValue
- },
- set(value) {
- 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)
- ) &&
- hasChanged(value, localValue)
- ) {
- localValue = value
- trigger()
- }
- i.emit(`update:${name}`, options.set ? options.set(value) : value)
- },
- }
- })
- const modifierKey =
- name === 'modelValue' ? 'modelModifiers' : `${name}Modifiers`
- // @ts-expect-error
- res[Symbol.iterator] = () => {
- let i = 0
- return {
- next() {
- if (i < 2) {
- return { value: i++ ? props[modifierKey] || {} : res, done: false }
- } else {
- return { done: true }
- }
- },
- }
- }
- return res
- }
先来看customRef
,这个是强化版的ref
允许用户增强get
、set
方法,以及自定义value
的处理。
ts- export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
- return new CustomRefImpl(factory) as any
- }
- class CustomRefImpl<T> {
- public dep?: Dep = undefined
- private readonly _get: ReturnType<CustomRefFactory<T>>['get']
- private readonly _set: ReturnType<CustomRefFactory<T>>['set']
- public readonly __v_isRef = true
- constructor(factory: CustomRefFactory<T>) {
- const { get, set } = factory(
- () => trackRefValue(this),
- () => triggerRefValue(this),
- )
- this._get = get
- this._set = set
- }
- get value() {
- return this._get()
- }
- set value(newVal) {
- this._set(newVal)
- }
- }
trackRefValue
和triggerRefValue
是基本上就是ref
那一套收集、触发依赖的方法,这里就不展开了(Vue 3.4 也对它的响应式进行了迭代,大家感兴趣的话后面再说)。这个CustomRefImpl
给useModel
中的入参传入了trackRefValue
和triggerRefValue
,这就意味着useModel
也实现了 Vue 的响应式。在get
的时候收集依赖,在set
的时候触发依赖。
useModel
定义的customRef
res
中使用localValue
作为组件自身的状态。使用watchSyncEffect
监听props
中绑定的变量的改变,去同步修改组件的状态,并且触发响应式依赖。watchSyncEffect
是一个同步的watchEffect
,它可以自动监听回调函数用到的所有响应式变量,随后触发回调函数。
res
的set
方法可以触发onUpdate:xxx
事件实现了子组件状态同步到亲组件的过程。
最后useModel
赋值了一个res[Symbol.iterator]
,在解构赋值的时候类似于一个[res, props[modifierKey]]
的数组,实现了返回单个变量和返回变量和修饰符两种形式的返回格式。见文档,可以const model = defineModel()
,也可以const [modelValue, modelModifiers] = defineModel()
。
setup 函数编译
代码转换、为代码块加上emits
和props
是在模板编译中实现的。
转换为 useModel
在 packages/compiler-sfc/src/compileScript.ts,compileScript
函数中有:
ts- 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)
- }
- }
这里的node
是<script setup>
模板中的 JS/TS 代码 AST 节点,ctx
是转换代码的上下文,这里就不展开了。processDefineModel
实现了defineModel
到useModel
的替换:
ts- export function processDefineModel(
- ctx: ScriptCompileContext,
- node: Node,
- declId?: LVal,
- ): boolean {
- // ...
- ctx.hasDefineModelCall = true
- // ...
-
- ctx.modelDecls[modelName] = {
- type,
- options: optionsString,
- runtimeOptionNodes,
- identifier:
- declId && declId.type === 'Identifier' ? declId.name : undefined,
- }
- // ...
- }
这里的modelDecls
记录了defineModel
涉及的props
,后面处理props
的时候会用到。
ts- function processDefineModel(
- ctx: ScriptCompileContext,
- node: Node,
- declId?: LVal,
- ) {
- // ...
- // 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
- }
ctx.helper('useModel')
就是插入_useModel
(这里可以和 Vite 的编译有关系,上面的编译结果是插入了useModel
)。ctx.s.appendLeft
这一段代码自然是插入useModel
的参数了。从而实现了从
ts- const modelValue = defineModel({
- default: 0
- })
到
ts- const modelValue = useModel(__props, "modelValue")
的转换。
添加 props
complieScript
调用genRuntimeProps
:
ts- const propsDecl = genRuntimeProps(ctx)
- if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`
genRuntimeProps
中合并defineModel
产生的props
:
ts- genRuntimeProps(
- // ...
- ) {
- // ...
- const modelsDecls = genModelProps(ctx)
- if (propsDecls && modelsDecls) {
- return `/*#__PURE__*/${ctx.helper(
- 'mergeModels',
- )}(${propsDecls}, ${modelsDecls})`
- } else {
- return modelsDecls || propsDecls
- }
- }
- export function genModelProps(ctx: ScriptCompileContext) {
- if (!ctx.hasDefineModelCall) return
- const isProd = !!ctx.options.isProd
- let modelPropsDecl = ''
- for (const [name, { type, options }] of Object.entries(ctx.modelDecls)) {
- // ...
- // codegenOptions 和 runtimeType 是 vue 编译时产生的 TS 类型映射到 Vue Props 类型的相关内容,不用管它
- // options 是给 defineModel 传入的 props 属性
- let decl: string
- if (runtimeType && options) {
- decl = ctx.isTS
- ? `{ ${codegenOptions}, ...${options} }`
- : `Object.assign({ ${codegenOptions} }, ${options})`
- } else {
- decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
- }
- 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 }`
- }
processDefineModel
标记了ctx.hasDefineModelCall = true
,在这里记录的ctx.modelDecls
,在genModelProps
被合并到props
中去。
添加 emits
complieScript
调用genRuntimeProps
:
ts- const emitsDecl = genRuntimeEmits(ctx)
- if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`
genRuntimeEmits
:
ts- export function genRuntimeEmits(ctx: ScriptCompileContext): string | undefined {
- let emitsDecl = ''
- //...
-
- if (ctx.hasDefineModelCall) {
- let modelEmitsDecl = `[${Object.keys(ctx.modelDecls)
- .map(n => JSON.stringify(`update:${n}`))
- .join(', ')}]`
- emitsDecl = emitsDecl
- ? `/*#__PURE__*/${ctx.helper(
- 'mergeModels',
- )}(${emitsDecl}, ${modelEmitsDecl})`
- : modelEmitsDecl
- }
- return emitsDecl
- }
processDefineModel
标记了ctx.hasDefineModelCall = true
,genRuntimeEmits
中合并emits
选项。
结语
本文介绍了 Vue 3.3 的特性defineModel
,并且对其编译过程与结果进行简介。
defineModel
是 Vue 3.4 转正的 API,极大简化了自定义双向绑定的处理。它使用useModel
定义的customRef
,利用 Vue 的响应式,完成来自上层组件的数据同步以及发起update:Xxx
事件。
另外,setup
的代码编译我不太熟,这里没有进行深入介绍。