Preact SSR实战:Unistore状态同步与Router同构路由详解
1. 为什么放弃 React 做 SSR,却选 Preact 这个“轻量替代”?
你可能刚在团队技术会上听到一句:“我们新项目要做 SSR,但别用 React,太重了。”——紧接着有人甩出一句:“试试 Preact 吧,体积小、API 兼容、生态也跑得起来。”你点点头,心里却嘀咕:Preact 真的能扛住 SSR 场景?它和 React 的差异,到底是“语法糖级兼容”,还是“底层机制级割裂”?更关键的是:当你要把Unistore(状态管理)和Preact Router(路由)一起塞进服务端渲染流水线时,那些在客户端开发中被自动屏蔽的边界问题,会像退潮后裸露的礁石一样,一个接一个撞上来。
这不是理论推演,而是我去年落地三个内部中后台 SSR 项目的实操结论:Preact 的 SSR 能力不是“开箱即用”,而是“开箱即踩坑”。它比 React 更早暴露服务端与客户端的执行环境鸿沟——比如document未定义、window不存在、事件监听器无法挂载、甚至setTimeout在 Node.js 中的行为差异。而恰恰是这些“理所当然”的前端假设,在 SSR 场景下成了第一道拦路虎。
Preact 的核心优势在于它的设计哲学:不追求功能完备,而追求最小必要实现。它的h()函数就是React.createElement的精简复刻;render()在客户端调用 DOM API,在服务端则调用renderToString()返回字符串;甚至连useEffect这类 Hook,Preact 也只实现了最基础的副作用调度逻辑,不带任何浏览器专属依赖。这种“克制”,让它在服务端环境下天然更干净——没有 React 那套复杂的 reconciler 调度层、没有 Fiber 树的深度克隆开销、也没有ReactDOMServer那种为兼容性妥协的冗余代码。实测下来,一个纯 Preact + Unistore 的 SSR bundle,服务端首屏 HTML 渲染耗时稳定在 8–12ms(Node.js v18.18,V8 TurboFan 优化后),比同构结构的 React 版本快 37% 左右。
但代价也很真实:Preact 不提供hydrateRoot这类现代 hydration API,也不内置Suspense服务端 fallback 支持;它的 Router 是基于history库封装的轻量路由,不支持<Suspense>包裹异步组件;Unistore 更是连“服务端 store 初始化”这种基础能力都不自带——你得自己手写createStore()并注入初始 state。换句话说,Preact 的 SSR 不是“给你一套轮子”,而是“给你一块钢板、一把锉刀、一张图纸,让你自己造轮子”。
提示:网上常有人说“Preact 是 React 的 3KB 替代品”,这是严重误导。它不是 React 的压缩包,而是另一条技术路径上的轻量实现。你在 React 里写的
useMemo(() => xxx, [a, b]),在 Preact 里照样能跑;但你在 React 里依赖的useTransition或useDeferredValue,Preact 根本没实现。判断是否能迁,关键不是看语法像不像,而是看你的业务是否真用到了那些高级特性。
所以回到标题——“Build a SSR App With Preact, Unistore, and Preact Router”——这根本不是一句简单的技术选型声明,而是一份隐含三重约束的工程契约:
- 第一重约束:你接受放弃 React 生态中某些“便利但昂贵”的抽象(如完整的 Suspense 边界、服务端数据预取框架);
- 第二重约束:你愿意为状态同步、路由匹配、hydration 一致性这些底层环节,亲手补全缺失的胶水逻辑;
- 第三重约束:你默认所有组件都必须是“同构友好”的——不能在
useEffect里直接操作document.body,不能在render阶段读取window.location,不能依赖localStorage初始化状态。
这三点,才是这个标题背后真正要解决的问题。接下来,我们就从零开始,把这块“钢板”锻造成可用的 SSR 应用骨架。
2. 服务端渲染的核心矛盾:HTML 字符串 ≠ 可交互页面
很多人以为 SSR 就是“在服务器上跑一遍renderToString(),把结果吐给浏览器”,然后万事大吉。我第一次这么干的时候,页面确实秒出,但点击按钮毫无反应——控制台安静得像凌晨三点的办公室。后来才发现,问题不在渲染,而在hydration(注水)失败。
Preact 的hydrate()函数,本质是把服务端生成的静态 HTML 字符串,和客户端 JS 执行后的虚拟 DOM 树做结构一致性校验。它逐节点比对:服务端输出的<div class="app">,客户端是否也生成了完全相同的<div class="app">?如果 class 名拼错一个字母、属性顺序颠倒、甚至多了一个空格,hydrate()就会放弃接管,转而执行 full mount(即清空 DOM,重新创建整棵树)。这就是你看到“页面有内容但无交互”的根本原因。
而 Preact Router 和 Unistore 正是两个最容易破坏 hydration 一致性的“高危模块”。
先看Preact Router。它的<Router>组件在服务端和客户端行为完全不同:
- 服务端:
<Router>接收一个urlprop(由 Express/Koa 路由传入),内部通过matchRoutes()匹配当前路径,只渲染匹配到的<Route>组件; - 客户端:
<Router>默认监听window.location,使用history库的createBrowserHistory()创建 history 实例,自动响应 URL 变化。
问题来了:如果你在服务端用url="/user/123"渲染,客户端却因为window.location.pathname是/user/123?ref=home(带 query 参数)导致路由匹配失败,<Router>就会渲染 fallback 页面,整个 DOM 结构就和服务器输出的不一致。hydrate()一看:“这树不对”,立刻放弃。
再看Unistore。它的connect()HOC(或useStore()Hook)会在组件 render 阶段读取 store 当前值。但如果 store 在服务端初始化时用了Date.now()、Math.random()或process.env.NODE_ENV,而客户端初始化时这些值变了(比如服务端是production,客户端本地是development),那组件首次 render 的输出就会不同。哪怕只是class="btn btn--primary"变成class="btn btn--primary debug-mode",hydration 就崩。
我们来实测一个典型崩坏场景:
// store.js import { createStore } from 'unistore'; export const store = createStore({ user: null, // ❌ 危险!服务端和客户端时间戳必然不同 loadedAt: Date.now(), // ❌ 更危险!process.env 在服务端是 'production',客户端可能是 'development' env: process.env.NODE_ENV }); // UserCard.jsx import { h, Component } from 'preact'; import { connect } from 'unistore/preact'; const UserCard = ({ user, loadedAt }) => ( <div class="user-card"><div class="user-card"><!-- index.html --> <!DOCTYPE html> <html> <head>...</head> <body> <div id="app"><!-- SSR content --></div> <!-- ✅ 把服务端计算好的数据,作为全局变量注入 --> <script>window.__INITIAL_STATE__ = {{ initialState | safeJson }}</script> <script>window.__CURRENT_URL__ = "{{ currentUrl }}"</script> <script src="/client.js"></script> </body> </html>然后在客户端入口统一读取:
// client.js import { render } from 'preact'; import { Router } from 'preact-router'; import App from './App'; import { store } from './store'; // ✅ 从 window 读取服务端注入的数据,而非重新计算 const initialState = window.__INITIAL_STATE__ || {}; const currentUrl = window.__CURRENT_URL__ || '/'; // 初始化 store 时,用服务端传来的数据 store.setState(initialState); // 渲染时,把 currentUrl 传给 Router,确保和服务端一致 render( <Router url={currentUrl}> <App /> </Router>, document.getElementById('app') );2.2 铁律二:所有非纯函数式副作用,必须包裹在isBrowser判断中
isBrowser不是某个库的 API,而是你必须自己定义的布尔常量:
// utils/env.js export const isBrowser = typeof window !== 'undefined' && window.document; // components/AnalyticsTracker.jsx import { useEffect } from 'preact/hooks'; import { isBrowser } from '../utils/env'; export default function AnalyticsTracker() { useEffect(() => { if (!isBrowser) return; // ✅ 服务端直接跳过 // 只在浏览器执行埋点 analytics.track('page_view', { path: window.location.pathname }); }, []); return null; }这个判断要覆盖所有地方:useEffect、useLayoutEffect、componentDidMount、甚至render()函数体内部(比如根据window.innerWidth动态设置 class)。
2.3 铁律三:所有组件的 props,必须保证服务端与客户端完全一致
这意味着:
- 不要用
Math.random()生成 key; - 不要用
new Date().toISOString()作为 prop; - 不要依赖
location.hash或location.search直接取值(它们在服务端不存在); - 如果必须用,务必用
urlprop 从服务端透传。
举个反例:
// ❌ 错误:组件内部读取 location,服务端报错且客户端值不可控 const Header = () => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; return <h1>Current: {path}</h1>; }; // ✅ 正确:由父组件传入,服务端和客户端都用同一个值 const Header = ({ currentPath }) => <h1>Current: {currentPath}</h1>;这三条铁律,不是“最佳实践”,而是 Preact SSR 能跑通的最低生存门槛。跳过任意一条,你都会在某个深夜收到告警:“首页白屏率突增至 42%”。
3. Unistore 的服务端初始化:从“状态快照”到“可序列化 store”
Unistore 是 Preact 生态里最轻量的状态管理方案,它的核心就两行代码:
// node_modules/unistore/src/index.js export const createStore = (state = {}) => ({ getState: () => state, setState: (update, cb) => { /* ... */ } });没有中间件、没有 devtools 插件、没有时间旅行——它就是一个带通知机制的 plain object。这种极简,让它在 SSR 场景下反而成了优势:没有隐藏的副作用,没有不可序列化的闭包,没有需要特殊处理的异步生命周期。
但优势的背面,是责任的转移:React Query 或 Redux Toolkit 会帮你处理“服务端数据预取 + 序列化 + 客户端 rehydration”,而 Unistore 把这件事完全交给你。
我们来拆解一个真实需求:用户登录态需要 SSR。服务端通过 cookie 解析出用户信息,渲染出带用户名的导航栏;客户端加载后,必须立刻拥有相同的用户对象,否则导航栏会闪动(先显示“登录”,再变成“Hi Alice”)。
3.1 步骤一:服务端获取初始状态并序列化
假设你用 Express:
// server.js import express from 'express'; import { renderToString } from 'preact-render-to-string'; import { h } from 'preact'; import App from './src/App'; import { store } from './src/store'; const app = express(); app.get('*', async (req, res) => { // ✅ 1. 从 cookie / header / DB 获取用户数据 const user = await getUserFromCookie(req); // ✅ 2. 初始化 store,并注入初始 state const initialState = { user, // ⚠️ 关键:必须排除不可序列化的值 // 例如:user.avatarBlob(Buffer)、user.createdAt(Date 对象) // 应该转为 user.avatarUrl(string)、user.createdAtISO(string) user: { id: user.id, name: user.name, avatarUrl: user.avatarUrl, createdAtISO: user.createdAt.toISOString() } }; // ✅ 3. 设置 store 初始值(注意:store 是单例,必须每次请求新建实例!) // 否则并发请求会互相污染 store.setState(initialState); // ✅ 4. 渲染应用 const html = renderToString(h(App, { url: req.url })); // ✅ 5. 把 initialState 注入 HTML res.send(` <!DOCTYPE html> <html> <body> <div id="app">${html}</div> <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script> <script src="/client.js"></script> </body> </html> `); });这里有两个极易踩的坑:
注意:
store必须是每个请求独立的实例。如果你在模块顶层export const store = createStore(),那么所有请求共享同一个 store 对象,A 用户的登录态会被 B 用户的请求覆盖。正确做法是:在每次请求中const store = createStore(initialState),或者用工厂函数封装。
注意:
JSON.stringify()会忽略undefined、function、Symbol、Date、RegExp等不可序列化类型。如果你的initialState里有new Date(),JSON.stringify({ t: new Date() })会变成{},客户端拿到空对象。必须提前转换为字符串(.toISOString())或数字(.getTime())。
3.2 步骤二:客户端还原 store 并避免重复初始化
客户端入口client.js不能简单地store.setState(window.__INITIAL_STATE__),因为:
setState()是异步的(它会 batch 更新并触发订阅);- 如果组件在
setState完成前就 render,会读到旧 state; - 更糟的是,如果
setState触发了副作用(比如useEffect里发请求),而此时 DOM 还没 hydrate 完,请求可能失败。
正确姿势是:在hydrate()之前,完成 store 初始化。
// client.js import { hydrate } from 'preact'; import { Router } from 'preact-router'; import App from './App'; import { store } from './store'; // ✅ 1. 立即读取并设置初始 state(同步) const initialState = window.__INITIAL_STATE__ || {}; store.setState(initialState); // 同步执行,无异步延迟 // ✅ 2. 渲染前,确保 store 已就绪 hydrate( <Router url={window.__CURRENT_URL__ || '/'}> <App /> </Router>, document.getElementById('app') );但这样还不够。如果App组件里有useEffect(() => { api.fetchData() }, []),它会在hydrate后立即执行,而此时 store 还没“激活”——因为setState虽然同步,但store.subscribe()的回调是异步触发的。我们需要一个更可靠的信号:store 初始化完成事件。
我采用的方案是:在服务端setState后,手动触发一次store.subscribe()的回调,确保所有组件的connect()或useStore()已绑定到最新 state。
// store.js import { createStore } from 'unistore'; export const store = createStore(); // ✅ 添加一个同步初始化方法 export const initStore = (state) => { store.setState(state); // ✅ 强制触发一次订阅,让所有已挂载组件更新 // (原理:store 内部维护一个 listeners 数组,setState 后遍历调用) // 这行代码模拟了 setState 后的 notify 行为 store.listeners.forEach(cb => cb(store.getState())); };然后在服务端和客户端分别调用:
// server.js store.initStore(initialState); // 替代 store.setState() // client.js store.initStore(window.__INITIAL_STATE__ || {}); // 替代 store.setState()这个initStore方法,是我在线上压测中发现的“保命技”:它把 store 的初始化从“异步通知”变成了“同步就绪”,彻底消除了 hydration 后首个 render 读取 stale state 的风险。
3.3 步骤三:处理异步数据加载(如 API 请求)
Unistore 本身不处理异步,但你可以用async/await+setState构建自己的数据流:
// actions/user.js import { store } from '../store'; export const loadUserProfile = async (userId) => { try { const data = await fetch(`/api/users/${userId}`).then(r => r.json()); // ✅ 服务端调用此 action 时,必须确保在 renderToString 前完成 // ✅ 客户端调用时,需配合 Suspense 或 loading state store.setState({ userProfile: data, loading: false }); } catch (err) { store.setState({ error: err.message, loading: false }); } };关键点在于:服务端必须预加载所有首屏所需数据。不能让renderToString()渲染到<UserProfile />组件时,发现store.getState().userProfile是null,然后才去loadUserProfile()——因为renderToString()是同步函数,它不会等await。
所以服务端逻辑要变成:
// server.js app.get('/user/:id', async (req, res) => { const userId = req.params.id; const user = await getUser(userId); // 预加载 const profile = await loadUserProfile(userId); // 预加载 const initialState = { user, profile }; store.setState(initialState); const html = renderToString(h(App, { url: req.url })); // ... });这就是 Unistore SSR 的真相:它不提供魔法,但给你足够的控制权。你放弃的是“开箱即用的数据预取框架”,换来的是对数据流 100% 的掌控——每一个fetch、每一次setState、每一处 hydration 失败,你都清楚知道它发生在哪一行代码。
4. Preact Router 的服务端路由匹配:URL 是唯一真理
Preact Router 的设计非常直白:它就是一个基于history库的路由匹配器。服务端没有history,所以它不“监听”URL,而是被动接收一个url字符串,然后做静态匹配。
这听起来简单,但实际落地时,90% 的 SSR 路由问题都源于一个事实:服务端拿到的 URL,和客户端window.location的 URL,根本不是一回事。
4.1 服务端 URL 的三大来源与陷阱
| 来源 | 示例 | 风险点 | 解决方案 |
|---|---|---|---|
Expressreq.url | /user/123?tab=posts#top | 包含 query 和 hash,而preact-router的matchRoutes()默认只匹配 pathname | ✅ 用new URL(req.url, 'http://a.com').pathname提取纯净 path |
| Nginx 代理转发 | https://example.com/api/user/123→ 转发到http://localhost:3000/user/123 | 服务端req.url是/user/123,但客户端window.location.href是https://example.com/user/123,导致Router初始化时url不一致 | ✅ 服务端用req.headers['x-forwarded-path']或req.originalUrl获取原始 path |
| CDN 缓存路径重写 | CDN 把/blog/2024/05/ssr-guide重写为/blog?id=202405ssrguide | 服务端匹配/blog?id=202405ssrguide,客户端却访问/blog/2024/05/ssr-guide,路由错乱 | ✅ 强制服务端和客户端使用同一套 path 解析逻辑,比如统一用path-to-regexp解析 |
我们以最常见的 Express 场景为例,写出健壮的 URL 处理:
// utils/url.js export const parseUrlForRouter = (req) => { // ✅ 1. 优先使用 x-forwarded-path(Nginx/CDN 透传) if (req.headers['x-forwarded-path']) { return req.headers['x-forwarded-path']; } // ✅ 2. 否则用 originalUrl(保留原始 path,不含 query/hash) const url = new URL(req.originalUrl, 'http://a.com'); return url.pathname; }; // server.js app.get('*', (req, res) => { const url = parseUrlForRouter(req); // ✅ 得到纯净 pathname // ✅ 3. 渲染时传给 Router const html = renderToString( h(Router, { url }, h(App)) ); res.send(`...<script>window.__CURRENT_URL__ = "${url}";</script>...`); });4.2<Router>的服务端与客户端双模式配置
Preact Router 的<Router>组件,通过urlprop 控制其行为模式:
- 当
url是字符串(如"/user/123"):进入服务端模式,只做一次静态匹配,不监听变化; - 当
url未传或为undefined:进入客户端模式,自动监听window.location。
所以,你的 App 组件必须能同时适配两种模式:
// App.jsx import { h, Fragment } from 'preact'; import { Router, route } from 'preact-router'; // ✅ 服务端:Router 传入 url,只渲染匹配的 Route // ✅ 客户端:Router 不传 url,自动监听,支持 SPA 导航 export default function App({ url }) { return ( <Router url={url}> <Home path="/" /> <UserPage path="/user/:id" /> <NotFound default /> </Router> ); } // UserPage.jsx import { h, useEffect } from 'preact'; import { useLocation } from 'preact-router'; // ✅ 使用 useLocation 获取当前 path,而不是 window.location export default function UserPage({ id }) { const location = useLocation(); // ✅ 服务端返回 { url: '/user/123', path: '/user/:id', params: { id: '123' } } useEffect(() => { // ✅ location 对象在服务端和客户端结构一致 console.log('Current user ID:', location.params.id); }, []); return <div>User ID: {id}</div>; }useLocation()是关键。它不是读取window.location,而是从<Router>的 context 中获取当前匹配结果。服务端渲染时,<Router url="/user/123">会把匹配结果注入 context;客户端运行时,<Router>会监听window.location并更新 context。组件无需关心来源,拿到的就是一致的location对象。
4.3 动态路由参数的 Hydration 一致性保障
<Route path="/user/:id">这种动态路由,服务端和客户端对id的解析必须 100% 一致。Preact Router 用path-to-regexp库做匹配,它对正则表达式的处理非常严格。
常见不一致场景:
| 场景 | 服务端匹配 | 客户端匹配 | 结果 |
|---|---|---|---|
path="/user/:id(\\d+) | /user/123→{ id: '123' } | /user/123→{ id: '123' } | ✅ 一致 |
path="/user/:id" | /user/abc→{ id: 'abc' } | /user/abc→{ id: 'abc' } | ✅ 一致 |
path="/user/:id" | /user/123/(结尾斜杠) | /user/123(无斜杠) | ❌ 服务端匹配失败,渲染 404,客户端匹配成功,hydration 崩溃 |
解决方案只有两个:
- 强制标准化 URL:服务端收到
/user/123/时,301 重定向到/user/123; - 路由配置容忍斜杠:
path="/user/:id/"(注意结尾斜杠),这样/user/123/和/user/123都能匹配。
我推荐方案 2,因为它不增加 HTTP 跳转,更符合 SSR 的“零延迟”目标。只需在所有 Route 的 path 后加/:
<Home path="/" /> <UserPage path="/user/:id/" /> <BlogPost path="/blog/:slug/" />然后确保你的服务端 URL 解析也去掉结尾斜杠:
export const parseUrlForRouter = (req) => { const path = /* ... */; return path.endsWith('/') ? path.slice(0, -1) : path; // ✅ 统一去除结尾斜杠 };这样,无论用户访问/user/123还是/user/123/,服务端和客户端都得到相同的path,<Route>匹配结果一致,hydration 自然成功。
4.4 服务端重定向(SSR Redirect)的实现
Preact Router 本身不提供服务端重定向 API(那是 Express 的事),但你需要一种方式,让组件逻辑能触发重定向,而不是渲染错误页面。
比如:用户未登录时访问/dashboard,应该 302 跳转到/login。
传统做法是在组件里useEffect(() => { if (!user) window.location.href = '/login' }),但这会导致服务端渲染出/dashboard页面,客户端才跳转——SEO 友好性为零,且有白屏。
正确做法是:在服务端渲染前,由组件的getInitialProps(类 Next.js)或自定义预加载函数,决定是否重定向。
由于 Preact Router 没有内置getInitialProps,我们手动实现:
// utils/router.js export const matchRoute = (url, routes) => { for (const route of routes) { const match = matchPath(url, route.path); if (match) return { ...match, component: route.component }; } return null; }; // server.js app.get('*', async (req, res) => { const url = parseUrlForRouter(req); const routeMatch = matchRoute(url, [ { path: '/dashboard', component: Dashboard }, { path: '/login', component: Login } ]); // ✅ 如果组件有 getServerSideProps,调用它 if (routeMatch?.component.getServerSideProps) { const redirect = await routeMatch.component.getServerSideProps({ req, url }); if (redirect?.redirect) { return res.redirect(302, redirect.redirect); } } // 继续渲染... });然后在Dashboard.jsx里:
export default function Dashboard() { return <div>Dashboard</div>; } // ✅ 组件静态方法,服务端调用 Dashboard.getServerSideProps = async ({ req }) => { const user = await getUserFromCookie(req); if (!user) { return { redirect: '/login' }; // ✅ 服务端直接 302 } };这个模式,把路由逻辑、权限校验、重定向全部收口到组件内部,既保持了前端开发的直觉,又保证了服务端的 SEO 和性能。
5. 从零搭建完整项目:文件结构、构建脚本与部署检查清单
现在,我们把前面所有原则落地为一个可运行的项目。这不是一个玩具 demo,而是我在生产环境验证过的最小可行 SSR 骨架。它包含三个核心部分:服务端(Node.js)、客户端(Preact)、构建系统(esbuild)。
5.1 项目文件结构:清晰分离,拒绝耦合
my-ssr-app/ ├── src/ │ ├── client/ # 客户端入口和组件 │ │ ├── index.js # client.js,hydrate 入口 │ │ ├── App.jsx # 主应用组件 │ │ └── components/ # 所有 Preact 组件 │ ├── server/ # 服务端逻辑 │ │ ├── index.js # Express 入口 │ │ ├── renderer.js # renderToString 封装 │ │ └── routes/ # 路由预加载逻辑 │ ├── store/ # Unistore 状态管理 │ │ ├── index.js # store 实例和 initStore │ │ └── actions/ # 所有 setState 操作 │ └── shared/ # 服务端与客户端共享代码 │ ├── utils/ # isBrowser、parseUrlForRouter 等 │ └── types/ # TypeScript 类型定义(可选) ├── public/ │ └── index.html # HTML 模板,含 __INITIAL_STATE__ 注入点 ├── build/ # 构建产物 │ ├── client/ # 客户端 JS/CSS │ └── server/ # 服务端 JS(ESM 格式) ├── package.json └── esbuild.config.js关键设计点:
src/client/和src/server/完全隔离:客户端代码不能 import 服务端模块(反之亦然),避免意外引入fs、path等 Node.js-only API;src/shared/是唯一共享区:只放纯函数、类型定义、工具函数,不包含任何副作用;public/index.html是模板,不是静态文件:它会被服务端动态注入__INITIAL_STATE__和__CURRENT_URL__;build/目录按环境拆分:build/client/供 Nginx 静态托管,build/server/是 Express 的入口。
5.2 构建脚本:esbuild 一键打包双端
我们放弃 Webpack,用 esbuild 实现 100ms 级构建。esbuild.config.js如下:
// esbuild.config.js import * as esbuild from 'esbuild'; // ✅ 客户端构建:target browser,不打包 preact/unistore await esbuild.build({ entryPoints: ['src/client/index.js'], bundle: true, minify: true, target: ['chrome58', 'firefox57', 'safari11', 'edge16'], format: 'iife', globalName: 'ClientApp', outfile: 'build/client/client.js', define: { 'process.env.NODE_ENV': '"production"', 'typeof window': '"object"' } }); // ✅ 服务端构建:target node,external preact/unistore await esbuild.build({ entryPoints: ['src/server/index.js'], bundle: true, platform: 'node', target: 'node18', format: 'esm', outfile: 'build/server/index.js', external: ['preact', 'preact-render-to-string', 'unistore', 'express'] });注意两个关键配置:
define: { 'typeof window': '"object"' }:告诉 esbuild,客户端代码里typeof window永远是"object",这样isBrowser判断会被编译时消除,减小包体积;external: [...]:服务端构建时,把 Preact 等核心依赖标记为 external,避免打包进 bundle,由 Node.js 运行时动态 require——这样既能热更新,又能减小部署包体积。
5.3 部署前必查的 7 项清单
上线前,我一定会逐项核对这份清单。少一项,就可能引发线上事故:
| 检查项 | 为什么重要 | 如何验证 |
|---|---|---|
1.__INITIAL_STATE__是否被正确注入 | 如果为空,客户端 store 就是空的,所有 connect 组件读不到数据 | 查看 HTML 源码,搜索window.__INITIAL_STATE__ =,确认 JSON 有效且非空 |
2.__CURRENT_URL__是否与服务端url一致 |
