vue3.4中的defineModel使用和源码一次性说清楚

450 阅读2分钟

目录:

  • 一、介绍

  • 二、使用 $emit更新父组件传递过来的数据(vue3.3以前)

  • 三、使用 defineModel 更新父组件传递过来的数据(vue3.4)

    • 1.defineModel传递多个v-model
  • 四、defineModel源码

    • 1.从编译后代码开始探索
    • 2.defineModel源码
    • 3.useModel 源码
    • 4.genRuntimeEmits源码
  • 五、总结

一、介绍

defineModel() 返回的值是一个 ref。

它可以像其他 ref 一样被访问以及修改。

它能起到在父组件和当前变量之间的双向绑定的作用。

它的 .value 和父组件的 v-model 的值同步。

当它被子组件改变时,会触发父组件绑定的值一起更新。

都知道,props 的设计原则是单项数据流。子组件默认情况下是无法更改父组件传递过来的数据。如果要更改vue3.3以前是通过 $emit 来实现的。

下面我们来对比一下使用 $emit 和 defineModel 来更新数据。

二、使用 $emit更新父组件传递过来的数据(vue3.3以前)

// 父组件
<template>
  <div class="father">
    <h1>我是父组件,子组件isShow的内容是:{{ isShow }}</h1>
    <button @click="showSonHandle">显示子组件</button>
    <Son v-model:isShow="isShow" />
  </div>
</template>

<script setup>
import Son from '@/components/Son.vue'

import { ref } from 'vue';

const isShow = ref(true)

const showSonHandle = () => {
  isShow.value = true
}
</script>
// 子组件
<script setup>
defineProps({
  isShow: {
    type: Boolean,
    default: true
  },
})
const emits = defineEmits(['update:isShow'])

const closeHandle = () => {
  emits('update:isShow', false)
}
</script>

<template>
  <div class="child" v-if="isShow">
    <h1>我是子组件</h1>
    <button @click="closeHandle">关闭</button>
  </div>
</template>

三、使用 defineModel 更新父组件传递过来的数据(vue3.4)

// 父页面
<template>
  <div class="father">
    <h1>我是父组件,子组件isShow的内容是:{{ isShow }}</h1>
    <button @click="showSonHandle">显示子组件</button>
    <Son v-model:isShow="isShow" />
  </div>
</template>

<script setup>
import Son from '@/components/Son.vue'

import { ref } from 'vue';

const isShow = ref(true)

const showSonHandle = () => {
  isShow.value = true
}
</script>
// 子页面
<script setup>
const isShowBool = defineModel('isShow')
const closeHandle = () => {
  isShowBool.value = false
}
</script>

<template>
  <div class="child" v-if="isShowBool">
    <h1>我是子组件</h1>
    <button @click="closeHandle">关闭</button>
  </div>
</template>

可以发现使用起来简直太棒了!比之前使用emit好用太多,它可以直接与父组件传的变量进行双向绑定。直接写上 const 变量名 = defineModel('双向绑定的值')

1.defineModel传递多个v-model

<template>
  <div class="father">
    <h1>我是父组件,son组件userName的值:{{ userName }}。email的值:{{ email }}</h1>
    <Son v-model:userName="userName" v-model:email="email" />
  </div>
</template>

<script setup>
import Son from '@/components/Son.vue'

import { ref } from 'vue';

const userName = ref('如花')
const email = ref('110@163.com')
</script>
// 子组件
<script setup>
const userName = defineModel('userName')

const email = defineModel('email')

</script>

<template>
  <div class="child">
    <h1>我是子组件</h1>
    <form action="#">
      <div>
        <label for="">用户名:</label>
        <input type="text" v-model="userName">
      </div>
      <div>
        <label for="">邮箱:</label>
        <input type="text" v-model="email">
      </div>
    </form>
  </div>
</template>

虽然可以传递多个,但是最好还是少用,而且通常来说,使用props方式父子组件通信,传的参数都是特别的,例如封装弹框组件,取消和确定的时候关闭当前组件。参数不会太多,总之遵守和函数方式一样最好,尽量别超过3个。

到这里,对于基本的用法我们是知道了,但是具体源码层面,我们还不是很清楚,接下来,我们往深一层看看。

四、defineModel源码

在之前,v-model我们是知道它的语法糖的

<input v-model="message">

等价于

<input 
  :value="message" 
  @input="message = $event.target.value"
