Vuejs搭建Server-render项目简要流程
概要
Vue.js
Vue.js是构建客户端程序的框架,默认情况下,Vue.js可以在浏览器输出vue组件,并进行生成和操作DOM。
服务端渲染
服务端渲染(Server-Side Rendering),是指由服务侧完成页面的 HTML 结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。
与客户端渲染的区别
两张图说明
image.png
image.png
第一张是单页应用(SPA)渲染结果,第2张是服务端渲染过的(SSR)。很容易看出来,相比于客户端渲染利用AJAX获取js文件再渲染出HTML的方式,服务端渲染在首次获取HTML的时候,模板包括数据已经被完整带过来了。
为什么使用服务端渲染
- 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
- 更快的内容到达时间 (time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。网站如果注重用户体验,这个指标是绝对关键的,在这种情况下,服务器端渲染 (SSR) 可以实现最佳的初始加载性能。
构建流程
目录结构
├── client
│ ├── build // 构建相关
│ ├── config // 配置相关
│ ├── public // 公共文件
│ ├── src // 源代码
│ │ ├── api // 所有请求
│ │ ├── assets // 主题 字体 CSS等静态资源
│ │ ├── components // 全局公用组件
│ │ ├── filters // 全局 filter
│ │ ├── router // 路由
│ │ ├── store // 全局 store管理
│ │ ├── modules // vuex子模块
│ │ ├── utils // 全局公用方法
│ │ ├── views // view
│ │ ├── entry-client.js // 仅运行于浏览器
│ │ ├── entry-server.js // 仅运行于服务器
│ │ ├── App.vue // 入口页面
│ │ └── main.js // 入口 加载组件 初始化等
│ ├── static // 第三方不打包资源
│ ├── .babelrc // babel-loader 配置
│ ├── eslintrc.js // eslint 配置项
│ ├── .gitignore // git 忽略项
│ ├── .postcssrc.js // css前缀配置
│ └── index.html // html模板
├── server
│ ├── bin // bin
│ ├── config // 数据库配置
│ ├── dao // 交互模型
│ ├── db // 数据库文件
│ ├── middleware // 中间件
│ ├── model // 数据模型
│ ├── route // 路由
│ ├── mock // 项目mock 模拟数据
│ ├── router // 路由
│ ├── sql // 通用sql语句管理
│ ├── static // 静态文件
│ └── utils // 全局公用方法
└── package.json // package.json
同构代码构建流程
输入命令vue init webpack vue-ssr-demo
使用vue初始化一个基于webpack的vue-ssr-demo项目,生成大致的client结构。
main.js
main.js
为应用通用入口,再纯客户端应用程序中,在此创建vue根实例,并直接挂载到DOM,但对于服务端渲染,因为此时运行在node环境,所以将挂载DOM的步骤转移到entry-client.js
中,main.js
中仅导出createApp
函数。
import Vue from 'vue'
import App from './App'
export function createApp (context) {
const app = new Vue({
render: h => h(App)
})
return { store, router, app }
}
entry-client.js
entry-client.js
在浏览器运行,负责创建应用程序,并挂载到DOM
import { createApp } from "./main";
const { app } = createApp();
// 挂载#app
router.onReady(() => {
app.$mount("#app");
});
entry-server.js
entry-server.js
在服务端运行,在每次渲染中将重复调用此函数,用来创建并返回应用实例。
javascript
import { createApp } from "./main";
export default (context) => {
//传入路由参数等构成的对象,后续将在此执行服务器端路由匹配和数据预取
const { app } = createApp();
return app
};
使用vue-router的路由
类似于main.js
中导出的createApp
函数,在router文件夹下创建index.js文件,在这里创建路由并已函数形式导出。
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
// 解决重复点击路由报错的BUG
const originalPush = VueRouter.prototype.push;
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err);
};
export function createRouter() {
const router = new VueRouter({
routes: [
{
path: "/",
redirect: "home"
},
{
path: "/home",
name: "Home",
component: () => import("../views/home.vue")
},
{
path: "/about",
name: "About",
component: () => import("../views/about.vue")
}
]
});
return router
}
在main.js
中导入vue-router
import Vue from 'vue'
import App from './App'
import { createRouter } from './router'
Vue.config.productionTip = false
/* eslint-disable no-new */
export function createApp (context) {
const router = createRouter()
const app = new Vue({
router,
context,
render: h => h(App)
})
return { router, app }
}
使用vuex 的状态管理
类似于main.js
中导出的createApp
函数,在store文件夹下创建index.js文件,在这里创建vuex并以函数形式导出。
import Vue from "vue";
import Vuex from "vuex";
import getters from "./getters";
//导入所有分割的vuex模块
const modulesFiles = require.context("./modules", true, /\.js$/);
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, "$1");
const value = modulesFiles(modulePath);
modules[moduleName] = value.default;
return modules;
}, {});
Vue.use(Vuex);
export function createStore() {
const store = new Vuex.Store({
modules,
getters
});
return store;
}
在main.js
中导入vuex
import Vue from 'vue'
import App from './App'
import { createStore } from './store'
import { createRouter } from './router'
Vue.config.productionTip = false
/* eslint-disable no-new */
export function createApp (context) {
const store = createStore()
const router = createRouter()
const app = new Vue({
router,
store,
context,
render: h => h(App)
})
return { store, router, app }
}
数据预取
在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。
另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。
混合所谓的意思就是在服务器端预先收到并处理的数据必须存放到一个数据容器或状态容器中,客户端进行挂载时需要取到这些数据来保证数据是同步的。
所以在数据预取之前需要做些准备工作,这里使用官方的状态管理库vuex
,模块代码如下:
// list.js
const state = {
num: 0
}
const mutations = {
ADD (state) {
state.num ++
console.log(state.num);
}
}
const actions = {
}
export default {
namespaced: true,
state,
mutations,
actions
}
// getters.js
const getters = {
num: state => state.list.num
}
export default getters
// index.js
import Vue from "vue";
import Vuex from "vuex";
import getters from "./getters";
//用于读取modules文件夹下的模块
const modulesFiles = require.context("./modules", true, /\.js$/);
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, "$1");
const value = modulesFiles(modulePath);
modules[moduleName] = value.default;
return modules;
}, {});
Vue.use(Vuex);
export function createStore() {
const store = new Vuex.Store({
modules,
getters
});
return store;
}
然后修改 main.js
//main.js
import Vue from 'vue'
import App from './App'
import { createStore } from './store'
import { createRouter } from './router'
import { sync } from 'vuex-router-sync' //用于同步路由状态(router.state)到vuex
import './elementUI/index'
Vue.config.productionTip = false
export function createApp (context) {
const store = createStore()
const router = createRouter()
//同步路由状态
sync(store, router)
const app = new Vue({
//将router和store注入
router,
store,
context,
render: h => h(App)
})
return { store, router, app }
}
准备工作做好了,下一步是进行数据的预取,在纯客户端应用程序里,我们通过访问一个路由,在其生命周期钩子函数里可以进行AJAX请求获取所需数据。所以在路由组件中进行数据预取是最合适和自然的方式。
由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。
首先想到在created钩子中获取,但是这个钩子同样在客户端会执行,明显是不行的。所以我们将在路由组件上暴露出一个自定义静态函数 preFetch。
//xxx.vue
export default {
preFetch(store) {
//返回Promise
return store.dispatch("list/getOneBlog", store.state.route.params.id);
},
...
computed: {
// 从 store 的 state 对象中的获取 item。
item () {
return this.$store.state.list.blogs[this.$route.params.id]
}
}
服务器端数据预取
通过router.getMatchedComponents()
获取与路由相匹配的组件,如果组件暴露出preFetch
方法,就调用这个方法来将数据存到store里。并将解析完成以后的状态附加到渲染上下文中。
//entry-server.js
import { createApp } from "./main";
export default (context) => {
const { router, store, app } = createApp();
return new Promise((resolve, reject) => {
router.push(context.url);//传入路由
//router.onReady(callback, [errorCallback]):该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。这可以有效确保服务端渲染时服务端和客户端输出的一致。
router.onReady(() => {
const matchComponents = router.getMatchedComponents();
if (!matchComponents.length) {
reject(context);
}
Promise.all(
matchComponents.map((component) => {
if (component.preFetch) {
return component.preFetch(store);
}
})
)
.then(() => {
//将解析完成的状态附加到渲染上下文中
context.state = store.state;
resolve(app);
})
.catch(reject);
});
});
};
当使用 template 时,
context.state
将作为window.__INITIAL_STATE__
状态,自动嵌入到最终的 HTML 中。
在客户端中,在挂载应用程序之前,store需要与服务端同步。
//entry-client.js
import { createApp } from './main'
const { router, store, app } = createApp()
// store替换使client rendering和server rendering匹配
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 挂载#app
router.onReady(() => {
app.$mount('#app')
})
客户端激活(水合)
所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。 在页面模板index.html 中,需要加一行注释
<!--vue-ssr-outlet-->
,这是vue-ssr注入HTML的地方
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
注入后
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<div id="app" data-server-rendered="true">
<button>home</button>
<button>about</button>
<div data-v-4782137e>
<h1 data-v-4782137e>about页</h1>
</div>
</body>
</html>
data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。
服务端代码
使用express-generator
生成express
应用程序框架。
//app.js
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var fs = require("fs");
var cache = require("lru-cache");
const { createBundleRenderer } = require("vue-server-renderer");
const resolve = (file) => path.resolve(__dirname, file);
const isProd = true;
var app = express();
let readyPromise;
// 跨域配置
app.all("*", function (req, res, next) {
// 设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
// 允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type,Access-Token,Appid,Secret,Authorization"
);
// 跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");
if (req.method.toLowerCase() == "options") res.sendStatus(200);
// 让options尝试请求快速结束
else next();
});
app.use(logger("dev"));
app.use(express.json());
app.use(
express.urlencoded({
extended: false,
})
);
app.use(cookieParser());
let renderer;
function createRenderer(bundle, options) {
return createBundleRenderer(
bundle,
Object.assign(options, {
cache: new cache({
max: 1000,
maxAge: 1000 * 60 * 10,
}),
runInNewContext: false,
})
);
}
//微缓存服务
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
})
app.use('/dist/', serve('../client/dist', true))
app.use('/public/', serve('../client/public', true))
if (isProd) {
const bundle = require("../client/dist/vue-ssr-server-bundle.json");
const template = fs.readFileSync(
resolve("../client/public/index.html"),
"utf-8"
);
const clientManifest = require("../client/dist/vue-ssr-client-manifest.json");
renderer = createRenderer(bundle, { template, clientManifest });
} else {
readyPromise = require("../client/build/setup-dev-server")(
app,
resolve("../client/public/index.html"),
(bundle, options) => {
renderer = createRenderer(bundle, options);
}
);
}
async function render(req, res) {
const s = Date.now();
res.setHeader("Content-Type", "text/html");
const context = {
title: "lan bi tou", // default title
url: req.originalUrl,
};
let result = await renderToStringPromise(context);
res.send(result);
}
function renderToStringPromise(context) {
return new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`);
}
resolve(html);
});
});
}
app.get(
"*",
isProd
? (req, res) => {
render(req, res);
}
: (req, res) => {
readyPromise.then(() => render(req, res));
}
);
module.exports = app;
webpack配置
配置可参考构建配置或参考demo中webpack配置
体验demo
参考文献:Vue SSR指南