查看原文
其他

Vue 项目 SSR 改造实战

前端大全 2019-11-13

(点击上方公众号,可快速关注)


作者:u3xyz

http://u3xyz.com/detail/29


前言

我们先看“疗效”,你可以打开我的博客 (www.u3xyz.com),通过查看源代码来看SSR直出效果。我的博客已经快上线一年了,但不吹不黑,访问量非常地小,我也一直在想办法提升访问量。当然,在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的配置。核心配置如下:


  1. const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

  2. <br>

  3. // ...

  4. const config = merge(baseConfig, {

  5.  target: 'web',

  6.  entry: './src/entry.client.js',

  7.  plugins: [

  8.    new webpack.DefinePlugin({

  9.      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),

  10.      'process.env.VUE_ENV': '"client"'

  11.    }),

  12.    new webpack.optimize.CommonsChunkPlugin({

  13.      name: 'vender',

  14.      minChunks: 2

  15.    }),

  16.    // extract webpack runtime & manifest to avoid vendor chunk hash changing

  17.    // on every build.

  18.    new webpack.optimize.CommonsChunkPlugin({

  19.      name: 'manifest'

  20.    }),

  21.    new VueSSRClientPlugin()

  22.  ]

  23. })


  • webpack.server.js 是生成Server Bundle的配置,核心配置如下:


  1. const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

  2. // ...

  3. const config = merge(baseConfig, {

  4.  target: 'node',

  5.  devtool: '#source-map',

  6.  entry: './src/entry.server.js',

  7.  output: {

  8.    libraryTarget: 'commonjs2',

  9.    filename: 'server-bundle.js'

  10.  },

  11.  externals: nodeExternals({

  12.    // do not externalize CSS files in case we need to import it from a dep

  13.    whitelist: /\.css$/

  14.  }),

  15.  plugins: [

  16.    new webpack.DefinePlugin({

  17.      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),

  18.      'process.env.VUE_ENV': '"server"'

  19.    }),

  20.    new VueSSRServerPlugin()

  21.  ]

  22. })

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. // app.js

  2. import Vue from 'vue'

  3. import App from './App.vue' // 根组件

  4. import {createRouter} from './routers/index'

  5. import {createStore} from './vuex/store'

  6. import {sync} from 'vuex-router-sync' // 把当VueRouter状态同步到Vuex中

  7. // createApp工厂方法

  8. export function createApp (ssrContext) {

  9.  let router = createRouter() // 创建全新router实例

  10.  let store = createStore() // 创建全新store实例

  11.  // 同步路由状态到store中

  12.  sync(store, router)

  13.  // 创建Vue应用

  14.  const app = new Vue({

  15.    router,

  16.    store,

  17.    ssrContext,

  18.    render: h => h(App)

  19.  })

  20.  return {app, router, store}

  21. }


  1. // entry.client.js

  2. import Vue from 'vue'

  3. import { createApp } from './app'

  4. const { app, router, store } = createApp()

  5. // 如果有__INITIAL_STATE__变量,则将store的状态用它替换

  6. if (window.__INITIAL_STATE__) {

  7.  store.replaceState(window.__INITIAL_STATE__)

  8. }

  9. router.onReady(() => {

  10.  // 通过路由勾子,执行拉取数据逻辑

  11.  router.beforeResolve((to, from, next) => {

  12.    // 找到增量组件,拉取数据

  13.    const matched = router.getMatchedComponents(to)

  14.    const prevMatched = router.getMatchedComponents(from)

  15.    let diffed = false

  16.    const activated = matched.filter((c, i) => {

  17.      return diffed || (diffed = (prevMatched[i] !== c))

  18.    })

  19.    // 组件数据通过执行asyncData方法获取

  20.    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)

  21.    if (!asyncDataHooks.length) {

  22.      return next()

  23.    }

  24.    // 要注意asyncData方法要返回promise,asyncData调用的vuex action也必须返回promise

  25.    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))

  26.      .then(() => {

  27.        next()

  28.      })

  29.      .catch(next)

  30.  })

  31.  // 将Vue实例挂载到dom中,完成浏览器端应用启动

  32.  app.$mount('#app')

  33. })


  1. // entry.server.js

  2. import { createApp } from './app'

  3. export default context => {

  4.  return new Promise((resolve, reject) => {

  5.    const { app, router, store } = createApp(context)

  6.    // 设置路由

  7.    router.push(context.url)

  8.    router.onReady(() => {

  9.      const matchedComponents = router.getMatchedComponents()

  10.      if (!matchedComponents.length) {

  11.        return reject({ code: 404 })

  12.      }

  13.      // 执行asyncData方法,预拉取数据

  14.      Promise.all(matchedComponents.map(Component => {

  15.        if (Component.asyncData) {

  16.          return Component.asyncData({

  17.            store: store,

  18.            route: router.currentRoute

  19.          })

  20.        }

  21.      })).then(() => {

  22.        // 将store的快照挂到ssr上下文上

  23.        context.state = store.state

  24.        resolve(app)

  25.      }).catch(reject)

  26.    }, reject)

  27.  })

  28. }


  1. // createStore

  2. import Vue from 'vue'

  3. import Vuex from 'vuex'

  4. // ...

  5. Vue.use(Vuex)

  6. // createStore工厂方法

  7. export function createStore () {

  8.  return new Vuex.Store({

  9.    // rootstate

  10.    state: {

  11.      appName: 'appName',

  12.      title: 'home'

  13.    },

  14.    modules: {

  15.      // ...

  16.    },

  17.    strict: process.env.NODE_ENV !== 'production' // 线上环境关闭store检查

  18.  })

  19. }


  1. // createRouter

  2. import Vue from 'vue'

  3. import Router from 'vue-router'

  4. Vue.use(Router)

  5. // createRouter工厂方法

  6. export function createRouter () {

  7.  return new Router({

  8.    mode: 'history', // 注意这里要使用history模式,因为hash不会发送到服务端

  9.    fallback: false,

  10.    routes: [

  11.      {

  12.        path: '/index',

  13.        name: 'index',

  14.        component: () => System.import('./index/index.vue') // 代码分片

  15.      },

  16.      {

  17.        path: '/detail/:aid',

  18.        name: 'detail',

  19.        component: () => System.import('./detail/detail.vue')

  20.      },

  21.      // ...

  22.      {

  23.        path: '/',

  24.        redirect: '/index'

  25.      }

  26.    ]

  27.  })

  28. }

