vue-cli搭建vue2项目改造ssr
张渊 Lv2

记录一次vue-cli搭建vue2项目改造ssr全过程,附代码注释。

通过 vue-cli 创建 vue 项目

可参考我之前写的一篇文章 vue-cli4.0搭建vue项目

安装 vue-server-renderer

1
2
# 进入项目目录执行
npm install vue vue-server-renderer --save

注意

  • 推荐使用 Node.js 版本 6+。
  • vue-server-renderer 和 vue 必须匹配版本。
  • vue-server-renderer 依赖一些 Node.js 原生模块,因此只能在 Node.js
    中使用。我们可能会提供一个更简单的构建,可以在将来在其他「JavaScript 运行时(runtime)」运行。

修改 src/router/index.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

/* 解决访问重复路由报错问题:NavigationDuplicated: Avoided redundant navigation to current location: "/xxx" 开始 */
const originalPush = VueRouter.prototype.push
// 修改 原型对象中的push方法
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
/* 解决访问重复路由报错问题:NavigationDuplicated: Avoided redundant navigation to current location: "/xxx" 结束 */

const routes = [
{
path: '/',
name: 'Home',
component: () => import(/* webpackChunkName: "home" */ '../views/Home.vue')
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
//每次用户请求都需要创建一个新的 router 实例
//创建 createRouter 工厂函数
export function createRouter() {
return new VueRouter({
routes
})
}

修改 src/store/index.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})

export function createStore() {
return store
}

创建 src/app.js 入口文件

安装 vuex-router-sync 工具,该工具主要是将 storerouter 连接起来。详细了解可参考 Vuex Router Sync

1
2
# 进入项目目录执行
npm install vuex-router-sync

代码修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Vue from "vue";
import App from './App.vue'
import { createRouter } from "./router";
import { createStore } from "./store";
// 把 Vue Router 当前的 $route 同步为 Vuex 状态的一部分
import { sync } from "vuex-router-sync"

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp() {
// 创建router实例
const router = createRouter()
const store = createStore()
// 同步路由状态(route state)到 store
sync(store, router)
// 创建应用程序实例,将 router 和 store 注入
const app = new Vue({
router,
store,
// 根据实例简单的渲染用用程序组件
render: h => h(App)
})
// 暴露 app, router 和 store
return { app, router, store }
}

创建 src/entry-server.js 文件

服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 - 但是稍后我们将在此执行服务器端路由匹配 (server-side route matching) 和数据预取逻辑 (data pre-fetching logic)。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// entry-server.js
import { createApp } from "./app"

export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 设置服务端 router 的位置
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到路由,执行reject函数,并返回404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
router: router.currentRoute
})
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
// Promise 应该 resolve 应用程序实例,以便它可以渲染
resolve(app)
})
}, reject)
})
}

创建 src/entry-client.js 文件

客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中:

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// entry-client.js
import { createApp } from './app'

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.length) {
return 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')
})

创建页面模板 public/index.template.html

当你在渲染 Vue 应用程序时,renderer 只从应用程序生成 HTML 标记 (markup)。在这个示例中,我们必须用一个额外的 HTML 页面包裹容器,来包裹生成的 HTML 标记。
注意 <!--vue-ssr-outlet--> 注释 – 这里将是应用程序 HTML 标记注入的地方,中间不能有空格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{{ title }}</title>
</head>

<body>
<!--vue-ssr-outlet-->
</body>

</html>

创建一个 Node.js web服务器

与服务器集成,在 Node.js 服务器中使用时相当简单直接,例如 Express

1
2
# 进入项目目录执行
npm install express --save

项目根目录下创建 server.js 文件,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const express = require('express');
const fs = require('fs');
const path = require('path');
const { createBundleRenderer } = require('vue-server-renderer');

const app = express();

const serverBundle = require('./dist/server/vue-ssr-server-bundle.json');
const clientManifest = require('./dist/client/vue-ssr-client-manifest.json');
const template = fs.readFileSync(path.resolve('./public/index.template.html'), 'utf-8');

