前言
在 Vue.js 中,组件是一个非常重要的概念,整个应用的页面都是通过组件渲染来实现的,本文主要分析组件的内部是如何更新的,其中也涉及到核心的 DOM diff 算法。
副作用渲染函数
副作用函数简单来说是,函数里面响应式数据改变会触发函数的执行,在 Vue 中 数据的变化会触发 setupRenderEffect 副作用渲染函数更新组件。
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
// 创建响应式的副作用渲染函数
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
// 渲染组件
}
else {
// 更新组件
let { next, vnode } = instance
// next 表示新的组件 vnode
if (next) {
// 更新组件 vnode 节点信息
updateComponentPreRender(instance, next, optimized)
}
else {
next = vnode
}
// 渲染新的子树 vnode
const nextTree = renderComponentRoot(instance)
// 缓存旧的子树 vnode
const prevTree = instance.subTree
// 更新子树 vnode
instance.subTree = nextTree
// 组件更新核心逻辑,根据新旧子树 vnode 做 patch
patch(prevTree, nextTree,
// 如果在 teleport 组件中父节点可能已经改变,所以容器直接找旧树 DOM 元素的父节点
hostParentNode(prevTree.el),
// 参考节点在 fragment 的情况可能改变,所以直接找旧树 DOM 元素的下一个节点
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG)
// 缓存更新后的 DOM 节点
next.el = nextTree.el
}
}, prodEffectOptions)
}
复制代码
可以看到,更新组件主要做三件事情:
- 更新组件 vnode 节点:这里会有一个条件判断,判断组件实例中是否有新的组件 vnode(用 next 表示),有则更新组件 vnode,没有 next 指向之前的组件 vnode。
- 渲染新的子树 vnode:因为数据发生了变化,模板又和数据相关,所以渲染生成的子树 vnode 也会发生相应的变化。
- 根据新旧子树 vnode 执行 patch 逻辑:渲染新的子树 vnode,因为数据发生了变化,模板又和数据相关,所以渲染生成的子树 vnode 也会发生相应的变化,接下来我们就来分析这个过程。
patch 流程
patch 流程的实现代码:
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
// 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
// n1 设置为 null 保证后续都走 mount 逻辑
n1 = null
}
const { type, shapeFlag } = n2
switch (type) {
case Text:
// 处理文本节点
break
case Comment:
// 处理注释节点
break
case Static:
// 处理静态节点
break
case Fragment:
// 处理 Fragment 元素
break
default:
if (shapeFlag & 1 /* ELEMENT */) {
// 处理普通 DOM 元素
processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
else if (shapeFlag & 6 /* COMPONENT */) {
// 处理组件
processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
else if (shapeFlag & 64 /* TELEPORT */) {
// 处理 TELEPORT
}
else if (shapeFlag & 128 /* SUSPENSE */) {
// 处理 SUSPENSE
}
}
}
function isSameVNodeType (n1, n2) {
// n1 和 n2 节点的 type 和 key 都相同,才是相同节点
return n1.type === n2.type && n1.key === n2.key
}
复制代码
在这个过程中,首先判断新旧节点是否是相同的 vnode 类型,如果不同,比如一个 div 更新成一个 ul,那么最简单的操作就是删除旧的 div 节点,再去挂载新的 ul 节点。
如果是相同的 vnode 类型,就需要走 diff 更新流程了,接着会根据不同的 vnode 类型执行不同的处理逻辑,这里我们仍然只分析普通元素类型和组件类型的处理过程。
处理组件
组件更新的相关逻辑:
const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
if (n1 == null) {
// 挂载组件
}
else {
// 更新子组件
updateComponent(n1, n2, parentComponent, optimized)
}
}
const updateComponent = (n1, n2, parentComponent, optimized) => {
const instance = (n2.component = n1.component)
// 根据新旧子组件 vnode 判断是否需要更新子组件
if (shouldUpdateComponent(n1, n2, parentComponent, optimized)) {
// 新的子组件 vnode 赋值给 instance.next
instance.next = n2
// 子组件也可能因为数据变化被添加到更新队列里了,移除它们防止对一个子组件重复更新
invalidateJob(instance.update)
// 执行子组件的副作用渲染函数
instance.update()
}
else {
// 不需要更新,只复制属性
n2.component = n1.component
n2.el = n1.el
}
}
复制代码
processComponent 主要通过执行 updateComponent 函数来更新子组件,updateComponent 函数在更新子组件的时候,会先执行 shouldUpdateComponent 函数,根据新旧子组件 vnode 来判断是否需要更新子组件。这里你只需要知道,在 shouldUpdateComponent 函数的内部,主要是通过检测和对比组件 vnode 中的 props、chidren、dirs、transiton 等属性,来决定子组件是否需要更新。
我们接着看 updateComponent 函数,如果 shouldUpdateComponent 返回 true ,那么在它的最后,先执行 invalidateJob(instance.update)避免子组件由于自身数据变化导致的重复更新,然后又执行了子组件的副作用渲染函数 instance.update 来主动触发子组件的更新。
所以 processComponent 处理组件 vnode,本质上就是去判断子组件是否需要更新,如果需要则递归执行子组件的副作用渲染函数来更新,否则仅仅更新一些 vnode 的属性,并让子组件实例保留对组件 vnode 的引用,用于子组件自身数据变化引起组件重新渲染的时候,在渲染函数内部可以拿到新的组件 vnode。
组件是抽象的,组件的更新最终还是会落到对普通 DOM 元素的更新。所以接下来我们详细分析一下组件更新中对普通元素的处理流程。
处理普通元素
代码实现:
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
isSVG = isSVG || n2.type === 'svg'
if (n1 == null) {
// 挂载元素
}
else {
// 更新元素
patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
}
}
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, optimized) => {
const el = (n2.el = n1.el)
const oldProps = (n1 && n1.props) || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
// 更新 props
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
// 更新子节点
patchChildren(n1, n2, el, null, parentComponent, parentSuspense, areChildrenSVG)
}
复制代码
更新元素的过程主要做两件事情:更新 props 和更新子节点。
首先是更新 props,这里的 patchProps 函数就是在更新 DOM 节点的 class、style、event 以及其它的一些 DOM 属性。
其次是更新子节点,我们来看一下这里的 patchChildren 函数的实现:
const patchChildren = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized = false) => {
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { shapeFlag } = n2
// 子节点有 3 种可能情况:文本、数组、空
if (shapeFlag & 8 /* TEXT_CHILDREN */) {
if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
// 数组 -> 文本,则删除之前的子节点
unmountChildren(c1, parentComponent, parentSuspense)
}
if (c2 !== c1) {
// 文本对比不同,则替换为新文本
hostSetElementText(container, c2)
}
}
else {
if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
// 之前的子节点是数组
if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 新的子节点仍然是数组,则做完整地 diff
patchKeyedChildren(c1, c2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
// 数组 -> 空,则仅仅删除之前的子节点
unmountChildren(c1, parentComponent, parentSuspense, true)
}
}
else {
// 之前的子节点是文本节点或者为空
// 新的子节点是数组或者为空
if (prevShapeFlag & 8 /* TEXT_CHILDREN */) {
// 如果之前子节点是文本,则把它清空
hostSetElementText(container, '')
}
if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 如果新的子节点是数组,则挂载新子节点
mountChildren(c2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
}
}
}
复制代码
对于一个元素的子节点 vnode 可能会有三种情况:纯文本、vnode 数组和空。那么根据排列组合对于新旧子节点来说就有九种情况。 首先来看一下旧子节点是纯文本的情况:
- 如果新子节点也是纯文本,那么做简单地文本替换即可;
- 如果新子节点是空,那么删除旧子节点即可;
- 如果新子节点是 vnode 数组,那么先把旧子节点的文本清空,再去旧子节点的父容器下添加多个新子节点。
接下来看一下旧子节点是空的情况:
- 如果新子节点是纯文本,那么在旧子节点的父容器下添加新文本节点即可;
- 如果新子节点也是空,那么什么都不需要做;
- 如果新子节点是 vnode 数组,那么直接去旧子节点的父容器下添加多个新子节点即可。
最后来看一下旧子节点是 vnode 数组的情况:
- 如果新子节点是纯文本,那么先删除旧子节点,再去旧子节点的父容器下添加新文本节点;
- 如果新子节点是空,那么删除旧子节点即可;
- 如果新子节点也是 vnode 数组,那么就需要做完整的 diff 新旧子节点了,这是最复杂的情况,内部运用了核心 diff 算法。
Diff 算法
新子节点数组相对于旧子节点数组的变化,无非是通过更新、删除、添加和移动节点来完成,而核心 diff 算法,就是在已知旧子节点的 DOM 结构、vnode 和新子节点的 vnode 情况下,以较低的成本完成子节点的更新为目的,求解生成新子节点 DOM 的系列操作。
1. 同步头部节点
头部节点同步的实现代码:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 3, e2 = 4
// (a b) c d
// (a b) e c d
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
// 相同的节点,递归执行 patch 更新节点
patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
break
}
i++
}
}
复制代码
在整个 diff 的过程,我们需要维护几个变量:头部的索引 i、旧子节点的尾部索引 e1和新子节点的尾部索引 e2。
同步头部节点就是从头部开始,依次对比新节点和旧节点,如果它们相同的则执行 patch 更新节点;如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。
2. 同步尾部节点
代码如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 3, e2 = 4
// (a b) c d
// (a b) e c d
// 2. 从尾部开始同步
// i = 2, e1 = 3, e2 = 4
// (a b) (c d)
// (a b) e (c d)
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
break
}
e1--
e2--
}
}
复制代码
同步尾部节点就是从尾部开始,依次对比新节点和旧节点,如果相同的则执行 patch 更新节点;如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。
3. 添加新的节点
判断新子节点是否有剩余的情况,如果满足则添加新子节点,实现代码如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 3, e2 = 4
// (a b) c d
// (a b) e c d
// ...
// 2. 从尾部开始同步
// i = 2, e1 = 3, e2 = 4
// (a b) (c d)
// (a b) e (c d)
// 3. 挂载剩余的新节点
// i = 2, e1 = 1, e2 = 2
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor
while (i <= e2) {
// 挂载新节点
patch(null, c2[i], container, anchor, parentComponent, parentSuspense, isSVG)
i++
}
}
}
}
复制代码
如果索引 i 大于尾部索引 e1 且 i 小于 e2,那么从索引 i 开始到索引 e2 之间,我们直接挂载新子树这部分的节点。
4. 删除多余节点
如果不满足添加新节点的情况,我就要接着判断旧子节点是否有剩余,如果满足则删除旧子节点,实现代码如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 4, e2 = 3
// (a b) c d e
// (a b) d e
// ...
// 2. 从尾部开始同步
// i = 2, e1 = 4, e2 = 3
// (a b) c (d e)
// (a b) (d e)
// 3. 普通序列挂载剩余的新节点
// i = 2, e1 = 2, e2 = 1
// 不满足
if (i > e1) {
}
// 4. 普通序列删除多余的旧节点
// i = 2, e1 = 2, e2 = 1
else if (i > e2) {
while (i <= e1) {
// 删除节点
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
}
复制代码
如果索引 i 大于尾部索引 e2,那么从索引 i 开始到索引 e1 之间,我们直接删除旧子树这部分的节点。
5. 处理未知子序列
单纯的添加和删除节点都是比较理想的情况,操作起来也很容易,但是有些时候并非这么幸运,我们会遇到比较复杂的未知子序列。
5.1 建立索引图
通常我们在开发过程中, 会给 v-for 生成的列表中的每一项分配唯一 key 作为项的唯一 ID,这个 key 在 diff 过程中起到很关键的作用。对于新旧子序列中的节点,我们认为 key 相同的就是同一个节点,直接执行 patch 更新即可。
我们根据 key 建立新子序列的索引图,实现如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 7, e2 = 7
// (a b) c d e f g h
// (a b) e c d i g h
// 2. 从尾部开始同步
// i = 2, e1 = 7, e2 = 7
// (a b) c d e f (g h)
// (a b) e c d i (g h)
// 3. 普通序列挂载剩余的新节点, 不满足
// 4. 普通序列删除多余的旧节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子序列开始索引,从 i 开始记录
const s1 = i
// 新子序列开始索引,从 i 开始记录
const s2 = i
// 5.1 根据 key 建立新子序列的索引图
const keyToNewIndexMap = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = c2[i]
keyToNewIndexMap.set(nextChild.key, i)
}
}
复制代码
新旧子序列是从 i 开始的,所以我们先用 s1、s2 分别作为新旧子序列的开始索引,接着建立一个 keyToNewIndexMap 的 Map<key, index> 结构,遍历新子序列,把节点的 key 和 index 添加到这个 Map 中
5.2 更新和移除旧节点
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 7, e2 = 7
// (a b) c d e f g h
// (a b) e c d i g h
// 2. 从尾部开始同步
// i = 2, e1 = 7, e2 = 7
// (a b) c d e f (g h)
// (a b) e c d i (g h)
// 3. 普通序列挂载剩余的新节点,不满足
// 4. 普通序列删除多余的旧节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子序列开始索引,从 i 开始记录
const s1 = i
// 新子序列开始索引,从 i 开始记录
const s2 = i
// 5.1 根据 key 建立新子序列的索引图
// 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
// 新子序列已更新节点的数量
let patched = 0
// 新子序列待更新节点的数量,等于新子序列的长度
const toBePatched = e2 - s2 + 1
// 是否存在要移动的节点
let moved = false
// 用于跟踪判断是否有节点移动
let maxNewIndexSoFar = 0
// 这个数组存储新子序列中的元素在旧子序列节点的索引,用于确定最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched)
// 初始化数组,每个元素的值都是 0
// 0 是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明这个新节点没有对应的旧节点
for (i = 0; i < toBePatched; i++)
newIndexToOldIndexMap[i] = 0
// 正序遍历旧子序列
for (i = s1; i <= e1; i++) {
// 拿到每一个旧子序列节点
const prevChild = c1[i]
if (patched >= toBePatched) {
// 所有新的子序列节点都已经更新,剩余的节点删除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 查找旧子序列中的节点在新子序列中的索引
let newIndex = keyToNewIndexMap.get(prevChild.key)
if (newIndex === undefined) {
// 找不到说明旧子序列已经不存在于新子序列中,则删除该节点
unmount(prevChild, parentComponent, parentSuspense, true)
}
else {
// 更新新子序列中的元素在旧子序列中的索引,这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar 始终存储的是上次求值的 newIndex,如果不是一直递增,则说明有移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
}
else {
moved = true
}
// 更新新旧子序列中匹配的节点
patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, optimized)
patched++
}
}
}
复制代码
我们建立了一个 newIndexToOldIndexMap 的数组,来存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,用于确定最长递增子序列,这个数组的长度为新子序列的长度,每个元素的初始值设为 0, 它是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明遍历旧子序列的过程中没有处理过这个节点,这个节点是新添加的。
具体的操作过程:正序遍历旧子序列,根据前面建立的 keyToNewIndexMap 查找旧子序列中的节点在新子序列中的索引,如果找不到就说明新子序列中没有该节点,就删除它;如果找得到则将它在旧子序列中的索引更新到 newIndexToOldIndexMap 中。
注意这里索引加了长度为 1 的偏移,是为了应对 i 为 0 的特殊情况,如果不这样处理就会影响后续求解最长递增子序列。
遍历过程中,我们用变量 maxNewIndexSoFar 跟踪判断节点是否移动,maxNewIndexSoFar 始终存储的是上次求值的 newIndex,一旦本次求值的 newIndex 小于 maxNewIndexSoFar,这说明顺序遍历旧子序列的节点在新子序列中的索引并不是一直递增的,也就说明存在移动的情况。
除此之外,这个过程中我们也会更新新旧子序列中匹配的节点,另外如果所有新的子序列节点都已经更新,而对旧子序列遍历还未结束,说明剩余的节点就是多余的,删除即可。
至此,我们完成了新旧子序列节点的更新、多余旧节点的删除,并且建立了一个 newIndexToOldIndexMap 存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,并确定是否有移动。
5.3 移动和挂载新节点
接下来,就到了处理未知子序列的最后一个流程,移动和挂载新节点,我们来看一下这部分逻辑的实现:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 6, e2 = 7
// (a b) c d e f g
// (a b) e c d h f g
// 2. 从尾部开始同步
// i = 2, e1 = 6, e2 = 7
// (a b) c (d e)
// (a b) (d e)
// 3. 普通序列挂载剩余的新节点, 不满足
// 4. 普通序列删除多余的节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子节点开始索引,从 i 开始记录
const s1 = i
// 新子节点开始索引,从 i 开始记录
const s2 = i //
// 5.1 根据 key 建立新子序列的索引图
// 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
// 5.3 移动和挂载新节点
// 仅当节点移动时生成最长递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
let j = increasingNewIndexSequence.length - 1
// 倒序遍历以便我们可以使用最后更新的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex]
// 锚点指向上一个更新的节点,如果 nextIndex 超过新子节点的长度,则指向 parentAnchor
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 挂载新的子节点
patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG)
}
else if (moved) {
// 没有最长递增子序列(reverse 的场景)或者当前的节点索引不在最长递增子序列中,需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, 2)
}
else {
// 倒序递增子序列
j--
}
}
}
}
复制代码
我们前面已经判断了是否移动,如果 moved 为 true 就通过 getSequence(newIndexToOldIndexMap) 计算最长递增子序列。 其中采用倒序的方式遍历新子序列,因为倒序遍历可以方便我们使用最后更新的节点作为锚点。在倒序的过程中,锚点指向上一个更新的节点,然后判断 newIndexToOldIndexMap[i] 是否为 0,如果是则表示这是新节点,就需要挂载它;接着判断是否存在节点移动的情况,如果存在的话则看节点的索引是不是在最长递增子序列中,如果在则倒序最长递增子序列,否则把它移动到锚点的前面。