coverPiccoverPic

Vue 3.4 特性:自定义双向绑定的语法糖,defineModel

defineModel

🐱🐱🐱随着 Vue 3.4 的更新,defineModel API 也正式加入了。它可以简化组件间双向绑定的操作,在自定义表单类组件中非常有用。

defineModel 以前的自定义双向绑定

defineModel可以看成是通过修改propsemits、事件监听或者watch实现自定义v-model双向绑定的语法糖。以前没有defineModel的时候,我们需要这样子:

ts
  1. // child
  2. <script setup lang="ts">
  3. import { ref, watch } from 'vue';
  4. const props = defineProps({
  5. modelValue: {
  6. default: 0
  7. }
  8. })
  9. const emits = defineEmits(['update:modelValue'])
  10. const modelValue = ref(props.modelValue)
  11. watch(() => props.modelValue, (val) => {
  12. modelValue.value = val
  13. })
  14. watch(modelValue, (val) => {
  15. emits('update:modelValue', val)
  16. })
  17. </script>
  18. <template>
  19. <div>
  20. <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
  21. </div>
  22. </template>

引用子组件,使用v-model进行双向绑定。

ts
  1. // parent
  2. <script setup lang="ts">
  3. import { ref } from 'vue'
  4. import Child from './child.vue';
  5. const count = ref(0)
  6. </script>
  7. <template>
  8. <button @click="count++">count</button>
  9. <Child v-model="count"></Child>
  10. </template>

defineModel 自定义双向绑定

defineModel下,我们在子组件自定义双向绑定只需要这样子:

ts
  1. <script setup lang="ts">
  2. const modelValue = defineModel({
  3. default: 0
  4. })
  5. </script>
  6. <template>
  7. <div>
  8. <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
  9. </div>
  10. </template>

而且defineModel还支持v-model添加修饰符:

ts
  1. // child
  2. <script setup lang="ts">
  3. const [modelValue, modifiers] = defineModel({
  4. default: 0,
  5. set (value) {
  6. // 如果有 v-model.notLessThan0 则...
  7. if (modifiers.notLessThan0) {
  8. return Math.max(value, 0)
  9. }
  10. // 返回原来的值
  11. return value
  12. }
  13. })
  14. </script>
  15. <template>
  16. <div>
  17. <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
  18. </div>
  19. </template>

modifiersv-model接受的修饰符,它是这样子的数据结构:{ 修饰符名: true },配合set选项,可以根据修饰符来对来自亲组件的赋值进行调整。

ts
  1. // parent
  2. <script setup lang="ts">
  3. import { ref } from 'vue'
  4. import Child from './child.vue';
  5. const count = ref(0)
  6. </script>
  7. <template>
  8. <button @click="count++">count</button>
  9. <Child v-model.notLessThan0="count"></Child>
  10. </template>

这里给子组件的v-model设置了notLessThan0修饰符,进入上面子组件defineModelset选项逻辑。

defineModel 原理

defineXxx系列的函数,本质上是在<script setup>中,Vue 的宏,要看原理,那先看它被编译成了什么。举个栗子🌰:

ts
  1. <script setup lang="ts">
  2. const modelValue = defineModel({
  3. default: 0
  4. })
  5. </script>
  6. <template>
  7. <div>
  8. <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
  9. </div>
  10. </template>

编译的结果:

ts
  1. const _sfc_main$2 = /* @__PURE__ */ defineComponent({
  2. __name: "child",
  3. props: {
  4. "modelValue": {
  5. default: 0
  6. },
  7. "modelModifiers": {}
  8. },
  9. emits: ["update:modelValue"],
  10. setup(__props) {
  11. const modelValue = useModel(__props, "modelValue");
  12. return (_ctx, _cache) => {
  13. return openBlock(), createElementBlock("div", null, [
  14. createBaseVNode("button", {
  15. type: "button",
  16. onClick: _cache[0] || (_cache[0] = ($event) => modelValue.value++)
  17. }, "count is " + toDisplayString(modelValue.value), 1)
  18. ]);
  19. };
  20. }
  21. });
  22. const _export_sfc = (sfc, props) => {
  23. const target = sfc.__vccOpts || sfc;
  24. for (const [key, val] of props) {
  25. target[key] = val;
  26. }
  27. return target;
  28. };
  29. const Child = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-bb686a29"]]);

_sfc_main$2中,自动添加了双向绑定的propsemits,以及调用了useModel函数。modelModifiers,其实就是往v-model命令中添加的修饰符,例如v-model.trim,此外,如果双向绑定的变量叫其他名字,例如v-model:test,对应地,修饰符的props属性名变成testModifiers

useModel

defineModel被编译成useModel,下面看一下useModel的逻辑。

