最近在研究SSR伺服器端渲染,自己寫了的小demo。 項目佈局 註:以防版本不對應產生的問題。package.json我也把放出來了,不過在文章的最後面 上圖是Vue官方的SSR原理介紹圖片。從這張圖片,我們可以知道:我們需要通過Webpack打包生成兩份bundle文件: Client Bundl ...
最近在研究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 用來監聽