前言
在 React 生态系统中,Next.js 作为 SSR 框架的代表,以其开箱即用的体验和强大的功能深受开发者喜爱。但对于想要完全掌控构建流程、深度定制部署方案、或者只是想学习 SSR 底层原理的开发者来说,Next.js 的"黑盒"特性反而成为了一种限制。
最近,我花了几个月时间,从零开始构建了一个轻量级但功能完整的 React SSR 框架——NSBP (Node React SSR by Webpack)。今天,我想分享一下这个项目的开发历程、技术选型、核心特性以及从中获得的实战经验。
在线演示: https://nsbp.erishen.cn/
GitHub: https://github.com/erishen/nsbp
一、为什么选择自建而不是 Next.js?
1.1 完全掌控构建流程
Next.js 的强大来自于它的自动化,但这也意味着你失去了一些控制权:
# Next.js 的构建过程(黑盒)
next build
# → 自动路由
# → 自动代码分割
# → 自动图片优化
# → ... 你看不到的几百个步骤
而在 NSBP 中,你可以精确控制每一个构建步骤:
// config/webpack.base.js
module.exports = {
entry: {
client: './src/client/index.tsx',
server: './src/server/index.ts'
},
plugins: [
new MiniCssExtractPlugin(),
new TerserPlugin(),
// ... 你可以添加任何 Webpack 插件
]
}
1.2 深度定制部署方案
Next.js 自带的 Vercel 部署确实方便,但如果你想:
- 在自己的 Linux 服务器上部署
- 使用 Docker Compose 编排多个服务
- 配置反向代理和负载均衡
- 实现特定的安全策略
那么自己掌控 Express 服务器会更有优势。
1.3 学习 SSR 的底层原理
通过手写 SSR 渲染逻辑,你将深入理解:
// SSR 渲染流程
const sheet = new ServerStyleSheet()
const jsx = sheet.collectStyles(
<Provider store={store}>
<StaticRouter location={reqPath}>
<Routes>{/* ... */}</Routes>
</StaticRouter>
</Provider>
)
const content = renderToString(jsx)
const styleTags = sheet.getStyleTags()
这些知识对你在任何 React SSR 项目中都非常有价值。
二、技术选型与架构设计
2.1 核心技术栈
| 类别 |
技术选型 |
版本 |
理由 |
| React |
React 19.2.3 |
最新版本,并发特性,更好的类型支持 |
|
| 状态管理 |
Redux Toolkit |
简化 Redux 开发,内置 Thunk 支持 |
|
| 路由 |
React Router DOM 7.12.0 |
稳定的客户端路由 |
|
| 代码分割 |
@loadable/component |
5.15.0,成熟的代码分割方案 |
|
| 构建工具 |
Webpack 5.96.0 |
模块化、可扩展、生态丰富 |
|
| 服务端 |
Express 5.2.1 |
轻量级、中间件生态完善 |
|
| 样式方案 |
Styled-components + Sass/Less |
多种选择,灵活切换 |
|
| TypeScript |
5.x |
类型安全,开发体验优秀 |
|
2.2 SSR 渲染架构
┌─────────────────────────────────────────┐
│ HTTP 请求 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Express 中间件层 │
│ ┌──────────────┬──────────────┐ │
│ │ Helmet (安全) │ Rate Limit │ │
│ └──────────────┴──────────────┘ │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ SSR 渲染层 │
│ 1. 创建 Redux Store │
│ 2. 预取路由数据 (loadData) │
│ 3. renderToString 渲染 HTML │
│ 4. 提取 Styled-components 样式 │
│ 5. 序列化状态到 window.context │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 返回 HTML + 初始状态 │
│ <html> │
│ <head> │
│ ${styleTags} │
│ </head> │
│ <body> │
│ <div id="root">${content}</div> │
│ <script> │
│ window.context = { state: ... } │
│ </script> │
│ </body> │
│ </html> │
└─────────────────────────────────────────┘
2.3 客户端 Hydration 流程
// src/client/index.tsx
const App = () => {
const store = useMemo(() => {
// 读取服务端传递的状态
if (isSEO() && window?.context?.state) {
return getStore(window.context.state)
}
return getStore()
}, [])
return (
<Provider store={store}>
<BrowserRouter>
<Routes>{/* ... */}</Routes>
</BrowserRouter>
</Provider>
)
}
// 等待代码分割组件加载完成
loadableReady(() => {
hydrateRoot(document.getElementById('root')!, <App />)
})
三、核心特性详解
3.1 三种渲染模式(灵活切换)
NSBP 支持通过 URL 参数控制渲染模式:
| 模式 |
URL |
场景 |
| SSR(默认) |
/ 或 /?nsbp=1 |
SEO 优化、首屏加载快 |
| CSR(纯客户端) |
/?nsbp=0 |
调试、开发体验 |
| SSR 回退 |
/?nsbp=1&from=link |
内部链接,允许客户端更新 |
实现逻辑:
// src/utils/index.ts
export const isSEO = () => {
if (typeof window !== 'undefined') {
const nsbp = getLocationParams('nsbp')
if (nsbp !== '') {
return parseInt(nsbp, 10)
}
return 1 // 默认 SSR
}
return 1
}
3.2 智能 SSR 数据预取
服务端预取数据,避免客户端二次请求:
// src/Routers.tsx
export default [
{
path: '/',
element: <Home />,
loadData: homeLoadData, // ← SSR 时预取数据
key: 'home'
}
]
// src/services/home.ts
export const loadData = (resolve: any, query: any) => {
return async (dispatch: any) => {
try {
const response = await fetch('/getPhotoMenu')
const data = await response.json()
// Redux Thunk action
dispatch({
type: FETCH_PHOTO_MENU_SUCCESS,
payload: data.data
})
resolve()
} catch (error) {
console.error('Data loading error:', error)
resolve()
}
}
}
效果对比:
传统 CSR 方式:
1. 客户端请求页面 → 返回空 HTML
2. 加载 JS bundle
3. 执行 JS
4. 发起 API 请求
5. 渲染内容
⏱️ 总耗时: ~2-3 秒
NSBP SSR 方式:
1. 服务端预取数据
2. 渲染完整 HTML
3. 返回给客户端
4. 客户端直接 hydrate
⏱️ 总耗时: ~0.5-1 秒
3.3 代码分割与懒加载
使用 @loadable/component 实现路由级别的代码分割:
import loadable from '@loadable/component'
const Home = loadable(() => import('@containers/Home'), {
fallback: <Loading />
})
const Photo = loadable(() => import('@containers/Photo'), {
fallback: <Loading />
})
Webpack 配置:
// config/webpack.client.js
const { ChunkExtractor } = require('@loadable/webpack-plugin')
module.exports = merge(baseConfig, {
mode: 'production',
plugins: [
new ChunkExtractor({
statsFile: path.resolve(__dirname, '../public/loadable-stats.json')
})
]
})
效果:
首次加载:
- 主 bundle: ~200KB
- 只加载首页代码
访问 /photo 页面:
- 按需加载 Photo 组件
- 不加载 Login 组件
3.4 多层安全防护体系
Helmet 安全头部
// src/server/index.ts
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https:'],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false
}))
安全效果:
| 攻击类型 |
防护措施 |
| XSS 攻击 |
CSP script-src 策略 |
| 点击劫持 |
X-Frame-Options: SAMEORIGIN |
| MIME 嗅探 |
X-Content-Type-Options: nosniff |
| 不安全的 HTTPS |
Strict-Transport-Security |
速率限制
if (process.env.ENABLE_RATE_LIMIT === '1') {
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 最多 100 次请求
message: 'Too many requests from this IP'
})
app.use('/api', limiter)
}
3.5 Docker 一键部署
生产环境 (docker-compose.yml):
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
container_name: nsbp-app
ports:
- "8081:3001"
environment:
- NODE_ENV=production
- ENABLE_RATE_LIMIT=1
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 3s
retries: 3
开发环境 (docker-compose.dev.yml):
services:
app:
build:
context: .
dockerfile: docker/Dockerfile.dev
container_name: nsbp-app-dev
ports:
- "3001:3001"
- "9229:9229" # Node.js debug port
volumes:
# 代码挂载(热重载)
- ./src:/app/src
- ./public:/app/public
- ./config:/app/config
command: ["dumb-init", "--", "pnpm", "run", "dev"]
一键启动:
# 生产环境
make prod
# 或
docker-compose up -d
# 开发环境
make dev
# 或
docker-compose -f docker-compose.dev.yml up --build
四、开发体验优化
4.1 完整的代码质量工具链
// package.json scripts
{
"scripts": {
"lint": "eslint src --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write **/*.{js,css,less,scss,ts,tsx}"
}
}
Git Hooks (Husky + lint-staged):
// .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
lint-staged --partial-staged
效果:
$ git add .
$ git commit -m "feat: add new component"
✔ Pre-commit checks
→ ESLint auto-fixed 3 files
→ Prettier formatted 5 files
[master 8a3f9d] feat: add new component
4.2 CLI 工具快速创建项目
# 安装 CLI
npm install -g nsbp-cli
# 创建新项目
npx nsbp-cli create my-app
# 项目结构
my-app/
├── src/
│ ├── client/
│ ├── server/
│ ├── containers/
│ ├── component/
│ ├── store/
│ └── utils/
├── docker/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── docker-compose.yml
│ └── docker-compose.dev.yml
├── config/
│ ├── webpack.base.js
│ ├── webpack.client.js
│ └── webpack.server.js
├── .env.example
├── Makefile
└── README.md
4.3 Make 命令简化操作
# 查看所有命令
make help
Available targets:
help Show this help message
build Build Docker images for production
dev Start development environment
prod Start production environment
down Stop and remove containers
logs View logs
restart Restart production containers
rebuild Rebuild and restart containers
shell Open shell in container
五、性能优化实践
5.1 Tree Shaking
// config/webpack.base.js
module.exports = {
optimization: {
usedExports: true, // 只导出使用的导出
sideEffects: false, // 没有副作用
}
}
5.2 代码压缩与混淆
// config/webpack.client.js (production)
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true, // 多进程压缩
terserOptions: {
compress: {
drop_console: true, // 移除 console.log
pure_funcs: ['console.log', 'console.info']
}
}
})
]
}
}
5.3 静态资源缓存优化
// src/server/index.ts
app.use(express.static('public', {
dotfiles: 'ignore',
setHeaders: (res, filePath) => {
// 缓存 1 年
if (filePath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
}
}))
5.4 图片尺寸探测
// 使用 probe-image-size 在 SSR 阶段获取图片尺寸
import { getPhotoWH } from '@server/photo'
// 客户端直接使用,避免二次请求
const [width, height, filename] = photos[0]
六、适用场景分析
✅ 适合使用 NSBP 的场景
-
个人项目和快速原型
- 需要快速搭建 SSR 应用
- 想要学习 SSR 原理
- 对构建流程有特殊需求
-
教育和学习目的
- 深入理解 React SSR
- 学习 Webpack 配置
- 掌握 Redux 状态管理
-
需要高度定制的项目
-
低资源环境部署
- 小型 VPS 服务器
- 需要优化镜像大小
- 精确控制资源占用
❌ 不适合使用 NSBP 的场景
-
企业级大型应用
- Next.js 的自动优化能节省大量时间
- 需要 Image Optimization、API Routes 等高级功能
- 团队规模大,需要统一的开发规范
-
快速迭代的项目
-
需要 AI/ML 功能
- Next.js 的 AI SDK 集成
- Vercel AI Platform 支持
七、遇到的问题与解决方案
7.1 React 19 Hydration 错误 #418
问题:在 HTTPS 环境下,Home 组件报错:
Minified React error #418
原因:组件中使用了 dangerouslySetInnerHTML 的 <script> 标签,导致服务端和客户端 HTML 不匹配。
// ❌ 错误写法
<script
dangerouslySetInnerHTML={{
__html: `setTimeout(() => { ... }, 800)`
}}
/>
解决方案:将脚本逻辑移到 useEffect 中。
// ✅ 正确写法
useEffect(() => {
const timer = setTimeout(() => {
// 客户端执行
}, 300)
return () => clearTimeout(timer)
}, [])
7.2 Docker 端口配置问题
问题:.env 文件在根目录,docker-compose.yml 在 docker/ 目录,导致环境变量无法读取。
# 目录结构
nsbp/
├── .env # 环境变量
└── docker/
└── docker-compose.yml # 配置文件
解决方案:
- 将
docker-compose.yml 移到根目录
- 更新 Makefile 移除
docker/ 路径前缀
- 更新 CLI 模板保持一致
// docker-compose.yml (现在在根目录)
services:
app:
build:
context: . // ← 直接使用根目录
dockerfile: docker/Dockerfile
7.3 Webpack Dev Server 热重载失效
问题:修改样式文件后,页面没有自动刷新。
原因:Browser Sync 配置不正确。
解决方案:正确配置 Browser Sync Webpack Plugin。
const BrowserSyncPlugin = require('browser-sync-webpack-plugin')
module.exports = {
plugins: [
new BrowserSyncPlugin({
host: 'localhost',
port: 3000,
proxy: {
target: 'http://localhost:3001',
ws: true
}
})
]
}
八、未来规划与改进方向
8.1 技术升级
8.2 性能优化
8.3 开发体验
九、总结
收获与经验
技术层面:
- 深入理解了 React SSR 的工作原理
- 掌握了 Webpack 的复杂配置
- 熟练使用 Redux Toolkit 状态管理
- 学会了 Docker 容器化部署
工程层面:
- 建立了完整的代码质量工具链
- 实现了自动化部署流程
- 掌握了 CLI 工具开发
- 理解了软件架构设计原则
对比 Next.js
| 特性 |
NSBP |
Next.js |
| 学习曲线 |
陡峭(需要 Webpack 知识) |
平缓(开箱即用) |
| 定制能力 |
完全可控 |
有限(受框架限制) |
| 部署灵活性 |
高(可自定义任何方案) |
中(优先 Vercel) |
| 开发体验 |
需要配置 |
优秀(零配置) |
| 性能优化 |
手动控制 |
自动优化 |
| 适用场景 |
学习、个人项目、高度定制 |
企业应用、快速开发 |
给新手的建议
- 先学 Next.js:理解 SSR 的基本概念和最佳实践
- 再学 Webpack:掌握构建工具的底层原理
- 尝试 NSBP:理解一个完整的 SSR 框架是如何工作的
- 动手实践:自己构建一些小项目,深入理解每个环节
十、快速开始
# 1. 安装依赖
pnpm install
# 2. 配置环境
cp .env.example .env
# 3. 启动开发
pnpm run dev
# 4. 访问应用
open http://localhost:3001
# 5. Docker 部署
make prod
结语
构建 NSBP 的过程是一次非常有价值的学习经历。虽然最终产品可能不会成为 Next.js 的替代品,但通过这个项目,我:
- 深入理解了现代前端工程化的每个环节
- 掌握了从零构建一个完整应用的能力
- 培养了解决复杂问题的思维模式
技术框架的选择没有绝对的对错,只有适不适合。Next.js 适合快速开发和大多数场景,但如果你想要完全掌控、深入学习、或者有特殊需求,那么从零构建也是一种选择。
希望这篇文章能对正在学习 React SSR 的你有所帮助!
项目链接:
相关文章:
- [如何从零开始构建一个 React SSR 应用(待写)]
- [Webpack 5 最佳实践指南(待写)]
- [TypeScript + Redux Toolkit 实战(待写)]
本文首发于 Erishen 的技术博客