首页
/ 基于 awesome-copilot 的 React 18 类组件迁移实战:react18-class-surgeon 的生命周期与 API 语义化改造指南

基于 awesome-copilot 的 React 18 类组件迁移实战:react18-class-surgeon 的生命周期与 API 语义化改造指南

2026-09-08 13:08:20作者:胡易黎Nicole

在 React 16/17 时代的类组件代码库里,componentWillMountcomponentWillReceivePropscomponentWillUpdate、Legacy Context、字符串 ref 与 findDOMNode 等模式往往"静默工作了多年"。升级到 React 18.3.1 后,这些废弃 API 会被显式告警暴露出来——它们正是 React 19 将真正移除的"地雷"。agents/react18-class-surgeon.agent.md 是 awesome-copilot 仓库中专门负责此类代码库迁移的智能体定义:它不做廉价的 UNSAFE_ 前缀改名,而是完成真正意义上的语义迁移。读完本文,你将掌握这七类迁移的完整决策树、可直接复用的前后对照代码、逐文件执行纪律与基于 grep 的零残留验证方法,并能理解该智能体如何在 commander 编排的多智能体管道中与其他角色协同。

一、角色定位:为什么要"语义迁移"而不是"加前缀"

agents/react18-class-surgeon.agent.md 的 frontmatter 可以看到该智能体的核心约束:

description: 'Class component migration specialist for React 16/17 → 18.3.1.
Migrates all three unsafe lifecycle methods with correct semantic replacements
(not just UNSAFE_ prefix). Migrates legacy context to createContext, string refs
to React.createRef(), findDOMNode to direct refs, and ReactDOM.render to createRoot.
Uses memory to checkpoint per-file progress.'
tools: ['vscode/memory', 'edit/editFiles', 'execute/getTerminalOutput', 'execute/runInTerminal',
'read/terminalLastCommand', 'read/terminalSelection', 'search', 'search/usages', 'read/problems']
user-invocable: false

要点有三:

  1. 它是管道中的专职执行者user-invocable: false 表明它不被用户直接唤起,而是由编排方触发。在 agents/react18-commander.agent.md 定义的 gated pipeline 中,class-surgeon 位于 PHASE 3(Class Component Surgery),上游是审计阶段产出的 .github/react18-audit.md,下游是自动批处理修复与测试守护。它是整条迁移流水线里改动量最大的一环。
  2. 它的输入不是代码,而是审计报告。先由 agents/react18-auditor.agent.md 执行"只读不修"的全量扫描(覆盖 unsafe lifecycle、legacy context、string refs、findDOMNodeReactDOM.render、批处理漏洞、事件委托假设与依赖兼容性),把每个命中位置按文件与迁移路径写成报告,class-surgeon 再据此逐文件施工。
  3. 为什么锁定 18.3.1:commander 文档解释了关键背景——React 18.3.1 被设计为对 React 19 将要移除的每一类 API 显式发出告警,因此在 18.3.1 上跑出一个零告警基线,就是迈向 React 19 迁移的直通前提。类组件代码库之所以更难,是因为这些废弃生命周期在 16/17 中只要不开 StrictMode 就不会告警,legacy context 与字符串 ref 更是全程零运行时报错地工作到 React 19 之前——这正是 agents/react18-commander.agent.md 中"Why This Is Harder Than 18 → 19"一节的判断依据。

与之配套,仓库还提供了模式级知识支撑:skills/react18-lifecycle-patterns/SKILL.md 提供三个废弃生命周期的速查决策表,skills/react18-legacy-context/SKILL.mdskills/react18-string-refs/SKILL.md 分别覆盖跨文件 context 迁移和 ref 全场景改造。下文的每个迁移模块都可以与这些 skill 的参考文件相互印证。

二、Boot Sequence:把审计报告当工作单,先过滤已完成文件

开工前有两步标准化动作(Agent 语义下属于"启动序列",人工操作同样适用):

