使用webpack构建vue3 ssr

一、流程

首先我们知道要想构建一个ssr,理解这个张图是必不可少的。从左边开始看起store、router和components就是平时写的页面里面用到的基本组件/逻辑。重点来看app.js对于csr来说项目只有一个app.js入口文件就可以了。但是ssr因为需要首屏渲染,也就是说直接请求页面时返回的不是空的html里面是有内容的。所有从app.js文件里面就开始不一样了。开始分为客户端打包和服务端打包。通过生成的serverBundle和clientBundle 一起在客户端激活。

二、ssr 开发环境

安装必要依赖

json 复制代码
{
  "devDependencies": {
    "babel-loader": "^10.1.1",
    "css-loader": "^7.1.4",
    "html-webpack-plugin": "^5.6.6",
    "koa-connect": "^2.1.1",
    "style-loader": "^4.0.0",
    "vue-loader": "^17.4.2",
    "vue-ssr-assets-plugin": "^0.4.8",
    "webpack": "^5.105.4",
    "webpack-cli": "^7.0.0",
    "webpack-dev-middleware": "^7.4.5",
    "webpack-hot-middleware": "^2.26.1",
    "webpack-merge": "^6.0.1",
    "webpack-node-externals": "^3.0.0"
  },
  "dependencies": {
    "@koa/router": "^15.3.1",
    "@unhead/vue": "^2.1.12",
    "koa": "^3.1.2",
    "koa-static": "^5.0.0",
    "vue": "^3.5.30",
    "vue-router": "^5.0.3",
    "vuex": "^4.1.0"
  }
}

创建基础目录结构

通用应用代码

routes/index.js

js 复制代码
import { createMemoryHistory, createWebHistory, createRouter as createVueRouter } from 'vue-router';

const routes = [
  {
    path: '/',
    component: () => import("../views/index/index.vue")
  }
]

export function createRouter() {
  const history = __SSR__ ? createMemoryHistory() : createWebHistory();

  return createVueRouter({
    history: history,
    routes,
  });
};

SSR 变量是通过webpack进行定义传入,根据客户端和服务端打包配置的不同变量值也不同。

js 复制代码
   new webpack.DefinePlugin({
     __SSR__: false // 客户端环境
   })

store/index.js

js 复制代码
import { createStore as createVuexStore } from 'vuex';

export function createStore() {
  return createVuexStore({
    state() {
      return {
        count: 0,
        posts: []
      }
    },
    mutations: {
      increment(state) {
        state.count++
      },
      setPosts(state, posts) {
        state.posts = posts
      }
    },
    actions: {
      async fetchPosts({ commit }) {
        const res = await fetch('https://api.example.com/posts')
        const posts = await res.json()
        commit('setPosts', posts)
      }
    }
    // 其他模块、getters 等
  })
}

app.js -- 创建 router、store 和根 Vue 实例

js 复制代码
import { createSSRApp } from 'vue';
import App from './views/app.vue';
import { createRouter } from './routes/index';
import { createStore } from './store/index';

export function createApp() {
  const app = createSSRApp(App);
  const router = createRouter();
  const store = createStore();

  app.use(router).use(store);

  return {
    app,
    router,
    store
  }
}

entry-client.js

js 复制代码
import { createApp } from './app';
import { createHead } from '@unhead/vue/client';

const { app, router, store } = createApp();
const head = createHead(); // 这里用到了一个meta组件库,用来管理标题
app.use(head);

// 替换服务端注入状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

// 热更新支持
if (import.meta.webpackHot) {
  import.meta.webpackHot.accept();
}

router.isReady().then(() => {
  app.mount('#app', true); // 第二个参数一定要传入true 打开ssr功能
});

entry-server.js

js 复制代码
import { createApp } from './app';
import { createHead } from '@unhead/vue/server';

