vue-ssr + vue-router + vuex 搭建项目

3,754 阅读10分钟

前言

最近学习了 vue-ssr 服务端渲染,为了加深记忆,将使用 vue-ssr 搭建一个简单项目。

友情提醒

一、ssr 构建流程

官方给出的构建图

ssr.29164cfb.png

构建过程大致如下

  • **app.js** 是应用的一个通用入口,该文件中会创建 Vue 实例,并导出到 Server 和 Client 中使用
import Vue from 'vue'
import App from './App.vue'

export default () => {
  const app = new Vue({
    render: h => h(App)
  })
  return {app}
}
  • Client entry & Server entry

Client entryServer entry 分别对应服务端入口和客户端入口, Client entry 客户端入口的功能就是将渲染 vue 实例手动挂载到 DOM 上即可,而 Server entry 服务端入口的功能只需将渲染的 vue 实例导出即可

// entry-client.js 
import createApp from './app'
const { app } = createApp();
app.$mount("#app");
// entry-server.js 
import createApp from './app'
export default () => {
  const { app } = createApp();
  return app; // 将渲染实例导出即可
}

**entry-server.js** 导出的函数相对于一个工厂函数,可以确保每个请求都返回单独的渲染实例

  • Webpack build

接下来就是 webpack 打包配置工作,因为存在两个 entry 入口,所以配置文件可以分成 baseclientserver 三个文件,最后会打包出 Server BundleClient Bundle。 前者 Server Bundle 的作用是在 node 中,将渲染好的 vue 实例转换成 HTML 返回给客户端; 而 Client Bundle 的作用是将由服务端返回的静态 HTML,使其变为由 Vue 管理的动态 DOM ,这个过程也被称为客户端激活

二、项目初始化

到这里,我们已经知道 vue ssr 的构建流程,接下来会通过真实的项目来探索 ssr 的运行机制,马上开始吧

2.1 项目结构

vue-ssr-demo
├─.gitignore 
├─README.md
├─package.json
├─src
|  ├─App.vue ----------------------------- 根组件
|  ├─app.js ------------------------------ 通用入口
|  ├─entry-client.js --------------------- 客户端入口
|  ├─entry-server.js --------------------- 服务端入口
|  ├─views
|  |   └Home.vue
├─public
|   ├─index.html ------------------------- client模版
|   └index.ssr.html ---------------------- server模版
├─build
|   ├─webpack.base.js -------------------- 公共打包配置      
|   ├─webpack.client.js ------------------ 客户端打包配置
|   └webpack.server.js ------------------- 服务端打包配置

按照👆目录结构搭建项目后,我们的目标首先上将该项目按照 SPA 项目一样能够本地运行

2.1.1 安装依赖

yarn add vue vue-loader @babel/core @babel/preet-env @babel-loader css-loader 
vue-style-loader webpack webpack-cli webpack-merge webpack-dev-server

2.1.2 打包入口

在前面有提到,app.js 作为通用入口,entry-client.js 是客户端入口,entry-server.js 是服务端入口,具体代码如下

  • app.js
import Vue from 'vue'
import App from './App.vue'

export default () => {
  const app = new Vue({
    render: h => h(App)
  })
  return {app}
}
  • entry-client.js
import createApp from './app'
const { app } = createApp();
app.$mount("#app");
  • entry-server.js
import createApp from './app'
export default () => {
  const { app } = createApp();
  return app; // 将渲染实例导出即可
}

2.1.3 打包配置

  • webpack.base.js 在公共打包配置中,需要配置打包出的文件位置、使用到的 Loader 以及公共使用的 Plugin
// build/webpack.base.js
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const resolve = dir => path.resolve(__dirname, dir)

module.exports = {
  output: {
    filename: '[name].bundle.js', 
    path: resolve('../dist') // 打包输出路径
  },
  // 扩展名
  resolve: {
    extensions: ['.js', '.vue', '.css', '.jsx']
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader'] 
      },
      {
        test: /\.ttf$/,
        use: 'url-loader'
      },
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        },
        exclude: /node_modules/
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
  ]
}

