likes
comments
collection
share

这是你不知道的defineModel!

作者站长头像
站长
· 阅读数 16

相信大伙都已经收到Vue3最新版的风了吧,新版本的更新中优化了不少此前在Vue3中比较“麻烦”的使用方法,下面是更新的简介图 👇

这是你不知道的defineModel!

相信看完上面的简介图,大伙对新特性已经有一个大概的了解了,下面就进入正文:defineModel是如何实现的

那接下来我就开始操作了🤺 (e 点点 q 点点 w 点点嘟嘟嘟嘟...)

defineModel核心

新旧对比

在开发的过程中,如果有需要通过子组件进行状态更新的话,v-model是一个绕不开的点。以前的v-model是这样用的 👇

<!-- Father.vue -->
<template>
  <span>count</span>
  <Child v-model="count" />
</template>

<script lang="ts" setup>
  import { ref } from 'vue'
  const count = ref<number>(0)
</script>
<!-- Child.vue -->
<template>
  count: {{ count }}
  <button @click="onClick">count</button>
</template>

<script lang="ts" setup>
  const $props = defineProps<{ modelValue: number }>()
  const $emits = defineEmit<{
    (e: 'update:modelValue', modelValue: number)
    // 注册update:modelValue事件,作为状态更新的回调
  }>()
  function onClick() {
    $emits('update:modelValue', $props.modelValue++)
    // 状态更新时发布事件
  }
</script>

在有了defineModel之后,我们就可以在Child.vue中这样实现 👇

<!-- Child.vue -->
<template>
  count: {{ count }}
  <button @click="onClick">count</button>
</template>

<script lang="ts" setup>
  const count = defineModel<number>()
  // 一步到位,完成事件注册和监听状态变化并发布事件
  function onClick() {
    count += 1
  }
</script>

相信看完上面的案例之后大伙就已经有一个大概的猜想了:

defineModel其实为组件实例注册了update:modelValue事件,并且在propssetter中又调用了update:modelValue事件,从而实现的v-model语法糖

上面的猜测又包含了两个问题:

  1. defineModel是如何注册update:modelValue事件的
  2. 如何在defineModel变量修改时发布update:modelValue事件的

从编译后代码开始探索

要验证上面的猜想,我们可以通过查看编译之后的Vue代码来完成。

这里我们通过Vue 官方 Playground来作为查看编译后代码的工具,同样是实现上面的例子,来看看编译后的Vue源码是怎么样的 👇

// Father.vue
const __sfc__ = _defineComponent({
  __name: 'App',
  setup(__props) {

    const count = ref(0)

    return (_ctx,_cache) => {
      return (_openBlock(), _createElementBlock(_Fragment, null, [
        _createElementVNode("h1", null, _toDisplayString(count.value), 1 /* TEXT */),
        _createVNode(Child, {
          modelValue: count.value,
          "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => ((count).value = $event))
          // 将v-model转换为modelValue属性以及update:modelValue事件
        }, null, 8 /* PROPS */, ["modelValue"])
      ], 64 /* STABLE_FRAGMENT */))
    }
  }
})
// Child.vue
const __sfc__ = _defineComponent({
  __name: 'Child',
  props: {
    "modelValue": {},
  },
  emits: ["update:modelValue"],
  setup(__props) {

    const compCount = _useModel(__props, "modelValue") 
    // 核心代码
    // 调用_useModel对传入的modelValue属性进行处理
    return (_ctx,_cache) => {
      return (_openBlock(), _createElementBlock(_Fragment, null, [
        _createTextVNode(" Comp count: " + _toDisplayString(compCount.value) + " ", 1 /* TEXT */),
        _createElementVNode("button", {
        onClick: _cache[0] || (_cache[0] = ($event) => (compCount.value++))
        }, " press ")
      ], 64 /* STABLE_FRAGMENT */))
    }
  }
})

通过上面的源码可以很清晰地看到,defineModel的核心其实是_useModel函数,通过_useModel为注册了v-modelprops执行双向绑定操作。

那就让我们继续Deep Down🤿,从Vue3源码中一探这_useModel究竟是何方神圣~

如何发布事件