export default async (param, options = {}) => {
  const { app, router, store } = createApp();
  const head = createHead();
  app.use(head);

  // 解析URL,如果 param 是字符串,则视为URL;否则视为context对象
  const url = typeof param === 'string' ? param : param.url;

  router.push(url);// 切换到该路由下
  await router.isReady();

  const ssrContext = {
    state: store.state,
    _matchedComponents: new Set(),
  }

   // 如果是生产环境(param 是对象),将状态注入到 context 中(bundle renderer 会将其序列化到 HTML)
  if (typeof param === 'object' && param !== null) {
    param.state = store.state;
  }

  // 根据 options.returnFull 决定返回值
  if (options.returnFull) {
    return { app, ssrContext, head }
  }
  return app;
}

这个文件里面vue3 ssr 和vue2 ssr的一个不同点就是,没有收集asyncData函数的操作,因为在vue3中提供了一个onServerPrefetch函数用于在服务端发送请求。

配置 Webpack

webpack.base.js

js 复制代码
const { VueLoaderPlugin } = require('vue-loader');
const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'development',
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js'
  },
  module: {
    rules: [
      { test: /\.vue$/, loader: 'vue-loader' },
      { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ },
      { test: /\.css$/, use: ['style-loader', 'css-loader'] }
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new webpack.DefinePlugin({
      __VUE_OPTIONS_API__: JSON.stringify(true),      // 启用选项式 API
      __VUE_PROD_DEVTOOLS__: JSON.stringify(false),   // 生产环境禁用 devtools
      __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false) // 禁用详细的 hydration 错误信息
    })
  ],
  resolve: { extensions: ['.js', '.vue'] }
}

webpack.client.dev.js

js 复制代码
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { VueSsrAssetsClientPlugin } = require('vue-ssr-assets-plugin');
const baseConfig = require('./webpack.base');
const { merge } = require('webpack-merge');

module.exports = merge(baseConfig, {
  target: 'web',
  entry: [
    'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000&reload=true',
    path.resolve(__dirname, '../client/entry-client.js')
  ],
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/',
    filename: 'js/[name].js',
    chunkFilename: 'js/[name].[chunkhash].js'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new VueSsrAssetsClientPlugin({
      fileName: './dist/ssr-manifest.json'
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname,'../server/index.html'),
      filename: 'index.ssr.html'
    }),
    new webpack.DefinePlugin({
      __SSR__: false // 客户端环境
    })
  ]
})

webpack.server.dev.js

js 复制代码
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack');
const { VueSsrAssetsServerPlugin } = require('vue-ssr-assets-plugin');

