React Router 架构决策 ADR-0008:TypeScript 模板为何只转换 app 代码为 JavaScript
本篇技术文章围绕 React Router 仓库中一份已采纳的架构决策记录(ADR)展开:Only support JS conversion for app code。该决策解释了为什么在脚手架工具中为 JavaScript 用户"动态降级" TypeScript 模板时,只转换应用目录(app/)内的代码,而不转换构建脚本与配置文件,并剖析了 ESM/CJS 双路线各自踩中的技术深坑。读完本文,你能理解"模板即单一可信源"的脚手架设计思想,看懂 Node.js 模块系统在真实工程中的约束,并能对照 create-react-router 的当前源码,验证这套决策最终是如何被模板化机制承接的。
背景:TypeScript 默认、JavaScript 可选的选择困境
React Router(及其前身 Remix)的项目脚手架默认使用 TypeScript,但始终有一批用户更倾向纯 JavaScript。决策文档(日期 2023-01-20,状态 accepted)记录了当时 npx create-remix 的交互场景——CLI 会直接询问用户选择 TS 还是 JS:
❯ npx create-remix@latest
? Where would you like to create your app? ./my-remix-app
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? (Use arrow keys)
❯ TypeScript
JavaScript
这个"语言选择"看似只是一个选项,背后却要维护两套完整的项目模板,由此引出了模板维护成本问题。
模板方案的演进:从"双份模板"到"仅维护 TS 变体"
文档还原了一条清晰的演进链:
- 最初:为每个模板分别维护 TypeScript 和 JavaScript 两个变体。它"能用",但巨大的模板内容被完整复制了两份,两套变体极难维护——任何一处功能更新都要同步改两次。
- 改进:团队决定只维护每个模板的 TS 变体。当用户选择 JavaScript 时,CLI 会先把 TS 模板拷贝下来,然后动态地把所有 TypeScript 相关代码转换成 JavaScript 等价物。
这个方案的边界划分很关键:转换 app/ 目录(即 Remix/React Router 应用代码)内的文件是可靠的,因为这部分代码由 Vite 统一构建,构建管线对 TS/JS 的处理是透明的;而转换 app/ 目录之外的 TS 相关代码则"棘手且易错"。
"app 代码内可靠、app 代码外易错"这个判断是整个 ADR 的支点,下面逐一拆解"易错"具体难在哪里。
为什么 app 目录之外的转换如此困难
app/ 之外通常是什么?package.json 里的 scripts、server.ts/seed.ts 这类 Node 直接执行的脚本、vite.config.ts、tsconfig.json、种子数据工具等。这些文件绕过了构建管线,由 Node 直接加载,于是 Node 的模块解析规则成为硬约束。
问题 1:.ts 文件的陈旧引用(Stale references)
文档给出的实例是 Indie 与 Blues 两个官方栈模板:当用户选择 JavaScript 后,模板里的构建/启动脚本仍然引用着 server.ts 和 seed.ts,脚本依赖也随之引用了 ts-node 这类 TS 专用运行工具——结果就是脚本直接跑不起来。
这个问题揭示了一个通用规律:模板转换不仅是"文件内容翻译",还必须同时改写所有指向这些文件的引用点(package.json scripts、import 语句、工具链依赖)。引用点分散在 JSON、shell 命令、代码三种介质中,静态改写很容易漏。
问题 2.a:ESM 路线(.mjs)
app/ 之外转 JS 时,最直觉的做法是转成 ESM 风格的 .mjs,因为 Remix 应用代码本身就用 ESM 语法。但 ESM 在 Node 中有两种启用方式,文档逐一分析了两者的死结:
- 方式 a:在
package.json中设置"type": "module"—— 文档指出这会立即破坏构建,因为该设置作用于整个包目录,覆盖到 app 代码而不只是 app 之外的脚本,与 Remix 的构建配置产生冲突。 - 方式 b:使用
.mjs扩展名 —— 看起来更有希望,但.mjs文件的 import 说明符必须带完整文件扩展名。而原 TS 模板中的相对导入普遍不带扩展名,于是出现这样的困境:
// ./script.mjs (converted from ./script.js)
import myHelper from "./my-helper";
// Should this be converted to `./my-helper.mjs`?
// Probably, but can we be sure?
myHelper();
把无扩展名相对导入可靠地补上正确扩展名是"不可治理"(untractable)的——因为目标文件未必都是 .js/.mjs,转换器无法保证每一次补全都正确。文档的结论是:或许存在某种解法,但复杂度代价过高。
问题 2.b:CJS 路线
如果不用 .mjs,Node 会把 app 目录外的脚本默认当作 CommonJS 处理。而 CJS 不支持 ESM 风格的 import/export,那就需要把所有 import/export 改写成 require/module.exports。
文档补充了一条容易被忽视的约束:转换后的代码是要给其他开发者阅读和编辑的,因此不能像构建产物那样生成一堆 import/export 的样板适配代码。import/export 的转换"或许可行,但同样复杂度代价很高"。
三条路线(双模板、ESM、CJS)全部被排除后,决策的收敛方向就清晰了。
决策:JS 转换只覆盖 app 代码
Only support JS conversion for app code, not for scripts or code outside of the Remix app directory.
(只为 app 代码提供 JS 转换,不为 app 目录之外的脚本和代码提供转换。)
这个决策的实质是划定转换能力的可信边界:构建管线管辖范围内(app/)的 TS→JS 转换交给工具自动化;构建管线管辖范围外(Node 直接执行的脚本与配置)保持 TypeScript 原样,把语言选择的责任上移给用户——通过选择模板来表达。
用户的三个选项与手动清理路径
根据决策,用户面对"想用 JavaScript"这一诉求时有三种选择:
- 使用 TypeScript 模板;
- 使用 TypeScript 模板,但 app 目录被自动转换为 JS(
app/外仍是 TS 文件与 TS 工具链); - 使用专门的 JavaScript 模板(dedicated Javascript template)。
如果选项 2 残留的 TS 让用户无法接受、又找不到合适的选项 3 模板,文档给出了完整的手动清理清单:
- 删除
tsconfig.json,或替换为等价的jsconfig.json; - 把 TS 专用工具替换为 JS 对应物(例如
ts-node->node); - 把剩余的
.ts文件改为.mjs,并同步更新所有引用点——包括 import 与package.jsonscripts 中的文件名引用。
注意这份清单恰好对应了前面分析的三类坑:配置文件、工具链依赖、陈旧引用——手动操作时照单排查即可。
源码印证:模板机制在 create-react-router 中的落地
ADR 提出时 CLI 还内置了"TS 或 JS"的交互式提问;到了当前仓库的 create-react-router,语言选择已经彻底"模板化"——在 packages/create-react-router 包内检索不到任何 TypeScript/JavaScript 的交互提问逻辑,语言差异完全由 --template 指定的模板承载,这正是 ADR 选项 3(专门模板)成为主流路径后的自然演进。
当前 CLI 的模板机制可以从源码中完整验证:
- 模板来源的五种合法形式:copyTemplate 的入口注释明确列出——本地文件或目录、GitHub
owner/repo简写、owner/repo/directory简写、完整 GitHub 仓库 URL、任意 tarball URL;非法模板会抛出 CopyTemplateError。 - GitHub 简写解析:copyTemplateFromGithubRepoShorthand 将
owner/name[/path]拆段后经 codeload 下载仓库 tarball 并解压;getRepoInfo 负责从tree分支 URL 中提取分支与子目录。 - 子目录过滤:tarball 解压时通过 tar 的
map钩子(copy-template.ts)按前缀过滤,只保留指定子目录——这就是能选owner/repo/templates/basic这类"仓库内某目录作为模板"的实现基础。 - 默认模板:copyTemplateToTempDirStep 中,未传
--template时回退到 remix-run 官方 templates 仓库的 default 模板;printHelp 的--help输出把上述五种模板形式与示例逐条列出,私有仓库还支持--token传访问令牌。 - CLI 流程:index.ts 中整个创建流程是显式步骤数组(
introStep→projectNameStep→copyTemplateToTempDirStep→copyTempDirToAppDirStep→ 依赖安装与 git 初始化等),模板拷贝先落到临时目录再复制到目标目录,并在 copyTempDirToAppDirStep 中做文件冲突检测,--overwrite可强制覆盖。
这条源码证据链说明:ADR 时代的"CLI 动态转换"与今天的"模板选择"并不矛盾——--template 机制把"选语言"变成了"选模板",把转换的责任从 CLI 的脆弱字符串改写,前移到模板作者的一次性维护,从工程上规避了问题 1、2.a、2.b 的全部风险。
要点回顾
- 维护 TS/JS 双份模板的重复成本,催生了"仅维护 TS 模板 + 动态转换"的中间方案;
app/之外的转换在 ESM(.mjs扩展名强制、"type": "module"污染全包)与 CJS(import/export 全量改写且产物需可读)两条路上都代价过高,加上package.jsonscripts 中的.ts陈旧引用问题,最终决策为只转换 app 代码;- 用户因此拥有 TS 模板 / 半转换模板 / 纯 JS 模板三条路径,且文档提供了
tsconfig.json→jsconfig.json、ts-node→node、.ts→.mjs三步手动清理清单; - 当前 create-react-router 源码显示该决策的落地形态:语言选择交由
--template模板机制承担,CLI 本身不再做任何交互式语言提问或代码级 TS→JS 转换。
这份 ADR 的价值在于它示范了一种务实的架构决策方法:先穷举候选路线并给出每条路线的失败证据(带代码示例),再把能力边界收缩到"可靠区间"内,剩下的复杂性交还给用户可控的模板层。对于任何需要为多语言用户提供脚手架的项目,这都是一份可直接借鉴的决策样本。
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 StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00