# Load audit report - this is your work order
cat .github/react18-audit.md | grep -A 100 "Source Files"

# Get all source files needing changes (from audit)
# Skip any already recorded in memory as completed
find src/ \( -name "*.js" -o -name "*.jsx" \) | grep -v "\.test\.\|\.spec\.\|__tests__" | sort

两个命令各承担一项职责:第一条从审计报告(由 react18-auditor 生成并保存的 .github/react18-audit.md,其结构与统计字段在 agents/react18-auditor.agent.md 的 Report Generation 一节有模板)中取出待改源文件清单;第二条用 find 重新枚举 src/ 下全部 JS/JSX,同时用 grep -v 排除 *.test.**.spec.*__tests__——与智能体"绝不触碰测试文件"的铁律一致。

启动序列的"跳过已完成文件"依赖 Memory 协议。每次处理完一个文件就写入一次检查点:

#tool:memory write repository "react18-class-surgery-progress" "completed:[filename]:[patterns-fixed]"

下次启动时先读取:

#tool:memory read repository "react18-class-surgery-progress"

这套"逐文件落盘、启动即恢复"的机制让迁移可以随时中断续跑,也保证多轮会话之间不会重复劳动。对应地,仓库中的 auditor 与 commander 也各自维护独立命名空间(react18-audit-progressreact18-migration-state),形成一套完整的迁移状态机。

三、迁移 1:componentWillMount —— 三种分支,三处归宿

识别特征:类组件中出现不带 UNSAFE_ 前缀componentWillMount()。React 18.3.1 给出的告警原文是:

componentWillMount has been renamed, and is not recommended for use.

正确的迁移没有唯一答案,取决于该方法实际做什么。智能体给出的三种情况如下。

Case A:仅初始化 state

// Before
componentWillMount() {
  this.setState({ items: [], loading: false });
}
// After:移至 constructor,直接赋值 this.state
constructor(props) {
  super(props);
  this.state = { items: [], loading: false };
}

注意 constructor 阶段不能调用 setState,直接赋值 this.state 才是合法写法——这也正是"初始化 state"这一类必须迁到 constructor 而非其他位置的原因。

Case B:执行副作用(fetch、订阅、DOM 组装)

// Before
componentWillMount() {
  this.subscription = this.props.store.subscribe(this.handleChange);
  fetch('/api/data').then(r => r.json()).then(data => this.setState({ data }));
}
// After:移至 componentDidMount,副作用要在挂载完成后执行
componentDidMount() {
  this.subscription = this.props.store.subscribe(this.handleChange);
  fetch('/api/data').then(r => r.json()).then(data => this.setState({ data }));
}

Case C:读取 props 派生初始 state

// Before
componentWillMount() {
  this.setState({ value: this.props.initialValue * 2 });
}
// After:constructor 中通过参数 props 计算初始值
constructor(props) {
  super(props);
  this.state = { value: props.initialValue * 2 };
}

明确的红线:不要只改名成 UNSAFE_componentWillMount 前缀只抑制告警,不解决语义问题——该方法之所以危险,是因为它在 render 前既可能跑副作用又可能因未来的并发特性被反复调用;只加前缀意味着 React 19 升级时还要再改一遍。这一判断与 skills/react18-lifecycle-patterns/SKILL.md 中"The UNSAFE_ Prefix Rule"一节完全一致:前缀只允许作为排期真实迁移前的临时占位,且必须留下 // TODO: React 19 will remove this... 形式的注释标记。

四、迁移 2:componentWillReceiveProps —— 先判断"有没有副作用",再选归宿

识别特征:类组件中的 componentWillReceiveProps(nextProps)。React 18.3.1 告警原文:

componentWillReceiveProps has been renamed, and is not recommended for use.

决策树只有两个分支,判断依据是方法内容是否包含异步或副作用:

方法是否触发异步工作或副作用?
  YES → componentDidUpdate
  NO(纯同步 state 派生)→ getDerivedStateFromProps

Case A:随 prop 变化触发异步副作用(最常见)

// Before
componentWillReceiveProps(nextProps) {
  if (nextProps.userId !== this.props.userId) {
    this.setState({ userData: null, loading: true });
    fetchUser(nextProps.userId).then(data => this.setState({ userData: data, loading: false }));
  }
}
// After:componentDidUpdate 用 prevProps 对比
componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    this.setState({ userData: null, loading: true });
    fetchUser(this.props.userId).then(data => this.setState({ userData: data, loading: false }));
  }
}

参数方向发生了翻转:旧 API 收到的是"未来的 props"(nextProps),比较式为 nextProps.x !== this.props.x;新 API 收到的是"过去的 props"(prevProps),此时更新已应用,比较式改为 prevProps.x !== this.props.x,取值一律使用 this.props.x。针对多次异步响应的竞态,skills/react18-lifecycle-patterns/references/componentWillReceiveProps.md 还给出了带请求序号(_requestId)的取消/防串扰模式:每次请求自增序号,响应回来时若序号已过期则丢弃,防止快速切换 userId 时旧响应覆盖新数据。

Case B:纯同步地从 props 派生 state

// Before
componentWillReceiveProps(nextProps) {
  if (nextProps.items !== this.props.items) {
    this.setState({ sortedItems: sortItems(nextProps.items) });
  }
}
// After:static getDerivedStateFromProps
static getDerivedStateFromProps(props, state) {
  if (props.items !== state.prevItems) {
    return {
      sortedItems: sortItems(props.items),
      prevItems: props.items,
    };
  }
  return null;
}
// 同时在 constructor 的 state 里初始化:
// this.state = { ..., prevItems: props.items }

getDerivedStateFromProps 的三个陷阱

该参考文件着重警告了它的三个反直觉行为,直接决定 Case B 的写法质量:

  1. 它在每次 render 都会触发(包括由 setState 引起的 render),而不仅仅在 prop 变化时。因此必须把"上一次比较用的 prop"(如 prevItems)存进 state,靠 props.items !== state.prevItems 做引用级对比,否则每次 setState 都会触发一遍重新派生,甚至形成死循环。
  2. 它是 static 方法,没有 this。不能访问 this.propsthis.state 或实例方法,任何 this.computeValue(props) 都会抛 ReferenceError;内部逻辑必须是与 props/state 无关的纯函数(如独立的 sortItems)。
  3. 绝不能在里面做副作用。需要随 prop 变化发起请求的,一律走 componentDidUpdate

决策兜底建议(来自参考文件原文):拿不准时优先选 componentDidUpdate,它永远安全;getDerivedStateFromProps 只适用于"纯粹的同步状态派生"这一窄场景。若在其中写复杂逻辑,反而要考虑是否应把预处理结果直接作为 prop 传给下游组件。

五、迁移 3:componentWillUpdate —— 读 DOM 与副作用各归其位

识别特征:类组件中的 componentWillUpdate(nextProps, nextState)。React 18.3.1 告警原文:

componentWillUpdate has been renamed, and is not recommended for use.

Case A:重渲染前需要读取 DOM(如滚动位置)

旧写法在两个生命周期之间用实例字段临时搬运 DOM 读数:

// Before
componentWillUpdate(nextProps, nextState) {
  if (nextProps.listLength > this.props.listLength) {
    this.scrollHeight = this.listRef.current.scrollHeight;
  }
}
componentDidUpdate(prevProps) {
  if (prevProps.listLength < this.props.listLength) {
    this.listRef.current.scrollTop += this.listRef.current.scrollHeight - this.scrollHeight;
  }
}

新写法把"提交到 DOM 之前读取快照"这一语义显式化为 getSnapshotBeforeUpdate,并借 componentDidUpdate 的第三个参数把快照传回:

