Shopify 弃 React Native 上了 HN 1272 分,我复现了它给 Agent 用的那套无头架构

上周四,Shopify 移动端负责人 Mustafa Ali 在官方工程博客发了篇文章,标题起得毫不遮掩:《Native is now the future of mobile at Shopify》。到我这周去核实时,Hacker News 上的讨论帖已经堆到 1272 分。

先把事情说清楚。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 参数解析写了这么一行:

css 复制代码
if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1++];

我对表达式 i + 1 做后缀自增,Node 直接甩给我一个 SyntaxError: Invalid left-hand side expression in postfix operation。JS 不允许对表达式求值结果做 ++,拆成两句就好。低级,但真实。

第二个坑值得多说两句,因为它恰好踩在了 Shopify 架构的原则上。我第一版的状态是放内存里的,跑起来才发现不对:CLI 每次调用都是一个新进程,我第一条命令加购成功,第二条查状态,购物车是空的------进程一死,状态全没。agent 面对的根本不是一个"活着的"应用,是一个每敲一条命令就失忆的应用。 所以我加了落盘:dispatch 之后把状态写进 JSON 文件,进程启动时恢复。这版跑通了,agent 通过 CLI 操作和断言的完整链路都能走:

ruby 复制代码
$ node agent_cli.js dispatch add_item SKU-001 2 --price 99
{"ok":true}
$ node agent_cli.js dispatch add_item SKU-001 1 --price 99
{"ok":true}
$ node agent_cli.js state
{
  "items": [
    {
      "sku": "SKU-001",
      "qty": 3,
      "price": 99
    }
  ],
  "status": "open"
}
$ node agent_cli.js dispatch checkout
{"ok":true,"total":297}

非法输入也能被 agent 感知------负数数量直接拒绝,非零退出码,结账之后再想加购也是干脆的失败:

lua 复制代码
$ node agent_cli.js dispatch add_item SKU-003 -5 --price 10
{"ok":false,"error":"invalid qty: -5"}

第三个坑是性能的,而且有点反直觉。落盘版跑得并不快:我写了个 bench,循环一千轮"重置、加购、结账、断言状态",平均一轮要 300 毫秒上下。排查下来,开销全在每次操作的同步写盘上。把持久化改成按需开启------CLI 模式落盘,纯迭代模式走内存------数字直接变了个数量级:

yaml 复制代码
1000 loops x (2 actions + 1 state read), 3000 assertions in total
total: 3.8 ms | per loop: 0.004 ms(纯内存模式,未落盘)

三千次断言,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 开关就在这里

javascript 复制代码
// cart_core.js ------ 购物车核心逻辑,零 UI 依赖,Node 里无头可跑
// 对齐 Shopify 博客里的原则:业务逻辑与 UI 完全解耦,状态可被 agent 直接观测

const fs = require('fs');
const STORE = __dirname + '/cart_state.json';

// 每次进程启动恢复上次状态,否则 CLI 每调一次 state 就被重置(踩过的坑)
let state = {
  items: [],        // { sku, qty, price }
  status: 'open',   // open | checked_out
};
if (fs.existsSync(STORE)) {
  state = JSON.parse(fs.readFileSync(STORE, 'utf8'));
}

function persist() {
  if (!process.env.CART_PERSIST) return; // bench 纯内存跑,CLI 模式才落盘(否则每轮300ms,全是磁盘开销)
  fs.writeFileSync(STORE, JSON.stringify(state));
}

function getState() {
  return JSON.parse(JSON.stringify(state)); // 防外部改引用
}

function dispatch(action) {
  const result = doDispatch(action);
  persist();
  return result;
}

function doDispatch(action) {
  switch (action.type) {
    case 'add_item': {
      if (state.status !== 'open') {
        return { ok: false, error: 'cart already checked out' };
      }
      const qty = Number(action.qty ?? 1);
      if (!Number.isInteger(qty) || qty <= 0) {
        return { ok: false, error: `invalid qty: ${action.qty}` };
      }
      const found = state.items.find((it) => it.sku === action.sku);
      if (found) {
        found.qty += qty;
      } else {
        state.items.push({ sku: action.sku, qty, price: action.price ?? 0 });
      }
      return { ok: true };
    }
    case 'checkout': {
      if (state.items.length === 0) {
        return { ok: false, error: 'cart is empty' };
      }
      state.status = 'checked_out';
      return { ok: true, total: state.items.reduce((s, it) => s + it.qty * it.price, 0) };
    }
    case 'reset': {
      state.items = [];
      state.status = 'open';
      return { ok: true };
    }
    default:
      return { ok: false, error: `unknown action: ${action.type}` };
  }
}