首先我们找到defineModel的源码,在92行中可以找到defineModel是通过调用useModel函数来实现的👇

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

  ctx.hasDefineModelCall = true // 将该组件标记为使用了defineModel
  ...
  // 在这里对被绑定到子组件的props进行标记,被标记为props类型的值将会在defineProps中被合并为组件的props
  // 由于这里不属于本文讨论的内容,如需查看请前往源码仓库

  ctx.s.overwrite(
    ctx.startOffset! + node.start!,
    ctx.startOffset! + node.end!,
    `${ctx.helper('useModel')}(__props, ${JSON.stringify(modelName)}${
      runtimeOptions ? `, ${runtimeOptions}` : ``
    })`
    // 从这里可以找到调用了useModel,并将对应的prop作为参数传递👆
  )
  return true
}

那么接下来就是defineModel的核心,useModel的实现了👇

export function useModel(
  props: Record<string, any>,
  name: string,
  options?: { local?: boolean }
): Ref {
  const i = getCurrentInstance()!
  if (__DEV__ && !i) {
    warn(`useModel() called without active instance.`)
    // 当组件实例不存在时则返回ref
    return ref() as any
  }

  if (__DEV__ && !(i.propsOptions[0] as NormalizedProps)[name]) {
    warn(`useModel() called with prop "${name}" which is not declared.`)
    // 当useModel被一个不存在的prop调用时,返回ref
    return ref() as any
  }

  // 通过watch监听或setter时发布事件的形式实现在修改时同步更新prop,而不需要显性注册`update:modelValue`事件
  if (options && options.local) {
  // 确认是否在defineModel中配置local属性为true
    const proxy = ref<any>(props[name])
    watch(
      () => props[name],
      v => (proxy.value = v)
    )

    watch(proxy, value => {
      if (value !== props[name]) {
        i.emit(`update:${name}`, value)
      }
    })
    return proxy
  } else {
    return {
      __v_isRef: true,
      get value() {
        return props[name]
      },
      set value(value) {
        i.emit(`update:${name}`, value)
      }
    } as any
    // 直接返回一个标记为ref的对象,当对这个对象进行赋值时即执行事件的发布
  }
}

如何注册update:modelValue事件

到此为止,defineModel的主体基本上已经较为清晰地展现出来了,但我们的第一个问题仍没有解决,defineModel是如何注册update:modelValue事件的?

其实这个问题已经很明显了,在上面的processDefineModel源码中,我将这段代码单独留下并进行标注👇

ctx.hasDefineModelCall = true // 将该组件标记为使用了defineModel

其实在这里defineModel就已经将这个组件标记为hasDefineModelCall,后续在defineEmits源码中我们可以找到defineEmit会自动为被标记为hasDefineModelCall的组件注册对应名称的update事件👇

export function genRuntimeEmits(ctx: ScriptCompileContext): string | undefined {
  ...
  if (ctx.hasDefineModelCall) {
  // 对标记为使用了defineModel的实例进行处理
    let modelEmitsDecl = `[${Object.keys(ctx.modelDecls)
      .map(n => JSON.stringify(`update:${n}`))
      // 为每一个使用defineModel注册的prop属性进行事件注册
      .join(', ')}]`
    emitsDecl = emitsDecl
      ? `${ctx.helper('mergeModels')}(${emitsDecl}, ${modelEmitsDecl})`
      : modelEmitsDecl
      // 将使用defineEmits注册的事件和使用defineModel注册的事件合并
  }
  return emitsDecl
}

新的问题

其实到这为止,defineModel的整个执行过程已经基本讲解完毕了,但是在看useModel的源码时我发现了一个问题,为什么要将option区分为local非local呢?

带着这个问题,我请教了chatGPT老师,得到了下面的答复👇 这是你不知道的defineModel!

好吧,我承认我没看懂,于是乎我找到了关于defineModel的Discussion并且在尤大给的Demo中找到了我想要的答案👇 这是你不知道的defineModel!

结语

其实本来想和defineProps是如何解构仍保持响应式一起写的,但是感觉如果放在一篇文章中篇幅就太长了,阅读体验不好,所以就放到下一篇中解析吧

如果文中有任何错误或者需要修改的地方,烦请指出,不胜感激

PS: 大伙都看蜘蛛侠:纵横宇宙了吗,真好看啊!特别是迈尔斯和格温看纽约的那个镜头,让我有一种在看边缘行者的快感😎,打算这周末去二刷了

我的个人博客:johnsonhuang_blog