Skip to content

React 17、18、19:版本、入口与行为演进 ​

看到class不能推断项目是React17;看到函数也不能推断已经获得React18的自动批处理。诊断版本差异至少要记录react/react-dom版本、根入口、开发或生产模式、StrictMode与JSX转换。版本决定可用能力,入口可能决定兼容行为,组件风格则是同一版本内的组织选择。

先识别工程里真正的基线 ​

本书现代主线以React/React DOM 19.2.7、createRoot和现代JSX转换运行,类维护篇也在同一环境中工作。维护历史项目先检查锁文件实际安装结果以及src/main入口,不依据README里一句“React18项目”。React的发布版本不是ECMAScript标准:class、箭头函数、解构属于JavaScript,JSX由工具转换,浏览器DOM由宿主提供,Server Components还需要框架构建与传输。

17已支持Hooks和类;Hooks并不是18才出现。18提供createRoot、Transition等能力,自动批处理在新根入口启用。19继续支持Component/PureComponent,但移除已弃用的旧入口及旧Context、string refs。推荐现代函数组件与支持类组件同时成立。

17的事件变化为什么影响埋点 ​

17将多数事件的委托位置从document移到React根容器。若React按钮调用stopPropagation,document上的冒泡监听不会继续收到事件;只靠document冒泡做全站采集的代码就可能漏掉点击。捕获监听是否适合,应由采集范围与隐私合同决定,不能为修复埋点随意穿透所有边界。

这是原生传播路径与React委托位置的变化,不是每个按钮都新注册了一份document监听。17还移除了React DOM事件池,旧代码为保留事件而调用persist通常不再必要;仍应在事件发生时读取所需值,DOM输入之后可能再变化,事件不复用并不意味着目标节点被冻结。这些是React DOM的历史行为,不能直接推广到其他渲染器。

同一段类组件,在四种入口下比较批处理 ​

以下实验刻意不用JSX、不安装框架,减少编译器变量。新建独立目录 batch-lab,写 package.json 为 {"private":true,"type":"module"}。第一次安装 npm install --save-exact react@17.0.2 react-dom@17.0.2 jsdom@26.1.0。Node需满足jsdom依赖的要求,本实验使用Node22。保存 batch.mjs:

js
import { JSDOM } from 'jsdom'

const dom = new JSDOM('<!doctype html><div id="root"></div>')
globalThis.window = dom.window
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: dom.window.navigator })
globalThis.document = dom.window.document
// 与模拟浏览器能力对齐,避免旧scheduler持有Node原生MessagePort。
globalThis.MessageChannel = dom.window.MessageChannel
const React = await import('react')
const ReactDOM = await import('react-dom')
const modern = process.argv[2] === 'modern'
let root
let instance
let ready
const mounted = new Promise(resolve => { ready = resolve })
const trace = []
class Counter extends React.Component {
  state = { count: 0, flag: false }
  componentDidMount() { instance = this; ready() }
  componentDidUpdate() { trace.push([this.state.count, this.state.flag]) }
  render() { return React.createElement('p', null, `${this.state.count}/${this.state.flag}`) }
}
const host = document.getElementById('root')
if (modern) {
  const { createRoot } = await import('react-dom/client')
  root = createRoot(host)
  root.render(React.createElement(Counter))
} else {
  ReactDOM.render(React.createElement(Counter), host)
}
await mounted
await new Promise(resolve => {
  setTimeout(() => {
    instance.setState(p => ({ count: p.count + 1 }))
    const between = instance.state.count
    instance.setState(p => ({ flag: !p.flag }), () => {
      console.log(JSON.stringify({ version: React.version, modern, between, trace, text: host.textContent }))
      resolve()
    })
  }, 0)
})
if (root) root.unmount()
else ReactDOM.unmountComponentAtNode(host)
dom.window.close()

jsdom没有真实浏览器的全部调度设施;初始化把MessageChannel与模拟window对齐,让旧scheduler采用可退出的计时器后备路径,避免Node原生MessagePort使命令在输出后不退出。本实验只比较提交与state,不测调度性能。

这里的setTimeout把更新放在React点击处理器之外;等待使用挂载与setState提交回调,不猜“等100ms应该渲染完”。trace只记didUpdate,所以初次挂载不计入。每次命令启动新进程,不让不同React版本混在同一模块缓存里。

依次运行与更换依赖:

sh
node batch.mjs legacy
npm install --save-exact react@18.3.1 react-dom@18.3.1
node batch.mjs legacy
node batch.mjs modern
npm install --save-exact react@19.2.7 react-dom@19.2.7
node batch.mjs modern

17 legacy与18 legacy最终都显示1/true,between为1,更新轨迹为 [[1,false],[1,true]];18 modern与19 modern最终同样显示1/true,但between为0,轨迹为 [[1,true]]。18的legacy入口会发出弃用警告并保留旧行为,因此“只升级包版本”没有等于“已启用新根”。不要在19运行legacy分支,它已移除render入口。此实验是模拟DOM中的提交轨迹验证,不用于证明真实浏览器绘制次数或性能收益。

updater始终按前面的待定state计算,所以最终值正确;变化的是两次请求是否组合提交,以及中途this.state能否已看到一次提交。若旧逻辑依赖between为1,修复为函数更新或提交回调;不要在每次setState外面加flushSync来强行恢复历史时间线。对照时必须同时记录版本、新旧根入口和开发严格模式。

19迁移不是只改入口 ​

先在18.3运行并处理弃用提示,再升级19,可以提早发现被移除接口。普通客户端渲染从ReactDOM.render改createRoot;已有服务端HTML使用hydrateRoot,不能当成空根重建。19要求现代JSX转换;本书Vite React插件提供它,仅升级react包不能修复旧构建链。

维护类时继续使用现代contextType/createContext;旧contextTypes/getChildContext要成对迁移,string ref换createRef或回调ref。函数组件的defaultProps改为参数默认值,类的defaultProps仍受支持,因此不能对全仓库机械删除。React19让函数组件接收ref prop,但类ref仍指向实例,不能按函数规则假定进入this.props.ref。

19的Actions、useActionState和useOptimistic会在第六章用表单正常与失败流程展开;它们不是任意函数组件升级后自动具备的业务重试或授权。开发StrictMode的重执行也不是生产重复提交的承诺,生命周期资源应遵守setup/cleanup对称,而不是删StrictMode隐藏泄漏。升级时应按这里识别出的旧接口逐项检查调用者,不把代码风格当成兼容性报告。

练习:保持旧版行为合同再升级 ​

在batch.mjs中把两次更新都改为count的函数式加一;仍在第一个更新后记录between。预测并实测17 legacy、18 legacy、18 modern、19 modern的最终count、between和trace。最终count必须全部为2,不允许通过同步刷新改变实验条件。再解释“最终结果一样”为什么不能证明中间观察也兼容。

查看结果与因果。完成维护篇后进入第五章,学习调度与等待协议。

用心记录每一步成长