基于 awesome-copilot 的 React 18 类组件迁移实战:react18-class-surgeon 的生命周期与 API 语义化改造指南
在 React 16/17 时代的类组件代码库里,componentWillMount、componentWillReceiveProps、componentWillUpdate、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
要点有三:
- 它是管道中的专职执行者。
user-invocable: false表明它不被用户直接唤起,而是由编排方触发。在 agents/react18-commander.agent.md 定义的 gated pipeline 中,class-surgeon 位于 PHASE 3(Class Component Surgery),上游是审计阶段产出的.github/react18-audit.md,下游是自动批处理修复与测试守护。它是整条迁移流水线里改动量最大的一环。 - 它的输入不是代码,而是审计报告。先由 agents/react18-auditor.agent.md 执行"只读不修"的全量扫描(覆盖 unsafe lifecycle、legacy context、string refs、
findDOMNode、ReactDOM.render、批处理漏洞、事件委托假设与依赖兼容性),把每个命中位置按文件与迁移路径写成报告,class-surgeon 再据此逐文件施工。 - 为什么锁定 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.md 与 skills/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-progress、react18-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 的写法质量:
- 它在每次 render 都会触发(包括由
setState引起的 render),而不仅仅在 prop 变化时。因此必须把"上一次比较用的 prop"(如prevItems)存进 state,靠props.items !== state.prevItems做引用级对比,否则每次setState都会触发一遍重新派生,甚至形成死循环。 - 它是 static 方法,没有
this。不能访问this.props、this.state或实例方法,任何this.computeValue(props)都会抛ReferenceError;内部逻辑必须是与 props/state 无关的纯函数(如独立的sortItems)。 - 绝不能在里面做副作用。需要随 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 contextTypes、static childContextTypes、getChildContext() 三件套。这是七个迁移中唯一不能单文件完成的: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.Consumerrender 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;
- 用
- 传给子组件的 ref:
ref传给自定义组件在 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.js 或 src/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 文档在七个迁移之外给出了七条执行规则,它们是保证迁移质量不滑坡的工程纪律:
- 一次只处理一个文件,该文件的所有迁移完成后再进入下一个;
- 每个文件完成后写入 memory 检查点;
- 遇到
componentWillReceiveProps,先分析它的行为再在getDerivedStateFromProps与componentDidUpdate之间做选择; - 遇到 legacy context,迁移 provider 前必须先追踪并找齐所有 consumer 文件;
- 永远不要把
UNSAFE_前缀当作永久修复——那是技术债,要做真正的迁移; - 绝不触碰测试文件(测试侧的
act()语义、RTL 渲染调用、批处理断言的修复由 PHASE 5 的 agents/react18-test-guardian.agent.md 专职负责); - 保留全部业务逻辑、注释、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。
参考资源索引
- 本智能体定义:agents/react18-class-surgeon.agent.md
- 编排与 Gate 定义:agents/react18-commander.agent.md、上游扫描器 agents/react18-auditor.agent.md
- 模式决策表:skills/react18-lifecycle-patterns/SKILL.md 及三个参考文件(
componentWillReceiveProps的决策树与 gDSFP 陷阱详解见 skills/react18-lifecycle-patterns/references/componentWillReceiveProps.md) - Legacy context 全量对照:skills/react18-legacy-context/SKILL.md 与 skills/react18-legacy-context/references/single-context.md
- String ref 场景集:skills/react18-string-refs/SKILL.md 与 skills/react18-string-refs/references/patterns.md
- 依赖侧配套(可选研读):plugins/react18-upgrade/plugin.json 与 plugins/react18-upgrade/README.md
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00