【应用场景】
之前基于Vue做过SPA应用,说实话,SPA一直有2个缺点让我十分不爽。其中一个是SEO,由于ajax 的存在,搜索引擎无法精确抓取你的页面内容。另外一个是首屏的加载确实慢,但是通过各种优化,SPA的首屏加载也还算是能够接受。但是,最近在折腾weex的时候发现它内置的<web>组件不支持展示部分HTML。如果要实现相应的功能可能即要改android端还要改ios端,有这个时间成本,还是用Vue SSR解决来的靠谱。下面来讲解下基于KOA 2.X 的Vue SSR的基本使用。
- 新增2个入口文件 enter-client.js 和 entry-server.js
- 配置webpack
- 完成router 和 store(数据预取)
- 基于koa 2.7完成server端基本配置
- 启动服务并测试
先看下目录结构(这个目录结构可能还需要优化,后期再更新,本文目的在于先跑通功能。PS:本文所有例子和配置都是手动写的,目的在于用最基本的代码跑通功能,而不是通过各种脚手架)
![](https://img.haomeiwen.com/i10754968/4e3e588edf170a2f.png)
新增2个入口文件 enter-client.js 和 entry-server.js
【entry-server.js】
// 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 && Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// console.log('store.state', store.state)
console.log('matchedComponents123', matchedComponents)
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
// Promise 应该 resolve 应用程序实例,以便它可以渲染
resolve(app)
}).catch(reject)
}, reject => {
console.log(reject)
})
router.onError((err) => {
console.log('err', err)
})
})
// return app
}
这个服务端入口文件的目的就是在于触发路由对应的每个模块的asyncData
函数,从而完成数据预取!SSR可没有什么异步渲染,先拿到数据再渲染页面。而这个asyncData
函数里面做了2件事
- 通过http客户端(随便用什么,比如axios之类的)获得接口信息。
- 通过vuex将接口获取的数据放到store里,已供页面使用(数据也有了,页面也有了,基本功能不就跑通了嘛~)
【entery-client.js】
import { createApp } from './app'
const {app, router} = createApp();
//解析完所有路由(包括异步路由)
router.onReady(() => {
app.$mount('#app')
})
上面客户端入口文件就做了1件事——完成路由解析后做下挂载,这个#app实现应该在模板文件里写好。
看下模板文件的内容:
<!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-->
this is template html
<div id="app"></div>
</body>
</html>
注意上面的注释不能省略,这是告诉vue ssr渲染在哪。
配置webpack
先写个webpack-base-conf.js,最后通过webpack-merge
插件合成最终的webpack-server.js和webpack-client.js
//wepback-base-conf.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); //生成html文件
const CleanWebpackPlugin = require('clean-webpack-plugin'); //每次build的时候清空之前的目录
const webpack = require('webpack');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
//把所有路径定位到项目工程根目录下
function resolve(dir) {
return path.resolve(__dirname, dir);
}
module.exports = {
devtool: 'source-map',
mode: 'none',
entry: {
main: resolve('../www/entry-server.js'),
},
output: {
path: resolve('dist'),
filename: '[hash].[name].[id].js'
},
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'@': resolve('../www')
}
},
devServer: {
contentBase: resolve('dist'),
historyApiFallback: true, //不跳转
// inline: true, //实时刷新
hot: true
},
//webpack 4 分割代码块的插件
optimization: {
splitChunks: {
chunks: "async",
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('../www'), resolve('../node_modules/webpack-dev-server/client')],
options: {
"plugins": [
"dynamic-import-webpack"
]
}
}
]
},
plugins: [
new CleanWebpackPlugin(),
new webpack.NamedModulesPlugin(),
new webpack.HotModuleReplacementPlugin(),
// new HtmlWebpackPlugin({
// title: 'Development',
// // template: resolve('src/index.html')
// }),
new VueLoaderPlugin()
]
}
//wepback-client-conf.js
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack-base-conf');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin');
//把所有路径定位到项目工程根目录下
function resolve(dir) {
return path.resolve(__dirname, dir);
}
module.exports = merge(baseConfig, {
//server端入口文件
entry: {
client: resolve('../www/entry-client.js')
},
// 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 并且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
// target: 'node',
devtool: 'source-map',
output: {
// libraryTarget: 'commonjs2'
path: resolve('../www/dist/client')
},
optimization: {
splitChunks: {
chunks: "async",
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
},
// externals: nodeExternals({
// // 不要外置化 webpack 需要处理的依赖模块。
// // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
// whitelist: /\.css$/
// }),
plugins: [
new HtmlWebpackPlugin({
template: resolve('../www/index.template.html')
}),
// 此插件在输出目录中
// 生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
//webpack-server-conf.js
const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack-base-conf');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
//把所有路径定位到项目工程根目录下
function resolve(dir) {
return path.resolve(__dirname, dir);
}
module.exports = merge(baseConfig, {
//server端入口文件
entry: {
server: resolve('../www/entry-server.js'),
},
// 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 并且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
target: 'node',
devtool: 'source-map',
output: {
libraryTarget: 'commonjs2',
path: resolve('../www/dist/client/')
},
externals: nodeExternals({
// 不要外置化 webpack 需要处理的依赖模块。
// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
whitelist: /\.css$/
}),
// 这是将服务器的整个输出
// 构建为单个 JSON 文件的插件。
// 默认文件名为 `vue-ssr-server-bundle.json`
plugins: [
// new HtmlWebpackPlugin({
// template: resolve('../www/index.template.html'),
// filename: 'index.template.html',
// files: {
// js: 'client.bundle.js'
// },
// excludeChunks: ['server']
// }),
new VueSSRServerPlugin()
]
})
完成router 和 store(数据预取)
看下app.js
//www/app.js
import Vue from 'vue';
import App from './App.vue';
import { createRouter } from './routes/index.js'
import { createStore } from './store/index.js'
import {sync} from 'vuex-router-sync'
export function createApp() {
const router = createRouter();
const store = createStore();
// 同步路由状态(route state)到 store
sync(store, router);
// console.log('router', router)
const app = new Vue({
router: router,
store: store,
render: h=> h(App)
})
return {
app,
router,
store
}
}
这www/app.js使用来返回v-router和vuex的store的
看下对应的路由文件和store文件
//www/routes/index.js
import Vue from 'vue';
import Router from 'vue-router';
// import App from '../App.vue'
Vue.use(Router);
export function createRouter() {
return new Router({
mode: 'history',
routes: [
{
path: '/p/:id',
name: 'article',
component: () => import('../components/article.vue')
// component: App
},
{
path: '/u',
name: 'user',
component: () => import('../components/user.vue'),
children: [
{
path: 'admin',
name: 'admin',
component: () => import('../components/admin.vue')
// component: App
}
]
},
]
})
//www/store/index.js
import Vuex from 'vuex';
import Vue from 'vue';
Vue.use(Vuex)
export function createStore() {
return new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
}
})
}
//store子模块article
export default {
namespaced: true,
state() {
return {
name: 'wcx'
}
},
getters() {
return {
}
},
mutations() {
return {
changeSyncName(state, params) {
state.name = params;
}
}
},
actions() {
return {
changeAsyncName(context, params) {
context.commit('changeSyncName', params)
}
}
}
}
这上面都是vuex的基本操作,不会的话可以看vuex官网
再看下App.vue
<template>
<div id="app">
this is App.vue12346666sssss111
<!-- <router-link to="/p/123">go</router-link> -->
<a href="/p/123" target="view_window">article.vue</a>
<a href="/u" target="view_window">user.vue</a>
<router-view></router-view>
</div>
</template>
<script>
export default {
asyncData ({ store, route }) {
// 触发 action 后,会返回 Promise
// return store.dispatch('fetchItem', route.params.id)
let promise = new Promise((resolve, reject) => {
resolve(123);
})
},
computed: {
// 从 store 的 state 对象中的获取 item。
item () {
// return this.$store.state.items[this.$route.params.id]
}
},
methods: {
},
}
</script>
article.vue
<template>
<div>
this is b.vue
{{$store.state.article.name}}
</div>
</template>
<script>
import articleStore from '../store/article.js'
export default {
asyncData ({ store, route }) {
store.registerModule('article', articleStore);
console.log('route', route)
// 触发 action 后,会返回 Promise
return store.dispatch('article/changeAsyncName', route.params.id)
// let promise = new Promise((resolve, reject) => {
// this.name = 'test'
// resolve(123);
// })
},
data() {
return {
store123: this.$store,
route123: this.$route,
name:''
}
},
created() {
// console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__)
},
computed: {
// 从 store 的 state 对象中的获取 item。
item () {
// return this.$store.state.items[this.$route.params.id]
}
}
}
</script>
admin.vue
<template>
<div>
this is admin.vue
<router-view to="/u/admin"></router-view>
</div>
</template>
<script>
import articleStore from '../store/article.js'
export default {
asyncData ({ store, route }) {
// store.registerModule('article', articleStore);
// console.log('route', route)
// // 触发 action 后,会返回 Promise
// return store.dispatch('article/changeAsyncName', 'admin')
let promise = new Promise((resolve, reject) => {
this.name = 'test'
resolve(123);
})
},
data() {
return {
}
},
created() {
// console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__)
},
computed: {
// 从 store 的 state 对象中的获取 item。
item () {
// return this.$store.state.items[this.$route.params.id]
}
}
}
</script>
user.vue
<template>
<div>
this is user.vue
<router-link to="/u/admin">go to admin</router-link>
<router-view></router-view>
</div>
</template>
<script>
import articleStore from '../store/article.js'
export default {
asyncData ({ store, route }) {
// store.registerModule('article', articleStore);
// console.log('route', route)
// // 触发 action 后,会返回 Promise
// return store.dispatch('article/changeAsyncName', 123)
let promise = new Promise((resolve, reject) => {
this.name = 'test'
resolve(123);
})
},
data() {
return {
}
},
created() {
// console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__)
},
computed: {
// 从 store 的 state 对象中的获取 item。
item () {
// return this.$store.state.items[this.$route.params.id]
}
}
}
</script>
基于koa 2.7完成server端基本配置
const path = require('path');
const Koa = require('koa');
const logger = require('koa-logger')
const router = require('./server/routes/index.js')//后端路由文件
const staticify = require('koa-static');
const home = staticify(path.resolve(__dirname, './www/dist/client'))
console.log(path.resolve(__dirname, './www/dist/client'))
// const webserve = require('koa-static');
// const home = webserve(path.resolve(__dirname, './www'));
let app = new Koa();
app.use(logger())
.use(home)
app.use(router.routes())
.use(router.allowedMethods())
.listen(8088, (ctx) => {
console.log(`server is runnning at 8088`)
});
后端路由文件
const Router = require('koa-router');
const router = new Router();
const Web = require('../controllers/Web')
router.get('*', Web.createHtml);
module.exports = router;
看下后端路由处理逻辑
//wwww/controllers/Web.js
const { renderer, createBundleRenderer } = require('vue-server-renderer');
const Vue = require('vue');
const fs = require('fs');
class Web {
static async createHtml(ctx, next) {
const app = new Vue({
data() {
return {
ctx: ctx,
name: 'wcx2018',
age: 13
}
},
template: `<div>hello you are visiting at {{ctx.url}} name: {{name}} age: {{age}}</div>`
});
//上下文
const context = {
url: ctx.url
}
const serverBundle = require('../../www/dist/client/vue-ssr-server-bundle.json')
const clientManifest = require('../../www/dist/client/vue-ssr-client-manifest.json')
//未传模板的写法
// renderer.createRenderer().renderToString(app, (err, html) => {
// if (err) {
// ctx.throw(500).end(err)
// } else {
// let ssrHtml = `
// <!DOCTYPE html>
// <html lang="en">
// <head><title>Hello</title></head>
// <body>${html}</body>
// </html>
// `
// ctx.body = ssrHtml
// }
// })
const renderer = createBundleRenderer(serverBundle, {
// runInNewContext: false, // 推荐
template: fs.readFileSync('./www/index.template.html', 'utf-8'),
clientManifest
})
// const a = await renderer.renderToString((err, html) => {
// if(err) {
// if(err.code === 404) {
// ctx.throw(404).end(err)
// }else {
// ctx.throw(500).end(err)
// }
// console.log(err)
// } else {
// ctx.body=321
// next()
// // ctx.body = html
// }
// })
const html = await renderer.renderToString(context)
ctx.body = html
}
}
module.exports = Web;
看下package.json
{
"name": "vue_ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"ssrbuild": "webpack --config ./build/webpack-server-conf.js",
"csrbuild": "webpack --config ./build/webpack-client-conf.js",
"ssrdev": "webpack-dev-server --config ./build/webpack-server-conf.js ",
"csrdev": "webpack-dev-server ---config ./build/webpack-client-conf.js "
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.4.4",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-syntax-jsx": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"babel-helper-vue-jsx-merge-props": "^2.0.3",
"babel-loader": "^8.0.5",
"babel-plugin-dynamic-import-node": "^2.2.0",
"babel-plugin-dynamic-import-webpack": "^1.1.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.7.0",
"babel-preset-env": "^1.7.0",
"clean-webpack-plugin": "^2.0.1",
"css-loader": "^2.1.1",
"html-webpack-plugin": "^3.2.0",
"koa": "^2.7.0",
"koa-logger": "^3.2.0",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"vue": "^2.6.10",
"vue-loader": "^15.7.0",
"vue-router": "^3.0.6",
"vue-server-renderer": "^2.6.10",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.10",
"vuex": "^3.1.0",
"vuex-router-sync": "^5.0.0",
"webpack": "^4.30.0",
"webpack-cli": "^3.3.1",
"webpack-merge": "^4.2.1",
"webpack-node-externals": "^1.7.2"
},
"dependencies": {
"@babel/runtime": "^7.4.4",
"webpack-dev-server": "^3.3.1"
}
}
先执行下npm run ssrbuild
,把www/dist/client下的vue-ssr-server-bundle.json复制出来,然后再执行 npm run csrbuild,把刚才的vue-ssr-server-bundle.json再复制进www/dist/client目录。
最后再在根目录下执行node app.js
看下www/dist/client目录下的文件和最终效果:
![](https://img.haomeiwen.com/i10754968/8c0c0294ba9d5494.png)
![](https://img.haomeiwen.com/i10754968/3062a9898fdce3b1.gif)
网友评论