VUE3浅析—VNode虚拟节点&diff算法&计算监听属性
前端主要是维护或者更新视图,要操作视图就需要操作DOM,如果直接操作DOM,那么就需要做大量的DOM操作,而现实中,我们或者只是改变了某一个DOM的某一部分内容,而视图中其他与该DOM相关的内容并没有改变,那么我们在更新视图的时候,只需要操作该DOM中改变的那部分内容即可,其他的内容不需要更改,这样就会提升性能。此时,VNode
就出现了,同步VNode与原来的Node进行diff(对比)
,找到改变的了内容,进行pathch(补丁)
即可。
VNode实际就是使用JS来描述一个DOM,就是纯的JS对象,它代表一个DOM元素,在运行时,就会根据这些信息创建新的DOM元素。在VUE3中,通过createVNode
将一个组件生成VNode,再通过render
将生成VNode挂载到对应的元素上。
import { createVNode, render } from 'vue'
// 生成VNode对象
const processBar = createVNode(ProcessBarComponent)
// 将VNode挂载到body上
render(processBar, document.body)
// 这是通过createVNode转化后的VNode对象:
{
anchor: null
appContext: null
children: null
component: {uid: 0, vnode: {…}, type: {…}, parent: null, appContext: {…}, …}
ctx: null
dirs: null
dynamicChildren: null
dynamicProps: null
el: div.wraps key: null
patchFlag: 0
props: null
ref: null
scopeId: null
shapeFlag: 4
slotScopeIds: null
ssContent: null
ssFallback: null
staticCount: 0
suspense: null
target: null
targetAnchor: null
transition: null
type: {__name: 'ProcessBarComponent', __hmrId: '43816b8f', __scopeId: 'data-v-43816b8f', setup: ƒ, render: ƒ, …}
__v_isVNode: true
__v_skip: true
}
在VUE3中DOM上有没有key属性,会直接影响diff算法的实现。diff算法在renderer.ts
中实现。
/**
* 无key的算法源码实现
*
* @param c1 原DOM
* @param c2 旧DOM
*/
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength)
let i
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 创建元素:同位置上的元素直接替换
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
// 如果多余出来元素,直接删除
if (oldLength > newLength) {
// 删除元素
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength
)
} else { // 如果少了元素,直接新增一个
// 新增元素
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength
)
}
}
总结:
/**
* 下述情况:newNode比oldNode多了一个nE元素,并且对比下来对应关系如下,替换规则为:
* nA-替换->oA nB-替换->oB nE-替换->oC 多出来一个nC,直接创建一个nC元素
*/
oldNode oA oB oC
newNode nA nB nE nC
/**
* 下述情况:newNode比oldNode少了一个oC元素,并且对比下来对应关系如下,替换规则为:
* nA-替换->oA nB-替换->oB 少一个oC,直接删除oC元素
*/
oldNode oA oB oC
newNode nA nB
i
。e1
和e2
。i
和尾序最长子序列e1
和e2
的大小:
i
大于尾序最长子序列e1
,并且小于等于尾序最长子序列e2
,说明新DOM比旧DOM长度大,那么需要新增元素。i
小于等于尾序最长子序列e1
,并且大于尾序最长子序列e2
,说明新DOM比旧DOM长度小,那么需要删除元素。 /**
* 有key的算法源码实现
*
* @param c1 原DOM
* @param c2 旧DOM
*/
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
// 1. sync from start
// (a b) c
// (a b) d e
/**
* 这里实际上就是在进行前序对比,也就是从左到右进行比对,一旦发现两个元素不相同,就结束前序对比,接着进行尾序对比。两个元素相同的依据是:key和type
* key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key
*/
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) { // 主要是判断元素是不是相同的,通过key和type进行判断,key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key
// 新增,patch函数中,如果n1 === n2就会return。
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break // 如果发现n1 != n2,直接跳出循环,进行下一步
}
i++ // 前序对比,下标++,实际上就是在求新旧DOM前序最长子序列
}
// 2. sync from end
// a (b c)
// d e (b c)
/**
* 这里实际上就是在进行尾序对比,也就是从右到左进行比对,一旦发现两个元素不相同,就结束尾序对比。两个元素相同的依据是:key和type
* key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key
*/
while (i <= e1 && i <= e2) { // 这里i的值最大只能等于e1或者e2,记住i的值,后续有用
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) { // 主要是判断元素是不是相同的,通过key和type进行判断,key就是 :key 赋的值,type就是当前DOM,比如是div或者ul等:n1.type === n2.type && n1.key === n2.key
// 新增,patch函数中,如果n1 === n2就会return。
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break // 如果发现n1 != n2,直接跳出循环,进行下一步
}
// 尾序对比,下标--,实际上就是在求旧DOM尾序最长子序列,因为新旧DOM的长度有可能不一样。所以新旧DOM的尾序最长子序列也可能不一样,需要用两个变量存储
e1--
e2--
}
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
/**
* 这里实际上就是在将多了元素进行删除,少了元素增加,i实际是上按照el和e2中最小的那个,即新旧DOM长度最小的那个长度。
*/
if (i > e1) { // 如果i比旧DOM的尾序最长子序列大,说明新DOM比旧DOM元素多,那么就需要新增元素
if (i <= e2) { // 这种情况下,i的值最大只能等于e2,
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) { // i的值最大只能等于e2,此时会循环将e2多出来的元素进行新增
patch( // 此时,patch函数的第一个参数是null,也就是e1位置上第i元素为null,e2位置上第i元素不为null,并且新增在该位置上
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
// 如果i比新DOM的尾序最长子序列大,说明新DOM比旧DOM元素少,那么就需要删除元素
else if (i > e2) {
while (i <= e1) { // 这种情况下,i的值最大只能等于e1,
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
// 以下是特殊情况,也就是说无序的情况,涉及到元素的移动,对比,查找,有点复杂,暂不做讲解
// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
else {
const s1 = i // prev starting index
const s2 = i // next starting index
// 5.1 build key:index map for newChildren
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present
let j
let patched = 0
const toBePatched = e2 - s2 + 1
let moved = false
// used to track whether any node has moved
let maxNewIndexSoFar = 0
// works as Map
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
// all new children have been patched so this can only be a removal
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// key-less node, try to locate a key-less node of the same type
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// looping backwards so that we can use last patched node as anchor
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
}
}
vue中,更新DOM是异步的,但是更新数据是同步的,所有有时候会发现我们获取的DOM上的数据依旧是上次的旧数据,这时候,就需要使用nextTick
函数进行处理。nextTick
函数会获取到DOM更新后的数据。VUE官网的解释如下:当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。nextTick()
可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。以下是nextTick
函数的两种用法:
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
const msg = ref('Hello World!')
// onMounted中才能获取到DOM
onMounted(() => {
console.log(document.querySelector('#myDiv'))
})
// 触发change事件的时候,是无法获取到DOM元素中更改更改后的新值的,必须使用nextTick函数才可以获取到DOM元素中更改更改后的新值的
const change = () => {
msg.value = 'world'
// 获取到的DOM中的值是Hello World!,即旧值,
console.log('msg', document.querySelector('#myDiv')!.innerHTML)
// 在nextTick函数中获取到的DOM中的值是world,即新值,
nextTick(() => console.log('nextTick--msg', document.querySelector('#myDiv')!.innerHTML))
}
// 使用async和await等待nextTick()执行完成之后获取DOM
// const change = async () => {
// msg.value = 'world'
// console.log('msg', document.querySelector('#myDiv')!.innerHTML)
// await nextTick()
// console.log('nextTick--msg', document.querySelector('#myDiv')!.innerHTML)
// }
// 在nextTick中使用回调函数
const change = () => {
msg.value = 'world'
// 获取到的DOM中的值是Hello World!,即旧值,
console.log('msg', document.querySelector('#myDiv')!.innerHTML)
// 在nextTick函数中获取到的DOM中的值是world,即新值,
nextTick(() => console.log('nextTick--msg', document.querySelector('#myDiv')!.innerHTML))
}
</script>
<template>
<div @click="change" id="myDiv" class="myDiv">{{ msg }}</div>
</template>
<style scoped lang="css">
.myDiv {
color: red;
width: 100px;
height: 60px;
}
</style>
nextTick的源码在packages/runtime-core/src/scheduler.ts
中,是一个方法。
// nextTick是一个函数,接收一个函数,最终返回一个Promise,也就是说,实际上我们传入的函数会被放在一个Promise中去执行,即把我们的函数变成异步去执行
export function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise; // p实际上就是一个Promise
return fn ? p.then(this ? fn.bind(this) : fn) : p; // 最终返回一个Promise
}
// 任务实际上是放在一个队列中的,就像VUE官网说的DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中。
export function queueJob(job: SchedulerJob) {
// the dedupe search uses the startIndex argument of Array.includes()
// by default the search index includes the current job that is being run
// so it cannot recursively trigger itself again.
// if the job is a watch() callback, the search will start with a +1 index to
// allow it recursively trigger itself - it is the user's responsibility to
// ensure it doesn't end up in an infinite loop.
if (
!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)
) {
if (job.id == null) { // 每一个异步任务都会被分配一个id,这个id是Vue自增的,也会被挂在到当前的实例上
queue.push(job); // 如果id为空,直接添加到异步队列末尾
} else {
// // 如果id不为空,那个就根据id计算出来索引,然后将当前job添加到异步队列中
// findInsertionIndex实际上就是根据二分查找,找到合适的索引位置,价格job添加到异步队列中,并保证queue中的job都是按照job.id递增排列的,这样做的目的是
// 为了保证每一个任务都会被添加到队列中,而不会被遗漏,并且保证任务不会被重复添加,还要保证父组件的操作一定要在子组件中的前边
queue.splice(findInsertionIndex(job.id), 0, job);
}
queueFlush(); // 核心就是创建了一个任务
}
}
// 实际上就是创建了一个微任务,然后执行flushJobs函数
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
}
}
// flushJobs中最终会执行我们传递的函数,主要的有三个操作:
function flushJobs(seen?: CountMap) {
// 省略部分代码
// 1、给队列中的任务排序,目的是保证父组件的操作一定要在子组件中的前边或者说跳过一些已经被卸载的组件的更新操作,比如在父组件更新的时候,子组件刚好被卸载了,那么该子组件的更新操作可以被跳过
queue.sort(comparator);
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex];
// 实际就是执行job,只不过就是封装处理了异常,真正的会执行我们的函数
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER);
}
} finally {
// 这里与配置的执行策略有关系:
// watch('', ()=>{}, {flush: 'post'}),如果flush配置的是post就会执行
flushPostFlushCbs(seen);
}
}
computed是VUE提供的一个用来监听和计算的api,常用的场景是就是:如果我们的购物车中,增加一个物品或者少一个物品,结算时的总价就要重新计算,而且computed是有缓存的,也就是说当总价依赖的数量和单价没有发生变化的时候,计算逻辑是不会变化的,总价直接获取上一次缓存的值,此时使用computed属性来计算总价是最合适的,下边是一个简单的购物车实现案例,主要利用computed属性实现。
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
const searchText = ref('')
interface Goods {
name: string
price: number
color: string
num: number
}
const goodsData = reactive<Goods[]>([
{
name: '红上衣',
price: 12.5,
color: '红色',
num: 1
},
{
name: '黑上衣',
price: 12.5,
color: '黑色',
num: 1
},
{
name: '红裤子',
price: 12.5,
color: '红色',
num: 1
},
{
name: '红帽子',
price: 12.5,
color: '红色',
num: 1
}
])
/**
* computed的对象写法,接收一个对象,对象中重写set和get方法。
* 这意味着不但可以对监听的对象取值,而且还可以赋值,例如:
* 直接操作searchData.value = []完全可以
*/
// const searchData = computed({
// get() {
// return goodsData.filter((item: Goods) => item.name.includes(searchText.value))
// },
// set(newValue) {
// searchData.value = []操作时,set函数会被触发
// console.log('newValue', newValue)
// }
// })
/**
* 接收一个函数的写法,该写法只支持获取值,而无法对监听的对象进行赋值操作。
* 直接操作searchData.value = []会报错value是一个制度属性
*/
const searchData = computed((): Goods[] =>
goodsData.filter((item: Goods) => item.name.includes(searchText.value))
)
const searchOnlyOne = () => (searchText.value = '上衣')
const deleteOne = (index: number) => goodsData.splice(index, 1)
const totalePrice = computed((): number =>
searchData.value.reduce((prve: number, item: Goods) => prve + item.price * item.num, 0)
)
</script>
<template>
<div title="搜索框">
<input v-model="searchText" placeholder="请输入……" />
<button @click="searchOnlyOne">只搜索上衣</button>
</div>
<br />
<div>
<table border="1px">
<thead>
<tr>
<th>名称</th>
<th>价格</th>
<th>颜色</th>
<th>数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in searchData" :key="index">
<td>{{ item.name }}</td>
<td>{{ item.price }}</td>
<td>{{ item.color }}</td>
<td>{{ item.num }}</td>
<td><button @click="deleteOne(index)">删除</button></td>
</tr>
</tbody>
<tfoot>
<tr>
总计:{{
totalePrice
}}
</tr>
</tfoot>
</table>
</div>
</template>
<style scoped lang="css"></style>
源码位置:packages/reactivity/src/computed.ts
// computed.ts
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false
) {
let getter: ComputedGetter<T>;
let setter: ComputedSetter<T>;
// computed两种写法,上边的购物车中案例中的写法:
const onlyGetter = isFunction(getterOrOptions); // 如果参数是一个方法,说明只有get函数参数,那么set函数直接报错
if (onlyGetter) {
getter = getterOrOptions;
setter = __DEV__
? () => {
console.warn(
"Write operation failed: computed value is readonly"
);
}
: NOOP;
} else { // 否则是对象参数写法
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
// 将处理好的get和set函数交给 ComputedRefImpl 处理
const cRef = new ComputedRefImpl(
getter,
setter,
onlyGetter || !setter,
isSSR
);
if (__DEV__ && debugOptions && !isSSR) {
cRef.effect.onTrack = debugOptions.onTrack;
cRef.effect.onTrigger = debugOptions.onTrigger;
}
return cRef as any;
}
// ComputedRefImpl实现
export class ComputedRefImpl<T> {
public dep?: Dep = undefined;
private _value!: T;
public readonly effect: ReactiveEffect<T>;
public readonly __v_isRef = true;
public readonly [ReactiveFlags.IS_READONLY]: boolean = false;
public _dirty = true; // 这是一个脏值检测标识。默认为true,也就是第一次进来是需要重新计算值的,computed的缓存与次相关
public _cacheable: boolean;
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean
) {
// ReactiveEffect类似于响应式原理中effect,专门收集依赖。第一次进来是不会走这块的,只有依赖发生了改变的时候才会走
// 实际上第二个参数是一个函数被放在一个调度器中,当依赖发生变化,就会触发trigger函数,而在trigger函数内部,就会执行effect.scheduler()
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) { // 如果为false,说明要重新计算值
this._dirty = true; // 设置为true。第二次进来的时候,默认从个缓存中获取。
triggerRefValue(this);
}
});
this.effect.computed = this;
this.effect.active = this._cacheable = !isSSR;
this[ReactiveFlags.IS_READONLY] = isReadonly;
}
// 这里就是劫持了value,所以computed返回的值里面,必须.value才能获取
get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this); // 将ref对象转化为原始对象
trackRefValue(self);
// 脏值检测,如果_dirty为true,重新计算,否则返回上一次值,也就实现了缓存机制
if (self._dirty || !self._cacheable) {
self._dirty = false;
self._value = self.effect.run()!; // 这里实际上是重新获取计算表达式的值,如果_dirty为true,就是需要重新计算值
}
return self._value; // 返回原来的值
}
set value(newValue: T) {
this._setter(newValue);
}
}
1、computed的对象写法:传入一个对象,对象里面实现get和set方法。computed的函数写法:传入一个函数,该情况下无法实现set值的逻辑。
2、computed参数交给ComputedRefImpl实现,在实现里定义了一个变量_dirty
,默认是true,该变量是实现computed缓存的关键。
3、computed是有缓存的,缓存实际上是通过脏值检测(_dirty
)实现的,当第一次进来的时候,_dirty
为true,会计算一次并奖结果返回且将_dirty
设置为false。如果依赖发生了变化,那么就会走依赖收集的那一套逻辑,并将_dirty
设置为true,因为_dirty
被设置为true,所以需要重新计算一次,否则,依赖没有发生变化的情况下,就不会触发依赖收集,即_dirty
保持为上次设置的false,就不会重新计算,返回上一次的值,进而实现缓存。
watch & watchEffect是VUE实现的用来监听属性变化的API,可用于监听一些属性的变化来实现特定的逻辑,watch & watchEffect是没有缓存的,也就是当属性变化一次的时候,watch & watchEffect就会被调用一次,以下是使用watch & watchEffect实现的购物车案例,注意和computed做比对。
<script setup lang="ts">
import { ref, reactive, watch, watchEffect } from 'vue'
const searchText = ref('')
interface Goods {
name: string
price: number
color: string
num: number
}
let goodsData = reactive<Goods[]>([
{
name: '红上衣',
price: 12.5,
color: '红色',
num: 1
},
{
name: '黑上衣',
price: 12.5,
color: '黑色',
num: 1
},
{
name: '红裤子',
price: 12.5,
color: '红色',
num: 1
},
{
name: '红帽子',
price: 12.5,
color: '红色',
num: 1
}
])
let searchData = reactive<Goods[]>(goodsData)
let totalePrice = ref<number>(0)
/**
* watch:监听值变化,如果变化,则执行指定的操作。
*
* @param target:目标对象,监听多个使用数据传递。
* @param cb: 回调函数,第一个参数是旧值,第二个参数是新值。
* @param options: 选项。可选项:
* immediate:是否在初始化时执行指定的操作。默认为false。
* deep:是否深入监听对象的属性(如果对象的属性可用)。如果deep为false,则只监听值,如果为true,则监听对象的子属性。所以是对象的话,一定设置为true
* flush:watch执行的时机,'pre': 在页面加载前被调用,'sync': 页面加载时被调用,'post': 页面加载后被调用
* @return stop:返回一个watchhandle,用于结束监听
*/
// const stop = watch(
// [searchText, goodsData],
// (): Goods[] => {
// searchData = goodsData.filter((item) => item.name.includes(searchText.value))
// totalePrice.value = searchData.reduce((prev, curr) => prev + curr.price * curr.num, 0)
// return searchData
// },
// {
// immediate: true,
// deep: true,
// flush: 'pre' || 'post' || 'sync'
// }
// )
// stop() 结束监听
// const User = {
// name: 'Demon Slayer',
// age: 18
// }
// 如果要监听一个对象的某一个属性的变化而不是全部属性的变化的时候,则可以使用下面的代码
// watch(
// () => User.name,
// (): Goods[] => {
// searchData = goodsData.filter((item) => item.name.includes(searchText.value))
// totalePrice.value = searchData.reduce((prev, curr) => prev + curr.price * curr.num, 0)
// return searchData
// },
// {
// immediate: true,
// deep: true,
// flush: 'pre' || 'post' || 'sync'
// }
// )
/**
* watchEffect:watch的高级监听,接收一个回调函数,函数里面只需要写入需要监听的对象即可做到监听,默认开启deep属性,
* 所以使用watchEffect监听对象,默认就是深度监听,并且默认立即执行
*/
watchEffect(() => {
searchText
searchData = goodsData.filter((item) => item.name.includes(searchText.value))
totalePrice.value = searchData.reduce((prev, curr) => prev + curr.price * curr.num, 0)
})
const deleteOne = (index: number) => {
goodsData.splice(index, 1)
}
const searchOnlyOne = () => {
searchText.value = '上衣'
}
</script>
<template>
<div>
<input v-model="searchText" placeholder="请输入……" />
<button @click="searchOnlyOne">只搜索上衣</button>
</div>
<div>
<table border="1px">
<thead>
<tr>
<th>名称</th>
<th>价格</th>
<th>颜色</th>
<th>数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in searchData" :key="index">
<td>{{ item.name }}</td>
<td>{{ item.price }}</td>
<td>{{ item.color }}</td>
<td>{{ item.num }}</td>
<td><button @click="deleteOne(index)">删除</button></td>
</tr>
</tbody>
<tfoot>
<tr>
总计:{{
totalePrice
}}
</tr>
</tfoot>
</table>
</div>
</template>
<style scoped lang="css"></style>
源码位置:packages/runtime-core/src/apiWatch.ts
// watch的函数定义里面实际上就是调用doWatch方法:
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
if (__DEV__ && !cb) {
if (immediate !== undefined) {
warn(
`watch() "immediate" option is only respected when using the ` +
`watch(source, callback, options?) signature.`
)
}
if (deep !== undefined) {
warn(
`watch() "deep" option is only respected when using the ` +
`watch(source, callback, options?) signature.`
)
}
}
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
if (__DEV__ && !cb) {
// 省略部分代码
const instance =
getCurrentScope() === currentInstance?.scope ? currentInstance : null
// const instance = currentInstance
let getter: () => any
let forceTrigger = false
let isMultiSource = false
// 一开始,要先格式化参数
if (isRef(source)) { // 如果target是一个ref对象,直接将source.value赋值getter
getter = () => source.value
forceTrigger = isShallow(source)
} else if (isReactive(source)) { // 如果target是一个reactive对象,直接将source赋值getter
getter = () => source
deep = true // 如果reactive对象,会直接开启deep,深度监听
} else if (isArray(source)) { // 如果target是一个数组对象,那么就要对数组中的每一个对象参数都进行格式化,再次确认属于ref对象还是reactive对象还是其他
isMultiSource = true
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
getter = () =>
source.map(s => {
if (isRef(s)) {
return s.value
} else if (isReactive(s)) {
return traverse(s) // 实际就是递归监听每一个属性
} else if (isFunction(s)) {
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
} else {
__DEV__ && warnInvalidSource(s)
}
})
} else if (isFunction(source)) { // 如果source是一个函数,会先判断下是否有cb,如果有cb,要对source进行封装处理
if (cb) {
// getter with cb
getter = () =>
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
} else { // 否则如果没有cb,那么就是执行watchEffect的逻辑
// no cb -> simple effect
getter = () => {
if (instance && instance.isUnmounted) {
return
}
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup]
)
}
}
} else {
getter = NOOP
__DEV__ && warnInvalidSource(source)
}
// 省略部分代码
if (cb && deep) { // 如果有cb和开启了deep,表明要对对象进行深度监听
const baseGetter = getter
getter = () => traverse(baseGetter())
}
let cleanup: () => void
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
}
}
// 省略部分代码
// 一开始给oldValue赋值一个空对象
let oldValue: any = isMultiSource
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
: INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
if (!effect.active) {
return
}
if (cb) { // 如果有cb,则计算一次新值newValue
// watch(source, cb)
const newValue = effect.run()
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue)) ||
(__COMPAT__ &&
isArray(newValue) &&
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
) {
// cleanup before running cb again
if (cleanup) {
cleanup()
}
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue, // 新值已经计算出来了
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE // 最开始oldValue默认设置的空对象,那么第一次执行的时候,此时oldValue就被设置为undefined
? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
? []
: oldValue,
onCleanup
])
oldValue = newValue // 随后将新值设置为oldValue,那么下一次的时候,oldValue就为上一次的newValue,而newValue在上边会重新计算,从而就会记录oldValue和newValue
}
} else {
// watchEffect
effect.run()
}
}
// important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727)
job.allowRecurse = !!cb;
let scheduler: EffectScheduler;
if (flush === "sync") { // 如果flush设置为sync,同步执行job
scheduler = job as any; // the scheduler function gets called directly
} else if (flush === "post") { // 如果flush设置为post,将job交给queuePostRenderEffect,并在组件加载完之后执行job
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense);
} else {
// default: 'pre'
// 默认flush设置为pre,将job加入到队列中,并设置job.pre = true,说明在组件加载完之前执行job
job.pre = true;
if (instance) job.id = instance.uid;
scheduler = () => queueJob(job);
}
// 最后将job和scheduler交给ReactiveEffect,进行依赖收集,当依赖发生变化的时候,就会处理已开收集,从而去执行job
const effect = new ReactiveEffect(getter, scheduler);
if (__DEV__) {
effect.onTrack = onTrack;
effect.onTrigger = onTrigger;
}
// initial run
if (cb) { // 如果设置了cb,并且immediate设置为true,则会立即执行job
if (immediate) {
job(); // 立马调用一次job函数,在job函数里面,会去计算一次新值。job的定义在上边
} else {
oldValue = effect.run(); // 否则计算一次oldValue,
}
} else if (flush === "post") {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense
);
} else {
effect.run();
}
const unwatch = () => {
effect.stop();
if (instance && instance.scope) {
remove(instance.scope.effects!, effect);
}
};
if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch);
return unwatch;
}
1、watch接收三个参数,target、cb、options,并返回一个watchstophandle,用于停止监听。
2、在options中可以设置watch的执行时机,对象的深度监听等。
3、watch最终将函调封装为job调度,依赖发生变化的时候,job被触发,每一次调用通过将上一次的新值赋值给旧值并且重新计算新值,来达到记录新旧值的机制。