3. 重构组件获取数据方式

关于状态管理,要严格遵守Redux思想。建议把应用所有状态都存于store中,组件使用时再mapState下来,状态更改严格使用action的方式。另一个要提一点的是,action要返回promise。这样我们就可以使用asyncData方法获取组件数据了

  1. const actions = {

  2.  getArticleList ({state, commit}, curPageNum) {

  3.    commit(FETCH_ARTICLE_LIST, curPageNum)

  4.    // action 要返回promise

  5.    return apis.getArticleList({

  6.      data: {

  7.        size: state.pagi.itemsPerPage,

  8.        page: curPageNum

  9.      }

  10.    }).then((res) => {

  11.      // ...

  12.    })

  13.  }

  14. }

  15. // 组件asyncData实现

  16. export default {

  17.  asyncData ({ store }) {

  18.    return store.dispatch('getArticleList', 1)

  19.  }

  20. }

3. SSR服务器实现

在完成构建和代码改造后,如果一切顺利。我们能得到下面的打包文件:

这时,我们可以开始实现SSR服务端代码了。下面是我博客SSR实现(基于Koa)

  1. // server.js

  2. const Koa = require('koa')

  3. const path = require('path')

  4. const logger = require('./logger')

  5. const server = new Koa()

  6. const { createBundleRenderer } = require('vue-server-renderer')

  7. const templateHtml = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')

  8. let distPath = './dist'

  9. const renderer = createBundleRenderer(require(`${distPath}/vue-ssr-server-bundle.json`), {

  10.  runInNewContext: false,

  11.  template: templateHtml,

  12.  clientManifest: require(`${distPath}/vue-ssr-client-manifest.json`)

  13. })

  14. server.use(function * (next) {

  15.  let ctx = this

  16.  const context = { url: ctx.req.url, pageTitle: 'default-title' }

  17.  // cgi请求,前端资源请求不能转到这里来。这里可以通过nginx做

  18.  if (/\.\w+$/.test(context.url)) {

  19.    return yield next

  20.  }

  21.  // 注意这里也必须返回promise  

  22.  return new Promise((resolve, reject) => {

  23.    renderer.renderToString(context, function (err, html) {

  24.      if (err) {

  25.        logger.error(`[error][ssr-error]: ` + err.stack)

  26.        return reject(err)

  27.      }

  28.      ctx.status = 200

  29.      ctx.type = 'text/html; charset=utf-8'

  30.      ctx.body = html

  31.      resolve(html)

  32.    })

  33.  })

  34. })

  35. // 错误处理

  36. server.on('error', function (err) {

  37.  logger.error('[error][server-error]: ' + err.stack)

  38. })

  39. let port = 80

  40. server.listen(port, () => {

  41.  logger.info(`[info]: server is deploy on port: ${port}`)

  42. })

4. 服务器部署

服务器部署,跟你的项目架构有关。比如我的博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:

5. 遇到的问题及解决办法

加载不到组件的JS文件

  1. [vue-router] Failed to resolve async component default: Error: Cannot find module 'js\main1.js'

  2. [vue-router] uncaught error during route navigation:

解决办法:

去掉webpack配置中的output.chunkFilename: getFileName('js/main[name]-$hash.js')

  1. 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中:

  1. mounted () {

  2.  let paragraph = require('./paragraph.vue')

  3.  Vue.component('paragraph', paragraph)

  4.  new Vue().$mount('#paragraph')

  5. },

串数据

dispatch的action没有返回promise,保证返回promise即可

路由跳转

路由跳转使用router方法或标签,这两种方式能自适应浏览器端和服务端,不要使用a标签

小结

本文主要记录了我的博客SSR过程:

  • 构建webpack改造

  • 代码改造

  • server端SSR实现

  • 上线部署

最后希望文章能对大家有些许帮助!


【关于投稿】


如果大家有原创好文投稿,请直接给公号发送留言。


① 留言格式:
【投稿】+《 文章标题》+ 文章链接

② 示例:
【投稿】《不要自称是程序员,我十多年的 IT 职场总结》:http://blog.jobbole.com/94148/

③ 最后请附上您的个人简介哈~




觉得本文对你有帮助?请分享给更多人

关注「前端大全」,提升前端技能

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存