module.exports = merge(baseConfig, {
  target: 'node',
  entry: { server: path.resolve(__dirname, '../client/entry-server.js') },
  output: {
    path: path.resolve(__dirname, '../dist'),
    chunkFilename: '[name].js', // 异步 chunk 输出到同一目录
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  externalsPresets: {
    node: true // 启动 nodejs 预设
  },
  externals: [nodeExternals()], // 主要作用是自动将 node_modules 里的依赖已经你nodejs原生模块声明为外部依赖,从而避免它们被打包进最终的 bundle中
  plugins: [
    new VueSsrAssetsServerPlugin(),
    new webpack.DefinePlugin({
      __SSR__: true // 服务端环境
    })
  ]
})

创建开发环境服务器

js 复制代码
const webpack = require('webpack');
const path = require('path');
const Koa = require('koa');
const { renderToString } = require('vue/server-renderer');
const koaConnect = require('koa-connect');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
const { transformHtmlTemplate } = require('@unhead/vue/server');
const serve = require('koa-static');
const fs = require('fs');
const KoaRouter = require('@koa/router');

const router = new KoaRouter();

// 引入配置
const clientConfig = require('../build/webpack.client.dev');
const serverConfig = require('../build/webpack.server.dev');

// 客户端编译
const clientCompiler = webpack(clientConfig);

// 服务端编译
const serverCompiler = webpack(serverConfig);

// 存储最新的 server bundle 和 client manifest
let serverBundleExports = null; // 服务端入口模块的导出
let clientManifest = null; // 客户端资源列表

// 必须调用 watch 或 run
serverCompiler.watch({}, (err) => {
  if (err) console.error('watch error:', err);
});

// 监听服务端编译完成,加载服务端入口
serverCompiler.hooks.done.tap('LoadServerBundle', (stats) => {
  if (stats.hasErrors()) {
    console.error('Server compile errors:', stats.toJson().errors);
    return;
  }

  if (stats.hasWarnings()) {
    console.warn('Server compile warnings:', stats.toJson().warnings);
  }

  const bundlePath = path.join(serverConfig.output.path, 'server-bundle.js');

  // 清除该模块及其所有子模块的缓存,确保下次 require 得到最新代码
  Object.keys(require.cache).forEach(key => {
    if (key.startsWith(serverConfig.output.path)) {
      delete require.cache[key]
    }
  });

  try {
    serverBundleExports = require(bundlePath);
    console.log('✅ Server bundle updated.');
  } catch (e) {
    console.error('❌ Failed to load server bundle:', e)
  }
});


clientCompiler.hooks.done.tap('SaveClientManifest', async (stats) => {
  const manifestPath = path.join(clientConfig.output.path, 'ssr-manifest.json');
  try {
    const fs = clientCompiler.outputFileSystem;
    const manifestJson = await new Promise((resolve, reject) => {
      fs.readFile(manifestPath, 'utf-8', (err, data) => {
        if (err) reject(err);
        else resolve(data);
      });
    });
    clientManifest = JSON.parse(manifestJson);
    console.log('✅ Client manifest updated.')
  } catch (e) {
    console.error('❌ Failed to read client manifest:', e)
  }
});

const app = new Koa();

// 挂载客户端中间件
let devMiddlewareInstance = devMiddleware(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
  serverSideRender: false,
  writeToDisk: false,
});

const hotMiddlewareInstance = hotMiddleware(clientCompiler, {
  path: '/__webpack_hmr',
  heartbeat: 2000
});

app.use(koaConnect(devMiddlewareInstance));
app.use(koaConnect(hotMiddlewareInstance));

// 生成资源标签
// 生成 CSS link 标签
function generateAssetLinks(manifest, components) {
  const links = [];
  if (manifest.main) {
    manifest.main.css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
  }
  components.forEach(name => {
    if (manifest[name]) {
      manifest[name].css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
    }
  });
  return [...new Set(links)].join('');
}

// 生成 JS script 标签(不包括状态注入脚本)
function generateAssetScripts(manifest, components) {
  const scripts = [];
  if (manifest.main) {
    manifest.main.js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
  }
  components.forEach(name => {
    if (manifest[name]) {
      manifest[name].js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
    }
  });
  return [...new Set(scripts)].join('');
}

router.get('/ssr-manifest.json', async (ctx) => {
  ctx.body = clientManifest;
});

router.get('/favicon.ico', async (ctx) => {
  ctx.body = fs.createReadStream(path.resolve(__dirname, '../favicon.ico'));
});

router.get(/.*/, async (ctx) => {
  // 等待编译完成
  if (!serverBundleExports || !clientManifest) {
    ctx.body = '⏳ 等待编译完成,请稍后刷新...'
    return
  }

  try {
    // 获取服务端入口的 render 函数
    const render = serverBundleExports.default || serverBundleExports.render;

    // 执行 render,传入 url 和 options,得到 app 和 ssrContext
    const { app, ssrContext, head } = await render(ctx.url, { returnFull: true })

    // 渲染为 HTML
    const appHtml = await renderToString(app, ssrContext);
    const registeredComponents = ssrContext._matchedComponents || new Set();
    const headerLinks = generateAssetLinks(clientManifest, registeredComponents);
    const footerScripts = generateAssetScripts(clientManifest, registeredComponents);
    let template = require('fs').readFileSync(path.resolve(__dirname, './index.html'), 'utf-8');

    template = template.replace('<div id="app"></div>', `<div id="app">${appHtml}</div>`);
    const htmlWithUnhead = await transformHtmlTemplate(head, template);

    const stateScript = `<script>window.__INITIAL_STATE__ = ${JSON.stringify(ssrContext.state).replace(/</g, '\\u003c')}</script>`;

    // 6. 最终拼装:先插入 CSS 链接到 </head> 前,再插入状态和 JS 脚本到 </body> 前
    let finalHtml = htmlWithUnhead
      .replace('</head>', `${headerLinks}</head>`)
      .replace('</body>', `${stateScript}${footerScripts}</body>`);

    ctx.body = finalHtml
  } catch (e) {
    console.error('❌ Render error:', e);
    ctx.status = 500;
    ctx.body = 'Internal Server Error';
  }
});

