Svelte 5 深入解析 .svelte.js 与 .svelte.ts 文件:在纯 JS 模块中编写响应式逻辑
在 Svelte 5 中,除了常见的 .svelte 组件文件外,.svelte.js 和 .svelte.ts 文件让开发者可以直接在普通 JavaScript/TypeScript 模块里使用 runes(如 $state、$derived)编写可复用的响应式逻辑,并在整个应用中共享响应式状态。本文以 Svelte 仓库中的官方文档为骨架,结合 svelte/compiler 的源码实现,讲清这类文件的定位、编译器处理入口(compileModule)、可用的编译选项,以及"跨模块共享状态"这一核心限制背后的原理与两种合规写法,帮助你在实际项目中正确组织共享的响应式逻辑模块。
什么是 .svelte.js 与 .svelte.ts 文件
根据 官方文档说明:
Besides
.sveltefiles, Svelte also operates on.svelte.jsand.svelte.tsfiles.These behave like any other
.jsor.tsmodule, except that you can use runes.
也就是说,这两类文件满足两个条件:
- 行为与普通的
.js/.ts模块完全一致:它们通过 ESMexport导出命名导出或默认导出,可以被任何模块import,不受组件模型约束; - 额外支持 runes:这是它们与普通 JS 模块的唯一区别。你可以在其中使用
$state、$derived等响应式原语。
典型用途有两类:
- 创建可复用的响应式逻辑:例如把"购物车""会话管理""主题切换"等逻辑从组件中抽离,写成带状态的独立模块,多个组件共同引用;
- 跨模块共享响应式状态:多个组件 import 同一个
.svelte.js模块时,拿到的是同一份响应式状态(但有约束,见下文)。
注意(适用前提):这是 Svelte 5 引入的概念。在 Svelte 4 及更早版本中不存在
.svelte.js/.svelte.ts文件类型,因此使用它意味着项目运行在 Svelte 5 的 runes 模式下。
编译器视角:compileModule 如何处理这类文件
在 Svelte 仓库源码中,处理 .svelte.js / .svelte.ts 的入口是 svelte/compiler 导出的 compileModule 函数(而不是用于 .svelte 文件的 compile)。见 compiler/index.js:
/**
* `compileModule` takes your JavaScript source code containing runes, and turns it into a JavaScript module.
*
* @param {string} source The component source code
* @param {ModuleCompileOptions} options
* @returns {CompileResult}
*/
export function compileModule(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_module_options(options, '');
const analysis = analyze_module(source, validated);
return transform_module(analysis, source, validated);
}
从源码结构看,其调用链是:
state.reset(...):重置每次编译独立的全局状态(源文件位置、警告过滤器等,见 compiler/state.js);validate_module_options:校验并填充模块编译选项;analyze_module:进入2-analyze阶段,分析 AST 中的 runes 用法;transform_module:进入3-transform阶段,把 runes 转换为底层信号(signals)调用,其中 source map 的回退文件名也被标记为input.svelte.js,见 phases/3-transform/index.js。
常见的构建工具(如 Vite、Rollup 的 Svelte 插件)会对 .svelte.js / .svelte.ts 扩展名走同一条管线:先把 TS 语法转译为 JS,再调用 compileModule 完成 runes 转换。对 .svelte.ts 而言,可以推断工具链负责剥离类型,而 runes 的语义转换仍由 Svelte 编译器完成。
模块编译选项:比组件少一大截
与 .svelte 组件不同,模块编译选项只保留了与"编译一个 JS 模块"相关的公共选项。在 validate-options.js 中可以看到:
export const validate_module_options =
/** @type {Validator<ModuleCompileOptions, ValidatedModuleCompileOptions>} */ (
object({
...common_options,
...Object.fromEntries(Object.keys(component_options).map((key) => [key, () => {}]))
})
);
其中 common_options(同文件第 11–49 行)定义了模块真正可用的选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
filename |
'(unknown)' |
源文件名,用于诊断信息 |
rootDir |
process.cwd() |
根目录,filename 会以它为基准做相对化 |
dev |
false |
是否生成开发模式代码(含额外校验) |
generate |
'client' |
生成目标,可取 'client' / 'server' / false |
warningFilter |
() => true |
警告过滤器 |
experimental.async |
false |
实验性异步特性开关 |
而所有组件级选项(css、customElement、namespace、runes 等 component_options)都被替换为空的 () => {} 占位实现——这与直觉一致:.svelte.js 文件没有 <style>、没有模板、不是组件,因此 CSS 相关、命名空间等选项对它没有意义。
典型用法:可复用的响应式逻辑模块
下面给出两种符合规则的模块写法(规则来源见下一节)。
写法一:导出"不可重赋"的状态对象
由于深层 $state 会生成响应式代理(state proxy),对象本身引用不变、内部属性可自由修改,因此可以安全导出:
/// file: store/counter.svelte.js
export const counter = $state({
count: 0
});
export function increment() {
counter.count += 1;
}
export function reset() {
counter.count = 0;
}
组件端直接消费即可:
<script>
import { counter, increment, reset } from '../store/counter.svelte.js';
</script>
<button onclick={increment}>clicks: {counter.count}</button>
<button onclick={reset}>reset</button>
因为 counter 是一个深层响应式状态代理,组件模板中读取 counter.count 时会自动建立依赖,任何对 counter.count 的修改(包括通过 increment)都会触发精确更新。$derived 同样可以在模块中使用:
/// file: store/cart.svelte.js
export const items = $state([]);
export const total = $derived(items.reduce((sum, i) => sum + i.price, 0));
export function add(item) {
items.push(item);
}
写法二:模块内部状态 + 显式 API
当状态本身需要被重新赋值(例如切换会话、清空整个列表)时,就不要直接导出它,改为导出访问函数:
/// file: store/session.svelte.js
let session = $state(null);
let loading = $state(false);
export function login(user) {
loading = true;
// ... 模拟网络请求 ...
session = user;
loading = false;
}
export function logout() {
session = null;
}
export function isLoggedIn() {
return session !== null;
}
模块内部的所有赋值与读取都在同一个文件里,编译器能看到全部引用,因此这里的重赋值完全合法;组件通过 isLoggedIn() 等函数读取当前值,读取发生在组件的响应式上下文中,依然可以建立依赖。
核心限制:为什么不能导出会被重新赋值的 state
原文档特别警告:你不能导出会被重新赋值的 state,并指向了 [state.md?utm_source=gitcode_repo_files)。该节给出的完整解释如下,这里完整继承其内容。
下面这种写法是不允许的:
/// file: state.svelte.js
export let count = $state(0);
export function increment() {
count += 1;
}
原因在于:Svelte 编译器会把这个文件中每一个对 count 的引用都进行转换。上面的代码大致等价于这样的编译器输出(引自 [state.md?utm_source=gitcode_repo_files)):
/// file: state.svelte.js (compiler output)
// @filename: index.ts
interface Signal<T> {
value: T;
}
interface Svelte {
state<T>(value?: T): Signal<T>;
get<T>(source: Signal<T>): T;
set<T>(source: Signal<T>, value: T): void;
}
declare const $: Svelte;
// ---cut---
export let count = $.state(0);
export function increment() {
$.set(count, $.get(count) + 1);
}
可以看到,$state(0) 被转换为一个底层信号(signal)对象,赋值被转换为 $.set(...)。而 count 本身被导出后,它的值已经是这个信号对象,而不是普通数字。由于编译器一次只处理一个文件,当另一个文件 import { count } from './state.svelte.js' 时,Svelte 并不知道需要把对 count 的每次引用包裹进 $.get / $.set。后果是:
// @filename: state.svelte.js
export let count = 0;
// @filename: index.js
// ---cut---
import { count } from './state.svelte.js';
console.log(typeof count); // 'object', not 'number'
导出的 count 在消费端是一个对象(信号),而非期望的数字——重赋值语义在模块边界处断裂了。
因此,跨模块共享状态只有两条合规路径,正是上文两种写法:
- 导出引用不变的对象,内部修改属性(
export const counter = $state({...})+ 修改counter.x)——因为引用没有变化,编译器不会把它包进$.state重赋值逻辑; - 不直接导出状态,导出读写函数(
getCount()/increment())——所有对状态的重赋值都留在模块内部。
实践要点小结
- 把
.svelte.js/.svelte.ts当作"能写 runes 的普通 JS 模块":优先用它承载可复用的响应式逻辑与跨组件共享状态; - 导出状态前检查该变量是否会被重新赋值:会被重赋值 → 用"内部状态 + 函数 API";只做属性级修改 → 可以导出深层
$state对象; - 模块编译只支持
filename、rootDir、dev、generate、warningFilter、experimental等公共选项,不要对模块传 CSS、命名空间等组件级选项(会被忽略或报未识别选项错误); - 该文件类型仅存在于 Svelte 5(runes 模式),从 Svelte 4 迁移时属于新增能力,可参考 v5 迁移指南 了解版本边界;
- 想查看编译器为某个
.svelte.js模块生成的代码,可以在官方 playground 中切换 "JS Output" 标签(该说法来自 [state.md?utm_source=gitcode_repo_files) 中的说明)。
延伸阅读(仓库内路径)
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