react-v16-Update renderPhase篇

前言

这篇主要还是笔记性质,一边探索一边记录。

因为Fiber链表性质,Update被重新实现,这里需要重新分析一下。

细节

这一节主要是对源码的分析。

先预设置一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class App extends React.Component{
state = {
text: 'Text'
}
changeText = () => {
this.setState({
text: 'Hello World'
})
}
render () {
return (
<div className="App">
<header className="App-header">
<div>{this.state.text}</div>
<button onClick={this.changeText}>change Text</button>
</header>
</div>
)
}
}

我们忽略事件相关的东西,专注setState。

始于setState

基于v15的理解,不管是props更新,还是state更新,实质上归根结底,还是setState触发的更新。

v16的props更新呢,它会不遵循这个路线吗?思前想后的结果是:不会。所以这里就直接分析setState了。

这里寻找这个定义挺容易的,直接命令行输入 grep -rn 'prototype.setState' ./packages就可以查出来。当然,断点更容易出来。

1
2
3
Component.prototype.setState = function(partialState, callback) {
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

这里涉及到了this.updater。不妨全局查一下:grep -rn '\.updater =' ./packages。出来的结果只有./packages/react-reconciler/src/ReactFiberClassComponent.js:497,也就是adoptClassInstance函数可能是调用,观察这个赋值的目标classComponentUpdater,也能基本证明这个猜测。

所以这里this.updater.enqueueSetState实质上就是classComponentUpdater.enqueueSetState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enqueueSetState(inst, payload, callback) {
// 此时inst为App实例 payload={text: 'Hello World'} callback=undefined
const fiber = getInstance(inst);
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);

const update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
update.callback = callback;
}
flushPassiveEffects(); // 这个例子中这个函数什么也没做
enqueueUpdate(fiber, update);
scheduleWork(fiber, expirationTime);
},

这里关系到update的实质上就是enqueueUpdate(fiber, update),说白了,这里update主要还是设置一个expirationTime, fiber节点上的更新队列才是实质核心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export function enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
// Update queues are created lazily.
const alternate = fiber.alternate;
let queue1;
let queue2;
if (alternate === null) {
// There's only one fiber.
// 仅有一个fiber节点 此时更新是一个初始渲染
// 此时由memoizedState创建一个更新即可 此时memoizedState是{element: ReactNode}结构
queue1 = fiber.updateQueue;
queue2 = null;
if (queue1 === null) {
// createUpdateQueue返回一个空update,baseState = {text: 'Text'}
queue1 = fiber.updateQueue = createUpdateQueue(fiber.memoizedState);
}
} else {
// 没有进入此分支
}
if (queue2 === null || queue1 === queue2) {
// There's only a single queue.
// 此时只有一个queue
appendUpdateToQueue(queue1, update);
} else {
// 没有进入此分支
}
}

这里enqueueUpdate主要是调用appendUpdateToQueue。这个函数基本可以理解为向update数组push一个元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function appendUpdateToQueue(queue, update) {
// lastUpdate===null说明之前是空的队列
if (queue.lastUpdate === null) {
// 此例中进入这个分支了 update是一个对象,结构如下
// {
// callback: null
// expirationTime: 1073741823
// next: null
// nextEffect: null
// payload: {text: "Hello World"}
// tag: 0
// }
queue.firstUpdate = queue.lastUpdate = update;
} else {
// 否则将update放到链表队列尾部
queue.lastUpdate.next = update;
queue.lastUpdate = update;
}
}

走完这一步时候,fiber<APP>.updateQueue链表加入了update,updateQueue上firstEffect指向了这个update。

然后enqueueSetState开始执行scheduleWork(fiber, expirationTime)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
// root指向FiberRoot
const root = scheduleWorkToRoot(fiber, expirationTime);
if (root === null) {
return;
}

if (
!isWorking &&
nextRenderExpirationTime !== NoWork &&
expirationTime > nextRenderExpirationTime
) {
// This is an interruption. (Used for performance tracking.)
interruptedBy = fiber;
resetStack();
}
markPendingPriorityLevel(root, expirationTime);
if (
// If we're in the render phase, we don't need to schedule this root
// for an update, because we'll do it before we exit...
!isWorking ||
isCommitting ||
// ...unless this is a different root than the one we're rendering.
nextRoot !== root
) {
const rootExpirationTime = root.expirationTime;
requestWork(root, rootExpirationTime);
}
}

