我们先看“疗效”,你可以打开我的博客u3xyz.com,通过查看源代码来看SSR直出效果。我的博客已经快上线一年了,但不吹不黑,访问量非常地小,我也一直在想办法提升访问量(包括在sf写文章,哈哈)。当然,在PC端,搜索引擎一直都是一个重要的流量来源。这里就不得不提到SEO。下图是我的博客以前在百度的快照:
细心的朋友会发现,这个快照非常简单,简单到几乎什么都没有。这也是没办法的事,博客是基于Vue的SPA页面,整个项目本来就是一个“空架子”,这个快照从博客2月份上线以来就一直是上面的样子,直到最近上线SSR。搜索引擎蜘蛛每次来抓取你的网站都是一个样子,慢慢得,它也就不会来了,相应的,网站的权重,排名肯定不会好。到目前为此,我的博客不用网址进行搜索都搜不到。在上线了SSR后,再加上一些SEO优化,百度快照终于更新了:
为什么要做SSR
文章开始基本已经回答了为什么要做SSR这个问题,当然,还有另一个原因是SSR概念现在在前端非常火,无奈在实际项目中没有机会,也只有拿博客来练手了。下面将详细介绍本博客项目SSR全过程。
SSR改造实战
总的来说SSR改造还是相当容易的。推荐在动手之前,先了解官方文档和官方Vue SSR Demo,这会让我们事半功倍。
1. 构建改造
上图是Vue官方的SSR原理介绍图片。从这张图片,我们可以知道:我们需要通过Webpack打包生成两份bundle文件:
- Client Bundle,给浏览器用。和纯Vue前端项目Bundle类似
- Server Bundle,供服务端SSR使用,一个json文件
不管你项目先前是什么样子,是否是使用vue-cli生成的。都会有这个构建改造过程。在构建改造这里会用到 vue-server-renderer 库,这里要注意的是 vue-server-renderer 版本要与Vue版本一样。下图是我的构建文件目录:
- util.js 提供一些公共方法
- webpack.base.js是公共的配置
- webpack.client.js 是生成Client Bundle的配置。核心配置如下:
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
// ...
const config = merge(baseConfig, {
target: 'web',
entry: './src/entry.client.js',
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'vender',
minChunks: 2
}),
// extract webpack runtime & manifest to avoid vendor chunk hash changing
// on every build.
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})
- webpack.server.js 是生成Server Bundle的配置,核心配置如下:
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
// ...
const config = merge(baseConfig, {
target: 'node',
devtool: '#source-map',
entry: './src/entry.server.js',
output: {
libraryTarget: 'commonjs2',
filename: 'server-bundle.js'
},
externals: nodeExternals({
// do not externalize CSS files in case we need to import it from a dep
whitelist: /.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
2. 代码改造
2.1 必须使用VueRouter, Vuex。ajax库建议使用axios
可能你的项目没有使用VueRouter或Vuex。但遗憾的是,Vue-SSR必须基于 Vue + VueRouter + Vuex。Vuex官方没有提,但其实文档和Demo都是基于Vuex。我的博客以前也没有用Vuex,但经过一翻折腾后,还是乖乖加上了Vuex。另外,因为代码要能同时在浏览器和Node.js环境中运行,所以ajax库建议使用axios这样的跨平台库。
2.2 两个打包入口(entry),重构app, store, router, 为每个对象增加工厂方法createXXX
每个用户通过浏览器访问Vue页面时,都是一个全新的上下文,但在服务端,应用启动后就一直运行着,处理每个用户请求的都是在同一个应用上下文中。为了不串数据,需要为每次SSR请求,创建全新的app, store, router。
上图是我的项目文件目录。
- app.js, 通用的启动Vue应用代码
- App.vue,Vue应用根组件
- entry.client.js,浏览器环境入口
- entry.server.js,服务器环境入口
- index.html,html模板
再看一下具体实现的核心代码:
// app.js
import Vue from 'vue'
import App from './App.vue' // 根组件
import {createRouter} from './routers/index'
import {createStore} from './vuex/store'
import {sync} from 'vuex-router-sync' // 把当VueRouter状态同步到Vuex中
// createApp工厂方法
export function createApp (ssrContext) {
let router = createRouter() // 创建全新router实例
let store = createStore() // 创建全新store实例
// 同步路由状态到store中
sync(store, router)
// 创建Vue应用
const app = new Vue({
router,
store,
ssrContext,
render: h => h(App)
})
return {app, router, store}
}
// entry.client.js
import Vue from 'vue'
import { createApp } from './app'
const { app, router, store } = createApp()
// 如果有__INITIAL_STATE__变量,则将store的状态用它替换
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
// 通过路由勾子,执行拉取数据逻辑
router.beforeResolve((to, from, next) => {
// 找到增量组件,拉取数据
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
// 组件数据通过执行asyncData方法获取
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
// 要注意asyncData方法要返回promise,asyncData调用的vuex action也必须返回promise
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
next()
})
.catch(next)
})
// 将Vue实例挂载到dom中,完成浏览器端应用启动
app.$mount('#app')
})
// entry.server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context)
// 设置路由
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 执行asyncData方法,预拉取数据
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store: store,
route: router.currentRoute
})
}
})).then(() => {
// 将store的快照挂到ssr上下文上
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
// createStore
import Vue from 'vue'
import Vuex from 'vuex'
// ...
Vue.use(Vuex)
// createStore工厂方法
export function createStore () {
return new Vuex.Store({
// rootstate
state: {
appName: 'appName',
title: 'home'
},
modules: {
// ...
},
strict: process.env.NODE_ENV !== 'production' // 线上环境关闭store检查
})
}
// createRouter
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
// createRouter工厂方法
export function createRouter () {
return new Router({
mode: 'history', // 注意这里要使用history模式,因为hash不会发送到服务端
fallback: false,
routes: [
{
path: '/index',
name: 'index',
component: () => System.import('./index/index.vue') // 代码分片
},
{
path: '/detail/:aid',
name: 'detail',
component: () => System.import('./detail/detail.vue')
},
// ...
{
path: '/',
redirect: '/index'
}
]
})
}
3. 重构组件获取数据方式
关于状态管理,要严格遵守Redux思想。建议把应用所有状态都存于store中,组件使用时再mapState下来,状态更改严格使用action的方式。另一个要提一点的是,action要返回promise。这样我们就可以使用asyncData方法获取组件数据了
const actions = {
getArticleList ({state, commit}, curPageNum) {
commit(FETCH_ARTICLE_LIST, curPageNum)
// action 要返回promise
return apis.getArticleList({
data: {
size: state.pagi.itemsPerPage,
page: curPageNum
}
}).then((res) => {
// ...
})
}
}
// 组件asyncData实现
export default {
asyncData ({ store }) {
return store.dispatch('getArticleList', 1)
}
}
3. SSR服务器实现
在完成构建和代码改造后,如果一切顺利。我们能得到下面的打包文件:
这时,我们可以开始实现SSR服务端代码了。下面是我博客SSR实现(基于Koa)
// server.js
const Koa = require('koa')
const path = require('path')
const logger = require('./logger')
const server = new Koa()
const { createBundleRenderer } = require('vue-server-renderer')
const templateHtml = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')
let distPath = './dist'
const renderer = createBundleRenderer(require(`${distPath}/vue-ssr-server-bundle.json`), {
runInNewContext: false,
template: templateHtml,
clientManifest: require(`${distPath}/vue-ssr-client-manifest.json`)
})
server.use(function * (next) {
let ctx = this
const context = { url: ctx.req.url, pageTitle: 'default-title' }
// cgi请求,前端资源请求不能转到这里来。这里可以通过nginx做
if (/.w+$/.test(context.url)) {
return yield next
}
// 注意这里也必须返回promise
return new Promise((resolve, reject) => {
renderer.renderToString(context, function (err, html) {
if (err) {
logger.error(`[error][ssr-error]: ` + err.stack)
return reject(err)
}
ctx.status = 200
ctx.type = 'text/html; charset=utf-8'
ctx.body = html
resolve(html)
})
})
})
// 错误处理
server.on('error', function (err) {
logger.error('[error][server-error]: ' + err.stack)
})
let port = 80
server.listen(port, () => {
logger.info(`[info]: server is deploy on port: ${port}`)
})
4. 服务器部署
服务器部署,跟你的项目架构有关。比如我的博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:
5. 遇到的问题及解决办法
加载不到组件的JS文件
[vue-router] Failed to resolve async component default: Error: Cannot find module 'jsmain1.js'
[vue-router] uncaught error during route navigation:
解决办法:
去掉webpack配置中的output.chunkFilename: getFileName('js/main[name]-$hash.js')
if you are using CommonsChunkPlugin, make sure to use it only in the client config because the serverbundle requires a single entry chunk.
所以对webpack.server.js不要对配置CommonsChunkPlugin,也不要设置output.chunkFilename
代码高亮codeMirror使用到navigator对象,只能在浏览器环境运行
把执行逻辑放到mounted回调中。实现不行,就封装一个异步组件,把组件的初始化放到mounted中:
mounted () {
let paragraph = require('./paragraph.vue')
Vue.component('paragraph', paragraph)
new Vue().$mount('#paragraph')
},
串数据
dispatch的action没有返回promise,保证返回promise即可
路由跳转
路由跳转使用router方法或<router-link />标签,这两种方式能自适应浏览器端和服务端,不要使用a标签