module.exports = { dispatch, getState };

agent_cli.js(72 行)------ 给 agent 用的无头操作入口

ini 复制代码
#!/usr/bin/env node
// 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');

function parseArgs(argv) {
  const [cmd, type, ...rest] = argv;
  const flags = {};
  const pos = [];
  for (let i = 0; i < rest.length; i++) {
    if (rest[i].startsWith('--')) { flags[rest[i].slice(2)] = rest[i + 1]; i++; }
    else pos.push(rest[i]);
  }
  return { cmd, type, pos, flags };
}

function main(argv) {
  const { cmd, type, pos, flags } = parseArgs(argv);

  if (cmd === 'state') {
    console.log(JSON.stringify(getState(), null, 2));
    return 0;
  }

  if (cmd === 'dispatch') {
    const action = { type };
    if (type === 'add_item') {
      action.sku = pos[0];
      action.qty = pos[1];
      if (flags.price !== undefined) action.price = Number(flags.price);
    }
    const result = dispatch(action);
    console.log(JSON.stringify(result));
    return result.ok ? 0 : 1; // 非0退出码,agent 一眼看出失败
  }

  // 批量模式:每行一个 dispatch/state 命令,模拟 agent 连续迭代的场景
  if (cmd === 'run') {
    const lines = require('fs').readFileSync(0, 'utf8').split('\n').filter(Boolean);
    let fails = 0;
    const t0 = performance.now();
    for (const line of lines) {
      const [c, t, ...r] = line.trim().split(/\s+/);
      if (c !== 'dispatch') continue;
      const action = { type: t };
      if (t === 'add_item') {
        action.sku = r[0];
        action.qty = r[1];
        const p = r.indexOf('--price');
        if (p !== -1) action.price = Number(r[p + 1]);
      }
      const result = dispatch(action);
      if (!result.ok) {
        console.log(`FAIL  ${line}  ->  ${result.error}`);
        fails++;
      }
    }
    const ms = (performance.now() - t0).toFixed(2);
    console.log(`--- ${lines.length} actions done, ${fails} failed, ${ms} ms total`);
    return fails ? 1 : 0;
  }

  console.error('unknown command:', cmd);
  return 2;
}

process.exit(main(process.argv.slice(2)));

bench.js(22 行)------ "操作→断言"迭代循环基准

javascript 复制代码
// bench.js ------ 模拟 agent 的"操作→断言"迭代循环,量一下无头路径的真实反馈速度
const { dispatch, getState } = require('./cart_core');

const N = 1000;
let assertions = 0;
const t0 = performance.now();

for (let i = 0; i < N; i++) {
  dispatch({ type: 'reset' });
  const r1 = dispatch({ type: 'add_item', sku: 'SKU-' + i, qty: 1, price: 10 });
  if (!r1.ok) throw new Error('unexpected: ' + r1.error);
  assertions++;
  const r2 = dispatch({ type: 'checkout' });
  if (!r2.ok || r2.total !== 10) throw new Error('assert failed: total=' + r2.total);
  assertions++;
  if (getState().status !== 'checked_out') throw new Error('state assert failed');
  assertions++;
}

const ms = performance.now() - t0;
console.log(`${N} loops x (2 actions + 1 state read), ${assertions} assertions in total`);
console.log(`total: ${ms.toFixed(1)} ms | per loop: ${(ms / N).toFixed(3)} ms(纯内存模式,未落盘)`);

就写到这,后面 Shopify 那篇 Shop 迁移的深度长文出了细节我再聊。

相关推荐
leoZ2312 小时前
第 10 篇 数据飞轮与冷启动:AI 产品的数据从哪来
人工智能·大模型·agent
坐吃山猪4 小时前
Harness Engineering知识总结
llm·agent·harness
李燚4 小时前
Agent 要用 API key,但明文一次都不能进模型——Secret Runtime 落地实录(第110篇)
ai·agent·credential·secret·eino·deepflux·secret runtime
invicinble4 小时前
agent操作Windows的原理,以及操作其他的操作系统里面的数据
agent
wangruofeng5 小时前
把AI判断做成「智能if语句」!Jev实测:一次调用3个判断,置信度直接路由
aigc·agent·ai编程
coderMax6 小时前
MCP 架构概览
agent
掰头战士6 小时前
多重影分身!恨不得把一个agent掰成两个? 还真能干!
typescript·llm·agent
武子康6 小时前
CLAUDE.md 引用 AGENTS.md 后,两边真的读到同一套规则吗?
人工智能·llm·agent
染指11106 小时前
119.Agent-LangChain核心组件-Runtime运行时
人工智能·langchain·agent