作为前端开发者,你是否遇到过这样的困惑:为什么同样是连续调用三次 setState,在 onClick 中只触发一次渲染,在 setTimeout 里却触发三次?为什么 React 18 之后这些行为又统一了?这背后涉及的是 React 渲染机制在不同版本中的深刻变迁。
本文将从源码和实战两个视角,带你理解 React 15 到 React 18+ 的更新时机演变,帮你在日常开发中做出更正确的性能决策。
一、React 15 时代:Stack Reconciler 与同步渲染
1.1 架构特点
React 15 采用Stack Reconciler(栈调和器),其核心特征是:递归、同步、不可中断。
当你调用 setState 时,React 会从触发更新的组件开始,递归地对比整棵虚拟 DOM 树(diff),然后一次性将变更同步提交到真实 DOM。整个过程就像一个深度优先的函数调用栈,一旦开始就必须走完。
setState → 递归 diff 整棵树 → 同步更新 DOM → 完成 (整个过程不可中断,主线程被占用)
1.2 致命问题
这意味着如果组件树很庞大,一次更新可能占用主线程几十甚至上百毫秒。在这段时间内,用户的点击、输入、动画全部被阻塞,页面会出现明显的卡顿和掉帧。
举个例子,一个列表页面有 1000 个子组件,用户在搜索框输入一个字符触发了 setState,React 必须同步地把 1000 个组件全部 diff 完、DOM 全部更新完,用户才能看到输入框里的字符出现。这就是 React 15 的性能瓶颈。
1.3 setState 的"异步假象"
即便在 React 15 中,setState 也并非真正的异步——它只是在 React 的合成事件和生命周期函数中被"批处理"了。React 内部通过一个 isBatchingUpdates 标志来控制:
// React 15 的简化逻辑 let isBatchingUpdates = false; function batchedUpdates(fn) { isBatchingUpdates = true; fn(); // 执行你的事件处理函数 isBatchingUpdates = false; // 现在才真正执行更新 flushBatchedUpdates(); }在 onClick 等合成事件中,React 会先设置 isBatchingUpdates = true,然后执行你的回调。在回调中无论调用多少次 setState,都只是把更新放进队列,等回调结束后才一次性处理。所以你在回调中 console.log(this.state) 看到的还是旧值。
但在 setTimeout、原生事件监听、Promise.then 中,isBatchingUpdates 是 false,每次 setState 都会立即触发更新:
// React 15/16/17 中 handleClick() { // 合成事件中:批处理,只触发一次渲染 this.setState({ a: 1 }); this.setState({ b: 2 }); this.setState({ c: 3 }); // → 合并为一次渲染 } handleClickWithTimeout() { setTimeout(() => { // setTimeout 中:不在批处理上下文,每次都立即渲染 this.setState({ a: 1 }); // → 渲染一次 this.setState({ b: 2 }); // → 渲染一次 this.setState({ c: 3 }); // → 渲染一次 // → 共三次渲染! }, 0); }二、React 16-17:Fiber 架构与 ExpirationTime 优先级模型
2.1 Fiber 的诞生
React 16 引入了全新的Fiber 架构,这是 React 历史上最重要的一次底层重写。Fiber 的核心目标是:将原来不可中断的同步递归,变成可中断的异步增量渲染。
每个 React 元素不再是简单的虚拟 DOM 对象,而是对应一个Fiber 节点。Fiber 节点是一个普通的 JS 对象,包含了组件的类型、状态、以及在树中的位置关系:
// Fiber 节点的核心结构(简化) { tag: FunctionComponent, // 组件类型标记 type: App, // 组件函数/类本身 stateNode: dom, // 对应的真实 DOM 节点 // 树结构:链表而非递归 child: firstChildFiber, // 第一个子节点 sibling: nextSiblingFiber, // 下一个兄弟节点 return: parentFiber, // 父节点 // 双缓冲 alternate: workInProgressFiber, // 指向另一棵树中的对应节点 // 更新相关 pendingProps: newProps, memoizedState: currentState, updateQueue: updates, effectTag: Placement | Update | Deletion, expirationTime: 1073741823, // 更新优先级 }2.2 树结构:从递归到链表
Stack Reconciler 使用的是树结构的递归遍历,天然不可中断(递归调用栈无法暂停)。Fiber 将树改成了链表结构:通过 child、sibling、return 三个指针串联。遍历变成了一个 while 循环,随时可以暂停和恢复:
App (Fiber) | ↓ child Header ——→ Content ——→ Footer sibling sibling | ↓ child List ——→ Sidebar sibling遍历顺序:App → Header → Content → List → Sidebar → Footer。每处理完一个 Fiber 节点,都可以检查:是否该让出主线程了?如果浏览器需要处理用户输入或渲染动画,就暂停 React 的工作,等空闲时再继续。
2.3 双缓冲机制(Double Buffering)
Fiber 架构维护两棵树:
- current 树:当前屏幕上显示的内容对应的 Fiber 树
- workInProgress 树:正在后台构建的新 Fiber 树
两棵树的对应节点通过 alternate 指针互相引用。当 workInProgress 树构建完成后,React 只需将 fiberRoot.current 指针从旧树切换到新树,这个切换是瞬间完成的。旧的 current 树变成下次更新的 workInProgress 树,节点被复用,减少 GC 压力。
这就像显卡的双缓冲:在后台缓冲区绘制下一帧,绘制完成后瞬间切换到前台显示。
2.4 两个阶段:Reconciliation 与 Commit
Fiber 将渲染工作拆成两个阶段,这是理解 React 更新时机的关键。
第一阶段:Reconciliation(调和/Render 阶段)
这个阶段的任务是"算出需要做哪些变更"。React 遍历 Fiber 树,对比新旧 props 和 state,标记哪些节点需要新增(Placement)、更新(Update)、删除(Deletion)。
核心特征:可中断。这个阶段不涉及任何 DOM 操作,只是在内存中做计算和标记。如果有更高优先级的任务到来,React 可以暂停当前工作,先处理高优先级任务,然后回来继续,甚至丢弃之前的工作重新开始。
正因为可能被中断和重新执行,这个阶段涉及的生命周期函数可能被调用多次。这就是 React 16 弃用 componentWillMount、componentWillReceiveProps、componentWillUpdate 的原因——如果你在这些生命周期里做了有副作用的操作(比如发请求、订阅事件),它们可能被执行多次,导致难以排查的 bug。
第二阶段:Commit(提交阶段)
这个阶段的任务是"把变更应用到真实 DOM"。React 遍历第一阶段生成的 effect list(需要变更的节点链表),依次执行 DOM 插入、更新、删除。
核心特征:同步不可中断。因为 DOM 操作必须一气呵成,否则用户会看到不一致的中间状态(比如列表里前几项更新了、后几项还没更新)。
这个阶段依次执行:getSnapshotBeforeUpdate → 执行 DOM 操作 → componentDidMount / componentDidUpdate → 执行 useEffect 的清理和回调。
2.5 ExpirationTime 优先级模型
React 16 引入了基于"过期时间"的优先级系统。每个更新都会被赋予一个 expirationTime,数值越大优先级越高。
计算逻辑的简化版本:
// Sync = 1073741823 (Max 31-bit integer,最高优先级) // MAGIC_NUMBER_OFFSET = Sync - 1 = 1073741822 function computeExpirationForFiber(currentTime, fiber) { // 如果当前在 DiscreteEvent 上下文中(点击、输入等) if (executionContext & DiscreteEventContext) { return computeInteractiveExpiration(currentTime); } // 普通异步更新 return computeAsyncExpiration(currentTime); } function computeInteractiveExpiration(currentTime) { // 高优先级:过期时间短(生产环境 150ms / 开发环境 500ms),数值大 return computeExpirationBucket(currentTime, 150, 100); } function computeAsyncExpiration(currentTime) { // 低优先级:过期时间长(5000ms),数值小 return computeExpirationBucket(currentTime, 5000, 250); }不同类型的用户交互对应不同的优先级:
- DiscreteEvent(离散事件):click、keydown、input 等用户直接操作,对应高优先级,150ms 内必须处理
- ContinuousEvent(连续事件):scroll、mousemove、drag 等持续性事件,对应中等优先级
- Default:数据请求回调、useEffect 中的更新等,对应普通优先级,5000ms 的过期窗口
- Sync:flushSync 强制同步更新,expirationTime 为最大值,立即处理
executionContext 是 React 内部的一个位掩码变量,用于标记当前执行环境:
// React 内部的执行上下文标记 let executionContext = NoContext; // 进入事件处理时 executionContext |= DiscreteEventContext; // 执行你的 onClick 回调 executionContext &= ~DiscreteEventContext; // 进入批处理时 executionContext |= BatchedContext;2.6 React 16-17 的 setState 批处理规则
这是实际开发中最容易踩坑的地方。用一个例子来说明:
function MyComponent() { const [a, setA] = useState(0); const [b, setB] = useState(0); const [c, setC] = useState(0); // 场景1:onClick 中连续调用 const handleClick = () => { setA(1); // 入队,不立即渲染 setB(2); // 入队,不立即渲染 setC(3); // 入队,不立即渲染 // → 合并为 1 次渲染 }; // 场景2:useEffect 中连续调用 useEffect(() => { setA(1); setB(2); setC(3); // → 合并为 1 次渲染(useEffect 执行时 executionContext 为 CommitContext, // 仍在 React 的工作上下文中,因此也会被批处理) }, []); // 场景3:setTimeout 中连续调用 const handleClickTimeout = () => { setTimeout(() => { setA(1); // 脱离了批处理上下文 setB(2); setC(3); // → 共 3 次渲染! }, 0); }; // 场景4:Promise 中连续调用 const handleFetch = () => { fetch('/api').then(() => { setA(1); // 脱离了批处理上下文 setB(2); setC(3); // → 共 3 次渲染! }); }; // 场景5:原生事件中连续调用 useEffect(() => { const handler = () => { setA(1); // 不在 React 合成事件体系内 setB(2); setC(3); // → 共 3 次渲染! }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }, []); }为什么会这样?核心在于 scheduleUpdateOnFiber(由 dispatchSetState 调用的调度函数)内部的判断逻辑:
// 简化版核心逻辑(React 16-17) // dispatchSetState 总是会调用 scheduleUpdateOnFiber function scheduleUpdateOnFiber(fiber, lane) { const root = markUpdateLaneFromFiberToRoot(fiber, lane); ensureRootIsScheduled(root); // 确保调度已安排 // 关键判断:当前是否在 React 的工作循环中? if (lane === SyncLane && executionContext === NoContext) { // 不在任何 React 上下文中 → 立即刷新同步队列 flushSyncCallbackQueue(); } // 否则:在 React 管辖的上下文中(事件处理、Commit 阶段等) // 只是标记了调度,等当前上下文结束后再统一处理 }场景 1(onClick)中,React 的事件系统在调用你的回调之前设置了 executionContext |= DiscreteEventContext,所以三次 setState 虽然都调用了 scheduleUpdateOnFiber,但不会立即 flush,回调结束后 React 统一处理。
场景 2(useEffect)中,React 在执行 useEffect 回调前设置了 executionContext |= CommitContext,同样在 React 的工作上下文中,因此也是批处理的。
场景 3(setTimeout)和场景 4(Promise)中,回调执行时 executionContext 为 NoContext,每次 setState 触发的 scheduleUpdateOnFiber 都会立即 flushSyncCallbackQueue(),所以各渲染一次。
2.7 requestCurrentTime 的批处理技巧
React 内部还有一个值得了解的机制:在同一个事件回调中多次触发更新,requestCurrentTime 会返回相同的时间值,确保它们计算出相同的 expirationTime,从而被合并处理:
function requestCurrentTime() { if (executionContext !== NoContext) { // 在 React 工作中,返回缓存的时间 return cachedCurrentTime; } // 否则重新计算 cachedCurrentTime = msToExpirationTime(performance.now()); return cachedCurrentTime; }这保证了在同一事件中的多个 setState 不仅被批处理,而且获得完全相同的优先级。
三、React 18+:Concurrent Mode 与 Lane 模型
3.1 最重要的变化:Automatic Batching
React 18 最重要的改变之一:所有更新都自动批处理,无论发生在什么上下文中。
// React 18 中 function MyComponent() { const [a, setA] = useState(0); const [b, setB] = useState(0); const [c, setC] = useState(0); useEffect(() => { setA(1); setB(2); setC(3); // React 18:只渲染 1 次!(React 17 会渲染 3 次) }, []); const handleClick = () => { setTimeout(() => { setA(1); setB(2); setC(3); // React 18:只渲染 1 次!(React 17 会渲染 3 次) }, 0); }; const handleFetch = () => { fetch('/api/data').then(() => { setA(1); setB(2); // React 18:只渲染 1 次! }); }; }如果你确实需要某个更新立即同步生效(比如在修改 DOM 后需要立即读取布局信息),可以使用 flushSync:
import { flushSync } from 'react-dom'; function handleClick() { flushSync(() => { setA(1); // 立即同步渲染 }); // 这里 DOM 已经更新 console.log(document.getElementById('a').textContent); // 新值 flushSync(() => { setB(2); // 再次立即同步渲染 }); }3.2 Lane 模型:取代 ExpirationTime
React 18 用Lane(车道)模型取代了 ExpirationTime。Lane 使用31 位二进制来表示优先级,每一位代表一条"车道":
const NoLane = 0b0000000000000000000000000000000; const SyncLane = 0b0000000000000000000000000000001; // 最高优先级 const InputContinuousLane = 0b0000000000000000000000000000100; const DefaultLane = 0b0000000000000000000000000010000; const TransitionLane1 = 0b0000000000000000000000001000000; const TransitionLane2 = 0b0000000000000000000000010000000; // ... 共 16 条 Transition 车道 const IdleLane = 0b0100000000000000000000000000000; const OffscreenLane = 0b1000000000000000000000000000000;为什么要从 ExpirationTime 换成 Lane?ExpirationTime 是一个数值,用大小比较来判断优先级关系,这有两个问题:
第一,无法表示一组不连续的优先级。比如你想同时处理优先级 3 和优先级 7,但跳过优先级 5,ExpirationTime 做不到。Lane 用位运算轻松实现:lanes = Lane3 | Lane7。
第二,灵活的集合操作。合并优先级:lanes |= newLane;检查是否包含:lanes & SyncLane !== 0;移除已处理的:lanes &= ~processedLane。这些位运算既高效又表达力强。
3.3 优先级的实际映射
在日常开发中,不同操作对应不同的 Lane:
| 用户操作 | Lane | 说明 |
|---|---|---|
| flushSync(() => setState()) | SyncLane | 强制同步,最高优先级 |
| onClick 中的 setState | SyncLane(默认)/ DiscreteEventLane | 用户点击,高优先级 |
| onChange(输入框) | SyncLane / InputContinuousLane | 用户输入,高优先级 |
| startTransition(() => setState()) | TransitionLane | 开发者标记的低优先级 |
| useDeferredValue 的延迟更新 | TransitionLane | 自动降级的低优先级 |
| useEffect 中的 setState | 取决于触发来源 | 通常是 DefaultLane |
| Suspense 回退 | 特殊处理 | 避免不必要的 Loading 闪烁 |
3.4 useTransition:主动标记低优先级更新
useTransition 是 React 18 给开发者的"优先级控制器"。它让你把某些不紧急的状态更新标记为 Transition(过渡),React 会优先处理其他高优先级更新,等空闲时再处理 Transition 更新。
import { useState, useTransition } from 'react'; function SearchPage() { const [inputValue, setInputValue] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { const value = e.target.value; // 高优先级:立即更新输入框(用户能立即看到自己输入的字符) setInputValue(value); // 低优先级:搜索结果的计算和渲染可以延后 startTransition(() => { const results = heavySearchComputation(value); setSearchResults(results); }); }; return ( <div> <input value={inputValue} onChange={handleChange} /> {isPending && <div className="loading-bar">搜索中...</div>} <SearchResultList results={searchResults} /> </div> ); }isPending 在 startTransition 的回调开始执行到对应渲染完成之间为 true。你可以用它来展示 loading 状态——注意不是传统的 Spinner(那会让用户感觉更慢),而是更轻量的指示(如进度条、内容变灰),让用户知道新内容正在加载,同时旧内容仍然可交互。
内部实现原理(简化):
function startTransition(setPending, callback) { // 1. 在高优先级上下文中设置 isPending = true // (此时还没进入 Transition 上下文,所以这个更新是高优先级的) const previousPriority = getCurrentUpdatePriority(); setCurrentUpdatePriority( higherEventPriority(previousPriority, ContinuousEventPriority) ); setPending(true); // 2. 进入 Transition 上下文 ReactCurrentBatchConfig.transition = {}; // 3. 在 Transition 上下文中设置 isPending = false // 这个更新被标记为 TransitionLane,会在 Transition 渲染完成时才生效 setPending(false); // 4. 执行回调中的 setState,也被标记为 TransitionLane callback(); // 5. 退出 Transition 上下文,恢复优先级 ReactCurrentBatchConfig.transition = null; setCurrentUpdatePriority(previousPriority); } // requestUpdateLane 内部会检查 ReactCurrentBatchConfig.transition // 如果不为 null,就返回 TransitionLane 而非默认的高优先级 Lane function requestUpdateLane(fiber) { if (ReactCurrentBatchConfig.transition !== null) { return claimNextTransitionLane(); // 返回一条 TransitionLane } return getCurrentUpdatePriority(); // 返回当前优先级 }关键细节:setPending(false) 是在 callback之前执行的,但因为它在 Transition 上下文内,这个"设为 false"的更新和 callback 中的 setState 是同一优先级,会在 Transition 渲染中一起生效。所以从用户视角看,isPending 会在 startTransition 调用后立即变为 true(高优先级渲染),直到 Transition 渲染完成才变为 false。
适用场景:
- 搜索框输入时过滤大列表
- Tab 切换时加载新内容
- 任何"用户操作 → 触发耗时渲染"的场景
3.5 useDeferredValue:值级别的优先级降级
如果说 useTransition 是在更新的发起端控制优先级,那么 useDeferredValue 就是在值的消费端控制优先级。
import { useState, useDeferredValue, memo } from 'react'; function SearchPage() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} /> {/* 输入框用最新的 query → 保持输入响应 */} {/* 列表用延迟的 deferredQuery → 等空闲时再更新 */} <HeavyList query={deferredQuery} /> </div> ); } // 重要:子组件必须用 memo 包裹,否则 useDeferredValue 无效 // 因为如果不 memo,父组件渲染时子组件总会跟着渲染, // 不管传入的 props 是旧值还是新值 const HeavyList = memo(({ query }) => { const items = heavyFilter(allItems, query); return ( <ul> {items.map(item => <li key={item.id}>{item.name}</li>)} </ul> ); });useDeferredValue 会在高优先级更新完成后,再用新值触发一次低优先级的重新渲染。在高优先级渲染中,deferredQuery 仍然保持旧值;等高优先级渲染完成,React 再用新值进行一次 Transition 优先级的渲染。
三个典型使用场景:
第一,无法控制更新源头时。当 setState 不在你控制的代码中(比如来自父组件的 props、URL 参数、第三方库),你没办法用 startTransition 包裹它,但可以在消费端用 useDeferredValue 延迟:
// 你无法控制 router 何时更新 searchParams function SearchResults() { const query = useSearchParams().get('q'); const deferredQuery = useDeferredValue(query); // ... }第二,配合 Suspense 避免 Loading 闪烁。当新数据还没加载完时,useDeferredValue 让 React 继续展示旧内容,而不是闪烁一个 Loading:
function ProfilePage({ userId }) { const deferredId = useDeferredValue(userId); const isStale = userId !== deferredId; return ( <div style={{ opacity: isStale ? 0.7 : 1 }}> <Suspense fallback={<Skeleton />}> <UserProfile userId={deferredId} /> </Suspense> </div> ); }第三,接收外部不可控 props 时。组件库场景中,组件作者无法控制使用者如何传递数据:
// 组件库中的 VirtualList 组件 function VirtualList({ items }) { const deferredItems = useDeferredValue(items); // 即使使用者频繁更新 items,列表渲染也不会阻塞其他高优先级更新 return <InternalList data={deferredItems} />; }3.6 useTransition vs useDeferredValue 选择指南
两者本质上都是利用 TransitionLane 来降低更新优先级,但使用场景不同:
用 useTransition当你能直接控制触发更新的 setState。这种情况更常见,你在事件处理函数中就能决定哪些更新是紧急的、哪些可以延后。isPending 标志让你能精确控制 loading 状态。
用 useDeferredValue当你只有一个值,无法控制它何时更新。典型场景是:props 来自父组件、URL 参数、第三方状态管理库。你没法在源头包 startTransition,但可以在消费端延迟这个值。
两者可以同时使用,但通常不需要——选一个即可。如果你犹豫不决,优先用 useTransition,因为它给你更多的控制(isPending 状态)。
四、实战总结:不同 React 版本的 setState 行为对照
4.1 批处理行为对照表
| 调用位置 | React 16-17 | React 18+ |
|---|---|---|
| onClick 回调中 | 批处理(1 次渲染) | 批处理(1 次渲染) |
| useEffect / useLayoutEffect 中 | 批处理(1 次渲染) | 批处理(1 次渲染) |
| setTimeout / setInterval 中 | 不批处理(多次渲染) | 批处理(1 次渲染) |
| Promise.then 中 | 不批处理(多次渲染) | 批处理(1 次渲染) |
| 原生事件监听中 | 不批处理(多次渲染) | 批处理(1 次渲染) |
| flushSync 中 | 同步立即渲染 | 同步立即渲染 |
4.2 性能优化决策树
面对性能问题时,可以按这个思路决策:
首先,确认是否真的有性能问题。用 React DevTools Profiler 测量实际的渲染耗时。不要过早优化。
其次,如果确实存在因为大量渲染导致的卡顿,判断哪些更新是紧急的(用户直接交互的反馈)、哪些是不紧急的(数据计算、列表过滤)。
然后,如果你能控制 setState,用 useTransition 包裹不紧急的更新。如果你不能控制(props、URL、外部库),用 useDeferredValue 延迟消费。
最后,配合 memo。这一点非常重要:useDeferredValue 要生效,消费延迟值的子组件必须用 React.memo 包裹。否则父组件一渲染,子组件也跟着渲染,延迟值带来的优化完全失效。同理,useTransition 中如果涉及子组件的渲染优化,memo 也是关键搭档。
4.3 常见误区
误区一:认为 useTransition 是节流(throttle)或防抖(debounce)的替代品。不是的。useTransition 不会减少 setState 的调用次数,它只是降低了更新的优先级。如果你需要减少调用频率(比如搜索接口的请求),仍然需要 debounce。两者可以配合使用。
误区二:在 startTransition 中放异步代码。在 React 18 中,startTransition 的回调必须是同步的。在回调中 await 或者放 setTimeout 会导致内部的 setState 脱离 Transition 上下文:
// ❌ React 18 中的错误用法 startTransition(async () => { const data = await fetchData(); // await 之后已经脱离 Transition 上下文 setResults(data); // 这个 setState 不是 Transition 优先级! }); // ✅ React 18 中的正确用法 const data = await fetchData(); // 先获取数据 startTransition(() => { setResults(data); // 同步地在 Transition 中设置 });值得一提的是,React 19 已经支持了异步 startTransition(称为 Async Actions)。在 React 19 中可以直接传入 async 函数,isPending 会保持 true 直到整个异步操作完成。但如果你的项目还在 React 18,请遵守同步回调的约束。
误区三:忘记给子组件加 memo。前面说了,useDeferredValue 不加 memo 等于白用。
五、总结
从 React 15 的 Stack Reconciler 到 React 16 的 Fiber 架构,再到 React 18 的 Concurrent Mode,React 的更新机制经历了三次质的飞跃:
React 15 是全同步的,一旦开始更新就不能停,大组件树会阻塞主线程。setState 的"异步"只是合成事件中的批处理假象。
React 16-17 引入 Fiber,将渲染拆成可中断的 Reconciliation 和不可中断的 Commit 两个阶段,通过 ExpirationTime 模型区分更新优先级。但批处理行为不一致——在 React 合成事件和生命周期/Effect 中会批处理,但在 setTimeout、Promise、原生事件等脱离 React 上下文的场景中不会。
React 18 带来了真正的并发渲染。Automatic Batching 统一了所有场景的批处理行为,Lane 模型提供了更灵活的优先级表达,useTransition 和 useDeferredValue 让开发者第一次能够主动控制更新优先级。
为了在遇到性能问题时能快速定位原因,做出正确的优化决策。希望这篇文章对你有帮助。
如果觉得有收获,欢迎点赞收藏。有问题欢迎评论区讨论。