SSR 中,处理 **css** 需要使用 vue-style-loader,但有个坑,vue-style-loader 由于版本老旧,不支持最新版本的 css-loader,所以需要安装 3.x 低版本的 css-loader, 或者按照该 Issues 进行配置

  • webpack.client.js
// build/webpack.client.js

const webpack = require('webpack')
const {merge} = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const resolve = dir => path.resolve(__dirname, dir)

const base = require('./webpack.base')
const isProd = process.env.NODE_ENV === 'production'

module.exports = merge(base, {
  entry: {
    client: resolve('../src/entry-client.js')
  },
  plugins: isProd ? [] : [
    new HtmlWebpackPlugin({
      template: resolve('../public/index.html')
    })
  ]
})
  • webpack.server.js
// build/webpack.server.js

const {merge} = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const resolve = dir => path.resolve(__dirname, dir)

const base = require('./webpack.base')

module.exports = merge(base, {
  entry: {
    server: resolve('../src/entry-server.js')
  },
  target:'node', // 服务端打包好的 JS 是给node使用
  output:{
    libraryTarget:'commonjs2' //  指定导出方式
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.ssr.html',
      template: resolve('../public/index.ssr.html'),
      minify: false, // 不压缩,不会删除注释
      excludeChunks: ['server']
    })
  ]
})

2.2.4 打包脚本

需要在 package.json 中配置打包脚本,再通过 npm 去执行脚本

"scripts": {
    "client:dev": "cross-env NODE_ENV=development webpack-dev-server --config ./build/webpack.client.js", // 本地开发
    "client:build": "cross-env NODE_ENV=productio webpack --config ./build/webpack.client.js --watch",  // client 打包
    "server:build": "cross-env NODE_ENV=productio webpack --config ./build/webpack.server.js --watch",  // server 打包
    "build": "concurrently \"npm run client:build\" \"npm run server:build\" ",
 },

[Tips]:使用 concurrently 可同时启动多个命令

此时,运行 npm run client:dev 即可本地开发,运行项目了; 运行 npm run build 即可同时打包出 Server BundleClient Bundle, 打包出的 dist 目录如下

├─dist
|  ├─client.bundle.js
|  ├─index.ssr.html
|  └server.bundle.js

现在,需要通过 SSR 服务端渲染,运行该项目

2.2 服务端渲染

服务端渲染的逻辑通常写在 server.js 文件中,主要作用是使用打包好的 Server Bundle,创建出一个 render , 最后将 render 转换成静态 HTML 返回浏览器即可,主要逻辑如下

// server.js
const Vue = require("vue");
const VueServerRender = require("vue-server-renderer");
const Koa = require("koa");
const Router = require("@koa/router");
const static = require("koa-static");
const fs = require("fs");
const path = require("path");
const resolve = (dir) => path.resolve(__dirname, dir);

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

const serverBundle = fs.readFileSync(resolve('./dist/server.bundle.js'), 'utf8')
const serverTemplate = fs.readFileSync(resolve('./dist/index.ssr.html'), 'utf8')
const render = VueServerRender.createBundleRenderer(serverBundle, {
  template: serverTemplate // 指定渲染模版 
})

router.get('/',async (ctx)=>{
    ctx.body = await render.renderToString(vm);
})

app.use(router.routes()); // 将路由注册到应用上
app.listen(3000);

需要注意的是,在 index.ssr.html 文件中,需要添加下面的注释,最终服务端渲染的 vue 实例会替换点该注释

<!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>
  <!--vue-ssr-outlet--> // 标注注入处
</body>
</html>

现在,执行 nodeman server.js 即可开启服务端渲染。

页面渲染如下

截屏2020-08-30上午9.28.40.png

页面源代码如下 截屏2020-08-30上午9.30.55.png

在代码中,首页加载的是 Home 组件

<template>
  <div class="home">
    Home Page
    <button @click="onClick">Click Me!</button>
  </div>
</template>

<script>
export default {
  name: 'Home',
  methods: {
    onClick () {
      alert('Home Page Click!')
    }
  }
}
</script>

