Vue 3 双向绑定 API defineModel 解析
defineModel
defineModel是 Vue 3.4 正式加入的 API 了。它可以简化组件间双向绑定的操作,在自定义表单类组件中非常有用。
以前的自定义双向绑定
defineModel可以看成是通过修改props、emits、事件监听或者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>
modifiers是v-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修饰符,进入上面子组件defineModel的set选项逻辑。
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中,自动添加了双向绑定的props、emits,以及调用了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允许用户增强get、set方法,以及自定义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)
}
}
trackRefValue和triggerRefValue是基本上就是ref那一套收集、触发依赖的方法,这里就不展开了(Vue 3.4 也对它的响应式进行了迭代,大家感兴趣的话后面再说)。这个CustomRefImpl给useModel中的入参传入了trackRefValue和triggerRefValue,这就意味着useModel也实现了 Vue 的响应式。在get的时候收集依赖,在set的时候触发依赖。
useModel定义的customRef res中使用localValue作为组件自身的状态。使用watchSyncEffect监听props中绑定的变量的改变,去同步修改组件的状态,并且触发响应式依赖。watchSyncEffect是一个同步的watchEffect,它可以自动监听回调函数用到的所有响应式变量,随后触发回调函数。
res的set方法可以触发onUpdate:xxx事件实现了子组件状态同步到亲组件的过程。
最后useModel赋值了一个res[Symbol.iterator],在解构赋值的时候类似于一个[res, props[modifierKey]]的数组,实现了返回单个变量和返回变量和修饰符两种形式的返回格式。见文档,可以const model = defineModel(),也可以const [modelValue, modelModifiers] = defineModel()。
setup 函数编译
代码转换、为代码块加上emits和props是在模板编译中实现的。
转换为 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实现了defineModel到useModel的替换:
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 = true,genRuntimeEmits中合并emits选项。
结语
本文介绍了 Vue 3.3 的特性defineModel,并且对其编译过程与结果进行简介。
defineModel是 Vue 3.4 转正的 API,极大简化了自定义双向绑定的处理。它使用useModel定义的customRef,利用 Vue 的响应式,完成来自上层组件的数据同步以及发起update:Xxx事件。可惜setup的代码编译不太熟,这里没有进行深入介绍。
有什么不足请批评指正......
大家的阅读是我发帖的动力。
另外,这是我的个人博客:https://deerblog.gu-nami.com,欢迎大家来玩
Vue 3 双向绑定 API defineModel 解析的更多相关文章
- vue的双向绑定原理解析(vue项目重构二)
现在的前端框架 如果没有个数据的双向/单向绑定,都不好意思说是一个新的框架,至于为什么需要这个功能,从jq或者原生js开始做项目的前端工作者,应该是深有体会. 以下也是个人对vue的双向绑定原理的一些 ...
- Vue.js双向绑定的实现原理和模板引擎实现原理(##########################################)
Vue.js双向绑定的实现原理 解析 神奇的 Object.defineProperty 这个方法了不起啊..vue.js和avalon.js 都是通过它实现双向绑定的..而且Object.obser ...
- vue的双向绑定原理及实现
前言 使用vue也好有一段时间了,虽然对其双向绑定原理也有了解个大概,但也没好好探究下其原理实现,所以这次特意花了几晚时间查阅资料和阅读相关源码,自己也实现一个简单版vue的双向绑定版本,先上个成果图 ...
- 西安电话面试:谈谈Vue数据双向绑定原理,看看你的回答能打几分
最近我参加了一次来自西安的电话面试(第二轮,技术面),是大厂还是小作坊我在这里按下不表,先来说说这次电面给我留下印象较深的几道面试题,这次先来谈谈Vue的数据双向绑定原理. 情景再现: 当我手机铃声响 ...
- Vue数据双向绑定原理及简单实现
嘿,Goodgirl and GoodBoy,点进来了就看完点个赞再go. Vue这个框架就不简单介绍了,它最大的特性就是数据的双向绑定以及虚拟dom.核心就是用数据来驱动视图层的改变.先看一段代码. ...
- vue数据双向绑定
Vue的双向绑定是通过数据劫持结合发布-订阅者模式实现的,即通过Object.defineProperty监听各个属性的setter,然后通知订阅者属性发生变化,触发相应的回调. 整个过程分为以下几步 ...
- 【学习笔记】剖析MVVM框架,简单实现Vue数据双向绑定
前言: 学习前端也有半年多了,个人的学习欲望还比较强烈,很喜欢那种新知识在自己的演练下一点点实现的过程.最近一直在学vue框架,像网上大佬说的,入门容易深究难.不管是跟着开发文档学还是视频教程,按步骤 ...
- Vue.js双向绑定原理
Vue.js最核心的功能有两个,一个是响应式的数据绑定系统,另一个是组件系统.本文仅仅探究双向绑定是怎样实现的.先讲涉及的知识点,再用简化的代码实现一个简单的hello world示例. 一.访问器属 ...
- vue的双向绑定和依赖收集
在掘金上买了一个关于解读vue源码的小册,因为是付费的,所以还比较放心 在小册里看到了关于vue双向绑定和依赖收集的部分,总感觉有些怪怪的,然后就自己跟着敲了一遍. 敲完后,发现完全无法运行, 坑啊 ...
- vue 之 双向绑定原理
一.实现双向绑定 详细版: 前端MVVM实现双向数据绑定的做法大致有如下三种: 1.发布者-订阅者模式(backbone.js) 思路:使用自定义的data属性在HTML代码中指明绑定.所有绑定起来的 ...
随机推荐
- Luogu P8112 [Cnoi2021] 符文破译 题解 [ 蓝 ] [ KMP ] [ 线性 dp ] [ 决策单调性 dp ]
符文破译:KMP + dp 的好题. 暴力 dp 不难打出一个暴力 dp:设计 \(dp_i\) 表示当前前 \(i\) 位全部完成了匹配,所需的最小分割数. 转移也是简单的,我们在 KMP 的过程中 ...
- SaaS+AI应用架构:业务场景、智能体、大模型、知识库、传统工具系统
大家好,我是汤师爷~ 在SaaS与AI应用的演进过程中,合理的架构设计至关重要.本节将详细介绍其五个核心层次: 业务场景层:发现和确定业务场景 智能体层:构建可复用的智能应用 大模型层:采用最合适的大 ...
- 用 DeepSeek 给对象做个网站,她一定感动坏了
大家好,我是程序员鱼皮.又是一年特殊的日子,作为一名程序员,总是幻想着自己有对象, 总是想着用自己贼拉牛 X 的编程技术给对象做个网站. 本文对应视频,观看体验更好哦:https://bilibili ...
- 用python做时间序列预测八:Granger causality test(格兰杰因果检验)
如果想知道一个序列是否对预测另一个序列有用,可以用Granger causality test(格兰杰因果检验). Granger causality test的思想 如果使用时间序列X和Y的历史值来 ...
- 新塘M051 关于 System Tick设置,3种方法操作
关于 System Tick设置,给出3种方法,学习并确认OK: 使用 M051BSPv3.01.001版本 一.使用函数CLK_EnableSysTick() 1 //Enable System T ...
- 最优化算法Nesterov Momentum牛顿动量法
这是对之前的Momentum的一种改进,大概思路就是,先对参数进行估计,然后使用估计后的参数来计算误差 具体实现: 需要:学习速率 ϵ, 初始参数 θ, 初始速率v, 动量衰减参数α每步迭代过程:
- ReviOS - 专为游戏优化的 Win11 / Win10 精简版系统
ReviOS介绍 ReviOS 渴望重新创建作为操作系统的 Windows 应该是 - 简单.ReviOS 是一款功能强大.高效的私有操作系统,适用于游戏玩家.高级用户和发烧友.由于资源.占用内存和存 ...
- Linux - centos6忘记root密码怎么办?
Linux的root密码修改不像Windows的密码修改找回,Windows的登录密码忘记需要介入工具进行解决.CentOS6和CentOS7的密码方法也是不一样的,具体如下 1.开机按esc 2 ...
- Docker 容器的数据卷 以及 数据卷容器
Docker 容器删除后,在容器中产生的数据还在吗? 答案是 不在 Docker 容器和外部机器可以直接交换文件吗? 在没有数据卷的情况下,答案是 不可以 如下图:外部机器:Windows系统(自己的 ...
- faker 函数支持哪些
3.2 常用函数 除了上述介绍的fake.name和fake.address生成姓名和地址两个函数外,常用的faker函数按类别划分有如下一些常用方法. 1.地理信息类 fake.city_suffi ...