Vue 响应式及双向绑定原理
一、响应式原理
1、Vue2 的实现
Vue2 的响应式采用数据劫持结合发布者-订阅者模式的方式,通过 Object.defineProperty() 来劫持各个属性的 getter 和 setter,在数据变化时发布消息给订阅者,触发相应的监听回调,但无法跟踪响应对象的属性添加操作。
2、Vue3 的实现
Vue3 采用 Proxy 代理来实现响应式,data 中的数据不再进行响应式追踪,而是转为 Proxy 代理进行追踪更新。
为什么要用 Proxy 重构
Object.defineProperty 用于在对象上定义一个新属性或修改一个现有属性,并返回该对象。
定义中强调了现有属性,也就是说 Object.defineProperty 只对对象上已有的属性做处理,对象上不存在的属性是无法处理的。这也是 Vue2 无法跟踪响应对象新增属性的原因。
二、双向绑定的应用
v-model 在表单输入元素或组件上创建双向绑定。
在表单元素上使用:
<template>
<p>Message is: {{ message }}</p>
<input v-model="message" placeholder="edit me" />
</template>
<script setup>
import { ref } from 'vue'
const message = ref('')
</script>
在组件上使用:
- 定义组件
- 使用组件
CustomInput.vue
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
App.vue
<template>
<p>Message is: {{ message }}</p>
<CustomInput v-model="message" />
</template>
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const message = ref('hello')
</script>
三、v-model 指令分析
v-model 对组件进行双向绑定,本质上是个语法糖,通过 prop 给子组件传递数据,子组件通过 v-on 进行事件绑定来修改数据。指令的实现:
- created 钩子函数中,如果有 lazy 修饰符,则 input 表单监听 change 事件,否则监听 input 事件;
- beforeUpdate 钩子函数中,要重新获取 onUpdate:modelValue 函数,因为重新渲染函数可能更改了这个函数,并且重新给 input 赋值;
- input 中输入新的内容后,如果有 trim 修饰符则进行去空格,如果有 number 修饰符或 input 类型是 number 则转成 number,然后通过 onUpdate:modelValue 修改 value 值。
packages/runtime-dom/src/directives/vModel.ts
// https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/directives/vModel.ts#L43
// We are exporting the v-model runtime directly as vnode hooks so that it can
// be tree-shaken in case v-model is never used.
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
// 获取到 vnode.props!['onUpdate:modelValue'] 对应的函数
el._assign = getModelAssigner(vnode)
const castToNumber =
number || (vnode.props && vnode.props.type === 'number')
// 如果有 lazy 修饰符,则监听 input 的 change 事件
// 否则监听 input 的 input 事件
addEventListener(el, lazy ? 'change' : 'input', e => {
if ((e.target as any).composing) return
let domValue: string | number = el.value
if (trim) {
// 如果有 trim 修饰符,则将 input 的 value 进行去空格
domValue = domValue.trim()
}
if (castToNumber) {
// 如果有 number 修饰符或 input 类型是 number
// 则把 input 的 value 变成 number 类型
domValue = toNumber(domValue)
}
el._assign(domValue)
})
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
if (!lazy) {
addEventListener(el, 'compositionstart', onCompositionStart)
addEventListener(el, 'compositionend', onCompositionEnd)
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
addEventListener(el, 'change', onCompositionEnd)
}
},
// set value on mounted so it's after min/max for type="range"
mounted(el, { value }) {
el.value = value == null ? '' : value
},
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
// 更新 onUpdate:modelValue 函数
el._assign = getModelAssigner(vnode)
// 如果 input 的值没变,则不进行任何操作
if ((el as any).composing) return
if (document.activeElement === el && el.type !== 'range') {
if (lazy) {
return
}
if (trim && el.value.trim() === value) {
return
}
if ((number || el.type === 'number') && toNumber(el.value) === value) {
return
}
}
const newValue = value == null ? '' : value
// 更新值
if (el.value !== newValue) {
el.value = newValue
}
}
}
可以看到,
- 数据 -> DOM:响应式数据 value 变化触发组件更新,input 的内容将发现变化;
- DOM -> 数据:vModelText 指令实现了对 input value 变化的监听,根据 vModelText 指令的修饰符处理完 input 的 value 值,然后通过 onUpdate:modelValue 对应的函数
$event => (value = $event)
完成响应式数据 value 的修改。
四、实现一个简单的双向绑定
let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')
// 数据劫持
Object.defineProperty(obj, 'text', {
configurable: true,
enumerable: true,
get() {
console.log('获取数据了')
},
set(newVal) {
console.log('数据更新了')
input.value = newVal
span.innerHTML = newVal
}
})
// 输入监听
input.addEventListener('keyup', function (e) {
obj.text = e.target.value
})