<style scoped>
.home{
  color: red;
}
</style>

可以看到,在页面中,我们写的 CSS 样式,以及 JS 中的点击事件并没有生效,页面源代码中也只有 HTML 代码。 在前面我们说过,客户端会打包出一个 Client Bundle ,文件名为 client.bundle.js, 该文件的作用就是将服务端返回的静态 HTML,使其变为由 Vue 管理的动态 DOM

现在问题是:如何在服务器返回的 html 中自动引入客户端打包的 js 文件?

2.3 客户端激活

因为客户端打包出的 js 文件名可以任意设置,还可设置 hash 值,所以不能直接写死,需要在 html 中动态注入。

2.3.1、生成 serverBundleJSON

在前面,我们将服务端的整个输出构建打包成 server.bundle.js 文件,而 vue-server-renderer 中的 server-plugin 插件可将服务端的整个输出构建成单个 JSON 文件的插件,默认的文件名为 vue-ssr-server-bundle.json

// build/webpack.server.js
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(base, {
  // ...
  plugins: [
    new VueSSRServerPlugin(),
    new HtmlWebpackPlugin({
      filename: 'index.ssr.html',
      template: resolve('../public/index.ssr.html'),
      minify: false, // 不压缩,就不会删除注释
      excludeChunks: ['server']
    })
  ]
})

2.3.1、生成 clientManifest

同时, vue-server-renderer 中的 client-plugin 插件可以生成客户端构建清单

// build/webpack.client.js
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(base, {
  // ...
  plugins: isProd ? [
    new VueSSRClientPlugin(),
  ]: [
    new HtmlWebpackPlugin({
      template: resolve('../public/index.html')
    })
  ]
})

现在,运行打包命令 npm run build ,可以看到 dist 目录下打包出的文件,以及JSON 文件中的内容

├─dist
|  ├─client.bundle.js
|  ├─index.ssr.html
|  ├─vue-ssr-client-manifest.json
|  └-vue-ssr-server-bundle.json
// vue-ssr-client-manifest.json
{
  "publicPath": "",
  "all": [
    "client.bundle.js"
  ],
  "initial": [
    "client.bundle.js"
  ],
  "async": [],
  "modules": {
    "2099bb14": [
      0
    ],
	}
}
// vue-ssr-server-bundle.json
{
  "entry": "server.bundle.js",
  "files": {
    "server.bundle.js": "module.exports=xxx"
 	}
}

两个JSON 文件就相当于映射文件,这样就不需要关心打包出的 JS 文件名。有了_服务器和客户端_的构建信息,我们就可以在 server.js 中自动推断和注入script 标签到所渲染的 HTML

// server.js

//...
const serverBundle = require("./dist/vue-ssr-server-bundle.json");
const serverTemplate = fs.readFileSync(
  resolve("./dist/index.ssr.html"),
  "utf8"
);
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
const render = VueServerRender.createBundleRenderer(serverBundle, {
  template: serverTemplate,
  clientManifest, // 注入前端打包好的 js 文件
});

现在查看 http://localhost:3000/ 页面源码,可以看到HTML 中自动注入了客户端打包好的 js 文件

截屏2020-08-30下午11.37.41.png

但此时页面还是未正常显示样式,原因在于,客户端会向服务端请求 js 静态文件,所以在服务器需要提供一个静态资源服务,需要安装 koa-static 依赖

// server.js

//...
const static = require('koa-static');

//...
router.get('/', async (ctx) => {
    ctx.body = await render.renderToString()
});

app.use(static(resolve("./dist/"))); // 静态服务需要放到路由前面
app.use(router.routes());

此时,页面就正常显示,同时响应点击事件了

截屏2020-08-30下午11.50.26.png

2.4、小结一下

