译:指令与平台边界 | TanStack 博客

5 分钟
好文翻译

指令与平台边界 | 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 已经提供了干净的解决方案,并与现有生态兼容。如果目标是跨框架的共享原语,那就需要真正的规范,而不是看起来像语法的厂商字符串。

指令可能引发竞争性动态

一旦指令成为竞争焦点,激励机制就会改变:

  1. 某厂商推出新指令;
  2. 它成为显性功能;
  3. 开发者期望处处可用;
  4. 其他框架被迫跟进;
  5. 语法在无规范的情况下扩散。

于是我们看到:

'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