ts
  1. export function useModel(
  2. props: Record<string, any>,
  3. name: string,
  4. options: DefineModelOptions = EMPTY_OBJ,
  5. ): Ref {
  6. const i = getCurrentInstance()!
  7. const camelizedName = camelize(name)
  8. const hyphenatedName = hyphenate(name)
  9. const res = customRef((track, trigger) => {
  10. let localValue: any
  11. watchSyncEffect(() => {
  12. const propValue = props[name]
  13. if (hasChanged(localValue, propValue)) {
  14. localValue = propValue
  15. trigger()
  16. }
  17. })
  18. return {
  19. get() {
  20. track()
  21. return options.get ? options.get(localValue) : localValue
  22. },
  23. set(value) {
  24. const rawProps = i.vnode!.props
  25. if (
  26. !(
  27. rawProps &&
  28. // check if parent has passed v-model
  29. (name in rawProps ||
  30. camelizedName in rawProps ||
  31. hyphenatedName in rawProps) &&
  32. (`onUpdate:${name}` in rawProps ||
  33. `onUpdate:${camelizedName}` in rawProps ||
  34. `onUpdate:${hyphenatedName}` in rawProps)
  35. ) &&
  36. hasChanged(value, localValue)
  37. ) {
  38. localValue = value
  39. trigger()
  40. }
  41. i.emit(`update:${name}`, options.set ? options.set(value) : value)
  42. },
  43. }
  44. })
  45. const modifierKey =
  46. name === 'modelValue' ? 'modelModifiers' : `${name}Modifiers`
  47. // @ts-expect-error
  48. res[Symbol.iterator] = () => {
  49. let i = 0
  50. return {
  51. next() {
  52. if (i < 2) {
  53. return { value: i++ ? props[modifierKey] || {} : res, done: false }
  54. } else {
  55. return { done: true }
  56. }
  57. },
  58. }
  59. }
  60. return res
  61. }

先来看customRef,这个是强化版的ref允许用户增强getset方法,以及自定义value的处理。

ts
  1. export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
  2. return new CustomRefImpl(factory) as any
  3. }
  4. class CustomRefImpl<T> {
  5. public dep?: Dep = undefined
  6. private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  7. private readonly _set: ReturnType<CustomRefFactory<T>>['set']
  8. public readonly __v_isRef = true
  9. constructor(factory: CustomRefFactory<T>) {
  10. const { get, set } = factory(
  11. () => trackRefValue(this),
  12. () => triggerRefValue(this),
  13. )
  14. this._get = get
  15. this._set = set
  16. }
  17. get value() {
  18. return this._get()
  19. }
  20. set value(newVal) {
  21. this._set(newVal)
  22. }
  23. }

trackRefValuetriggerRefValue是基本上就是ref那一套收集、触发依赖的方法,这里就不展开了(Vue 3.4 也对它的响应式进行了迭代,大家感兴趣的话后面再说)。这个CustomRefImpluseModel中的入参传入了trackRefValuetriggerRefValue,这就意味着useModel也实现了 Vue 的响应式。在get的时候收集依赖,在set的时候触发依赖。

useModel定义的customRef res中使用localValue作为组件自身的状态。使用watchSyncEffect监听props中绑定的变量的改变,去同步修改组件的状态,并且触发响应式依赖。watchSyncEffect是一个同步的watchEffect,它可以自动监听回调函数用到的所有响应式变量,随后触发回调函数。

resset方法可以触发onUpdate:xxx事件实现了子组件状态同步到亲组件的过程。

最后useModel赋值了一个res[Symbol.iterator],在解构赋值的时候类似于一个[res, props[modifierKey]]的数组,实现了返回单个变量和返回变量和修饰符两种形式的返回格式。见文档,可以const model = defineModel(),也可以const [modelValue, modelModifiers] = defineModel()

setup 函数编译

代码转换、为代码块加上emitsprops是在模板编译中实现的。

转换为 useModel

在 packages/compiler-sfc/src/compileScript.ts,compileScript函数中有:

ts
  1. if (node.type === 'ExpressionStatement') {
  2. const expr = unwrapTSNode(node.expression)
  3. // process `defineProps` and `defineEmit(s)` calls
  4. if (
  5. processDefineProps(ctx, expr) ||
  6. processDefineEmits(ctx, expr) ||
  7. processDefineOptions(ctx, expr) ||
  8. processDefineSlots(ctx, expr)
  9. ) {
  10. ctx.s.remove(node.start! + startOffset, node.end! + startOffset)
  11. } else if (processDefineExpose(ctx, expr)) {
  12. // defineExpose({}) -> expose({})
  13. const callee = (expr as CallExpression).callee
  14. ctx.s.overwrite(
  15. callee.start! + startOffset,
  16. callee.end! + startOffset,
  17. '__expose',
  18. )
  19. } else {
  20. processDefineModel(ctx, expr)
  21. }
  22. }

这里的node<script setup>模板中的 JS/TS 代码 AST 节点,ctx是转换代码的上下文,这里就不展开了。processDefineModel实现了defineModeluseModel的替换:

ts
  1. export function processDefineModel(
  2. ctx: ScriptCompileContext,
  3. node: Node,
  4. declId?: LVal,
  5. ): boolean {
  6. // ...
  7. ctx.hasDefineModelCall = true
  8. // ...
  9. ctx.modelDecls[modelName] = {
  10. type,
  11. options: optionsString,
  12. runtimeOptionNodes,
  13. identifier:
  14. declId && declId.type === 'Identifier' ? declId.name : undefined,
  15. }
  16. // ...
  17. }

这里的modelDecls记录了defineModel涉及的props,后面处理props的时候会用到。

ts
  1. function processDefineModel(
  2. ctx: ScriptCompileContext,
  3. node: Node,
  4. declId?: LVal,
  5. ) {
  6. // ...
  7. // defineModel -> useModel
  8. ctx.s.overwrite(
  9. ctx.startOffset! + node.callee.start!,
  10. ctx.startOffset! + node.callee.end!,
  11. ctx.helper('useModel'),
  12. )
  13. // inject arguments
  14. ctx.s.appendLeft(
  15. ctx.startOffset! +
  16. (node.arguments.length ? node.arguments[0].start! : node.end! - 1),
  17. `__props, ` +
  18. (hasName
  19. ? ``
  20. : `${JSON.stringify(modelName)}${optionsRemoved ? `` : `, `}`),
  21. )
  22. return true
  23. }

ctx.helper('useModel')就是插入_useModel(这里可以和 Vite 的编译有关系,上面的编译结果是插入了useModel)。ctx.s.appendLeft这一段代码自然是插入useModel的参数了。从而实现了从

ts
  1. const modelValue = defineModel({
  2. default: 0
  3. })

ts
  1. const modelValue = useModel(__props, "modelValue")

的转换。

添加 props

complieScript调用genRuntimeProps

ts
  1. const propsDecl = genRuntimeProps(ctx)
  2. if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`

genRuntimeProps中合并defineModel产生的props

ts
  1. genRuntimeProps(
  2. // ...
  3. ) {
  4. // ...
  5. const modelsDecls = genModelProps(ctx)
  6. if (propsDecls && modelsDecls) {
  7. return `/*#__PURE__*/${ctx.helper(
  8. 'mergeModels',
  9. )}(${propsDecls}, ${modelsDecls})`
  10. } else {
  11. return modelsDecls || propsDecls
  12. }
  13. }
  14. export function genModelProps(ctx: ScriptCompileContext) {
  15. if (!ctx.hasDefineModelCall) return
  16. const isProd = !!ctx.options.isProd
  17. let modelPropsDecl = ''
  18. for (const [name, { type, options }] of Object.entries(ctx.modelDecls)) {
  19. // ...
  20. // codegenOptions 和 runtimeType 是 vue 编译时产生的 TS 类型映射到 Vue Props 类型的相关内容,不用管它
  21. // options 是给 defineModel 传入的 props 属性
  22. let decl: string
  23. if (runtimeType && options) {
  24. decl = ctx.isTS
  25. ? `{ ${codegenOptions}, ...${options} }`
  26. : `Object.assign({ ${codegenOptions} }, ${options})`
  27. } else {
  28. decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
  29. }
  30. modelPropsDecl += `\n ${JSON.stringify(name)}: ${decl},`
  31. // also generate modifiers prop
  32. const modifierPropName = JSON.stringify(
  33. name === 'modelValue' ? `modelModifiers` : `${name}Modifiers`,
  34. )
  35. modelPropsDecl += `\n ${modifierPropName}: {},`
  36. }
  37. return `{${modelPropsDecl}\n }`
  38. }

processDefineModel标记了ctx.hasDefineModelCall = true,在这里记录的ctx.modelDecls,在genModelProps被合并到props中去。

添加 emits

complieScript调用genRuntimeProps

ts
  1. const emitsDecl = genRuntimeEmits(ctx)
  2. if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`

genRuntimeEmits:

ts
  1. export function genRuntimeEmits(ctx: ScriptCompileContext): string | undefined {
  2. let emitsDecl = ''
  3. //...
  4. if (ctx.hasDefineModelCall) {
  5. let modelEmitsDecl = `[${Object.keys(ctx.modelDecls)
  6. .map(n => JSON.stringify(`update:${n}`))
  7. .join(', ')}]`
  8. emitsDecl = emitsDecl
  9. ? `/*#__PURE__*/${ctx.helper(
  10. 'mergeModels',
  11. )}(${emitsDecl}, ${modelEmitsDecl})`
  12. : modelEmitsDecl
  13. }
  14. return emitsDecl
  15. }

processDefineModel标记了ctx.hasDefineModelCall = truegenRuntimeEmits中合并emits选项。

结语

本文介绍了 Vue 3.3 的特性defineModel,并且对其编译过程与结果进行简介。

defineModel是 Vue 3.4 转正的 API,极大简化了自定义双向绑定的处理。它使用useModel定义的customRef,利用 Vue 的响应式,完成来自上层组件的数据同步以及发起update:Xxx事件。

另外,setup的代码编译我不太熟,这里没有进行深入介绍。

0 条评论未登录用户
Ctrl or + Enter 评论
🌸 Run