本小节通过实现了 SSR 渲染的小 DEMO,梳理了主要流程,大致如下

  • 配置 clientserver 不同入口文件,通过 webpack 的不同配置文件,打包出 client bundlerserver bundler
  • server bundler 运行在 node 端,将渲染好的 Vue 实例转换成 HTML 返回给客户端,而 client bundler 会将 返回的 HTML 使其转变为由 Vue 管理的动态 DOM
  • 使用 vue-server-renderer 提供的插件,可打包服务端和客户端的构建清单,通过该清单,可以自动向 要返回的 HTML 中自动注入客户端打包好的 js 文件

三、集成 vue-router

在本小节,我们将探索如何在 Vue SSR 中集成 vue-router, 通常当使用服务端渲染时,只有首屏页面通过服务端渲染,而后续的页面切换还是通过前端路由。(ps: 首屏指的的在哪回车 哪就是首屏,而非首页)

3.1 安装依赖

yarn add vue-router

3.2 Router 配置

在服务端渲染时,同 vue 实例一样,每个请求都需要创建一个新的实例,所以我们创建了 create-router.js  文件来生成 router 实例

// src/create-router.js  
import Vue from 'vue';
import VueRouter from 'vue-router';

Vue.use(VueRouter);

const Foo = () => import('./components/Foo'); // 异步组件
const Bar = () => import('./components/Bar');

export default ()=>{
    const router = new VueRouter({
        mode:'history',
        routes:[
            {path:'/',component:Foo},
            {path:'/bar',component:Bar}
        ]
    })

    return router; // 每次调用此方法都可以返回一个路由实例
}

在 app.js 文件中可以导入并使用该函数

// src/app.js
import Vue from 'vue';
import App from './App.vue'
import createRouter from './create-router'

export default ()=>{
    const router = createRouter();
    const app = new Vue({
        router,
        store,
        render: h => h(App)
    });

    return {app,router}
}

在服务端,需要根据当前请求路径渲染对应的路由组件,渲染成功后再返回

// server.js
// ...
router.get("/(.*)", async (ctx) => {
    const context = {url: ctx.url}
    ctx.body = await render.renderToString(context);
});

render.renderToString() 中的参数会被传递到 server-entry.js 文件中,我们需要根据 url 找出对应的组件,渲染后返回

// src/server-entry.js

import createApp from './app'
export default (context) => { // 含着当前访问服务端的路径
  return new Promise((resolve, reject) => {
    const { app, router } = createApp();
    router.push(url);
    router.onReady(() => {
      const matchComponents = router.getMatchedComponents(); // 获取匹配的组件,返回值是数组
      if (!matchComponents.length) {
        return reject({ code: 404 });
      }
      resolve(app);
    }, reject);
  });
}

因为,某些路径对应的组件是异步加载的,所以使用 onReady 事件,其回调会在异步组件解析完成之后执行,同时,执行 getMatchedComponents() 可获取匹配到的组件,当返回的数组长度为 0,也就说明访问路径错误,需要返回 404,所以,在 server.js 需要对 404 错误进行捕获

router.get("/(.*)", async (ctx) => {
    try{
        ctx.body = await render.renderToString({url:ctx.url});
    }catch(e){
        if(e.code == 404){
            ctx.body = 'page not found'
        }
    }
});

此时,当我们访问 /bar 路径,会直接在服务器渲染好该路径对应的组件 Bar, 然后再返回给浏览器。

截屏2020-08-31下午11.45.39.png

可以看到组件 Bar的内容以及样式都是直接返回的。

四、集成 vuex

集成 vuex 和集成 vue-router 套路一样,每个请求都会创建一个新的 vuex 实例,在 vuex 的 action 中,通常会异步请求数据,而本小节将探讨如何在服务端异步请求数据,并最终更新到页面上

4.1 安装依赖

yarn add vuex

4.2 vuex 配置

// create-store.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default () => {
  const store = new Vuex.Store({
    state: {
      name: 'Bob'
    },
    mutations: {
      changeName (state, payload) {
        state.name = payload
      }
    },
    actions: {
      asyncChangeName ({commit}, payload) {
        return new Promise((resolve,reject)=>{ // 模拟异步更新数据
          setTimeout(() => {
            commit('changeName', payload)
            resolve();
          }, 2000);
        })
      }
    }
  })
  return store
}

