博客已全站升级到https,若是遇到没法访问,请手动加上 https://前缀
咱们先看“疗效”,你能够打开个人博客u3xyz.com,经过查看源代码来看SSR直出效果。个人博客已经快上线一年了,但不吹不黑,访问量很是地小,我也一直在想办法提高访问量(包括在sf写文章,哈哈)。固然,在PC端,搜索引擎一直都是一个重要的流量来源。这里就不得不提到SEO。下图是个人博客之前在百度的快照:css
细心的朋友会发现,这个快照很是简单,简单到几乎什么都没有。这也是没办法的事,博客是基于Vue的SPA页面,整个项目原本就是一个“空架子”,这个快照从博客2月份上线以来就一直是上面的样子,直到最近上线SSR。搜索引擎蜘蛛每次来抓取你的网站都是一个样子,慢慢得,它也就不会来了,相应的,网站的权重,排名确定不会好。到目前为此,个人博客不用网址进行搜索都搜不到。在上线了SSR后,再加上一些SEO优化,百度快照终于更新了:html
文章开始基本已经回答了为何要作SSR这个问题,固然,还有另外一个缘由是SSR概念如今在前端很是火,无奈在实际项目中没有机会,也只有拿博客来练手了。下面将详细介绍本博客项目SSR全过程。前端
总的来讲SSR改造仍是至关容易的。推荐在动手以前,先了解官方文档和官方Vue SSR Demo,这会让咱们事半功倍。vue
上图是Vue官方的SSR原理介绍图片。从这张图片,咱们能够知道:咱们须要经过Webpack打包生成两份bundle文件:node
无论你项目先前是什么样子,是不是使用vue-cli生成的。都会有这个构建改造过程。在构建改造这里会用到 vue-server-renderer 库,这里要注意的是 vue-server-renderer 版本要与Vue版本同样。下图是个人构建文件目录:webpack
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() ] })
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() ] })
可能你的项目没有使用VueRouter或Vuex。但遗憾的是,Vue-SSR必须基于 Vue + VueRouter + Vuex。Vuex官方没有提,但其实文档和Demo都是基于Vuex。个人博客之前也没有用Vuex,但通过一翻折腾后,仍是乖乖加上了Vuex。另外,由于代码要能同时在浏览器和Node.js环境中运行,因此ajax库建议使用axios这样的跨平台库。ios
每一个用户经过浏览器访问Vue页面时,都是一个全新的上下文,但在服务端,应用启动后就一直运行着,处理每一个用户请求的都是在同一个应用上下文中。为了避免串数据,须要为每次SSR请求,建立全新的app, store, router。nginx
上图是个人项目文件目录。git
再看一下具体实现的核心代码:github
// 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' } ] }) }
关于状态管理,要严格遵照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) } }
在完成构建和代码改造后,若是一切顺利。咱们能获得下面的打包文件:
这时,咱们能够开始实现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}`) })
服务器部署,跟你的项目架构有关。好比个人博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:
加载不到组件的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') },
串数据
dispatch的action没有返回promise,保证返回promise便可
路由跳转
路由跳转使用router方法或<router-link />标签,这两种方式能自适应浏览器端和服务端,不要使用a标签
本文主要记录了个人博客u3xyz.comSSR过程:
最后但愿文章能对你们有些许帮助!
愿文地址:Vue项目SSR改造实战