前言
最近学习了 vue-ssr 服务端渲染,为了加深记忆,将使用 vue-ssr 搭建一个简单项目。
友情提醒
- vue ssr 入门教程:建议阅读官网 Vue SSR 指南
- vue ssr 官方Demo: 尤大大写的 vue-hackernews (ps: 该 Demo 项目需要设置代理,才能本地运行)
- 本文项目源码:https://github.com/Yangjia23/mini-vue-ssr
一、ssr 构建流程
官方给出的构建图
构建过程大致如下
**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 entry
和 Server 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
入口,所以配置文件可以分成 base
、client
、server
三个文件,最后会打包出 Server Bundle
和 Client 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 Bundle
和 Client 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
即可开启服务端渲染。
页面渲染如下
页面源代码如下
在代码中,首页加载的是 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 文件
但此时页面还是未正常显示样式,原因在于,客户端会向服务端请求 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());
此时,页面就正常显示,同时响应点击事件了
2.4、小结一下
本小节通过实现了 SSR
渲染的小 DEMO
,梳理了主要流程,大致如下
- 配置
client
、server
不同入口文件,通过webpack
的不同配置文件,打包出client bundler
和server 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, 然后再返回给浏览器。
可以看到组件 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
中
如有错误,欢迎大家指出交流
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 前端壹甲壹 原文链接:https://juejin.im/post/6867330846194892808