这个函数主要是从Fiber开始往上遍历,更新对应节点的childExpirationTime属性,然后返回FiberRoot节点。这里涉及到更新时机判定,但是这里暂时没有研究过这个时间,所以先放放。然后接下来会执行requestWork(root, rootExpirationTime)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
addRootToSchedule(root, expirationTime);
if (isRendering) {
// 禁止递归调用 后面的任务在结束后再重新开始
return;
}

if (isBatchingUpdates) { // 此时isBatchingUpdates === true
// 在批处理结束后开始清洗工作(针对脏组件|Fiber?)
if (isUnbatchingUpdates) { // 此时isUnbatchingUpdates === false 里面逻辑不会进入
// 除非被排除在unbatchedUpdates,否则现在需要开始进行清洗
nextFlushedRoot = root;
nextFlushedExpirationTime = Sync;
performWorkOnRoot(root, Sync, false);
}
return;
}
// 后面因为return 都不会执行 也就不会直接进入render调用栈
// TODO: Get rid of Sync and use current time?
if (expirationTime === Sync) {
performSyncWork();
} else {
scheduleCallbackWithExpirationTime(root, expirationTime);
}
}

此时入注释中所标,这个函数几乎不会执行任何东西,除了开头那一句——addRootToSchedule(root, expirationTime)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function addRootToSchedule(root, expirationTime) {
// Add the root to the schedule.
// Check if this root is already part of the schedule.
if (root.nextScheduledRoot === null) {
// This root is not already scheduled. Add it.
root.expirationTime = expirationTime;

if (lastScheduledRoot === null) {
firstScheduledRoot = lastScheduledRoot = root;
root.nextScheduledRoot = root;
} else {
// 没进来 略
}
} else {
// 没进来 略
}
}

作为一个链表,lastScheduledRoot代表的是下一个操作、读取的目标。所以这里线索基本上可以锁定到读取了lastScheduledRoot变量的函数。这里能列入候选的函数只有两个

  • findHighestPriorityRoot
  • addRootToSchedule

但是满足场景的目标只有findHighestPriorityRoot。进一步反推,findHighestPriorityRoot调用者只有performWork——所以呢,不管什么,这个场景下,最后引起DOM更新的,必定、也必须是performWork。

Tips: 这里之所以说lastScheduledRoot代表的是下一个操作,是因为这里没有对应的nextScheduledRoot变量,这个nextScheduledRoot直接挂到root节点上了,所以lastScheduledRoot就是下一个,也是最后一个。

Tips: 关于findHighestPriorityRoot可以后面看看Reconciler部分分析,会有详细分析。这里仅仅做脉络推导。

略过的Event

不管怎样,v16更新后更新逻辑因为基础数据结构变化,出了一些必要的变化,总之这里更新后DOM确确实实不再是setState直接引起的了。它被耦合进了事件这一块。当更新队列处理完毕之后,React只是不动声色lastScheduledRoot赋值给了fiberRoot,然后由事件机制处理了后续。

但是这里不打算调过头去研究新的Event了。所以还是通过断点来过去调用栈。这里产生的调用栈是:

1
2
3
4
5
6
dispatchInteractiveEvent
->interactiveUpdates
-->dispatchEvent
--->performSyncWork
---->performWork
----->performWorkOnRoot -> renderRoot & completeRoot

这里暂时不管是如何调用下来的,但是这里能确认的是当断点走过performWorkOnRoot函数。Text在DOM上就完成了从Text到HelloWorld的过程。

performWorkOnRoot

这个函数其实在render篇已经提到过了。不过这里重点是要把Update部分单独拎出来讲,侧重点有所不同。

这里基础的路径还是:

1
2
3
4
5
6
performWorkOnRoot
->beginWork
-->updateClassComponent
---->updateClassInstance
----->processUpdateQueue
------>getStateFromUpdate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function getStateFromUpdate<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
update: Update<State>,
prevState: State,
nextProps: any,
instance: any,
): any {
switch (update.tag) {
case ReplaceState: {
// 略
}
case CaptureUpdate: {
// 略
}
case UpdateState: {
const payload = update.payload;
let partialState;
if (typeof payload === 'function') {
partialState = payload.call(instance, prevState, nextProps);
} else {
partialState = payload;
}
if (partialState === null || partialState === undefined) {
return prevState;
}
return Object.assign({}, prevState, partialState);
}
case ForceUpdate: {
hasForceUpdate = true;
return prevState;
}
}
return prevState;
}

