用Sapper构建高性能SSR应用的实战经验与避坑指南
我的写法,亲测靠谱
我用 Sapper 做过三个上线项目,最长的维护了两年多。不是那种 demo 级别,是真实用户每天在用、SEO 要跑、SSR 渲染不能崩、API 会超时、图片加载会失败的项目。Sapper 已经停止维护了(SvelteKit 是官方接班人),但很多老项目还在跑,而且——说实话,它真挺稳。前提是,你得避开一些它默认不提醒、文档里也不说清楚的坑。
先说最核心的一条:所有数据获取逻辑,必须收口到 preload 函数里,哪怕只是想读个 query 参数。我最早图省事,在 onMount 里发请求,结果首页 SEO 全挂,Google 抓到的全是 loading 状态。折腾半天发现:Sapper 的 SSR 是靠 preload 返回的 Promise resolve 后才生成 HTML 的,onMount 是纯客户端生命周期,服务端根本不会执行。
正确写法:
<script context="module">
export async function preload({ params, query }) {
const id = params.id;
const res = await this.fetch(/api/posts/${id});
const post = await res.json();
// 这个对象会直接注入到组件 props 中
return { post };
}
</script>
<script>
export let post; // 注意:这里必须声明,且名字要和 preload 返回的 key 一致
</script>
<h1>{post.title}</h1>
<p>{post.content}</p>
错误写法(别再这么干了):
<script>
import { onMount } from 'svelte';
let post = null;
onMount(async () => {
const res = await fetch(/api/posts/1);
post = await res.json(); // ❌ 服务端不执行,SSR 失效,首屏白屏或 loading
});
</script>
这几种错误写法,别再踩坑了
1. 在 preload 里直接调用 fetch,却不处理跨域或 base URL
本地开发没问题,一上生产就 404。因为 fetch('/api/xxx') 在服务端执行时,走的是 Node.js 的 HTTP 请求,不是浏览器环境,所以它不会自动拼 http://localhost:3000,也不会走 webpack dev server 的 proxy。我第一次部署到 Vercel 上,所有 preload 请求都 404,查了三小时才发现:服务端 fetch 默认发到 localhost:3000,而我的 API 部署在 https://jztheme.com/api。
解决方案:统一配置 API 地址,服务端和客户端共用。
// src/node_modules/@app/config.js
export const API_BASE = process.browser
? 'https://jztheme.com/api'
: 'https://jztheme.com/api';
// 在 preload 里
import { API_BASE } from '$lib/config.js';
export async function preload({ params }) {
const res = await this.fetch(${API_BASE}/posts/${params.id}); // ✅
return { post: await res.json() };
}
2. 把异步逻辑塞进 <script> 标签顶层,以为能等 preload 完
Sapper 不会帮你等。下面这段代码会报错:Cannot destructure property 'post' of 'undefined'。
<script context="module">
export async function preload({ params }) {
return { post: { id: 1, title: 'Hello' } };
}
</script>
<script>
export let post;
const { id, title } = post; // ❌ 此时 post 还没注入,解构就崩了
</script>
正确做法:要么加空值判断,要么用 $$props 检查,但我更推荐用 if 块在模板里兜底:
{#if post}
<h1>{post.title}</h1>
{:else}
<div class="loading">Loading...</div>
{/if}
3. 在 preload 里 throw 错误,却不定义 error.svelte
Sapper 会静默失败,页面空白,控制台连 error 都不打。必须在 src/routes/_error.svelte 里写个兜底页,否则用户点错一个链接就看到白屏。我有个项目上线三天,客服反馈“文章页打不开”,最后发现是某条数据里字段名从 author_name 改成了 authorName,preload 里直接 post.author_name.toUpperCase() 崩了,没 error 页面,就卡死。
实际项目中的坑
静态资源路径别信 public/ 直接访问
比如你放了个 public/images/logo.svg,在组件里写 <img src="/images/logo.svg">,本地开发 OK,但部署到子路径(如 /blog/)下就 404。Sapper 的 base 配置只影响路由,不影响静态资源引用。我的解法是:全部走 $lib/assets/,配合 Rollup 别名:
// rollup.config.js
alias({
entries: [
{ find: '$lib/assets', replacement: path.resolve('src/lib/assets') }
]
})
<!-- 这样写,打包时会被正确解析 -->
<img src="$lib/assets/logo.svg" alt="logo" />
动态导入(code-splitting)别乱用 import() 加 await
我在某个详情页里试过:const Component = await import('./Dynamic.svelte'),结果 build 报错,提示 “dynamic imports not supported in SSR”。Sapper 的服务端渲染不支持顶层 await import。真要动态加载,得包一层 onMount,或者用 component={Dynamic} + let Dynamic = null + onMount(() => import(...)) 的方式。
最后一个小细节:CSS 作用域问题
Sapper 默认开 css: true,但如果你在 preload 里用了 this.fetch,又在组件里写了 :global(.foo),某些版本会把 global 样式漏掉。我的 fix 是:统一关掉组件级 CSS,改用 __layout.svelte 引全局 CSS,组件内只用内联 style 或 CSS-in-JS 库(比如 svelte-use-css)。
结语
以上是我用 Sapper 做真实项目两年多踩出来、修过来、压箱底的最佳实践。没有银弹,有些方案不是最优,但足够稳定;有些坑修了还是有边角 case,但不影响主流程。Sapper 已停更,但只要你的项目还跑着,这些经验就还有用。
如果你有更好的 SSR 数据流组织方式、更优雅的错误边界处理、或者怎么让 preload 和 TypeScript 类型推导配合得更好,欢迎评论区交流。这个系列我还会写 SvelteKit 迁移实战,下篇见。

暂无评论