React New Features 17-18
React 17:过渡版本的意义
React 17 被官方描述为"过渡版本"——它没有引入颠覆性的新功能,但包含了一些重要的底层变化,为 React 18 的 concurrent features 铺平道路。
理解 React 17 的变化,对于理解 React 18 的工作原理至关重要。
React 17:新 JSX 变换
旧 JSX 变换的问题
在旧的 JSX 变换下,每当你写 JSX 时:
function App() {
return <div>Hello</div>;
}
Babel/TypeScript 会把它转换成这样的代码:
function App() {
return React.createElement('div', null, 'Hello');
}
这意味着你必须在文件中显式导入 React:
import React from 'react';
function App() {
return <div>Hello</div>; // 需要 React 在作用域内
}
这个要求让很多开发者困惑——我只是写 JSX,为什么需要导入 React?
新 JSX 变换的工作原理
React 17 引入了一种新的 JSX 变换(Babel/TypeScript plugin)。新变换不再调用 React.createElement,而是调用一个全新的函数:
// 新变换后的代码
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
return _jsx('div', { children: 'Hello' });
}
关键变化:
- 不需要显式导入 React(在大多数情况下)
- JSX 编译器自动引入
react/jsx-runtime中的jsx函数 - 这是编译时优化,不影响运行时性能
迁移到新 JSX 变换
对于大多数项目,升级到新 JSX 变换是向后兼容的。
如果你使用的是 Create React App 或 Next.js,它们已经内置支持新 JSX 变换。
对于自定义 Babel 配置,需要升级 @babel/plugin-transform-react-jsx:
npm install @babel/plugin-transform-react-jsx@7
// babel.config.js
module.exports = {
plugins: [
['@babel/plugin-transform-react-jsx', {
runtime: 'automatic' // 启用新变换
}]
]
};
React 17 事件委托的变化
另一个 React 17 的重要变化是事件委托的 destination 变了。
在 React 17 之前,所有事件都委托到 document 上:
flowchart LR
A[Click Event] --> B[document]
B --> C[React Event System]
C --> D[Component Handler]
React 17 开始,事件委托到 React 应用的根节点(root)上:
flowchart LR
A[Click Event] --> B[#root div]
B --> C[React Event System]
C --> D[Component Handler]
为什么这个变化重要?
这个变化让多个 React 应用可以在同一个页面中共存而不互相干扰:
<!-- 页面中有多个 React 应用 -->
<div id="app1"></div>
<div id="app2"></div>
<div id="legacy-app"></div>
在旧的委托模型下,所有事件都到 document,不同应用的事件可能互相影响。新模型下,每个应用只处理自己 root 节点内的事件。
React 18:并发渲染的到来
React 18 引入了 Concurrent Rendering(并发渲染),这是 React 核心架构的重大升级。并发渲染不是某个单一功能,而是一整套让 React 能够同时准备多个版本的 UI 的机制。
并发渲染的核心概念
在并发渲染下,React 可以中断正在进行的渲染工作,优先处理更紧急的任务,然后在适当的时候恢复之前的工作。
这对于用户体验是革命性的:即使应用处于大量更新中,React 18 仍然能保持 UI 的响应性。
flowchart TD
A[User types in input] --> B[High Priority: Input responds immediately]
A --> C[Low Priority: Heavy list re-render]
B --> D[UI stays responsive]
C --> E[Can be interrupted]
E --> F[Resumed later]
F --> G[List eventually updates]
Automatic Batching
什么是 Batching
Batching 是指 React 把多次状态更新合并成一次重新渲染的行为。
在 React 18 之前,只有在事件处理函数中的 setState 才会被批处理:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// 旧 React:这里只有事件处理函数中的更新会被批处理
setCount(c => c + 1); // 不触发重新渲染
setTimeout(() => {
setCount(c => c + 1); // 在 setTimeout 中,不会被批处理
}, 0);
}
return <button onClick={handleClick}>Count: {count}</button>;
}
React 18 的 Automatic Batching
React 18 的 automatic batching 让所有状态更新都被批处理,不管它们在哪里发生:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// React 18:所有更新都会被批处理
setCount(c => c + 1); // 不会立即重新渲染
fetch('/api').then(() => {
setCount(c => c + 1); // 也会被批处理
});
}
return <button onClick={handleClick}>Count: {count}</button>;
}
在 React 18 中,点击按钮只会触发一次重新渲染,而不是两次。
flushSync
在某些场景下,你可能希望状态更新立即生效(不被批处理)。flushSync 可以做到这一点:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1); // 立即重新渲染
});
// 这个 setState 会单独渲染
flushSync(() => {
setFlag(f => !f);
});
}
flushSync 的使用场景有限,主要用于:
- 需要立即读取 DOM 的场景(比如获取滚动位置)
- 与非 React 代码集成时需要同步的状态
Concurrent Features
startTransition
startTransition 是 React 18 引入的最重要的新 API 之一。它让你标记某些状态更新为非紧急的。
import { useState, startTransition } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [isStale, setIsStale] = useState(false);
function handleSearch(searchQuery) {
setIsStale(true);
startTransition(() => {
// 这个更新被标记为 non-urgent
// 即使它需要较长时间,React 也会保持 UI 响应
setResults(searchQuery);
setIsStale(false);
});
}
return (
<div>
<input onChange={e => handleSearch(e.target.value)} />
{isStale ? <Spinner /> : <ResultsList data={results} />}
</div>
);
}
transitions 的工作原理:
- 当用户开始输入,
isStale立即更新(高优先级) results的更新被标记为 transition,在后台处理- 如果用户继续输入,React 会中断
results的更新,优先处理新的输入 - 输入稳定后,React 完成
results的更新
useDeferredValue
useDeferredValue 是 startTransition 的 hook 版本,适合在 props 传递深层的场景使用:
import { useState, useDeferredValue } from 'react';
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<SlowList text={deferredQuery} /> {/* 使用 deferred value */}
</div>
);
}
useDeferredValue 和 startTransition 的选择:
- 如果状态更新在组件内部,用
startTransition - 如果你需要对来自父组件的 props 做延迟处理,用
useDeferredValue
useTransition
useTransition 返回一个 transition 状态,让你能够:
- 知道某个更新是否在 transition 中
- 启动一个 transition
import { useState, useTransition } from 'react';
function TabContainer() {
const [tab, setTab] = useState('about');
const [isPending, startTransition] = useTransition();
function switchTab(newTab) {
startTransition(() => {
setTab(newTab);
});
}
return (
<div>
{isPending ? <Spinner /> : <Tabs active={tab} />}
<button onClick={() => switchTab('about')}>About</button>
<button onClick={() => switchTab('posts')}>Posts</button>
</div>
);
}
React 18 Strict Mode 的 double invocation
React 18 的 Strict Mode 会在开发环境下故意 double-invoke(双重调用)以下内容:
- Component functions
- useState initializer functions
- useEffect cleanup and setup functions
function App() {
useEffect(() => {
console.log('Effect setup'); // 会打印两次
return () => console.log('Effect cleanup'); // cleanup 也会调用
}, []);
return <div>Content</div>;
}
为什么 Strict Mode 要 double-invoke
这是 React 团队为了暴露潜在的不纯代码和资源泄漏而有意为之。
Double invocation 帮助发现:
- 不应该在渲染期间执行的副作用
- 不完整的 cleanup 逻辑
- 依赖渲染期间计算的副作用
与生产环境的关系
Strict Mode 的 double invocation 只在开发环境发生。生产环境中组件只会渲染一次。
如果你看到开发环境下的 double render,日志打印两次,这是正常行为,不是 bug。
useId hook
React 18 引入了 useId,用于在 SSR 场景下生成稳定的唯一 ID,避免水合不匹配。
SSR 水合不匹配问题
在 SSR 中,服务器和客户端可能生成不同的 HTML。如果使用递增 ID(如 id="count-1", id="count-2"),服务器渲染的 ID 可能与客户端水合时的 ID 不一致:
// 服务器渲染:生成 id="1", id="2"
// 客户端水合:也生成 id="1", id="2"
// 这通常没问题,但如果组件树不同时...
useId 的解决方案
import { useId } from 'react';
function PasswordField() {
const id = useId();
return (
<div>
<label htmlFor={id}>Password:</label>
<input id={id} type="password" />
</div>
);
}
useId 生成的 ID:
- 在服务器和客户端是稳定的
- 即使在多个组件实例中也不会冲突
- 格式类似
:r0:,:r1:,:r2:
useId 的限制
useId 生成的 ID 不能用于多个相关联的元素的 for/id 对。正确的做法是:
// 错误:每个元素都用 useId
const id = useId();
return (
<>
<label htmlFor={id}>Name</label>
<input id={id} /> {/* id 只用于一个元素 */}
<label htmlFor={id}>Email</label> {/* 错误!id 冲突 */}
<input id={id} />
</>
);
// 正确:每个 label/input 对使用独立的 ID
const nameId = useId();
const emailId = useId();
return (
<>
<label htmlFor={nameId}>Name</label>
<input id={nameId} />
<label htmlFor={emailId}>Email</label>
<input id={emailId} />
</>
);
useSyncExternalStore
useSyncExternalStore 是 React 18 推荐的订阅外部状态源的方式。
什么时候需要 useSyncExternalStore
当你需要让 React 组件订阅 React 之外的状态源(如 Redux store、window 尺寸、第三方状态库)时,需要用 useSyncExternalStore。
基本用法
import { useSyncExternalStore } from 'react';
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useSyncExternalStore(
subscribe, // 订阅函数,返回 unsubscribe
getSnapshot, // 获取当前快照
getServerSnapshot // 服务端渲染时的快照
);
return size;
}
与 useState/useContext 的选择
| 场景 | 推荐方案 |
|---|---|
| React 组件内部状态 | useState |
| 跨组件共享的简单状态 | useContext |
| 全局状态库(Redux/Zustand) | useSyncExternalStore 或库提供的 hook |
| 浏览器 API(resize、scroll) | useSyncExternalStore |
React 18 之前的替代方案
在 React 18 之前,社区有一些自己的解决方案,比如 use-subscription。React 18 将这个模式标准化为了 useSyncExternalStore。
Suspense SSR 改进
React 18 对 SSR 的 Suspense 做了重大改进。
旧 SSR 的问题:流式 HTML + 水合
在 React 17 的 SSR 中:
- 服务器发送完整的 HTML
- 客户端下载 JavaScript
- 客户端执行水合(Hydration)
问题在于:如果某个组件需要较长时间加载,整个页面会等待它。
React 18 的改进: Selective Hydration
React 18 的 SSR Suspense:
- 流式 HTML:服务器尽快发送 HTML,同时等待数据
- 选择性水合:React 优先水合已经可见的用户交互区域
function App() {
return (
<div>
<Suspense fallback={<Spinner />}>
<Comments /> {/* 通常在视口下方 */}
</Suspense>
<Suspense fallback={<Spinner />}>
<Header /> {/* 用户首先看到的 */}
</Suspense>
</div>
);
}
React 18 会优先水合 Header 的区域,让用户可以尽快与 Header 交互,而 Comments 在后台加载。
面试中的表达
面试官问 React 17/18 新特性,通常是在考察你对 React 演进方向和并发模型的理解:
React 17 的两个重要变化:新 JSX 变换让我们不再需要显式导入 React,事件委托从 document 改到了 root 节点;后者为多应用共存和 React 18 的并发渲染铺平了道路。
React 18 的核心是 Concurrent Rendering。startTransition 和 useDeferredValue 让你把某些更新标记为 non-urgent,React 可以在用户继续输入时中断它们,保持 UI 响应。Automatic Batching 则把所有更新都批处理,包括 setTimeout 和 Promise 中的更新,减少不必要的重新渲染。
Strict Mode 在开发环境下 double-invoke 组件,帮助暴露不纯的副作用代码。useId 用于 SSR 场景下生成稳定的唯一 ID,避免水合不匹配。useSyncExternalStore 是 React 推荐的订阅外部状态的方式。Suspense SSR 的改进让 React 可以流式发送 HTML 并选择性水合,优先响应用户交互区域。
延展阅读
- React 18 Alpha 发布公告 — React 团队官方宣布的 React 18 计划
- React 18 新特性详解 — React 18 正式发布博客
- Dan Abramov: React as a UI Runtime — React 作为 UI 运行时的深入理解
- Sequences, tasks, microtasks, priorities — React 调度机制