先把事情说清楚。Shopify 不是那种"用了两周觉得不好用"的路人用户。2020 年他们高调 all-in React Native,Shop、Shopify、Point of Sale、Inbox 几个旗舰应用全押在上面,还给生态贡献了 React Native Skia、FlashList、Restyle 这些几乎是品类默认选择的库。2025 年 1 月,Mustafa Ali 还专门写过一篇文章说 RN 的未来依然光明,Shopify 会继续投入。
一年多以后,掉头了。而且不是渐进迁移:Shop 应用直接从零重写,借助 AI,六个工程师十二周走完了从 PoC 到应用商店上架的全流程。他们最大的那个主应用,三百多个屏幕、带桌面小组件、Apple Watch 应用和 Siri 快捷指令的那位,也在年内重写完。
账是怎么算坏的
官方博客里有个细节挺少见——他们没有把 RN 骂一顿再走。原话是 "React Native apps can be fast. Ours are.",我们家的 RN 应用跑得挺快。掉头的原因不是性能,是算账的变量变了。
2020 年选 RN 的理由是三条:同一个功能别写两遍、没有移动端背景的工程师也能上手、不用追两个平台的功能对齐。这套逻辑的核心假设就一句话:把功能写两遍,就等于两倍的活。
这个假设撑了六年。然后 LLM 把它拆了。 Shopify 从 2021 年就开始在内部用 LLM 写代码,比 ChatGPT 发布还早一年。到 2025 年底他们发现,agent 已经能拿着 iOS 版代码当参考,把同一个功能在 Android 上实现出来,反过来也行。重写 Shop 应用之前,他们先用 LLM 把几个大应用的核心模块用 Swift 和 Kotlin 重建了一遍做验证,效果"好到惊讶"——这是原话。
所以真正的账本是这样的:跨平台框架卖的是"省掉第二份实现"的人力成本,而这个成本的很大一块,现在 agent 替你付了。省人力的柱子塌了,原生的优势却一根没少:贴平台能力、用第一方工具链、少几层框架和依赖的隔离层。这笔账对一家头部公司来说,答案不难算。
还有个催化剂:RN 的 New Architecture 迁移。官方博客承认,这次升级意味着要重访 native module 集成、渲染层、共享代码和平台代码的边界——反正都是伤筋动骨的大工程,那不如先停下来,把所有选项重新摆一遍。
真正让我想动手的段落
说实话,"大公司弃 RN"这种事每年都有,Airbnb 2018 年就干过一次。这回让我想动手的,是博客后半段讲工程实践的部分——他们怎么让 agent 真正高效干活。
先说 Helix,他们自建的重写系统。思路很克制:不搞 one-shot。你指着一个屏幕,Helix 去读对应的 RN 代码,然后提出一串按顺序切好的 checkpoint,每个小到几分钟能 review 完。每个 checkpoint 必须用测试证明自己的行为、和运行中的应用做视觉比对、过两道对抗性代码评审、最后还得有个人点头,才算提交,才开始下一个。所有评审反馈都会被记住,越往后循环越自动。
但整篇博客里我觉得最值钱的是这句大实话:agent 几秒钟就能改完代码,但要花好几分钟才能验证一次结果——因为他们一直在" babysitting "模拟器。模拟器交互依赖 accessibility tree 或者截图,慢而且脆。模型再好,测不了自己的活,迭代就是空谈。
他们的解法就一条架构原则:业务逻辑必须和 UI 完全解耦,能脱离移动端界面在桌面环境无头运行。然后通过一个 CLI 把它暴露给 agent,让 agent 以毫秒级而不是分钟级的节奏去 dispatch 操作、检查状态、断言结果。模拟器只在真正需要验证 UI 的时候才连上,走远程模式做端到端测试。
这句话对我来说有个天然的验证冲动:解耦加 CLI,这事儿门槛没那么高,我能不能自己走一遍?
我复现了一遍,踩了三个坑
我起了个最小化的 demo:一个购物车领域模块加一个 CLI 入口,Node 环境,几十行。目标很具体——agent 不碰任何 UI,纯靠命令行 dispatch 操作、读状态、断言结果,然后量一下这个反馈回路到底多快。
第一个坑来得比想象中快,是语法级的。我给 CLI 参数解析写了这么一行:
if (<span>rest</span><span>[i]</span><span>.startsWith</span>('--')) flags<span>[rest[i]</span><span>.slice</span>(<span>2</span>)] = <span>rest</span><span>[i + 1++]</span>;
我对表达式 i + 1 做后缀自增,Node 直接甩给我一个 SyntaxError: Invalid left-hand side expression in postfix operation。JS 不允许对表达式求值结果做 ++,拆成两句就好。低级,但真实。
第二个坑值得多说两句,因为它恰好踩在了 Shopify 架构的原则上。我第一版的状态是放内存里的,跑起来才发现不对:CLI 每次调用都是一个新进程,我第一条命令加购成功,第二条查状态,购物车是空的——进程一死,状态全没。agent 面对的根本不是一个"活着的"应用,是一个每敲一条命令就失忆的应用。 所以我加了落盘:dispatch 之后把状态写进 JSON 文件,进程启动时恢复。这版跑通了,agent 通过 CLI 操作和断言的完整链路都能走:
<span>$ </span>node agent_cli.js dispatch add_item <span>SKU</span>-<span>001</span> <span>2</span> --price <span>99</span>
{<span>"ok"</span><span>:true</span>}
<span>$ </span>node agent_cli.js dispatch add_item <span>SKU</span>-<span>001</span> <span>1</span> --price <span>99</span>
{<span>"ok"</span><span>:true</span>}
<span>$ </span>node agent_cli.js state
{
<span>"items"</span>: [
{
<span>"sku"</span>: <span>"SKU-001"</span>,
<span>"qty"</span>: <span>3</span>,
<span>"price"</span>: <span>99</span>
}
],
<span>"status"</span>: <span>"open"</span>
}
<span>$ </span>node agent_cli.js dispatch checkout
{<span>"ok"</span><span>:true</span>,<span>"total"</span><span>:</span><span>297</span>}
非法输入也能被 agent 感知——负数数量直接拒绝,非零退出码,结账之后再想加购也是干脆的失败:
$ node agent_cli.js dispatch add_item SKU<span>-003</span> <span>-5</span> <span>--price 10</span>
{<span>"ok"</span>:<span>false</span>,<span>"error"</span>:<span>"invalid qty: -5"</span>}
第三个坑是性能的,而且有点反直觉。落盘版跑得并不快:我写了个 bench,循环一千轮"重置、加购、结账、断言状态",平均一轮要 300 毫秒上下。排查下来,开销全在每次操作的同步写盘上。把持久化改成按需开启——CLI 模式落盘,纯迭代模式走内存——数字直接变了个数量级:
<span>1000 </span><span>loops</span> <span>x</span> <span>(2</span> <span>actions</span> <span>+</span> <span>1</span> <span>state</span> <span>read),</span> <span>3000 </span><span>assertions</span> <span>in</span> <span>total</span>
<span>total:</span> <span>3.8</span> <span>ms</span> <span>|</span> <span>per loop:</span> <span>0.004</span> <span>ms(纯内存模式,未落盘)</span>
三千次断言,3.8 毫秒。和落盘版差了七八万倍,和 Shopify 博客里描述的"模拟器路径动辄几分钟"差了七个数量级。当然我的玩具 demo 和他们的生产系统没法比——他们的 CLI 还有远程模式,能驱动真模拟器做 E2E——但"agent 毫秒级拿到状态断言"这条路径,我算是亲手确认能走通了。
这三个坑都是我复现时真实撞上的。第二个坑(进程失忆)尤其有价值——它让我理解了为什么 Shopify 要专门强调 CLI 可以"远程连接"运行中的模拟器:agent 需要持续面对同一个活的应用实例,这在架构上不是可选项。
冷水也得泼
几个社区里提得比较多的质疑,我觉得都成立,得写出来。
先说数据。Shopify 公布的那些性能数据——iOS 冷启动降 23%、Android 降 50%、崩溃会话率从 0.5% 压到 0.05%、Android 构建提速 75%——对照组是旧架构的 RN,不是 New Architecture。InfoQ 和 Reddit 上都有人指出这一点。也就是说,"迁移到 New Architecture 说不定也能拿到类似数字"这个可能性并没有被排除。RN 没有在这份数据里被证死刑。
然后是全原生的实打实代价:OTA 更新没得做了,每次发版都得过应用商店审核。有 HN 评论说得很透——原生的发布更难回滚,所以团队会被迫在发布上建更多工具、测得更狠、几周才发一个大版本。这是文化差异,也是真实成本,看你怎么权衡。
还有一点别误读:这次撤的是旗舰应用,不是 RN 本身。博客写得很清楚:小型的内部工具、管理界面还会继续用 RN。FlashList 周下载两百万,还在找长期接管的维护方,Skia 有人接盘继续维护。对绝大多数没有 Shopify 那个体量和 agent 工程能力的团队来说,2020 年那份跨平台的账,今天依然算得过来。
所以我的结论是:这不是 RN 的葬礼,是技术选型多了个自变量。以前算这类账,变量是团队规模、人力成本、平台覆盖;现在得加一行——agent 能替你承担多少份"第二实现" 。这行数字每个月都在变,变了就得重算。Shopify 只是第一家把重算结果公开发出来的大公司,我猜它不会是最后一家。
那前端工程师呢
过去几年,跨平台框架算是前端工程师的护城河之一——会 JS,就能把手伸进移动端。现在 agent 能拿着一个平台的实现当参考,把另一个平台的代码翻译出来,纯语言层面的门槛在快速贬值。 而 Shopify 给出的新架构原则里,藏着一个更值得押注的方向:让你的代码可被 agent 验证。业务逻辑和 UI 解耦、能无头运行、状态可被观测、操作有明确的成功失败信号——这套东西在他们的博客里是为了让 agent 快速迭代,但平移到前端 Web 开发、后端服务,一样成立。我复现时踩的第二个坑就是证明:状态不持久、agent 就失忆;反馈不快、agent 就空转。
架构能力没贬值,它只是换了考评标准。以前考"你的代码人类好不好改",现在多了一条"你的代码 agent 好不好测"。这后一条,多数项目现在是不及格的,包括我自己之前的不少项目。 demo 不用找我要,直接贴在下面(三个文件共 160 行),复制就能跑。想体感那组数字的差距,跑两遍 bench 对比:CART_PERSIST=1 node bench.js(落盘模式)和 node bench.js(纯内存),差距会比看我的文字直接得多。
cart_core.js(66 行)—— 零 UI 依赖的购物车内核,persist 开关就在这里
<span>// cart_core.js —— 购物车核心逻辑,零 UI 依赖,Node 里无头可跑</span>
<span>// 对齐 Shopify 博客里的原则:业务逻辑与 UI 完全解耦,状态可被 agent 直接观测</span>
<span>const</span> fs = <span>require</span>(<span>'fs'</span>);
<span>const</span> <span>STORE</span> = __dirname + <span>'/cart_state.json'</span>;
<span>// 每次进程启动恢复上次状态,否则 CLI 每调一次 state 就被重置(踩过的坑)</span>
<span>let</span> state = {
<span>items</span>: [], <span>// { sku, qty, price }</span>
<span>status</span>: <span>'open'</span>, <span>// open | checked_out</span>
};
<span>if</span> (fs.<span>existsSync</span>(<span>STORE</span>)) {
state = <span>JSON</span>.<span>parse</span>(fs.<span>readFileSync</span>(<span>STORE</span>, <span>'utf8'</span>));
}
<span>function</span> <span>persist</span>(<span></span>) {
<span>if</span> (!process.<span>env</span>.<span>CART_PERSIST</span>) <span>return</span>; <span>// bench 纯内存跑,CLI 模式才落盘(否则每轮300ms,全是磁盘开销)</span>
fs.<span>writeFileSync</span>(<span>STORE</span>, <span>JSON</span>.<span>stringify</span>(state));
}
<span>function</span> <span>getState</span>(<span></span>) {
<span>return</span> <span>JSON</span>.<span>parse</span>(<span>JSON</span>.<span>stringify</span>(state)); <span>// 防外部改引用</span>
}
<span>function</span> <span>dispatch</span>(<span>action</span>) {
<span>const</span> result = <span>doDispatch</span>(action);
<span>persist</span>();
<span>return</span> result;
}
<span>function</span> <span>doDispatch</span>(<span>action</span>) {
<span>switch</span> (action.<span>type</span>) {
<span>case</span> <span>'add_item'</span>: {
<span>if</span> (state.<span>status</span> !== <span>'open'</span>) {
<span>return</span> { <span>ok</span>: <span>false</span>, <span>error</span>: <span>'cart already checked out'</span> };
}
<span>const</span> qty = <span>Number</span>(action.<span>qty</span> ?? <span>1</span>);
<span>if</span> (!<span>Number</span>.<span>isInteger</span>(qty) || qty <= <span>0</span>) {
<span>return</span> { <span>ok</span>: <span>false</span>, <span>error</span>: <span>`invalid qty: <span>${action.qty}</span>`</span> };
}
<span>const</span> found = state.<span>items</span>.<span>find</span>(<span>(<span>it</span>) =></span> it.<span>sku</span> === action.<span>sku</span>);
<span>if</span> (found) {
found.<span>qty</span> += qty;
} <span>else</span> {
state.<span>items</span>.<span>push</span>({ <span>sku</span>: action.<span>sku</span>, qty, <span>price</span>: action.<span>price</span> ?? <span>0</span> });
}
<span>return</span> { <span>ok</span>: <span>true</span> };
}
<span>case</span> <span>'checkout'</span>: {
<span>if</span> (state.<span>items</span>.<span>length</span> === <span>0</span>) {
<span>return</span> { <span>ok</span>: <span>false</span>, <span>error</span>: <span>'cart is empty'</span> };
}
state.<span>status</span> = <span>'checked_out'</span>;
<span>return</span> { <span>ok</span>: <span>true</span>, <span>total</span>: state.<span>items</span>.<span>reduce</span>(<span>(<span>s, it</span>) =></span> s + it.<span>qty</span> * it.<span>price</span>, <span>0</span>) };
}
<span>case</span> <span>'reset'</span>: {
state.<span>items</span> = [];
state.<span>status</span> = <span>'open'</span>;
<span>return</span> { <span>ok</span>: <span>true</span> };
}
<span>default</span>:
<span>return</span> { <span>ok</span>: <span>false</span>, <span>error</span>: <span>`unknown action: <span>${action.type}</span>`</span> };
}
}
<span>module</span>.<span>exports</span> = { dispatch, getState };
agent_cli.js(72 行)—— 给 agent 用的无头操作入口
<span>#!/usr/bin/env node</span>
// agent_cli.js —— 给 agent 用的无头操作入口
// 用法:
// node agent_cli.js state
// node agent_cli.js dispatch add_item SKU-001 2 --price 99
// node agent_cli.js dispatch checkout
// node agent_cli.js run < script.txt (批量脚本,一行一个命令)
const { dispatch, getState } = require('./cart_core')<span>;</span>
function parseArgs(argv) {
const <span>[cmd, type, ...rest]</span> = argv<span>;</span>
const <span>flags</span> = {}<span>;</span>
const <span>pos</span> = []<span>;</span>
for (let <span>i</span> = <span>0</span><span>; i < rest.length; i++) {</span>
if (rest<span>[i]</span>.startsWith('--')) { flags<span>[rest[i]</span>.slice(2)] = rest<span>[i + 1]</span><span>; i++; }</span>
else pos.push(rest<span>[i]</span>)<span>;</span>
}
return { cmd, type, pos, flags }<span>;</span>
}
function main(argv) {
const { cmd, type, pos, flags } = parseArgs(argv)<span>;</span>
if (<span>cmd</span> === <span>'state'</span>) {
console.log(JSON.stringify(getState(), null, 2))<span>;</span>
return 0<span>;</span>
}
if (<span>cmd</span> === <span>'dispatch'</span>) {
const <span>action</span> = { type }<span>;</span>
if (<span>type</span> === <span>'add_item'</span>) {
<span>action.sku</span> = pos[<span>0</span>]<span>;</span>
<span>action.qty</span> = pos[<span>1</span>]<span>;</span>
if (flags.price !== undefined) <span>action.price</span> = Number(flags.price)<span>;</span>
}
const <span>result</span> = dispatch(action)<span>;</span>
console.log(JSON.stringify(result))<span>;</span>
return result.ok ? 0 : 1<span>; // 非0退出码,agent 一眼看出失败</span>
}
// 批量模式:每行一个 dispatch/state 命令,模拟 agent 连续迭代的场景
if (<span>cmd</span> === <span>'run'</span>) {
const <span>lines</span> = require(<span>'fs'</span>).readFileSync(<span>0</span>, <span>'utf8'</span>).split(<span>'\n'</span>).filter(Boolean)<span>;</span>
let <span>fails</span> = <span>0</span><span>;</span>
const <span>t0</span> = performance.now()<span>;</span>
for (const line of lines) {
const <span>[c, t, ...r]</span> = line.trim().split(/\s+/)<span>;</span>
if (c !== 'dispatch') continue<span>;</span>
const <span>action</span> = { type: t }<span>;</span>
if (<span>t</span> === <span>'add_item'</span>) {
<span>action.sku</span> = r[<span>0</span>]<span>;</span>
<span>action.qty</span> = r[<span>1</span>]<span>;</span>
const <span>p</span> = r.indexOf(<span>'--price'</span>)<span>;</span>
if (p !== -1) <span>action.price</span> = Number(r[p + <span>1</span>])<span>;</span>
}
const <span>result</span> = dispatch(action)<span>;</span>
if (!result.ok) {
console.log(`FAIL ${line} -> ${result.error}`)<span>;</span>
fails++<span>;</span>
}
}
const <span>ms</span> = (performance.now() - t0).toFixed(<span>2</span>)<span>;</span>
console.log(`--- ${lines.length} actions done, ${fails} failed, ${ms} ms total`)<span>;</span>
return fails ? 1 : 0<span>;</span>
}
console.error('unknown command:', cmd)<span>;</span>
return 2<span>;</span>
}
process.exit(main(process.argv.slice(2)))<span>;</span>
bench.js(22 行)—— "操作→断言"迭代循环基准
<span>// bench.js —— 模拟 agent 的"操作→断言"迭代循环,量一下无头路径的真实反馈速度</span>
<span>const</span> { dispatch, getState } = <span>require</span>(<span>'./cart_core'</span>);
<span>const</span> N = <span>1000</span>;
<span>let</span> assertions = <span>0</span>;
<span>const</span> t0 = performance.<span>now</span>();
<span>for</span> (<span>let</span> i = <span>0</span>; i < N; i++) {
<span>dispatch</span>({ <span>type</span>: <span>'reset'</span> });
<span>const</span> r1 = <span>dispatch</span>({ <span>type</span>: <span>'add_item'</span>, <span>sku</span>: <span>'SKU-'</span> + i, <span>qty</span>: <span>1</span>, <span>price</span>: <span>10</span> });
<span>if</span> (!r1.<span>ok</span>) <span>throw</span> <span>new</span> <span>Error</span>(<span>'unexpected: '</span> + r1.<span>error</span>);
assertions++;
<span>const</span> r2 = <span>dispatch</span>({ <span>type</span>: <span>'checkout'</span> });
<span>if</span> (!r2.<span>ok</span> || r2.<span>total</span> !== <span>10</span>) <span>throw</span> <span>new</span> <span>Error</span>(<span>'assert failed: total='</span> + r2.<span>total</span>);
assertions++;
<span>if</span> (<span>getState</span>().<span>status</span> !== <span>'checked_out'</span>) <span>throw</span> <span>new</span> <span>Error</span>(<span>'state assert failed'</span>);
assertions++;
}
<span>const</span> ms = performance.<span>now</span>() - t0;
<span>console</span>.<span>log</span>(<span>`<span>${N}</span> loops x (2 actions + 1 state read), <span>${assertions}</span> assertions in total`</span>);
<span>console</span>.<span>log</span>(<span>`total: <span>${ms.toFixed(<span>1</span>)}</span> ms | per loop: <span>${(ms / N).toFixed(<span>3</span>)}</span> ms(纯内存模式,未落盘)`</span>);
就写到这,后面 Shopify 那篇 Shop 迁移的深度长文出了细节我再聊。
比争论 RN 存废更值钱的,是'代码是否便于 agent 验证'这条新架构标准。前端与移动端团队重估选型、想给 agent 搭快速反馈回路的工程实践者都该读。