这里场景下的核心是Object.assign({}, prevState, partialState)。很好理解。

processUpdateQueue这个函数在这里需要关注点的是,更新了workInProgress.memoizedState。但是这是App这个fiber节点的事情。不妨回顾有关ChildReconciler的分析。当我们把文首的例子拆成Fiber,有几个节点呢(这里由FunctionComponent->ClassComponent了)?

这里答案是6个。我们添加一个button。

1
2
3
4
5
6
1. FiberRoot tag = 3
2. fiberNode{elementType = App} tag = 1
3. fiberNode{elementType = 'div'} tag = 5
4. fiberNode{elementType = 'header'} tag = 5
5. fiberNode{elementType = 'div'} tag = 5
6. fiberNode{elementType = 'button'} tag = 5

这里完全可以做一个小结,在这个更新的的render环节,主要是两个Fiber节点变了。

  • 第二个节点memoizedState变化为{text: ‘Hello World’}
  • 第五个节点里面memoizedProps和pendingProps节点里面分别保存了新旧不同的children。

以v15的Diff算法。会针对第五个节点执行创建新节点对旧节点进行替换、插入第六个节点。我们后面再看看V16里面是如何实现Diff的。

总之,这是completeRoot环节的问题。切略过不提。

Hook的实现

原本想过如何去理解Hook,但是最后决定把它作为Update的一个小结来分析。

由很多人说Hook其实可以作为Redux的替代,但是Redux本身是借助setState实现,所以这里看看Hook是如何处理的。

这里需要一个新的例子。这里改造一下。

1
2
3
4
5
6
7
8
9
10
11
12
import React, { useState } from 'react';
function App () {
const [text, setText] = useState('Text');
return (
<div className="App">
<header className="App-header">
<div>{text}</div>
<button onClick={() => { setText('Hello World') }}>change Text</button>
</header>
</div>
)
}

看看useState的定义:

1
2
3
4
5
6
7
8
9
10
11
export function useState<S>(initialState: (() => S) | S) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current;
return dispatcher;
}
const ReactCurrentDispatcher = {
current: (null: null | Dispatcher),
}

这个逻辑埋得有点深。断点在useState('Text')之前,可以发现它是ƒ bound dispatchAction(),最终是对dispatchAction的处理。

再看看调试工具里面的调用栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
App (App.js:5)
renderWithHooks (react-dom.development.js:13449)
updateFunctionComponent (react-dom.development.js:15199)
beginWork (react-dom.development.js:16252)
performUnitOfWork (react-dom.development.js:20279)
workLoop (react-dom.development.js:20320)
renderRoot (react-dom.development.js:20400)
performWorkOnRoot (react-dom.development.js:21357)
performWork (react-dom.development.js:21267)
performSyncWork (react-dom.development.js:21241)
interactiveUpdates$1 (react-dom.development.js:21526)
interactiveUpdates (react-dom.development.js:2268)
dispatchInteractiveEvent (react-dom.development.js:5085)

根据renderWithHooks函数,可以做出的论断是ReactCurrentDispatcher.current 可能的值是 HooksDispatcherOnUpdate && HooksDispatcherOnMount。初始渲染阶段,它是HooksDispatcherOnMount,之后它是HooksDispatcherOnUpdate。

这里得看看HooksDispatcherOnMount,然后才是HooksDispatcherOnUpdate。

为什么是这个顺序?因为setText是一个函数,后面在update环节会调用。而它里面有很多变量,必须在这里形成闭包缓存起来以备后面使用。

如果无法理解这个mount & update。这里做个简要分析:

  • 当我们初次渲染渲染时候,App函数会运行,useState会运行第一次。这是一个初始化
  • 当App里面onClick触发setText时候,useState里面会有第二次运行。但是我们的useState依然会运行第二次。
  • 这里问题来了: 这两次useState运行过程中,又应当是怎样的实现的数据变更和变量传递呢?

