Vue 3.4 特性:自定义双向绑定的 API,defineModel

520 阅读3分钟

defineModel

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

以前的自定义双向绑定

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

// 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进行双向绑定。

// 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下,我们在子组件自定义双向绑定只需要这样子:

<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添加修饰符:

// 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>

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

// 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修饰符,进入上面子组件defineModelset选项逻辑。

defineModel 原理

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

<script setup lang="ts">
const modelValue = defineModel({
  default: 0
})
</script>
<template>
  <div>
    <button type="button" @click="modelValue++">count is {{ modelValue }}</button>
  </div>
</template>

编译的结果:

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

useModel

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

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允许用户增强getset方法,以及自定义value的处理。

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)
  }
}

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函数中有:

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实现了defineModeluseModel的替换:

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的时候会用到。

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的参数了。从而实现了从

const modelValue = defineModel({
  default: 0
})

const modelValue = useModel(__props, "modelValue")

的转换。

添加 props

complieScript调用genRuntimeProps

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

genRuntimeProps中合并defineModel产生的props

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

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

genRuntimeEmits:

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 = truegenRuntimeEmits中合并emits选项。

结语

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

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

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

菜狗狗第一次发帖,有什么不足请批评指正...... 大家的阅读是我发帖的动力。
另外,这是我的个人博客:deerblog.gu-nami.com/