Vue 3.4 组件开发新范式:defineModel的底层逻辑与实践

文末有我帮助多人拿到前端offer的文章 !!!

defineModel是 Vue 3.4中推出的 的 Composition API 中一个重要的新特性。用于定义响应式模型的新函数。意在简化组件中响应式状态的管理,尤其是在处理复杂组件状态时。

组件v-model

defineModel使用之前先来回顾一下以前组件上的v-model是如何使用的。

v-model其实就是value属性和input事件的语法糖

<input type="text" :value="iptVal" @input="$event => iptVal = $event.target.value" />
<!-- v-model -->
<input type="text" v-model="iptVal" />

我们在表单开发时通常会使用v-model指令来完成数据绑定,组件 components 上同样可以使用该指令以实现双向绑定。上面的示例是v-mdoel在表单元素上的语法糖使用,而当在一个组件上使用时,它其实是modelValue属性和update:modelValue事件的语法糖

<IptCpn :modelValue="searchText" @update:modelValue="newValue => searchText = newValue" />
<!-- v-model -->
<IptCpn v-model="searchText" />

在组件内部需要做两件事来实现功能,参见官方文档的两句话:

  1. 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  2. 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件

所以子组件代码为:

<!-- IptCpn.vue -->
<script setup>
  defineProps(["modelValue"])
  defineEmits(["update:modelValue"])
</script>

<template>
  <input
    type="text"
    :value="modelValue"
    @input="$event => $emit('update:modelValue', $event.target.value)"
  />
</template>

这时组件v-model就可以工作了,但是子组件中的元素还是必须绑定value属性和监听input事件,如果子组件中的表单元素也想使用语法糖写法v-model来绑定状态,需要使用拥有 getter 和 setter 的computed来编写:

<!-- IptCpn.vue -->
<script setup>
  import { computed } from "vue"
  const props = defineProps(["modelValue"])
  const emit = defineEmits(["update:modelValue"])

  const modelValueComputed = computed({
    set(val) {
      emit("update:modelValue", val)
    },
    get() {
      return props.modelValue
    },
  })
</script>

<template>
  <input type="text" v-model="modelValueComputed" />
</template>

defineModel使用

defineModel是一个编译器宏,无需导入直接使用。可以用来声明一个双向绑定 prop,通过父组件的 v-model 来使用。

  • 如果第一个参数是一个字符串字面量,它将被用作 prop 名称;
  • 否则,不传参,prop 名称将默认为 "modelValue"
// 声明 "modelValue" prop,由父组件通过 v-model 使用
const model = defineModel()

// 声明 "count" prop,由父组件通过 v-model:count 使用
const count = defineModel("count")

在以上两种情况下,传递一个额外的对象,它可以包含 prop 的选项和 model ref 的值转换选项。

// 声明带选项的 "modelValue" prop,表明该modelValue为 字符串 类型,更改为别的类型会报警告⚠
const model = defineModel({ type: String })

// 声明带选项的 "count" prop
const count = defineModel("count", { type: Number, default: 0 })

更新数据时,触发 update:modelValue 事件,如果是命名props,如count,则触发update:count事件

function inc() {
  //在被修改时,触发 "update:modelValue" 事件
    
  model.value = '小新学研社'
  // 在被修改时,触发 "update:count" 事件
  count.value++
}
  • 修饰符:解构 defineModel() 的返回值以获取修饰符
const [modelValue, modelModifiers] = defineModel()

// 对应 v-model.trim
console.log(modelModifiers.trim)

修饰符转换器:使用修饰符时,可能需要在读取或将其同步回父组件时对其值进行转换。我们可以通过使用 get 和 set 转换器选项来实现这一点:

const [modelValue, modelModifiers] = defineModel({
  // get() 省略了,因为这里不需要它
  set(value) {
    // 如果使用了 .trim 修饰符,则返回裁剪过后的值
    if (modelModifiers.trim) {
      return value.trim()
    }
    // 否则,原样返回
    return value
  }
})

TypeScript集成:使用泛型接收类型参数来指定 model 值和修饰符的类型:

const modelValue = defineModel<string>()
//    ^? Ref<string | undefined>

// 用带有选项的默认 model,设置 required 去掉了可能的 undefined 值
const modelValue = defineModel<string>({ required: true })
//    ^? Ref<string>

const [modelValue, modifiers] = defineModel<string, "trim" | "uppercase">()
//    ^? Record<'trim' | 'uppercase', true | undefined>

defineModel底层逻辑

defineModel 是在编译时进行处理的,而不是在运行时。底层实现依赖于 Vue 的响应式系统编译器的转换能力

它在编译阶段将特定的代码结构转换或扩展为更详细的实现代码。processDefineModel函数用于在编译阶段处理 defineModel 调用,解析并提取相关属性和选项

