React New Features 17-18

深入理解 React 17 和 React 18 的重大变化:新 JSX 变换、事件委托变更、Automatic Batching、Concurrent Rendering、Suspense SSR 改进,以及 startTransition、useDeferredValue 等新 API。

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 的工作原理:

  1. 当用户开始输入,isStale 立即更新(高优先级)
  2. results 的更新被标记为 transition,在后台处理
  3. 如果用户继续输入,React 会中断 results 的更新,优先处理新的输入
  4. 输入稳定后,React 完成 results 的更新

useDeferredValue

useDeferredValuestartTransition 的 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>
  );
}

useDeferredValuestartTransition 的选择:

  • 如果状态更新在组件内部,用 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 中:

  1. 服务器发送完整的 HTML
  2. 客户端下载 JavaScript
  3. 客户端执行水合(Hydration)

问题在于:如果某个组件需要较长时间加载,整个页面会等待它。

React 18 的改进: Selective Hydration

React 18 的 SSR Suspense:

  1. 流式 HTML:服务器尽快发送 HTML,同时等待数据
  2. 选择性水合: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 并选择性水合,优先响应用户交互区域。


延展阅读