当前位置: 首页 > news >正文

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 里依赖的useTransitionuseDeferredValue,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; }

这个判断要覆盖所有地方:useEffectuseLayoutEffectcomponentDidMount、甚至render()函数体内部(比如根据window.innerWidth动态设置 class)。

2.3 铁律三:所有组件的 props,必须保证服务端与客户端完全一致

这意味着:

  • 不要用Math.random()生成 key;
  • 不要用new Date().toISOString()作为 prop;
  • 不要依赖location.hashlocation.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()会忽略undefinedfunctionSymbolDateRegExp等不可序列化类型。如果你的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().userProfilenull,然后才去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-routermatchRoutes()默认只匹配 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.hrefhttps://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 崩溃

解决方案只有两个:

  1. 强制标准化 URL:服务端收到/user/123/时,301 重定向到/user/123
  2. 路由配置容忍斜杠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 服务端模块(反之亦然),避免意外引入fspath等 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一致
http://www.jsqmd.com/news/1068383/

相关文章:

  • Ubuntu 18.04 部署 Eclipse Theia 云原生 IDE 实战指南
  • [LeetCode] 104、二叉树的最大深度
  • 为什么这个DevOps工具集合能入选GitHub Trending?awesome-devops背后的完整故事
  • QtBitcoinTrader安全机制详解:AES-256加密与RSA保护如何保障你的资产安全 [特殊字符]
  • python 零碎知识 super用法
  • Rcpp包开发全流程:从C++代码到CRAN发布的完整指南
  • Burp Suite高级功能使用指南:会话管理与自动化测试全攻略
  • 基于ddddocr与Captcha-Killer构建高精度验证码自动化识别工具链
  • FastStream核心功能详解:6倍加速下载、智能字幕、音视频调节全解析
  • python web自动化selenium【元素定位与操作】及弹窗(alert/confirm/prompt)操作及上传附件7
  • 通俗易懂理解RANSAC算法
  • AI编程提示词工程:从324条实战样本看工作流逆向设计
  • 如何用AMD Ryzen AI软件构建本地智能助手:一个完整的零配置开发指南
  • HACG数据管理终极指南:本地缓存与网络同步的最佳实践
  • k8s环镜搭建(续2)
  • synp与yarn import对比:哪款工具更适合你的项目需求
  • docker安装svn
  • Modbus协议报文深度解析:从字节结构到实战调试
  • DPF外部UI开发:跨进程插件界面实现原理与实战指南
  • Coblocks入门教程:零基础打造响应式WordPress网站的7个步骤
  • Asciidoctor.js CLI工具深度解析:自动化文档构建与发布流程
  • IntelliJ IDEA 2021.2.2版本如何正确使用IDE Eval Reset插件
  • 10分钟上手Anycost GAN:Jupyter Notebook交互式实验教程 [特殊字符]
  • 终极指南:5分钟搞定ENScan_GO企业信息收集工具配置,解决Cookie认证难题 [特殊字符]
  • 如何用qdata在5分钟内获取百度搜索指数数据:新手入门教程
  • VGG19.tv_in1k进阶应用:图像嵌入与特征表示的高级技巧
  • 通信架构设计源码范例
  • 凯源智能KT3351馈线弧光保护装置
  • VS Code设置插件默认安装路径
  • Awaken:终极跨平台EPUB阅读器 - 基于WebDAV的免费全平台同步解决方案