Brick Next架构设计与前端性能优化实践

设计师冰冰 框架 阅读 1,113
赞 13 收藏
二维码
手机扫码查看
反馈

又踩坑了,Brick Next 的 SSR 数据脱水失败

今天上线前最后测一把,发现页面首屏数据全空,Loading 转了半天才出来内容。本地开发环境好好的,一上预发就抽风。排查半小时,定位到是 Brick Next 在 SSR 模式下,客户端 hydration 时拿不到服务端传下来的初始状态,也就是所谓的“脱水失败”。

Brick Next架构设计与前端性能优化实践

说白了就是:服务端渲染的时候数据是有的,HTML 返回了正确的内容,但前端接管的时候 Redux 或状态管理里没接上,于是重新发起请求,导致闪屏 + 多余请求。

这里我踩了个坑:以为 Brick Next 的数据脱水是开箱即用的,结果文档里提了一嘴要手动配置 window.__INITIAL_STATE__,但我一开始图省事,直接用了社区的一个 starter 项目,里面 state 注入方式是错的 —— 它用的是 JSON.stringify 后拼字符串写进 script 标签,但没有做 XSS 转义,导致某些特殊字符(比如换行、双引号)破坏了 JS 语法,页面直接报错,__INITIAL_STATE__ 变成 undefined。

折腾了半天才发现问题出在模板注入

先说我怎么排查的:

  • 打开浏览器,看源码,搜 __INITIAL_STATE__,找到了那一行 JS 注入代码
  • 复制那串 JSON 出来跑 JSON.parse,报错:Uncaught SyntaxError: Unexpected token
  • 一看,有个字段值是:"desc": "用户说:"这体验不行啊"" —— 这个反斜杠没被正确转义,直接拼进去就成了语法错误

所以问题本质不是 Brick Next 不支持,而是我们自己把数据塞进 HTML 的方式太粗糙了。

后来试了下几种方案:

  • 方案一:自己写 escape 函数,替换掉 "<> 等字符 —— 麻烦,容易漏
  • 方案二:用 serialize-javascript 这个库,专门干这事的
  • 方案三:改用 <script type="application/json" id="state"> 放纯 JSON,然后 JS 里读取并解析

我一开始想用方案三,看起来更干净。但问题是,application/json 里的内容不能包含未转义的 HTML 实体,而且还要处理 CSP 安全策略,有些公司不允许内联 script 执行 eval 类操作。虽然不 eval,但 document.getElementById('state').textContent 也算“动态执行”,可能被拦。

最后还是选了方案二,引入 serialize-javascript,简单粗暴,亲测有效。

核心代码就这几行

服务端渲染模板注入部分(Node.js / Koa 示例):

import serialize from 'serialize-javascript';

// 假设你已经拿到了数据
const initialState = {
  user: { id: 1, name: '张三' },
  posts: [],
  desc: '用户说:"这体验不行啊"'
};

// 错误写法 ❌
// const badScript = &lt;script&gt;window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}&lt;/script&gt;;

// 正确写法 ✅
const safeScript = &lt;script&gt;window.__INITIAL_STATE__ = ${serialize(initialState)};&lt;/script&gt;;

// 最终返回的 HTML 字符串中插入
res.send(
  &lt;!DOCTYPE html&gt;
  &lt;html&gt;
    &lt;head&gt;&lt;title&gt;My App&lt;/title&gt;&lt;/head&gt;
    &lt;body&gt;
      &lt;div id=&quot;root&quot;&gt;${renderedApp}&lt;/div&gt;
      ${safeScript}
    &lt;/body&gt;
  &lt;/html&gt;
);

注意:serialize-javascript 会自动帮你处理:

  • 引号转义
  • 避免 被截断
  • 防止 __proto__ 等原型污染
  • 可选是否加 IIFE 包裹(这里不需要)

然后在客户端入口文件里,记得读这个变量:

// client-entry.js
const preloadedState = window.__INITIAL_STATE__;

// 如果你在用 Redux
const store = createStore(rootReducer, preloadedState, middleware);

// 清理全局变量,防污染
delete window.__INITIAL_STATE__;

这样 hydration 就能正常接上了,首屏不会闪烁,API 也不会重复请求。

这里注意我踩过好几次坑

第一个坑:别忘了删 window.__INITIAL_STATE__。虽然不影响功能,但留着是个安全隐患,别人可以在控制台看到完整初始状态,尤其如果里面有敏感信息(比如未脱敏的用户数据),就不合适了。

第二个坑:如果你用了模块热更新(HMR),开发环境下也可能读到上一次残留的 __INITIAL_STATE__。建议在开发环境也走 mock 数据 + 动态加载,而不是每次刷新都依赖服务端注入。或者干脆只在 SSR 模式下启用该逻辑。

第三个坑:Redux 的 reducer 结构必须和服务端生成的一致。我之前本地测试改了 reducer 名字叫 postList,服务端还是 posts,导致数据没挂载上去,debugger 跟了半天才发现是 key 对不上。

为什么不用 react-helmet-async 或其他状态容器?

有人可能会说,用 Context + 自定义 Provider 不就好了?理论上可以,但 Brick Next 默认还是推荐集中式的初始状态注入,尤其是涉及多页面、多路由、SEO 场景时,分散在各个 context 里反而难管理。

而且 Brick Next 内部对 window.__INITIAL_STATE__ 有做类型校验和 merge 策略,你要是自己搞一套 hydration 机制,后期升级容易出兼容问题。

所以结论是:老老实实用官方模式,只是别再手写 JSON.stringify 往里塞了。

改完后还有一两个小问题,但无大碍

现在首屏数据稳定了,但发现 Chrome DevTools 里偶尔提示 Failed to load resource: net::ERR_FAILED,查了是某个字体资源 404,跟当前问题无关。估计是 build 时 asset publicPath 没配对,但这不属于本文范畴,先放一放。

另外,在低版本 Android 浏览器上,serialize-javascript 输出的 ES6 字符(如 Unicode 字符)可能会解析失败。解决方案是在 babel 编译时确保最终产出是 ES5,或者使用 isJSONSafe: true 选项:

serialize(initialState, { isJSONSafe: true })

这个选项会强制把所有非 ASCII 字符转成 uXXXX 形式,兼容性更好。

以上是我踩坑后的总结

SSR 数据脱水这事,看着简单,真出问题很难快速定位。尤其是当你依赖第三方 starter 或 boilerplate 时,很容易被带沟里。记住一句话:只要往 HTML 里插 JS 数据,就必须用安全序列化工具,别信自己的正则。

Brick Next 其实没错,错的是我们自己偷懒没看全文档。官方文档里其实写了推荐使用 serialize-javascript,但我当时跳着看,只看了“怎么定义 __INITIAL_STATE__”,没看“如何安全注入”这一节,血的教训。

以上是我个人对 Brick Next SSR 状态注入的完整踩坑记录,有更优的实现方式欢迎评论区交流。比如你有没有试过用 structuredClone + Blob URL 的方式?我还没敢在线上用,怕兼容性翻车。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论