Mount && Dispatch

第一阶段是Mount,这是初始化渲染环节里面的处理方式。

HooksDispatcherOnMount.useState:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
last: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
// Flow doesn't know this is non-null, but we do.
((currentlyRenderingFiber: any): Fiber),
queue,
): any));
return [hook.memoizedState, dispatch];
}

mountWorkInProgressHook构建了一个空的Hook数据结构,它和Fiber很像,或者说,它是fiber的一个子集。

1
2
3
4
5
6
7
export type Hook = {
memoizedState: any,
baseState: any,
baseUpdate: Update<any, any> | null,
queue: UpdateQueue<any, any> | null,
next: Hook | null,
};

Tips: Hook也被储存在链表结构中。它们使用以下变量进行储存,next连接所有Hook:

1
2
3
4
5
6
> let currentHook: Hook | null = null;
> let nextCurrentHook: Hook | null = null;
> let firstWorkInProgressHook: Hook | null = null;
> let workInProgressHook: Hook | null = null;
> let nextWorkInProgressHook: Hook | null = null;
>

这里初始化渲染是将firstWorkInProgressHook,workInProgressHook都设为了这个新建的hook。

而currentlyRenderingFiber变量在renderWithHooks函数里面有定义,它是当前渲染的Fiber节点。

1
currentlyRenderingFiber = workInProgress;

所以说,就这个setText函数来说,未看其内容,已经可以知道,它可以获取queue(尤其是内部的memoizedState值,这里场景是text变量),同时也可以访问到对应的Fiber节点,它形成一个闭包。接下来看看dispatchAction函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const alternate = fiber.alternate;
if (
fiber === currentlyRenderingFiber ||
(alternate !== null && alternate === currentlyRenderingFiber)
) {
// 这里没有进入此分支 暂时略过
} else {
flushPassiveEffects();

const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);

const update: Update<S, A> = {
expirationTime,
action,
eagerReducer: null,
eagerState: null,
next: null,
};

// Append the update to the end of the list.
// 以下逻辑是 是将update添加到链表尾部
// 当queue.last===null.此时queue是空的。queue.next = queue.last = update
// 否则。走下面注释的逻辑
const last = queue.last;
if (last === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
const first = last.next;
if (first !== null) {
// 检查queue.last 如果有值。追加到update.next上
update.next = first;
}
// queue.last.next = update。将update放到了queue链表最后一个下一个节点
last.next = update;
}
queue.last = update; // 正式将last指针移到update

if (
fiber.expirationTime === NoWork &&
(alternate === null || alternate.expirationTime === NoWork)
) {
// The queue is currently empty, which means we can eagerly compute the
// next state before entering the render phase. If the new state is the
// same as the current state, we may be able to bail out entirely.
// 当前队列为空,这意味我们可以直接进行新的state计算 如果新的state和旧的完全一直
// 那么就可以什么不做了
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher;
try {
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
// Stash the eagerly computed state, and the reducer used to compute
// it, on the update object. If the reducer hasn't changed by the
// time we enter the render phase, then the eager state can be used
// without calling the reducer again.
// 缓存之前update对象上 计算出的state,以及用来计算这个state的reducer函数
// 如果reducer函数在进入render phase时没有变化,那么可直接使用之前缓存的值而不需要重新计算
update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 这是一个捷径。我们可以直接结束而不去规划re-render
// 但是它还是有可能稍后重新定义update——如果后面这个组件因为其他原因被re-render
// 并且此时Reducer函数被更改的话。
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally { }
}
}
scheduleWork(fiber, expirationTime);
}
}

虽然里面逻辑很多,但是它核心的地方却只有几个:

  • 根据action参数(Hello World)创建了一个update
  • update被加入到了queue链表上
  • 执行scheduleWork

此时因为scheduleWork在batchedUpdates函数下游,isBatchingUpdates(这个变量在batchedUpdates更改并后续requestWork中引用)被赋值为true,所以scheduleWork并不会引发后面的commit phase阶段。

而是由事件系统触发了。调用栈其实和上面Event提到的一致。

