zoukankan      html  css  js  c++  java
  • Vue项目SSR改造实战

    我们先看“疗效”,你可以打开我的博客u3xyz.com,通过查看源代码来看SSR直出效果。我的博客已经快上线一年了,但不吹不黑,访问量非常地小,我也一直在想办法提升访问量(包括在sf写文章,哈哈)。当然,在PC端,搜索引擎一直都是一个重要的流量来源。这里就不得不提到SEO。下图是我的博客以前在百度的快照:

    SSR前快照

    细心的朋友会发现,这个快照非常简单,简单到几乎什么都没有。这也是没办法的事,博客是基于Vue的SPA页面,整个项目本来就是一个“空架子”,这个快照从博客2月份上线以来就一直是上面的样子,直到最近上线SSR。搜索引擎蜘蛛每次来抓取你的网站都是一个样子,慢慢得,它也就不会来了,相应的,网站的权重,排名肯定不会好。到目前为此,我的博客不用网址进行搜索都搜不到。在上线了SSR后,再加上一些SEO优化,百度快照终于更新了:

    SSR后快照

    为什么要做SSR

    文章开始基本已经回答了为什么要做SSR这个问题,当然,还有另一个原因是SSR概念现在在前端非常火,无奈在实际项目中没有机会,也只有拿博客来练手了。下面将详细介绍本博客项目SSR全过程。

    SSR改造实战

    总的来说SSR改造还是相当容易的。推荐在动手之前,先了解官方文档官方Vue SSR Demo,这会让我们事半功倍。

    1. 构建改造

    VueSSR原理

    上图是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服务器实现

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

    dist文件

    这时,我们可以开始实现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用于请求转发:

    u3xyz架构

    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标签

     

  • 相关阅读:
    Spring Security 记住我功能 详解
    浅谈前端SPA(单页面应用)
    Token问什么可以避免CSRF/XSRF?
    总结 XSS 与 CSRF 两种跨站攻击
    localStorage,sessionStorage和cookie的区别及使用
    cookie,token验证的区别
    彻底弄懂session,cookie,token
    HTTP cookies 详解
    纯css3实现文字间歇滚动效果
    我的less学习之路
  • 原文地址:https://www.cnblogs.com/zjw2004112/p/11821871.html
Copyright © 2011-2022 走看看