译:指令与平台边界 | TanStack 博客
指令与平台边界 | TanStack 博客
网址:https://tanstack.com/blog/directives-and-the-platform-boundary
发布时间:2025年10月24日
作者:Tanner Linsley
翻译官:Qwen 3 Max
JavaScript 生态中悄然兴起的趋势
多年来,JavaScript 仅有一个真正有意义的指令:“use strict”。它是标准化的,由运行时强制执行,并且在所有环境中行为一致。它代表了语言、引擎与开发者之间清晰的契约。
然而,如今我们正目睹一种新趋势的出现:各大框架开始发明自己的顶层指令。“use client”、“use server”、“use cache”、“use workflow”……这类指令正在整个生态中不断涌现。它们看起来像语言特性,出现在语言特性通常出现的位置,并影响代码的解析、打包和执行方式。
但关键区别在于:这些并非标准化的 JavaScript 特性。运行时并不理解它们,没有统一的规范加以约束,每个框架都可以自由定义其含义、规则和边界情况。
这种做法短期内或许显得便捷,但长期来看却会加剧混淆、增加调试难度,并给工具链和代码可移植性带来额外负担——这些正是我们过去已经历过的教训。
当指令看起来像平台特性,开发者就会把它当作平台特性
文件顶部的指令看起来极具权威性,给人一种“这是语言层面的事实”而非“框架提示”的印象。这种错觉会引发一系列问题:
- 开发者误以为这些指令是官方标准;
- 整个生态开始将它们视为共享的 API 表面;
- 新手难以区分 JavaScript 本身与框架的“魔法”;
- 平台与厂商之间的界限变得模糊;
- 调试体验下降,工具链不得不为这些行为做特殊处理。
我们已经看到这种混淆。许多开发者如今认为 “use client” 和 “use server” 就是现代 JavaScript 的工作方式,却不知道它们仅在特定构建流程和服务器组件语义下才有效。这种误解揭示了一个更深层次的问题。
功劳归功:use server 与 use client
某些指令之所以存在,是因为多个工具需要一个简单、统一的协调点。实践中,“use server” 和 “use client” 是一种务实的垫片(shim),用于在 React Server Components(RSC)环境中告知打包器和运行时代码允许在何处执行。正因为其作用范围明确(仅限执行位置),它们在多个打包工具中获得了相对广泛的支持。
即便如此,一旦面对真实世界的复杂需求,这些指令的局限性也很快显现。在大规模应用中,我们往往需要与正确性及安全性密切相关的参数和策略:HTTP 方法、请求头、中间件、认证上下文、追踪信息、缓存行为等。而指令本身并没有自然的方式来承载这些选项,导致这些信息常常被忽略、另作处理,或通过衍生出新的指令变体来重新编码。
指令开始力不从心:选项与“指令周边”API
当一个指令在创建后不久就需要配置选项,或催生出类似 “use cache:remote” 的兄弟指令,以及像 cacheLife(...) 这样的辅助函数时,这通常意味着该功能本应是一个 API,而不是文件顶部的一行字符串。既然你终究需要一个函数,那就干脆全部用函数来实现。
例如:
"use cache:remote";
const fn = () => "value";
对比显式、带来源和选项的 API:
// 显式 API,具备来源和配置选项
import { cache } from "next/cache";
export const fn = cache(() => "value", {
strategy: "remote",
ttl: 60,
});
对于需要详细配置的服务器行为:
import { server } from "@acme/runtime";
export const action = server(
async (req) => {
return new Response("ok");
},
{
method: "POST",
headers: { "x-foo": "bar" },
middleware: [requireAuth()],
},
);
API 具备来源(通过 import)、版本控制(通过包管理)、组合能力(通过函数)和可测试性。而指令通常不具备这些特性,强行将选项编码进指令中,往往会成为一种设计上的“异味”。
共享语法却无共享规范,可能成为脆弱的基础
一旦多个框架开始采用指令,我们就陷入了最糟糕的局面:
| 类别 | 共享语法 | 共享契约 | 结果 |
|---|---|---|---|
| ECMAScript | ✅ | ✅ | 稳定且通用 |
| 框架 API | ❌ | ❌ | 隔离但无害 |
| 框架指令 | ✅ | ❌ | 混乱且不稳定 |
缺乏统一定义的共享语法会导致:
- 语义漂移:每个框架自行定义语义;
- 可移植性问题:代码看似通用,实则不然;
- 工具链负担:打包器、linter 和 IDE 必须猜测或追踪行为;
- 平台摩擦:标准组织被生态预期“绑架”,难以推进规范。
我们此前在装饰器(decorators)上就经历过类似困境:TypeScript 推广了一套非标准语义,社区广泛采用,但 TC39 最终选择了不同方向,导致许多人至今仍在痛苦地迁移。
“这不就是换个语法的 Babel 插件或宏吗?”
功能上,确实如此。指令和自定义转换都能在编译时改变行为。问题不在于能力,而在于表层形式和观感。
- 指令看起来像平台特性:无需 import,没有明确归属,没有显式来源,给人一种“这就是 JavaScript”的错觉。
- API/宏则指向明确的所有者:import 提供了来源、版本和可发现性。
最理想情况下,指令相当于在文件顶部调用一个全局、无 import 的函数,比如 window.useCache()。这正是其风险所在:它隐藏了提供方,将框架语义伪装成语言本身。
例如:
"use cache";
const fn = () => "value";
对比显式 API:
// 显式 API(可追踪、可归属、可发现)
import { createServerFn } from "@acme/runtime";
export const fn = createServerFn(() => "value");
或全局“魔法”:
// 全局魔法(无 import,提供方隐藏)
window.useCache();
const fn = () => "value";
为何这很重要?
- 归属与来源:import 告诉你谁提供了该行为,指令则没有;
- 工具链友好性:API 存在于包空间中,指令则需要整个生态做特殊处理;
- 可移植性与迁移成本:替换一个 import 的 API 很简单,而清理散布在文件中的指令语义则代价高昂且模糊;
- 教育与预期管理:指令模糊了平台边界,而 API 则让边界清晰可见。
因此,尽管 Babel 插件或宏可以实现相同功能,但基于 import 的 API 能清晰地将其保留在“框架空间”内。而指令则将相同行为移入“语言空间”的假象中——这正是本文的核心关切。
“加命名空间能解决吗?”(例如 "use next.js cache")
命名空间有助于人类识别,但无法解决根本问题:
- 它仍然看起来像平台特性:顶层字符串字面量暗示这是语言,而非库;
- 它仍缺乏模块级别的来源和版本信息:import 能编码这两者,字符串不能;
- 它仍需工具链(打包器、linter、IDE)特殊处理,而非利用标准的 import 解析机制;
- 它仍会推动无规范的“伪标准化”,只是加了厂商前缀;
- 它相比替换 import API,迁移成本更高。
例如:
"use next.js cache";
const fn = () => "value";
对比显式 API:
// 显式、可归属的 API,具备来源和版本控制
import { cache } from "next/cache";
export const fn = cache(() => "value");
如果目标是明确来源,import 已经提供了干净的解决方案,并与现有生态兼容。如果目标是跨框架的共享原语,那就需要真正的规范,而不是看起来像语法的厂商字符串。
指令可能引发竞争性动态
一旦指令成为竞争焦点,激励机制就会改变:
- 某厂商推出新指令;
- 它成为显性功能;
- 开发者期望处处可用;
- 其他框架被迫跟进;
- 语法在无规范的情况下扩散。
于是我们看到:
'use server'
'use client'
'use cache'
'use cache:remote'
'use workflow'
'use streaming'
'use edge'
甚至连持久化任务、缓存策略和执行位置等运行时语义,都被编码为指令。这些本应属于能力模型,而非语法模型。将它们编码为指令,等于在标准流程之外设定方向,值得警惕。
对于需要丰富选项的功能,应优先考虑 API 而非指令
持久化执行(如 “use workflow”、“use step”)就是一个典型例子,但原则具有普适性:指令只能将行为简化为布尔值,而许多功能需要选项和演进空间。编译器和转换工具可以支持任一形式;关键在于为长期清晰性和可维护性选择合适的形式。
例如:
'use workflow'
'use step'
替代方案:使用带来源和选项的显式 API:
import { workflow, step } from "@workflows/workflow";
export const sendEmail = workflow(
async (input) => {
/* ... */
},
{ retries: 3, timeout: "1m" },
);
export const handle = step(
"fetchUser",
async () => {
/* ... */
},
{ cache: 60 },
);
函数形式同样便于 AST 分析和编译器转换,同时具备来源(import)和类型安全。
另一种方案是注入一次全局对象并为其添加类型:
// 一次性初始化
globalThis.workflow = createWorkflow();
// 全局类型声明(如 global.d.ts)
declare global {
var workflow: typeof import('@workflows/workflow').workflow
}
使用时仍保持 API 形态,无需指令:
export const task = workflow(
async () => {
/* ... */
},
{ retries: 5 },
);
用编译器来提升开发者体验当然是好事——JSX 就是一个成功的例子!但我们必须谨慎负责:通过具备明确来源和类型的 API 来扩展,而非使用看起来像语言本身的顶层字符串。这些是建议,而非教条。
隐性的锁定效应可能悄然形成
即使没有恶意,指令在设计上就容易造成锁定:
- 认知锁定:开发者对某厂商的指令语义形成肌肉记忆;
- 工具链锁定:IDE、打包器和编译器必须针对特定运行时;
- 代码锁定:指令位于语法层面,移除或迁移成本高昂。
指令或许看起来不像专有功能,但它们对生态语法的重塑,使其比普通 API 更具“专有性”。
若真想共建共享原语,就应协作制定规范和 API
我们确实面临真实问题需要解决:
- 服务器执行边界
- 流式传输与异步工作流
- 分布式运行时原语
- 持久化任务
- 缓存语义
但这些问题应通过 API、能力模型和未来标准 来解决,而非通过打包器推动的、无规范约束的“伪语法”。
如果多个框架真心希望共建共享原语,负责任的做法是:
- 跨框架协作制定规范;
- 在适当时机向 TC39 提案;
- 将非标准功能明确限定在 API 空间,而非语言空间。
指令应稀有、稳定、标准化,并谨慎使用,而非在各厂商间泛滥成灾。
为何这与 JSX/虚拟 DOM 时代不同
有人可能会将对指令的批评,类比于早期对 React JSX 或虚拟 DOM 的质疑。但两者的失败模式截然不同。JSX 和 VDOM 从未伪装成语言特性——它们始终伴随显式 import、明确来源和清晰的工具边界。而指令则位于文件顶层,外观酷似平台特性,却在缺乏共享规范的情况下制造了生态预期和工具负担。
总结
框架指令今天或许带来“开发者体验魔法”,但当前趋势可能导向一个更加碎片化的未来——生态将被工具定义的“方言”割裂,而非由标准统一。
我们可以追求更清晰的边界。
框架当然应该创新,但也应明确区分框架行为与平台语义,而非为了短期采用率模糊这条界限。清晰的边界,才能让整个生态受益。
此文自动发布于:github issues