const render = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推荐
template, // (可选)页面模板
clientManifest // (可选)客户端构建 manifest
});

app.use(express.static('./dist/client', { index: false }))

app.get('*', (req, res) => {
const context = {
title: 'vue2-ssr-template',
url: req.url
}
// 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
// 现在我们的服务器与应用程序已经解耦!
render.renderToString(context, (err, html) => {
console.log(html)
// 处理异常……
res.end(html)
})
})
// 访问端口
const port = 3003;
app.listen(port, function () {
console.log(`server started at localhost:${port}`);
});

配置 vue.config.js

  • vue-cli4 脚手架搭建完成后,项目目录中没有 vue.config.js 文件,需要手动创建。vue.config.js 是一个可选的配置文件,如果项目的 (和 package.json 同级的) 根目录中存在这个文件,那么它会被 @vue/cli-service 自动加载。
  • 根目录下创建 vue.config.js ,已创建可忽略。

注意:配置文件中用到了 lodash.merge 合并对象,安装命令 npm install lodash.merge --save

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// vue.config.js
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 = {
css: {
extract: false
},
outputDir: "./dist/" + target,
configureWebpack: () => ({
// 将 entry 指向应用程序的 server / client 文件
entry: `./src/entry-${target}.js`,
// 对 bundle renderer 提供 source map 支持
devtool: 'source-map',
target: TARGET_NODE ? 'node' : 'web',
node: TARGET_NODE ? undefined : false,
output: {
libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
},
// https://webpack.js.org/configuration/externals/#function
// https://github.com/liady/webpack-node-externals
// 外置化应用程序依赖模块。可以使服务器构建速度更快,
// 并生成较小的 bundle 文件。
externals: TARGET_NODE
? nodeExternals({
// 不要外置化 webpack 需要处理的依赖模块。
// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
allowlist: [/\.css$/]
})
: undefined,
optimization: {
splitChunks: TARGET_NODE ? false : undefined
},
plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
}),
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
return merge(options, {
optimizeSSR: false
})
})
}
}

配置项目打包脚本

配置脚本需要用到 cross-env 插件,主要是用来运行跨平台设置和使用环境变量的脚本。

安装

1
npm install cross-env --save

使用

在 package.json 文件中,scripts选项下,添加以下脚本:

1
2
3
4
5
6
7
8
"scripts": {
"serve": "vue-cli-service serve", // 项目运行命令
"build": "npm run build:server && npm run build:client", // 打包服务端、客户端
"build:client": "vue-cli-service build", // 打包客户端
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build", // 打包服务端
"server": "node server", // 启动node服务环境
"start": "npm run build:server && npm run build:client && npm run server" // // 打包服务端、客户端并启动node服务环境
}

至此整个ssr改造完成,可通过 npm run start 在本地环境编译打包启动node服务,访问 http://localhost:3003 查看运行效果。

注意:端口号可根据自己配置进行访问

查看网页源代码发现根元素上添加了一个特殊的属性:data-server-rendered,该属性让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载

1
<div id="app" data-server-rendered="true">......</div>

最后

项目目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public
├── favicon.ico
├── index.html
└── index.template.html # 页面模板
src
├── components # 组件
├── router # 路由文件
│ └── index.js
├── store # 状态管理
│ └── index.js
├── app.js # 通用 entry(universal entry)
├── App.vue
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器
vue.config.js # vue构建配置

打包后 dist 目录结构

1
2
3
4
5
6
7
8
9
client # 客户端打包文件
├── img
├── js
├── favicon.ico
├── index.html
├── index.template.html
└── vue-ssr-client-manifest.json
server # 服务端打包文件
└── vue-ssr-server-bundle.json

参考资料

Vue CLI
Vue.js
Vue Router
Vuex
Vue SSR 指南

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 12.9k 访客数 访问量