>
  • 1.绑定数据:将表单元素的值与 Vue 实例中的数据进行绑定。
  • 2.监听事件:监听表单元素的输入事件(如 input 或 change),并更新 Vue 实例中的数据。

1.从编译后代码开始探索

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

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

/* Analyzed bindings: {
  "userName": "setup-ref",
  "email": "setup-ref"
} */
import { useModel as _useModel } from 'vue'

const __sfc__ = {
  __name: 'App',
  // 核心代码
  props: {
    "userName": {},
    "userNameModifiers": {},
    "email": {},
    "emailModifiers": {},
  },
  // 核心代码
  emits: ["update:userName""update:email"],
  setup(__props, { expose: __expose }) {
  __expose();
  // 核心代码
const userName _useModel(__props, 'userName')
const email _useModel(__props, 'email')

function render(_ctx, _cache, $props$setup$data$options{
  // 核心代码
  _createElementVNode("input", {
    type"text",
    "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (($setup.userName) = $event))
  }, null512 /* NEED_PATCH */), [
    [_vModelText, $setup.userName]
  ])
}
__sfc__.render = render
__sfc__.__scopeId = "data-v-7ba5bd90"
__sfc__.__file = "src/App.vue"
export default __sfc__

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

那就让我们继续看看_useModel。

首先我们找到defineModel(packages/compiler-sfc/src/script/defineModel.ts)的源码,在92行中可以找到defineModel是通过调用useModel函数来实现的。

2.defineModel源码

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

  // 将该组件标记为使用了defineModel
  ctx.hasDefineModelCall = true
  ...
  
  // 这里调用了useModel
  ctx.s.overwrite(
    ctx.startOffset! + node.callee.start!,
    ctx.startOffset! + node.callee.end!,
    ctx.helper('useModel'),
  )
  // 并将对应的prop作为参数传递
  ctx.s.appendLeft(
    ctx.startOffset! +
      (node.arguments.length ? node.arguments[0].start! : node.end! - 1),
    `__props, ` +
      (hasName
        ? ``
        : `${JSON.stringify(modelName)}${optionsRemoved ? `` : `, `}`),
  )

  return true
}

接下来就是defineModel的核心,useModel(packages/runtime-core/src/helpers/useModel.ts)的实现了👇

3.useModel 源码

xport function useModel<
  M extends PropertyKey,
  T extends Record<stringany>,
  K extends keyof T,
  G = T[K],
  S = T[K],
>(
  props: T,
  name: K,
  options?: DefineModelOptions<T[K], G, S>,
): ModelRef<T[K], M, G, S>
export function useModel(
  props: Record<stringany>,
  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[0as 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 localValueany
    let prevSetValueany = EMPTY_OBJ
    let prevEmittedValueany
    // 通过监听props传来的值是否更新,需要同步更新一下
    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
        // 隐式注册`update:modelValue`事件
        i.emit(`update:${name}`, emittedValue)
        // 如果本地值通过 setter 进行了转换,
        // 但传递给父组件的值没有变化,父组件不会触发任何更新,
        // 也就不会进行属性同步。
        // 然而,本地的输入状态可能会因此失去同步,
        // 因此我们需要在这里强制进行一次更新。
        if (
          hasChanged(value, emittedValue) &&
          hasChanged(value, prevSetValue) &&
          !hasChanged(emittedValue, prevEmittedValue)
        ) {
          trigger()
        }
        // 将本次更新的内容缓存起来
        prevSetValue = value
        prevEmittedValue = emittedValue
      },
    }
  })
// 返回一个标记为ref的对象,当对这个对象进行赋值时即执行事件的发布
  return res
}

到这里,有了大概的流程。但是确实最后一个注册事件。 在源码packages/compiler-sfc/src/compileScript.ts 目录下,948行发现了emits 是调用 genRuntimeEmits(packages/compiler-sfc/src/script/defineEmits.ts[48行])

4.genRuntimeEmits源码

function genRuntimeEmits(ctx: ScriptCompileContext): string | undefined {
  let emitsDecl = ''
  ...
  // 这里在上面processDefineModel 设置过值
  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
}

这两个地方它通过hasDefineModelCall 判断,然后将事件合并了进去。

五、总结

最后得到这个图。

Vue官方编译代码网站:play.vuejs.org/ Vue代码:github.com/vuejs/core