app.use(serve(path.resolve(__dirname, '../dist'))); // 假设静态文件放在 public 目录

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('🚀 Koa SSR server running on http://localhost:3000')
})

这里有几点需要注意的地方

  1. serverCompiler 实例创建后必须要用 watch或run来进去启动
  2. 由于vue3在整体设计上相对于vue2的差别,所以serverCompiler实例创建后它的文件系统并没有使用内存而是直接用输出到磁盘。所以服务端的模块引入使用"写入磁盘 + 清除缓存"的策略。 vue2的处理策略是:createBundleRenderer 接收的不是一个普通的 CommonJS 模块,而是一个由 vue-server-renderer/server-plugin 生成的特殊的 JSON 文件(vue-ssr-server-bundle.json)。这个 JSON 包含了服务端入口代码的所有内容,但它不需要通过 Node.js 的 require 去加载。createBundleRenderer 内部自己解析这个 JSON,并直接执行其中的代码。因此,它完美地绕过了 require 必须从磁盘读取文件的限制。webpack 将这个 JSON 输出到内存后,renderer 直接从内存中读取并使用,自然就不会有任何问题
  3. 根据生成的clientManifest 自定义函数来拼接请求字符串

启动

js 复制代码
  "scripts": {
    "build:client": "webpack --config build/webpack.client.prod.js",
    "build:server": "webpack --config build/webpack.server.prod.js",
    "build": "npm run build:client && npm run build:server",
    "dev": "node ./server/index.dev.js",
    "start": "node ./server/index.js"
  },

生成环境

webpack.client.prod.js

js 复制代码
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const { VueSsrAssetsClientPlugin } = require('vue-ssr-assets-plugin');

module.exports = merge(baseConfig, {
  target: 'web',
  mode: 'production',
  entry: { client: './client/entry-client.js' },
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/',
    filename: 'js/[name].[contenthash].js',
    chunkFilename: 'js/[name].[contenthash].js'
  },
  plugins: [
    new VueSsrAssetsClientPlugin({
      fileName: './dist/ssr-manifest.json'
    }),
    new HtmlWebpackPlugin({
      template: './server/index.html',
      filename: 'index.ssr.html',
      minify: { removeComments: true, collapseWhitespace: true }
    }),
    new webpack.DefinePlugin({
      __SSR__: false // 客户端环境
    })
  ]
})

webpack.server.prod.js

js 复制代码
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack');
const { VueSsrAssetsServerPlugin } = require('vue-ssr-assets-plugin');

module.exports = merge(baseConfig, {
  mode: 'production',
  target: 'node',
  entry: { server: './client/entry-server.js' },
  output: {
    libraryTarget: 'commonjs2'
  },
  externalsPresets: {
    node: true // 启动 nodejs 预设
  },
  externals: [nodeExternals()], // 主要作用是自动将 node_modules 里的依赖已经你nodejs原生模块声明为外部依赖,从而避免它们被打包进最终的 bundle中
  plugins: [
    new webpack.DefinePlugin({
      __SSR__: true // 服务端环境
    }),
    new VueSsrAssetsServerPlugin()
  ]
})

