首页
/ Svelte 5 深入解析 .svelte.js 与 .svelte.ts 文件:在纯 JS 模块中编写响应式逻辑

Svelte 5 深入解析 .svelte.js 与 .svelte.ts 文件:在纯 JS 模块中编写响应式逻辑

2026-09-06 11:35:35作者:羿妍玫Ivan

在 Svelte 5 中,除了常见的 .svelte 组件文件外,.svelte.js.svelte.ts 文件让开发者可以直接在普通 JavaScript/TypeScript 模块里使用 runes(如 $state$derived)编写可复用的响应式逻辑,并在整个应用中共享响应式状态。本文以 Svelte 仓库中的官方文档为骨架,结合 svelte/compiler 的源码实现,讲清这类文件的定位、编译器处理入口(compileModule)、可用的编译选项,以及"跨模块共享状态"这一核心限制背后的原理与两种合规写法,帮助你在实际项目中正确组织共享的响应式逻辑模块。

什么是 .svelte.js 与 .svelte.ts 文件

根据 官方文档说明

Besides .svelte files, Svelte also operates on .svelte.js and .svelte.ts files.

These behave like any other .js or .ts module, except that you can use runes.

也就是说,这两类文件满足两个条件:

  1. 行为与普通的 .js / .ts 模块完全一致:它们通过 ESM export 导出命名导出或默认导出,可以被任何模块 import,不受组件模型约束;
  2. 额外支持 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);
}

从源码结构看,其调用链是:

  1. state.reset(...):重置每次编译独立的全局状态(源文件位置、警告过滤器等,见 compiler/state.js);
  2. validate_module_options:校验并填充模块编译选项;
  3. analyze_module:进入 2-analyze 阶段,分析 AST 中的 runes 用法;
  4. 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 实验性异步特性开关

而所有组件级选项(csscustomElementnamespacerunescomponent_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文档的"Passingstateacrossmodules"一节](https://gitcode.com/GitHubTrending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02runes/02state 文档的 "Passing state across modules" 一节](https://gitcode.com/GitHub_Trending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02-runes/02-state.md?utm_source=gitcode_repo_files)。该节给出的完整解释如下,这里完整继承其内容。

下面这种写法是不允许的:

/// file: state.svelte.js
export let count = $state(0);

export function increment() {
	count += 1;
}

原因在于:Svelte 编译器会把这个文件中每一个对 count 的引用都进行转换。上面的代码大致等价于这样的编译器输出(引自 [state文档](https://gitcode.com/GitHubTrending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02runes/02state 文档](https://gitcode.com/GitHub_Trending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02-runes/02-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 在消费端是一个对象(信号),而非期望的数字——重赋值语义在模块边界处断裂了。

因此,跨模块共享状态只有两条合规路径,正是上文两种写法:

  1. 导出引用不变的对象,内部修改属性export const counter = $state({...}) + 修改 counter.x)——因为引用没有变化,编译器不会把它包进 $.state 重赋值逻辑;
  2. 不直接导出状态,导出读写函数getCount() / increment())——所有对状态的重赋值都留在模块内部。

实践要点小结

  • .svelte.js / .svelte.ts 当作"能写 runes 的普通 JS 模块":优先用它承载可复用的响应式逻辑跨组件共享状态
  • 导出状态前检查该变量是否会被重新赋值:会被重赋值 → 用"内部状态 + 函数 API";只做属性级修改 → 可以导出深层 $state 对象;
  • 模块编译只支持 filenamerootDirdevgeneratewarningFilterexperimental 等公共选项,不要对模块传 CSS、命名空间等组件级选项(会被忽略或报未识别选项错误);
  • 该文件类型仅存在于 Svelte 5(runes 模式),从 Svelte 4 迁移时属于新增能力,可参考 v5 迁移指南 了解版本边界;
  • 想查看编译器为某个 .svelte.js 模块生成的代码,可以在官方 playground 中切换 "JS Output" 标签(该说法来自 [state文档](https://gitcode.com/GitHubTrending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02runes/02state 文档](https://gitcode.com/GitHub_Trending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02-runes/02-state.md?utm_source=gitcode_repo_files) 中的说明)。

延伸阅读(仓库内路径)

登录后查看全文
热门项目推荐
相关项目推荐