在 app.js 文件中导入使用

import Vue from 'vue'
import App from './App.vue'
import createRouter from './create-route'
import createStore from './create-store'

export default () => {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })
  return {app, router, store}
}

在 Foo.vue 页面组件中显示 vuex 中的数据,并通过 dispath action 触发异步更新数据

// ./src/components/Foo.vue
<template>
  <div>
    {{name}},
    {{this.$store.state.name}}
  </div>
</template>

<script>
export default {
  name: 'Foo',
  data () {
    return {
      name: 'foo'
    }
  },
  mounted () {
    this.$store.dispatch("asyncChangeName", 'Tom')
  }
}
</script>

通过在 mounted hook 中手动触发异步更新,页面 3s 之后会显示 Tom, 然而,在服务端渲染中,并没有 mounted hook, 那如何执行 dispatch action 呢? 并且更新数据到页面上呢?

在服务端,我们会根据访问路径,匹配出需要渲染的路由组件,而渲染组件时可能需要使用 vuex 中的数据,所以在路径组件中会放置数据预取逻辑,通常是暴露出一个自定义静态函数 asyncData,该函数会在路由组件实例化渲染之前调用,所以无法访问 this, 需要传递 store 进入

// ./src/components/Foo.vue
<template>
  <div>
    {{name}},
    {{this.$store.state.username}}
  </div>
</template>

<script>
export default {
  name: 'Foo',
  data () {
    return {
      name: 'foo'
    }
  },
  asyncData (store) {
    // 触发 action 后,会返回 Promise
    return store.dispatch('asyncChangeName', 'Tom')
  }
}
</script>

在服务端,当所以组件中的 asyncData 返回的 promise resolve 后,此时服务器中的 store 现在已经填充入渲染应用程序所需的状态,当我们将状态附加到上下文,状态将自动序列化为 window.__INITIAL_STATE__,并注入 HTML

import createApp from './app';

export default (context)=>{ // context.url 这里包含着当前访问服务端的路径
    return new Promise((resolve,reject)=>{
        const {app, router, store} = createApp();
        router.push(context.url); //
        // 该回调会在 异步组件解析完成之后执行
        router.onReady(()=>{ 
            const matchComponents = router.getMatchedComponents(); // 获取匹配到的组件
            if(matchComponents.length > 0){
                // 调用组件对应的asyncData
                Promise.all(matchComponents.map(component=>{
                    // 需要所有的asyncdata方法执行完毕后 才会响应结果
                    if(component.asyncData){
                        // 返回的是promise
                        return component.asyncData(store);
                    }
                })).then(()=>{
                    context.state = store.state;// 将状态放到上下文中
                    resolve(app)
                },reject)
            }else{
                reject({code:404});  // 没有匹配到路由
            }
        },reject)
    })
} 

context.state 将作为 window.__INITIAL_STATE__ 状态时,在客户端创建 store 时,就应该替换 state

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default () => {
  const store = new Vuex.Store({
    // ...
	})

  if(typeof window !=='undefined' && window.__INITIAL_STATE__){
    store.replaceState(window.__INITIAL_STATE__)
  }
  return store
}

总结

至此,一个简单的 Vue SSR Demo 已经完成,最后梳理下关键点

  • 客户端和服务端会打包出两个不同的 Bundle,服务器 Bundle 的作用是创建出一个 render , 最后将 render 转换成静态 HTML 返回浏览器,而浏览器 Bundle 的作用则是客户端激活
  • 需要通过 vue-server-renderer 插件将浏览器打包的JS 文件自动注入到服务端返回的 HTML
  • 集成 vue-router 的关键是找出请求路径对应的路由组件(可能异步组件),当组件解析完成之后返回实例 app
  • 集成 vuex 的关键是在路由组件中增加自定义静态函数 asyncData,异步组件实例化之前更新数据,并将数据挂载到上下文,挂载到 window.__INITIAL ``_STATE__ 传递给到客户端的 store

如有错误,欢迎大家指出交流