为什么要做SSR 为了更好的seo抓取,当然,还有另一个原因是SSR概念现在在前端非常火。下面将详细介绍本博客项目SSR全过程。
SSR改造实战 总的来说SSR改造还是相当容易的。推荐在动手之前,先了解Vue 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的配置。核心配置如下:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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的配置,核心配置如下:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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模板 再看一下具体实现的核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 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} }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 // 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') })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // 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) }) }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 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检查 }) }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // 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方法获取组件数据了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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) } }
4. SSR服务器实现 在完成构建和代码改造后,如果一切顺利。我们能得到下面的打包文件:
这时,我们可以开始实现SSR服务端代码了。下面是我博客SSR实现(基于Koa)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 // 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; ' 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}`) })
5. 服务器部署 服务器部署,跟你的项目架构有关。比如我的博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:
6. 遇到的问题及解决办法 1 2 3 4 5 6 7 8 9 10 11 加载不到组件的JS文件 [vue-router] Failed to resolve async component default: Error: Cannot find module 'js\main1.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 server bundle 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’) },1 2 3 4 5 串数据 dispatch的action没有返回promise,保证返回promise即可 路由跳转 路由跳转使用router方法或标签,这两种方式能自适应浏览器端和服务端,不要使用a标签
注:本文转自u3xyz ,仅供本人学习存档。