// After
getSnapshotBeforeUpdate(prevProps, prevState) {
  if (prevProps.listLength < this.props.listLength) {
    return this.listRef.current.scrollHeight;
  }
  return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
  if (snapshot !== null) {
    this.listRef.current.scrollTop += this.listRef.current.scrollHeight - snapshot;
  }
}

这是三者中唯一必须成对迁移的方法:getSnapshotBeforeUpdate 的返回值会被作为 snapshot 参数交给紧随其后的 componentDidUpdate,用 snapshot !== null 判断是否需要执行后续 DOM 补偿,正好对应 React 官方"读 DOM 必须在提交前、改 DOM 必须在提交后"的生命周期纪律。

Case B:更新前执行副作用(取消请求等)

// Before
componentWillUpdate(nextProps) {
  if (nextProps.query !== this.props.query) {
    this.cancelCurrentRequest();
  }
}
// After:componentDidUpdate 中按旧 props 取消旧请求,再启动新请求
componentDidUpdate(prevProps) {
  if (prevProps.query !== this.props.query) {
    this.cancelCurrentRequest();
    this.startNewRequest(this.props.query);
  }
}

六、迁移 4:Legacy Context —— 唯一的跨文件迁移

识别特征:static contextTypesstatic childContextTypesgetChildContext() 三件套。这是七个迁移中唯一不能单文件完成的:provider(childContextTypes + getChildContext)与全部 consumer(contextTypes)必须同步改造,漏掉任何一个 consumer,它就会读到错误 context 或直接拿到 undefined。因此执行规则明确要求:迁移 provider 之前,必须先借助 search/usages 与 grep 找出它的所有消费方。

Provider 侧:childContextTypes + getChildContext → createContext

// Before
class ThemeProvider extends React.Component {
  static childContextTypes = {
    theme: PropTypes.string,
    toggleTheme: PropTypes.func,
  };
  getChildContext() {
    return { theme: this.state.theme, toggleTheme: this.toggleTheme };
  }
  render() { return this.props.children; }
}
// After —— 新建独立文件 ThemeContext.js 存放 context 对象
export const ThemeContext = React.createContext({ theme: 'light', toggleTheme: () => {} });
// Provider 组件本体
class ThemeProvider extends React.Component {
  render() {
    return (
      <ThemeContext.Provider value={{ theme: this.state.theme, toggleTheme: this.toggleTheme }}>
        {this.props.children}
      </ThemeContext.Provider>
    );
  }
}

这里有一处值得特别澄清的版本细节:agent 文档在 provider 示例的骨架里写了 <ThemeContext value={...}> 的简写形式,而 skills/react18-legacy-context/references/single-context.md 明确给出版本对照——<ThemeContext value={...}>(省略 .Provider)是 React 19 的 JSX 简写;在本文的目标版本 React 18.3.1 上必须写作 <ThemeContext.Provider value={...}>。新建 context 文件时,React.createContext(...) 的默认值应尽量与旧 getChildContext() 的返回结构保持一致(如 { theme: 'light', toggleTheme: () => {} }),这样尚未被 provider 包裹的组件也有合理兜底。

类 Consumer 侧:contextTypes(复数)→ contextType(单数)

// Before
class ThemedButton extends React.Component {
  static contextTypes = { theme: PropTypes.string };
  render() { return <button className={this.context.theme}>{this.props.label}</button>; }
}
// After
class ThemedButton extends React.Component {
  static contextType = ThemeContext;
  render() { return <button className={this.context.theme}>{this.props.label}</button>; }
}

新旧两版的差异要点:

  • static contextType(单数)取代 static contextTypes(复数),语义从"订阅若干具名值"变为"绑定唯一一个 context 对象";
  • 不再需要 PropTypes 声明;
  • this.context 现在拿到的是 provider 传入 value完整对象,而非按 key 挑选后的子集;
  • 每个类组件只能绑定一个 contextType,消费多个 context 时需退回到 Context.Consumer render prop 嵌套,或考虑把类组件改写为函数组件以便使用 useContext——后者在同一参考文件的函数消费示例中有完整演示:
function ThemedHeader({ title }) {
  const { theme } = useContext(ThemeContext);
  return <h1 className={`header-${theme}`}>{title}</h1>;
}

完整"provider + 类 consumer + 函数 consumer + 多 context"对照与迁移后的验证 grep 命令,见 skills/react18-legacy-context/references/single-context.md 与多 context 场景的 multi-context.md、context 文件骨架 context-file-template.md

七、迁移 5:String Refs → React.createRef()

识别特征:JSX 里 ref="myInput" 搭配 this.refs.myInput 访问。字符串 ref 在 React 16.3 被废弃、18.3.1 告警、React 19 移除。

// Before
render() {
  return <input ref="myInput" />;
}
handleFocus() {
  this.refs.myInput.focus();
}
// After
constructor(props) {
  super(props);
  this.myInputRef = React.createRef();
}
render() {
  return <input ref={this.myInputRef} />;
}
handleFocus() {
  this.myInputRef.current.focus();
}

智能体文档给出的基础规则是"每个 ref="name" 都要与对应的 this.refs.name 成对迁移"。真实代码库远比单例复杂,skills/react18-string-refs/references/patterns.md 按场景给出了完整的对照,实践中可按如下规则套用:

  • 组件内多个 ref:每个字符串 ref 各自声明一个命名 createRef() 字段(如登录表单的 emailFieldRef / passwordFieldRef),互不共享;
  • 列表 / 动态 ref(最易出错):旧写法用 this.refs[\tab_${index}`]` 动态拼名。两种新方案任选其一:
    • Map 按业务 id 惰性创建并缓存 ref:tabRefs = new Map()getOrCreateRef(id) 中不存在才 set(id, React.createRef()),访问时 this.tabRefs.get(id)?.current?.focus()
    • 或用 callback ref 直接以 index 为键存 DOM 节点:ref={el => { this.tabRefs[i] = el; }},注意 callback ref 存的是节点本身、不需要 .current
  • 传给子组件的 refref 传给自定义组件在 React 18 下需要子组件用 forwardRef 转发(const MyInput = forwardRef(function MyInput(props, ref) { ... }));React 19 中 ref 才作为普通 prop 直传、可去掉 forwardRef
  • callback ref 的性能细节:不要在 render 里写内联箭头函数作为 callback ref,它每次 render 都会生成新函数,导致 ref 先以 null 调用再以元素调用(产生"闪烁");应使用类字段箭头函数保持稳定引用。

八、迁移 6:findDOMNode → 直接挂 ref

识别特征:import ReactDOM from 'react-dom' 后调用 ReactDOM.findDOMNode(this) 拿到组件根 DOM 节点再操作。它被废弃正是因为破坏了 React 对组件边界的抽象,且随组件层级变化可能拿到非预期节点。

// Before
import ReactDOM from 'react-dom';
class MyComponent extends React.Component {
  handleClick() {
    const node = ReactDOM.findDOMNode(this);
    node.scrollIntoView();
  }
  render() { return <div>...</div>; }
}
// After
class MyComponent extends React.Component {
  containerRef = React.createRef();
  handleClick() {
    this.containerRef.current.scrollIntoView();
  }
  render() { return <div ref={this.containerRef}>...</div>; }
}

要点是把"隐式定位到组件根节点"改为"显式在目标元素上挂 ref",随后所有对根节点的操作都改走 this.containerRef.current

九、迁移 7:ReactDOM.render → createRoot —— 解锁自动批处理的关键

识别特征:入口文件(通常为 src/index.jssrc/main.js)中的 ReactDOM.render(<App />, document.getElementById('root'))

// Before
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
// After
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

这一步迁移的意义远超 API 形式本身。如 agent 文档所强调,"This migration is required to unlock automatic batching":停留在 legacy root 的应用拿不到 React 18 的自动批处理能力。审计侧同样把 ReactDOM.hydrate(对应迁移为 hydrateRoot)与 unmountComponentAtNode 纳入扫描范围,见 agents/react18-auditor.agent.md 的 PHASE 6。自动批处理之所以是"头号静默运行时破坏者":React 16/17 中 setTimeout、Promise 回调与原生事件处理器里的 setState 各自触发立即重渲染,而 React 18 对它们一律批量合并。若类组件里有"setState 之后立刻读 this.state 做条件判断"的异步链(auditor 文档给出的 async handleClick() 危险模式即典型),旧行为会消失、状态会按旧值计算。这正是 class-surgeon 完成后由 agents/react18-batching-fixer.agent.md 在 PHASE 4 接手处理(必要时以 flushSync 保留语义上必需的同步渲染)的衔接点。

十、执行纪律:逐文件推进、绝不越界

class-surgeon 文档在七个迁移之外给出了七条执行规则,它们是保证迁移质量不滑坡的工程纪律:

  1. 一次只处理一个文件,该文件的所有迁移完成后再进入下一个;
  2. 每个文件完成后写入 memory 检查点;
  3. 遇到 componentWillReceiveProps先分析它的行为再在 getDerivedStateFromPropscomponentDidUpdate 之间做选择;
  4. 遇到 legacy context,迁移 provider 前必须先追踪并找齐所有 consumer 文件
  5. 永远不要UNSAFE_ 前缀当作永久修复——那是技术债,要做真正的迁移;
  6. 绝不触碰测试文件(测试侧的 act() 语义、RTL 渲染调用、批处理断言的修复由 PHASE 5 的 agents/react18-test-guardian.agent.md 专职负责);
  7. 保留全部业务逻辑、注释、Emotion 样式与 Apollo hooks,迁移只改变生命周期/API 形态,不改业务语义。

十一、完成验证:四组 grep 全部清零

全部文件处理完毕后,智能体会执行一组验证脚本,用"残留模式计数"而非"肉眼抽查"来判定完成度——这是一个可复制到任何待迁移仓库的通用验收流程:

echo "=== UNSAFE lifecycle check ==="
grep -rn "componentWillMount\b\|componentWillReceiveProps\b\|componentWillUpdate\b" \
  src/ --include="*.js" --include="*.jsx" | grep -v "UNSAFE_\|\.test\." | wc -l
echo "above should be 0"

echo "=== Legacy context check ==="
grep -rn "contextTypes\s*=\|childContextTypes\|getChildContext" \
  src/ --include="*.js" --include="*.jsx" | grep -v "\.test\." | wc -l
echo "above should be 0"

echo "=== String refs check ==="
grep -rn "this\.refs\." src/ --include="*.js" --include="*.jsx" | grep -v "\.test\." | wc -l
echo "above should be 0"

echo "=== ReactDOM.render check ==="
grep -rn "ReactDOM\.render\s*(" src/ --include="*.js" --include="*.jsx" | wc -l
echo "above should be 0"

第一组的排除条件值得留意:它排除了 UNSAFE_ 前缀与测试文件,因此统计的是裸用旧生命周期的残留;验证通过后再写最终 memory 并回报给 commander:

#tool:memory write repository "react18-class-surgery-progress" "complete:all-deprecated-count:0"

在 commander 的管道定义中,这一 Gate 的验收标准是"源码零废弃模式 + 构建通过",随后状态机推进到 batching 阶段,最终由 commander 亲自执行 Final Validation Gate(npm run build 退出码为 0、测试 0 失败、构建输出无 React 弃用告警)。若仍有告警残留,commander 会把具体 warning 消息回传给 class-surgeon 做定点复修——详见 agents/react18-commander.agent.md 的 Migration Checklist 与 Final Validation Gate。

参考资源索引

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391