export function processDefineModel(
  ctx: ScriptCompileContext,
  node: Node,
  declId?: LVal,
): boolean {
  //先检查你调用的是否是 defineModel,如果不是则直接返回 false
  if (!isCallOf(node, DEFINE_MODEL)) {
    return false
  }
  //是, 它会设置 hasDefineModelCall 标志为 true,表示已经调用defineModel
  ctx.hasDefineModelCall = true

  //解析类型参数、模型名称(modelName)和选项(options)
  const type =
    (node.typeParameters && node.typeParameters.params[0]) || undefined
  let modelName: string
  let options: Node | undefined
  const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
  const hasName = arg0 && arg0.type === 'StringLiteral'
  //如果传入名称,就用传入的,没有传入就用 modelValue
  if (hasName) {
    modelName = arg0.value
    options = node.arguments[1]
  } else {
    modelName = 'modelValue'
    options = arg0
  }


  //重复的模型名称,报错
  if (ctx.modelDecls[modelName]) {
    ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node)
  }

  let optionsString = options && ctx.getString(options)
  let optionsRemoved = !options
  const runtimeOptionNodes: Node[] = []
  //根据选项options的类型和内容,处理并生成选项节点数组runtimeOptionNodes,将选项加入到runtimeOptionNodes数组中保存
  if (
    options &&
    options.type === 'ObjectExpression' &&
    !options.properties.some(p => p.type === 'SpreadElement' || p.computed)
  ) {
    let removed = 0
    for (let i = options.properties.length - 1; i >= 0; i--) {
      const p = options.properties[i]
      const next = options.properties[i + 1]
      const start = p.start!
      const end = next ? next.start! : options.end! - 1
      if (
        (p.type === 'ObjectProperty' || p.type === 'ObjectMethod') &&
        ((p.key.type === 'Identifier' &&
          (p.key.name === 'get' || p.key.name === 'set')) ||
          (p.key.type === 'StringLiteral' &&
            (p.key.value === 'get' || p.key.value === 'set')))
      ) {
        // remove runtime-only options from prop options to avoid duplicates
        optionsString =
          optionsString.slice(0, start - options.start!) +
          optionsString.slice(end - options.start!)
      } else {
        // remove prop options from runtime options
        removed++
        ctx.s.remove(ctx.startOffset! + start, ctx.startOffset! + end)
        // record prop options for invalid scope var reference check
        runtimeOptionNodes.push(p)
      }
    }
    if (removed === options.properties.length) {
      optionsRemoved = true
      ctx.s.remove(
        ctx.startOffset! + (hasName ? arg0.end! : options.start!),
        ctx.startOffset! + options.end!,
      )
    }
  }

  //函数更新编译上下文,包括模型声明、绑定类型和转换 defineModel 调用为 useModel。
  ctx.modelDecls[modelName] = {
    type,
    options: optionsString,
    runtimeOptionNodes,
    identifier:
      declId && declId.type === 'Identifier' ? declId.name : undefined,
  }
  // register binding type
  ctx.bindingMetadata[modelName] = BindingTypes.PROPS

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

由于 defineModel 是编译器层面的特性,它没有以传统函数的形式出现。编译器将其展开为以下内容:

  • 一个名为 modelValue 的 prop,本地 ref 的值与其同步;
  • 一个名为 update:modelValue 的事件,当本地 ref 的值发生变更时触发。

而在 defineModel出现之前的组件v-model使用方式有助于理解其底层机制。

·展开宏

编译过程中,defineModel 宏被调用,处理为具体的响应式状态声明和事件处理。

·定义响应式状态

在子组件中使用ref API定义了一个响应式状态,这个变量与父组件传递的 props 中的 modelValue 属性关联。

·监听 props 变化

通过 watch API 或 watchEffect API 监听 props.modelValue 的变化,当其值变化时,同步更新响应式状态的值。即父组件更新状态。

·生成事件监听器

当在子组件中修改响应式状态的值时,调用emit触发 update:modelValue 事件,并将新的值传递给父组件。即子组件更新状态。

·v-model 模板编译

编译器编译模板时会识别 v-model 指令,并将其转换为对响应式状态的读写操作。编译器会根据子组件生成的响应式状态,替换模板中的 v-model 绑定,父组件更改响应式状态也会更新prop即子组件中的数据。

defineModel 就是使用此种方式实现父子组件状态的双向绑定,背后的核心是 Vue 3 的响应式系统,将组件的状态封装为一个响应式的模型对象。这样,当状态发生变化时,Vue 能够自动追踪变化并更新 DOM。

优势分析

  1. 简化代码defineModel 提供了一种更简洁的语法来处理双向绑定,避免了手动定义 props 和 emits 的繁琐代码。
  2. 提高代码可读性:使组件的逻辑更加清晰,易于理解和维护。
  3. 更好的类型推断:与 TypeScript 结合使用时,defineModel 能够提供更好的类型推断,减少类型错误。
  4. 响应式状态管理defineModel 声明的属性会自动转换为响应式状态,便于在组件中进行状态管理。

《聊聊前端面试那些事儿》

给你们推荐一篇我帮助多人拿到前端offer的文章,希望大家看完以后都可以领取到心仪的offer哦!

  • 18
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值