vue-cli3 SSR 服务端渲染从零剖析构建

979 阅读7分钟

关注公众号: 微信搜索 前端工具人 ; 收货更多的干货

一、开篇

  • 上篇 vue2.x SSR 服务端渲染从零剖析构建,优缺点解读 , 介绍了 SSR 的优缺点及从零构建配置
  • 接下来介绍 vue-cli3 搭建 SSR 以及 热重载(开发过程中无需每次打包运行,实时看到更改效果)提升开发效率
  • 热重载个人觉得是开发 SSR 项目 必备姿势;
  • 这里就不在介绍 SSR 原理,优缺点之类的了; 详情请移步上篇文章 vue2.x SSR 服务端渲染从零剖析构建,优缺点解读
  • 坑也不少,具体的还是忘了,大多数都是 插件依赖版本问题,可参考我的目录,或者留言;看到后及时回复
  • 源码地址:https://github.com/laijinxian/vue2.x-ssr-template/tree/master/vue-cli3.x-ssr 来个 star

二、SSR 代码构建

1. vue-router 导出一个工厂函数,用于创建新的

import Vue from 'vue';
import VueRouter from 'vue-router';
import Home from '../views/Home'
Vue.use(VueRouter);
/**
 * TODO 
 * 注意: 默认路由不要使用懒加载 component: () => import('../views/About'), 后续路由可以
 * 不然会报错 repalce ...  router ...  错什么的; 
 */
const routes = [{
  path'/',
  name'Home',
  componentHome
},{
  path'/about',
  name'About',
  component() => import(/* webpackChunkName: 'about' */ '../views/About')
}];
export function createRouter() {
  return new VueRouter({
    mode'history'// 必须是history 模式
    routes
  })
}

2. main.js 导出一个工厂函数,用于创建新的

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'
import '@/mixins'

// 导出一个工厂函数,用于创建新的
export function createApp () {
  // 创建 router 和 store 实例
  const router = createRouter()
  const store = createStore()

  // 同步路由状态(route state)到 store
  sync(store, router)

  // 创建应用程序实例,将 router 和 store 注入
  const app = new Vue({
    router,
    store,
    renderh => h(App)
  })

  // 暴露 app, router 和 store。
  return { app, router, store }
}

3. src 下新增 entry-client.jsentry-server.js

// entry-client.js
// 写法 1
// import { createApp } from './main'
// const { app, router, store } = createApp()
// if (window.__INITIAL_STATE__) {
//   store.replaceState(window.__INITIAL_STATE__)
// }
// router.onReady(() => {
//   app.$mount('#app')
// })

// 写法 2
import { createApp } from './main'
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.lengthreturn next()

    // 这里如果有加载指示器 (loading indicator),就触发

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {
      // 停止加载指示器(loading indicator)
      next()
    }).catch(next)
  })
  app.$mount('#app'true)
})
// entry-server.js
import { createApp } from './main'

// 导出函数将由 bundlerender 调用
// 数据预取是异步的,所以应该返回一个Promise
export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    // 设置服务器端 router 的位置
    router.push(context.url);

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
     
      const matchedComponents = router.getMatchedComponents();

      // 匹配不到的路由,执行 reject 函数,返回错误
      if (!matchedComponents.length) {
        // 做点什么
        // vue-cli3 直接reject 会导致引入文件错误
        // return reject(new Error({ code: 404 }))
      }

      // 对所有匹配的路由组件调用 `asyncData()`
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.rendered = () => {
          context.state = store.state
        }
        resolve(app)
      }).catch(reject)
      resolve(app)
    }, reject)
  })
}

4. 增加 vue.config.js 这是 vue-cli3webpack 打包构建的配置文件

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const nodeExternals = require('webpack-node-externals');
const merge = require('lodash.merge');
const TARGET_NODE = process.env.WEBPACK_TARGET === 'node';
const target = TARGET_NODE ? 'server' : 'client';
 
module.exports = {
  configureWebpack() => ({
    // 将 entry 指向应用程序的 server / client 文件
    entry`./src/entry-${target}.js`,
    // 对 bundle renderer 提供 source map 支持
    devtool'source-map',
    targetTARGET_NODE ? 'node' : 'web',
    nodeTARGET_NODE ? undefined : false,
    output: {
      libraryTargetTARGET_NODE ? 'commonjs2' : undefined
    },
    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // 外置化应用程序依赖模块。可以使服务器构建速度更快,
    // 并生成较小的 bundle 文件。
    externalsTARGET_NODE
      ? nodeExternals({
          // 不要外置化 webpack 需要处理的依赖模块。
          // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
          // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
          allowlist: [/\.css$/]
        })
      : undefined,
    optimization: { splitChunksTARGET_NODE ? false : undefined },
    plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  }),
  chainWebpackconfig => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        merge(options, {
          optimizeSSRfalse
        });
      });
    // fix ssr hot update bug
    if (TARGET_NODE) {
      config.plugins.delete("hmr");
    }
  }
}

5. src 下新增 template/index.ssr.html 里面注释很重要不能删

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>index.template.html</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

6. src 下新增 server/index.js

const fs = require("fs");
const Koa = require("koa");
const express = require('express')
const path = require("path");
const koaStatic = require("koa-static");
const app = new Koa();
// const app = express()

