实现ssr服务端渲染demo
最近在研究SSR服务器端渲染,自己写了的小demo。
项目布局
├── build // 配置文件
│ │── webpack.base // 公共配置
│ │── webpack.client // 生成Client Bundle的配置
│ │── webpack.server // 生成Server Bundle的配置
├── dist // 项目打包路径
├── public // 模板文件
│ │── index.html // Client模板html文件
│ │── index.ssr.html // Server模板html文件
├── src // 源码目录
│ ├── assets // 图片目录
│ ├── components // 组件
│ │ ├── Bar.vue // Bar测试组件
│ │ ├── Foo.vue // Foo测试组件
│ │── App.vue // Vue应用的根组件
│ │── main.js // 入口基础文件
│ ├── client-entry.js // 浏览器环境入口
│ ├── server-entry.js // 服务器环境入口
│ │ ├── router.js // 路由配置
│ │ ├── store.js // vuex的状态管理
├── favicon.ico // 图标
注:以防版本不对应产生的问题。package.json我也把放出来了,不过在文章的最后面
上图是Vue官方的SSR原理介绍图片。从这张图片,我们可以知道:我们需要通过Webpack打包生成两份bundle文件:
-
Client Bundle,给浏览器用。和纯Vue前端项目Bundle类似
-
Server Bundle,供服务端SSR使用,一个json文件
技术栈
vue + vuex + vue-router + webpack +ES6/7 + less + koa
拆分 Webpack 打包配置
构建文件目录
webpack.base.js 是公共配置,配置如下:
// 基础的webpack配置
// webpack专用配置
const path = require(\'path\')
const VueLoader = require(\'vue-loader/lib/plugin\')
const resolve = dir => {
return path.resolve(__dirname, dir)
}
module.exports = {
output: {
filename: \'[name].bundle.js\',
path: resolve(\'../dist\')
},
resolve: {
extensions: [\'.js\', \'.vue\']
},
module: {
rules: [{
test: /\.js$/,
use: {
loader: \'babel-loader\',
options: {
presets: [\'@babel/preset-env\']
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: [\'vue-style-loader\', \'css-loader\']
},
{
test: /\.vue$/,
use: \'vue-loader\'
},
{
test: /\.less$/,
loader: \'vue-style-loader!css-loader!less-loader\'
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: {
loader: \'url-loader\',
options: {
limit: 300000,
name: \'[name].[ext]?[hash]\'
}
}
}
]
},
plugins: [
new VueLoader()
]
}
webpack.client.js 是生成Client Bundle的配置,配置如下:
const merge = require(\'webpack-merge\')
const base = require(\'./webpack.base\')
const path = require(\'path\')
const resolve = dir => {
return path.resolve(__dirname, dir)
}
const ClientRenderPlugin = require(\'vue-server-renderer/client-plugin\')
const HtmlWebpackPlugin = require(\'html-webpack-plugin\')
module.exports = merge(base, {
entry: {
client: resolve(\'../src/client-entry.js\')
},
plugins: [
new ClientRenderPlugin(),
new HtmlWebpackPlugin({
filename: \'index.html\',
template: resolve(\'../public/index.html\')
})
]
})
webpack.server.js是生成Server Bundle的配置,配置如下:
const merge = require(\'webpack-merge\')
const base = require(\'./webpack.base\')
const path = require(\'path\')
const resolve = dir => {
return path.resolve(__dirname, dir)
}
const VueSSRServerPlugin = require(\'vue-server-renderer/server-plugin\')
const HtmlWebpackPlugin = require(\'html-webpack-plugin\')
module.exports = merge(base, {
entry: {
server: resolve(\'../src/server-entry.js\')
},
target: \'node\', // 用给node来使用
// devtool: \'source-map\',
output: {
libraryTarget: \'commonjs2\'
},
plugins: [
new VueSSRServerPlugin(),
new HtmlWebpackPlugin({
filename: \'index.ssr.html\',
template: resolve(\'../public/index.ssr.html\'),
excludeChunks: [\'server\'] // 排查某个模块
}),
]
})
下图是我的项目文件目录
components 目录下是组件
App.vue Vue应用的根组件
client-entry.js 浏览器环境入口
server-entry.js 服务器环境入口
main.js 入口基础文件
router.js 路由配置文件
store.js vuex状态管理文件
前端渲染 Demo
前端渲染demo部分比较简单,就包含两个组件:Foo 和 Ba
Foo.vue
<template>
<div >
<p @click="handleClick">Foo--{{num}}-点击测试js是否正常</p>
<p>{{this.$store.state.name}}</p>
<p>-----图片分割线----</p>
<img :src="logo" alt="">
<img src="../assets/images/kfbg.png" alt="">
</div>
</template>
<script>
export default {
data(){
return {
num:0,
logo: require(\'../assets/images/kfbg.png\')
}
},
asyncData(store) {
// asyncData 方法只在服务端执行,并且只在页面组件中执行
return store.dispatch(\'changeName\')
},
mounted: function() {
this.$store.dispatch(\'changeName\')
},
methods: {
handleClick() {
this.num ++;
}
}
}
// vue 优化 pwa+ ssr 实现预缓存效果 //vue多页面一般都用ssr写 //学而思、掘金、新闻类网站用的的ssr
</script>
Bar.vue
<template>
<div>
bar
<p>Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。
</div>
</template>
App.vue
<template>
<div id="app">
<p class="nav">
<router-link :class="{\'currentClass\':path==\'/\'}" to="/">foo</router-link>
<router-link :class="{\'currentClass\':path==\'/Bar\'}" to="/Bar">Bar</router-link>
</p>
<router-view></router-view>
</div>
</template>
<script>
export default {
// data(){
// return {
// path: this.$store.state.route.path
// }
// },
computed: {
path(){
return this.$store.state.route.path
}
}
}
</script>
<style lang="less" scope>
.nav{
text-align: center;
display: flex;
align-items: center;
a{
flex: 2;
background: #f5f5f5;
text-decoration:none;
color: #333;
&.currentClass{
background:#f43553;
color: #fff;
}
}
}
</style>
router.js
import Vue from \'vue\'
import Foo from \'./components/Foo.vue\'
import VueRouter from \'vue-router\'
Vue.use(VueRouter)
export default () => {
const router = new VueRouter({
mode: \'history\',
routes: [
{
path: \'/\',
component: Foo
},
{
path: \'/bar\',
component: () => import(\'./components/Bar.vue\')
},
]
})
return router
}
store.js
import Vue from \'vue\'
import Vuex from \'vuex\'
Vue.use(Vuex)
export default () => {
const store = new Vuex.Store({
state: {
name: \'\'
},
mutations: {
changeName(state) {
state.name = \'yxf\'
}
},
actions: {
changeName({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit(\'changeName\')
resolve()
})
})
}
}
})
if(typeof window !== \'undefined\' && window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
return store
}
拆分 JS 入口
在前端渲染的时候,只需要一个入口 main.js。现在要做后端渲染,就得有两个 JS 文件:client-entry.js 和 server-entry.js 分别作为浏览器和服务器的入口。
main.js基础文件
//入口文件
import Vue from \'vue\'
import createRouter from \'./router\'
import App from \'./App.vue\'
import createStore from \'./store\'
import { sync } from \'vuex-router-sync\' // 把当前VueRouter状态同步到Vuex中
export default () => {
const router = createRouter()
const store = createStore()
sync(store, router)
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
client-entry.js 浏览器入口
import createApp from \'./main\'
const { app, router } = createApp()
router.onReady(() => {
app.$mount(\'#app\')
})
server-entry.js 服务器入口
import createApp from \'./main\'
// 服务器需要调用当前这个文件产生一个vue实例
export default context => {
// 涉及到异步组件的问题
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 设置路由
router.push(context.url)
// 返回的实例应跳转到 / 如/bar
router.onReady(() => {
const matchs = router.getMatchedComponents()
console.log(matchs.length)
if(matchs.length === 0) {
reject({ code: 404 })
}
// matchs匹配到所有的组件,整个都在服务端执行的
Promise.all(
matchs.map(component => {
if(component.asyncData) {
// asyncData 是在服务端调用的
return component.asyncData(store)
}
})
).then(() => {
// 以上all中的方法,会改变store中的state
context.state = store.state;// 把vuex的状态挂载到上下文中,会将状态挂到window上
resolve(app)
}).catch(reject)
},reject)
})
}
// 服务器端配置好后,需要导出给node使用
模板文件
index.html client模板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>Document</title>
</head>
<body>
<div id="app"></div>
<!--vue-ssr-outlet-->
</body>
</html>
index.ssr.html server模板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>Document</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
编写服务端渲染主体逻辑
Vue SSR 依赖于包 vue-server-render,它的调用支持两种入口格式:createRenderer 和 createBundleRenderer,前者以 Vue 组件为入口,后者以打包后的 JS 文件为入口,本文采取后者。
server.js
const Koa = require(\'koa\')
const Router = require(\'koa-router\')
const server = new Koa()
const router = new Router()
const path = require(\'path\')
const static = require(\'koa-static\')
const fs = require(\'fs\')
const { createBundleRenderer } = require(\'vue-server-renderer\')
const serverBundle = require(\'./dist/vue-ssr-server-bundle.json\')
//渲染打包后的结果
const template = fs.readFileSync(path.resolve(__dirname, \'./dist/index.ssr.html\'), \'utf8\')
//客户端manifest.json
const clientManifest = require(\'./dist/vue-ssr-client-manifest.json\')
const render = createBundleRenderer(serverBundle, {
template, // 模板里必须要有 vue-ssr-outlet
clientManifest
})
router.get(\'/\',async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString({url: \'/\'}, (err, data) => {
if(err) reject(err);
resolve(data);
})
})
})
server.use(router.routes())
// koa 静态服务中间件
server.use(static(path.resolve(__dirname,\'./dist\')))
server.use( async ctx => {
try{
ctx.body = await new Promise((resolve, reject) => {
render.renderToString({ url: ctx.url }, (err, data) => {
if(err) reject(err)
resolve(data)
})
})
}catch (e) {
ctx.body = \'404\'
}
})
server.listen(3002, () => {
console.log(\'服务器已启动!\')
})
项目地址:https://github.com/xiaonizi66/vue-ssr-demo
package.json
{
"name": "ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development",
"client:build": "webpack --config ./build/webpack.client.js --mode production",
"server:build": "webpack --config ./build/webpack.server.js --mode production"
},
"author": "",
"license": "ISC",
"dependencies": {
"koa": "^2.7.0",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"vue": "^2.6.10",
"vue-loader": "^15.7.1",
"vue-router": "^3.1.2",
"vue-server-renderer": "^2.6.10",
"vuex": "^3.1.1",
"vuex-router-sync": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"babel-loader": "^8.0.6",
"css-loader": "^3.2.0",
"html-webpack-plugin": "^3.2.0",
"less": "^3.9.0",
"less-loader": "^5.0.0",
"url-loader": "^2.1.0",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.10",
"webpack": "^4.39.1",
"webpack-cli": "^3.3.6",
"webpack-dev-server": "^3.8.0",
"webpack-merge": "^4.2.1"
}
}
最终渲染效果:
项目运行
git clone https://github.com/xiaonizi66/vue-ssr-demo
npm install
npm run server:build
npm run cilent:build
nodemon server.js
也可在build后面加上 — –watch 如:npm run server:build — –watch 用来监听