server/index.js

js 复制代码
const koa = require('koa');
const serve = require('koa-static');
const Router = require('@koa/router');
const path = require('path');
const fs = require('fs');
const { renderToString } = require('vue/server-renderer');
const { transformHtmlTemplate } = require('@unhead/vue/server');

const app = new koa();
const router = new Router();

// 静态文件服务
app.use(serve(path.join(__dirname, '../dist')));

const template = fs.readFileSync(path.join(__dirname, '../dist/index.ssr.html'), 'utf-8');
const createApp = require('../dist/server.bundle.js').default;
const clientManifest = require('../dist/ssr-manifest.json');

router.get('/favicon.ico', async (ctx) => {
  ctx.body = fs.createReadStream(path.resolve(__dirname, '../favicon.ico'));
});

// 辅助函数:生成 CSS 链接标签
function generateAssetLinks(manifest, components) {
  const links = [];
  if (manifest.main) {
    manifest.main.css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
  }
  components.forEach(name => {
    if (manifest[name]) {
      manifest[name].css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
    }
  });
  return [...new Set(links)].join('');
}

// 辅助函数:生成 JS 脚本标签(不包括状态脚本)
function generateAssetScripts(manifest, components) {
  const scripts = [];
  if (manifest.main) {
    manifest.main.js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
  }
  components.forEach(name => {
    if (manifest[name]) {
      manifest[name].js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
    }
  });
  return [...new Set(scripts)].join('');
}

router.get(/.*/, async (ctx) => {
  try {
    const { app, head, ssrContext } = await createApp(ctx.url, { returnFull: true });
    const appContent = await renderToString(app, ssrContext);
     // 获取当前路由匹配的组件集合(用于资源注入)
    const components = ssrContext._matchedComponents || new Set();

    // 生成资源标签
    const headerLinks = generateAssetLinks(clientManifest, components);
    const footerScripts = generateAssetScripts(clientManifest, components);
    let html = template.replace('<div id="app">', `<div id="app">${appContent}`);
    // 使用 Unhead 注入由组件声明的 head 标签(title、meta 等)
    html = await transformHtmlTemplate(head, html);
     // 生成状态注入脚本
    const stateScript = `<script>window.__INITIAL_STATE__ = ${JSON.stringify(ssrContext.state).replace(/</g, '\\u003c')}</script>`;

    // 最终拼装:CSS 链接插入 </head> 前,状态和 JS 脚本插入 </body> 前
    html = html.replace('</head>', `${headerLinks}</head>`)
               .replace('</body>', `${stateScript}${footerScripts}</body>`);

    ctx.type = 'text/html';
    ctx.body = html;
  } catch (error) {
    console.log('请求错误', error);
    ctx.status = 500;
    ctx.body = '服务器错误';
  }
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('Koa SSR server running on http://localhost:3000')
});

仓库:https://gitee.com/kinghiee/vue3-ssr.git

相关推荐
北寻北爱2 小时前
面试篇-webpack+vite
前端
wuhen_n2 小时前
回溯算法入门 - LeetCode经典回溯算法题
前端·javascript·算法
xcs194052 小时前
前端 vue this.$nextTick(() => {
前端·javascript·vue.js
广州华水科技2 小时前
如何在基础设施安全中有效实现GNSS位移监测的应用?
前端
大漠_w3cpluscom2 小时前
前端怎么提升自己的CSS编写能力?
前端
我是若尘2 小时前
大数据量渲染优化:分批渲染技术详解
前端
ruanCat2 小时前
pnpm 踩坑实录:用 public-hoist-pattern 拯救被严格隔离坑掉的依赖
前端·npm·node.js
yuki_uix2 小时前
渲染优化三件套:React.memo、useMemo、useCallback 的使用边界
前端·react.js
徐同保2 小时前
如何为 Node.js 多层子进程启动调试(以 OpenClaw 为例)
前端