const resolve = file => path.resolve(__dirname, file);
// 1. 开放dist目录
app.use(koaStatic(resolve("./dist")));

// 2. 获取一个 createBundleRenderer
const { createBundleRenderer } = require("vue-server-renderer");
const serverBundle = require(resolve('../dist/vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('../dist/vue-ssr-client-manifest.json'))
const template = fs.readFileSync(resolve('../src/template/index.ssr.html'), 'utf-8')

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
});

function renderToString(context) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html);
    });
  });
}

// 3. 添加一个中间件来处理所有请求
app.use(async ctx => {
  const context = {
    title: "ssr test",
    url: ctx.url
  };

  // 将 context 数据渲染为 HTML
  const html = await renderToString(context);
  ctx.body = html;
});

const port = process.env.PORT || 8090
app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})

7. package.json 增加打包命令

...
"start""node server/index.js",
"build:client""vue-cli-service build",
"build:server""cross-env WEBPACK_TARGET=node vue-cli-service build --mode server",
"build""npm run build:server && move dist\\vue-ssr-server-bundle.json bundle && npm run build:client && move bundle dist\\vue-ssr-server-bundle.json"
...

8. 执行命令构建

  • yarn run build or npm run build
  • yarn run start or npm run start
  • 浏览器输入http://localhost:8090/ 即可看到页面
  • 怎么辨别是否服务端渲染成功 请看上篇文章有介绍

三、 热重载 --> 开发利器

1. 原理 参考 锅巴哥热重载文章

  • 通过 compiler 编译webpack配置文件,监听文件修改,获取最新的 vue-ssr-server-bundle.json 实时编译
  • 通过webpack dev server 获取最新的 vue-ssr-client-manifest.json
  • 结合 vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json 渲染html页面返回给浏览器

2. 安装需要的插件

  • yarn add -D webpack memory-fs concurrently or cnpm install webpack memory-fs concurrently -D
  • yarn add koa-router axios or cnpm install koa-router axios -S

3. src/serve 下 新增 setup-dev-server.js

const webpack = require("webpack");
const axios = require("axios");
const MemoryFS = require("memory-fs");
const fs = require("fs");
const path = require("path");
const Router = require("koa-router");

// 1. webpack的配置文件在 /node_modules/@vue/cli-service/webpack.config.js 中
const webpackConfig = require("@vue/cli-service/webpack.config");
const { createBundleRenderer } = require("vue-server-renderer");

// 2. 编译 webpack 配置文件
const serverCompiler = webpack(webpackConfig);
const mfs = new MemoryFS();

// 指定输出文件到内存流中
serverCompiler.outputFileSystem = mfs;

// 3. 监听文件修改,实时编译获取最新的 vue-ssr-server-bundle.json
let bundle;
serverCompiler.watch({}, (err, stats) => {
  if (err) {
    throw err;
  }
  stats = stats.toJson();
  stats.errors.forEach(error => console.error(error));
  stats.warnings.forEach(warn => console.warn(warn));
  const bundlePath = path.join(
    webpackConfig.output.path,
    "vue-ssr-server-bundle.json"
  );
  bundle = JSON.parse(mfs.readFileSync(bundlePath, "utf-8"));
  console.log("new bundle generated");
});

// 处理请求
const handleRequest = async ctx => {
  console.log("path", ctx.path);
  if (!bundle) {
    ctx.body = "等待webpack打包完成后在访问";
    return;
  }
  // 4. 获取最新的 vue-ssr-client-manifest.json
  const clientManifestResp = await axios.get(
    "http://localhost:8080/vue-ssr-client-manifest.json"
  );
  const clientManifest = clientManifestResp.data;

  const renderer = createBundleRenderer(bundle, {
    runInNewContext: false,
    template: fs.readFileSync(
      path.resolve(__dirname, "../src/index.temp.html"),
      "utf-8"
    ),
    clientManifest: clientManifest
  });
  const html = await renderToString(ctx, renderer);
  ctx.body = html;
};

function renderToString(context, renderer) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html);
    });
  });
}

const router = new Router();

router.get("*", handleRequest);

module.exports = router;

4. src/serve 下 新增 ssr.js 开发热重载入口文件

// server/ssr.js
const Koa = require("koa");
const KoaStatis = require("koa-static");
const path = require("path");

const resolve = file => path.resolve(__dirname, file);
const app = new Koa();

const isDev = process.env.NODE_ENV !== "production";
const router = isDev ? require("./setup-dev-server.js") : require("./index.js");

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

// 开放目录
app.use(KoaStatis(resolve("../dist")));

const port = process.env.PORT || 3000;

app.listen(port, () => {
  console.log(`server started at localhost: ${port}`);
});

module.exports = app;

5. package.json 增加构建命令

...
"dev""concurrently \"npm run serve\" \"npm run dev:serve\" ",
"dev:serve""cross-env WEBPACK_TARGET=node node ./server/ssr.js",
...

6. 执行命令构建

  • yarn run dev or npm run dev
  • 浏览器输入 http://localhost:8081/ 即可看到效果
  • 测试 随便一个vue文件更改点东西 浏览器便自动重载效果

7. 提示

热重载原理可参考 锅巴哥热重载文章 有详细介绍

四、 结语

后续应该会添加 vue3.0 ssr 版本 不过时间就不确定了