手写 Nuxt 核心原理
约 2749 字大约 9 分钟
2026-01-21
文件结构
整体的文件结构如下:
dist打包后生成的目录
client
client_bundle.js
server_bundle.js
frontend
router
index.js
store
index.js
page
home.vue
about.vue
entry-client.js
App.vue
main.js
src
index.js
package.json
webpack.base.config.js
webpack.server.config.js
webpack.client.config.js
pnpm-lock.yaml
.gitignore
实现步骤
安装相关依赖及文件配置
pnpm add express pnpm add vue-router pnpm add pinia pnpm add @vue/server-renderer pnpm add nodemon -D pnpm add webpack-merge -D pnpm add pino-pretty pino -D pnpm add vue vue-loader babel-loader @babel/preset-env -D pnpm add webpack webpack-cli webpack-node-externals -D这里创建两套 webpack 配置用于分别打包 Node 与 Vue 代码
webpack.server.config.jsconst { resolve } = require('path'); const nodeExternals = require('webpack-node-externals'); const { VueLoaderPlugin } = require('vue-loader'); /** @type {import('webpack').Configuration} */ module.exports = { target: 'node', mode: 'development', entry: './src/index.js', output: { filename: 'server_bundle.js', path: resolve(__dirname, './dist'), }, externals: [nodeExternals()], module: { rules: [ { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-env'], }, }, { test: /\.vue$/, loader: 'vue-loader', }, ], }, resolve: { extensions: ['.js', '.vue', '.ts'], }, plugins: [new VueLoaderPlugin()], };webpack.client.config.jsconst { resolve } = require('path'); const { VueLoaderPlugin } = require('vue-loader'); module.exports = { mode: 'development', target: 'web', entry: './frontend/entry-client.js', output: { filename: 'client_bundle.js', path: resolve(__dirname, 'dist/client'), publicPath: '/client/', }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader' }, { test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' }, ], }, resolve: { extensions: ['.js', '.vue'] }, plugins: [new VueLoaderPlugin()], };package.json{ "scripts": { "dev": "nodemon src/index.js | pino-pretty", "build:server": "webpack --config webpack.server.config.js --watch", "build:client": "webpack --config webpack.client.config.js --watch", "preview": "nodemon ./dist/server_bundle.js | pino-pretty" }, "dependencies": { "@vue/server-renderer": "^3.5.27", "express": "^5.2.1", "pino": "^10.2.1" }, "devDependencies": { "@babel/preset-env": "^7.28.6", "babel-loader": "^10.0.0", "nodemon": "^3.1.11", "pino-pretty": "^13.1.3", "vue": "^3.5.27", "vue-loader": "^17.4.2", "webpack": "^5.104.1", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", "webpack-node-externals": "^3.0.0" } }对外暴露 createApp
在 Vue 的 SSR 场景中,需要通过
createSSRApp创建应用实例,然后向外暴露函数createApp,服务端就可以通过该方法获取应用实例,再通过renderToString将其渲染为 HTML注
在客户端入口里调用
createApp并执行app.mount('#app')时,Vue 会复用已有 DOM,并接管事件监听与响应式能力(即 hydration)main.jsimport { createSSRApp } from 'vue'; import App from './App.vue'; export default function createApp() { const app = createSSRApp(App); return { app }; }entry-client.jsimport createApp from './main'; const { app } = createApp(); app.mount('#app');App.vue<template> <div> <h2>Vue</h2> <div>{{ counter }}</div> <button @click="plus"></button> </div> </template> <script setup> import { ref } from 'vue'; const counter = ref(0); const plus = () => { counter.value++; }; </script>重要
在 Vue 的 SPA 中,用户每次打开页面都会重新创建一个 App 对象实例、Router 实例、Store 实例等等,因此每个对象实例的状态并不会被污染
而在 SSR 应用中,应用通常只在服务器启动时初始化一次,如果直接复用同一个实例去处理所有请求,请求之间就会共享状态进而导致 "跨请求状态污染" 的问题。所以为了避免这个问题,我们将
createApp写成工厂函数,每个请求调用一次,返回一组全新的 App/Router/Store 实例,这样状态就被隔离了从 Vue 到字符串 HTML
在服务端通过调用
@vue/server-renderer提供的renderToString方法把应用实例渲染为 HTML 字符串src/index.jsconst pino = require('pino'); const express = require('express'); const createApp = require('../frontend/main').default; const { resolve } = require('path'); const { renderToString } = require('@vue/server-renderer'); const app = express(); const logger = pino(); app.get('/', async (req, res) => { let { app: vueApp } = createApp(); let appString = await renderToString(vueApp); res.send( ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="app">${appString}</div> </body> </html> `, ); }); app.listen(9999, () => { logger.info('Server is running at http://localhost:9999'); });此时渲染的内容并不可交互,因为此时还没有进入到激活模式,因此接下来要做的是让 HTML 变得可交互,这一步就是 Hydration
Hydration
Hydration 通过在服务端渲染的 HTML 中引入客户端 bundle 来完成。客户端 bundle 执行后会触发应用的挂载流程,在复用现有 DOM 结构的前提下,为页面补充事件监听和响应式逻辑,使其从 "静态 HTML" 转变为可交互的应用
src/index.jsconst pino = require('pino'); const express = require('express'); const createApp = require('../frontend/main').default; const { resolve } = require('path'); const { renderToString } = require('@vue/server-renderer'); const app = express(); const logger = pino(); app.use('/client', express.static(resolve(__dirname, '../dist/client'))); app.get('/', async (req, res) => { let { app: vueApp } = createApp(); let appString = await renderToString(vueApp); res.send( [ '<!DOCTYPE html>', '<body>', '<div id="app">' + appString + '</div>', '<script src="/client/client_bundle.js"></script>', '</body>', ].join('\n'), ); }); app.listen(9999, () => { logger.info('Server is running at http://localhost:9999'); });注
不稳定的数据源,例如
Math.random()、Date.now()等依赖浏览器 API 的逻辑、以及请求间共享状态,都会导致服务端与客户端渲染结果不一致进而导致 hydration mismatch解决方式是把这些逻辑放到客户端挂载后执行,或用环境判断隔离
路由的实现
在真实场景下,一个 SSR 应用往往需要根据不同的 URL 返回不同的页面内容,这也就引入了路由系统,用于将请求路径映射到对应的页面组件
在 SSR 场景中,路由需要同时参与 服务端渲染阶段 与 客户端激活阶段。路由不再只是前端的视图切换逻辑,而是直接决定服务端在渲染阶段应该输出哪一份 HTML,因此成为服务端渲染流程的一部分
重要
在 vue-router 中,路由的 history 实现是与运行环境强相关的,而 SSR 应用同时运行在 服务端(Node) 和 客户端(浏览器) 两种环境中,因此不能在路由模块中直接写死某一种 history 类型。
如果在路由模块中直接写死某一种 history 实现,会导致代码只能运行在单一环境中,从而破坏 SSR 中 "同一套路由配置在服务端与客户端复用" 的前提
因此在代码中我们需要在服务端中使用
createMemoryHistory,在客户端中使用createWebHashHistory或createWebHistoryrouter/index.jsimport { createRouter } from 'vue-router'; const routes = [ { path: '/', redirect: '/home' }, { path: '/home', component: () => import('../frontend/page/home.vue') }, { path: '/about', component: () => import('../frontend/page/about.vue') } ] // 与创建 app 实例类似,router 实例也需要使用函数的形式进行创建来避免状态交叉污染 export const createRouterInstance = (history) => { const router = createRouter({ history, routes }) return router }entry-client.jsimport createApp from './main'; import { createWebHistory } from 'vue-router'; import { createRouterInstance } from './router'; const { app } = createApp(); // 客户端有真实的浏览器 history,需要用 createWebHistory 才能与地址栏同步 const router = createRouterInstance(createWebHistory()); app.use(router); // 等路由准备好再挂载,避免首屏 hydration 时出现路由不一致 router.isReady().then(() => { app.mount('#app'); });server/index.jsconst pino = require('pino'); const express = require('express'); const createApp = require('../frontend/main').default; const { resolve } = require('path'); const { createMemoryHistory } = require('vue-router'); const { renderToString } = require('@vue/server-renderer'); const { createRouterInstance } = require('../frontend/router'); const app = express(); const logger = pino(); app.use('/client', express.static(resolve(__dirname, '../dist/client'))); app.get('/*', async (req, res) => { let { app: vueApp } = createApp(); // 服务端没有浏览器环境(location/history API 不存在),必须用内存路由避免依赖 DOM let router = createRouterInstance(createMemoryHistory()); vueApp.use(router); // 先把服务端路由切到当前请求的 URL,确保渲染的是正确页面 // 如果不在服务端显式地将路由切换到当前请求路径,SSR 将始终渲染默认路由对应的页面,导致返回的 HTML 与实际 URL 不匹配 await router.push(req.url || '/'); // 等待路由就绪(包含异步路由组件/路由守卫),避免 SSR 渲染出错或内容不完整 await router.isReady(); let appString = await renderToString(vueApp); res.send( ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="app">${appString}</div> <script src="/client/client_bundle.js"></script> </body> </html> `, ); });Store 的实现
在传统 SPA 中,每一次页面刷新都会创建新的应用实例,状态天然与单个用户绑定。而在 SSR 场景中,应用运行在一个长期存活的服务端进程中,如果直接复用同一个 Store 实例处理多个请求,就会导致跨请求状态污染。因此,Store 必须与 App、Router 一样,采用 工厂函数的形式创建,确保每个请求都拥有一份独立的状态实例
重要
为了避免首屏 hydration mismatch,需要在服务端渲染后导出初始 state,并在客户端挂载前恢复到 Pinia 中,使两端的首屏状态一致
store/index.jsimport { createPinia } from 'pinia'; export const createPiniaInstance = () => { const pinia = createPinia(); return pinia; };entry-client.jsimport createApp from './main'; import { createWebHistory } from 'vue-router'; import { createPiniaInstance } from './store'; import { createRouterInstance } from './router'; const { app } = createApp(); const pinia = createPiniaInstance(); const router = createRouterInstance(createWebHistory()); app.use(router); app.use(pinia); // 服务端注入的初始 state 会挂在 window 上,客户端先恢复再挂载 if (window.__INITIAL_STATE__) { pinia.state.value = window.__INITIAL_STATE__; } router.isReady().then(() => { app.mount('#app'); });server/index.jsconst pino = require('pino'); const express = require('express'); const createApp = require('../frontend/main').default; const { resolve } = require('path'); const { createMemoryHistory } = require('vue-router'); const { renderToString } = require('@vue/server-renderer'); const { createPiniaInstance } = require('../frontend/store'); const { createRouterInstance } = require('../frontend/router'); const app = express(); const logger = pino(); app.use('/client', express.static(resolve(__dirname, '../dist/client'))); app.get('/*', async (req, res) => { let { app: vueApp } = createApp(); let pinia = createPiniaInstance(); let router = createRouterInstance(createMemoryHistory()); vueApp.use(router); vueApp.use(pinia); await router.push(req.url || '/'); await router.isReady(); let appString = await renderToString(vueApp); // 将服务端初始 state 序列化注入 HTML,供客户端恢复 // replace 防止 </script> 提前闭合(XSS / HTML 注入) let state = JSON.stringify(pinia.state.value).replace(/</g, '\\u003c'); res.send( [ '<!DOCTYPE html>', '<body>', '<div id="app">' + appString + '</div>', '<script>window.__INITIAL_STATE__= '+ state + '</script>', '<script src="/client/client_bundle.js"></script>', '</body>', ].join('\n'), ); });注
window.__INITIAL_STATE__并不是全局或持久状态,它只存在于当前请求返回的 HTML 中,每一次页面级请求都会重新执行 SSR 流程,并生成一份新的初始状态快照,因此不存在因用户 "非首次访问" 而导致状态未注入或被复用的问题优化 Webpack 配置
通过
webpack-merge将公用的配置抽取出来减少重复代码webpack.base.config.jsconst { VueLoaderPlugin } = require('vue-loader'); /** @type {import('webpack').Configuration} */ module.exports = { mode: 'development', module: { rules: [ { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-env'], }, }, { test: /\.vue$/, loader: 'vue-loader', }, ], }, plugins: [new VueLoaderPlugin()], resolve: { extensions: ['.js', '.vue', '.json'], }, };webpack.client.config.jsconst { resolve } = require('path'); const { merge } = require('webpack-merge'); const { DefinePlugin } = require('webpack'); const baseConfig = require('./webpack.base.config'); /** @type {import('webpack').Configuration} */ module.exports = merge(baseConfig, { target: 'web', entry: './frontend/entry-client.js', output: { filename: 'client_bundle.js', path: resolve(__dirname, 'dist/client'), publicPath: '/client/', }, plugins: [ new DefinePlugin({ __VUE_OPTIONS_API__: false, __VUE_PROD_DEVTOOLS__: false, }), ], });webpack.server.config.jsconst { resolve } = require('path'); const { merge } = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); const nodeExternals = require('webpack-node-externals'); /** @type {import('webpack').Configuration} */ module.exports = merge(baseConfig, { target: 'node', entry: './src/index.js', output: { filename: 'server_bundle.js', path: resolve(__dirname, './dist'), }, externals: [nodeExternals()], });