到了这里,最后的疑问可能就是commit阶段里,后续它究竟是如何获取queue链表了,这里还是call by share相关知识了,这里不再提及,主要还是对hook变量上的queue做了修改,此时hook.queue被添加了一个update到尾部上。当setText被导出,这个hook就会因为闭包被缓存再mountState的作用域里面不会被GC。

由于这个hook每次运行都会重新生成新的hook,所以多个FunctionComponent里面相同的setText使用不会读取到旧的值。

而且因为hook没有被导出过,renderWithHook也由相关render阶段执行,所以也无法在React组件之外访问到它。

以下是重点。前面我们提到了对hook的创建,操作,链表结构,以及firstWorkInProgressHook变量。但是它们都没有做导出。这里hook是链表,它和WorkInProgress是相同的性质,它将可以类似全局性质的获取、变更。

回头仔细观察renderWithHooks函数。其中两句显得尤为关键。

1
2
3
4
let children = Component(props, refOrContext);
// 略
const renderedWork: Fiber = (currentlyRenderingFiber: any);
renderedWork.memoizedState = firstWorkInProgressHook;

综上,可以知道renderedWork.memoizedState变量被赋值未新建的那个Hook。为什么在末尾重点提到它呢?因为它不但承上,而且启下,是整个hook和fiber结构的联结点。

现在已知memoizedState不但会保存常规的memoizedState值,还会保存ReactElement和Hook。

Update

然后就是我们想知道的更新方面的环节。当renderWithHook再度调起App(),此时HooksDispatcherOnUpdate.useState就有了用武之地,它实质指向updateState。

1
2
3
4
5
6
7
8
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}

而updateReducer里面有这样的返回:

1
2
3
4
5
6
7
8
9
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S, ): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}

这里hook执行后的返回值大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
baseState: "Text"
baseUpdate: null
memoizedState: "Text"
next: null
queue: {
dispatch: ƒ ()
last: {expirationTime: 1073741823, action: "Hello World", eagerReducer: ƒ, eagerState: "Hello World", next: {…}}
lastRenderedReducer: ƒ basicStateReducer(state, action)
lastRenderedState: "Text"
}
}

参见updateWorkInProgressHook,它返回的主要是nextCurrentHook的一个副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function updateWorkInProgressHook(): Hook {
if (nextWorkInProgressHook !== null) {
// 略 未进入此分支
} else {
// Clone from the current hook.
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
queue: currentHook.queue,
baseUpdate: currentHook.baseUpdate,

next: null,
};

if (workInProgressHook === null) {
// This is the first hook in the list.
workInProgressHook = firstWorkInProgressHook = newHook;
} else {
// Append to the end of the list.
workInProgressHook = workInProgressHook.next = newHook;
}
nextCurrentHook = currentHook.next;
}
return workInProgressHook;
}

这里关键是nextCurrentHook变量,再回头看看renderWithHooks,里面有一句:

nextCurrentHook = current !== null ? current.memoizedState : null

所以一切都顺理成章了。它们都指向了之前创建的hook。

到这里我们就可以明白,这个遍历是如何从dispatch传递到update环节的。在Mount环节,我们初始化了一个Hook,然后再dispatch我们更新了这个Hook,并将他赋值到了当前fiberNode的memoizedState属性。最后我们更新环节则更换了一个useState函数,它在里面获取了dispatch变更后的Hook,然后执行了后续渲染。

在之前的ChildReconciler篇里面其实有提到renderWithHooks。但是那时候只是专注于它的结果,它返回的是一颗展开完毕的VDOM树。

这里我们仍然不做接下来的细节分析,但是,对于最简单的Hook更新,我们已经对其数据流变化一清二楚了。

Commit

这里还是走的Event这块。

1
2
3
4
5
6
dispatchInteractiveEvent
->interactiveUpdates
-->dispatchEvent
--->performSyncWork
---->performWork
----->performWorkOnRoot -> renderRoot & completeRoot

但是为了能更好代入Hook这块,我们做一些更细致的工作:

1
2
3
4
5
6
7
8
9
10
11
dispatchInteractiveEvent
->interactiveUpdates
-->dispatchEvent
--->performSyncWork
---->performWork
----->performWorkOnRoot -> renderRoot() {
workLoop
->performUnitOfWork
-->beginWork
---->updateFunctionComponent ->renderWithHooks
} & completeRoot

这样,就能将Event这块和Hook这块衔接上了。