2017-02-04 11:34:28

vue2服务端渲染:按需分块加载的首屏优化策略

本文介绍一种SSR与按需分块加载的首屏优化策略,可以达到将所有路由全部按需分块加载,并且完全不影响首屏速度的效果。

背景

SSR与按需分块加载,博主最初使用下来感觉其实意义有限

假如全部路由都使用按需分块加载,那么首屏时间很可能不降反增,因为首屏对应的组件也同样要在客户端通过jsonp生成async script标签并异步获得。生成的标签如下:

<script type="text/javascript" charset="utf-8" async="" src="/dist/4.5e0b0c5a53f9c9a9cbfd.js"></script>

这会严重影响首屏加载速度。

假如又只有一小部分不常被访问的路由需要使用按需分块加载,那么能节省的代码量可能非常小,以博客为例,vue全家桶加一些其他的依赖占了几乎80%的体积

.2.1.10@vue: 157.97 KB (39.3%)
.2.2.0@vue-router: 54.82 KB (13.6%)
.0.15.3@axios: 35.04 KB (8.72%)
.0.5.3@vue-meta: 29.31 KB (7.29%)
.2.1.1@vuex: 20.49 KB (5.10%)
.2.0.0@vue-style-loader: 6.79 KB (1.69%)
.0.11.9@process: 5.17 KB (1.29%)
.0.25.0@css-loader: 1.47 KB (0.366%)
.10.3.0@vue-loader: 1.09 KB (0.271%)
.3.0.0@vuex-router-sync: 981 B (0.238%)
.2.2.1@webpack: 509 B (0.124%)
<self>: 88.29 KB (22.0%)

除非SPA自身的代码超过1M,否则那分出去的一小部分路由就只有几十KB,效果堪称鸡肋。

那么,有没有一个办法,既能让所有路由全部按需分块加载,并且完全不影响首屏速度的效果呢?

答案是有的,本文就是介绍如何使用SSR的服务端,来达到这样的效果。

博主称之为按需分块加载的首屏优化策略

Demo

目前,本博客的所有页面均使用了这种首屏优化策略,读者可以自行刷新页面,可以发现,没有任何异步标签被jsonp生成。

实际上,所有路由都被分出去后,app.js从533KB降到了504KB(未gzip前),刚好减小了一个vue-meta的大小,博主之所有做这个优化的原因,也是因为使用了vue-meta后app.js增加的代码太多,希望能通过分块来减小打包大小。

从结果来说,效果刚好达到博主的期望

流程

本文策略的流程如下

  • 服务器启动时将所有分块文件读进内存
  • 首屏访问时解析路由对应的组件,拿到该组件被分块出去的文件名称
  • 根据名称获取内容后,直接和全局状态window.__INITIALSTATE一同写回response
  • 在客户端手动做合并分块的操作
  • 简单修改异步组件

由于开发环境做这种首屏优化意义相当小,因为前端工程师开发时关注的并不是加载速度,所以这也是一个正式环境下独享的一种优化策略。

服务器启动时将所有分块文件读进内存

这里判定名称数组长度为3的是分块文件,依据是本博客webpack打包时加入了hash值,格式为name.[hash].js
如果读者没有加入hash,自行修改数组长度为2即可。

const isProd = process.env.NODE_ENV === 'production'

const chunkObj = {}
if (isProd) {
  const fileArr = fs.readdirSync('./dist')
  for (let i = 0, len = fileArr.length; i < len; i++) {
    const fileName = fileArr[i]
    const arr = fileName.split('.')
    if (arr.length === 3 && arr[0] !== 'app') {
      const input = fs.readFileSync(`./dist/${fileName}`, 'utf-8')
      chunkObj[fileName] = input
    }
  }
}

首屏访问时解析路由对应的组件,拿到该组件被分块出去的文件名称

这一步是如果不知道分块命名的话是非常难的,原因在于,System.import导入的多个异步组件时,他们的导入顺序是无序的,因为都是ES6 Module异步导入,至少博主没有发现这个顺序有算出来的办法。

另外,异步组件只能在客户端中拿到分块的名称,此时导入异步组件的函数如下:

function () { 
  return __webpack_require__.e/* import() */(4).then(__webpack_require__.bind(null, 51));
}

可以使用webpack命名分块的功能来实现这一点, 我专门列了一篇文章

在上面这个链接的情况下, 需要定义好分块的名称

if (!process.BROWSER) {
  Post.chunkName = Page.chunkName = 'CreatePostView'
  TagPager.chunkName = 'TagPager'
  Tag.chunkName = 'Tag'
  BlogPager.chunkName = 'BlogPager'
  Archive.chunkName = 'Archive'
}

根据名称获取内容后,拼好HTML写回response

修改server-entry.js

  return Promise.all(router.getMatchedComponents().map((component, index) => {
    // 新增如下四句
    const chunkName = component.chunkName
    if (typeof chunkName === 'string') {
      context.chunkName = chunkName
    }
    if (component.preFetch) {
      return component.preFetch(store, context).catch(() => {})
    }
  })).then((arr) => {
    isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)

    context.initialState = store.state
    return realApp
  })

修改renderStream的end事件

    renderStream.on('end', () => {
      if (context.initialState) {
        res.write(
          `<script>window.__INITIAL_STATE__=${
          JSON.stringify(context.initialState)
          }</script>`
        )
      }

      let tail = html.tail
      if (isProd && typeof context.chunkName === 'string') {
        for (let key in chunkObj) {
          if (key.split('.')[0] === context.chunkName) {
            const chunk = `<script type="text/javascript" charset="utf-8">${chunkObj[key]}</script></body>`
            tail = tail.replace('</body>', chunk)
            break
          }
        }
      }

      res.end(tail)
      log.debug(`whole request: ${Date.now() - s}ms`)
    })

在客户端手动做合并分块的操作

这一步也是难点,相当坑,坑死人。

博主的webpack配置中,由于要使用tree-shaking来省下20KB左右的打包大小,因此没有分vendor公共块出去。

如果分了vendor,那么直接把上一步生成的script标签迁移到vendor块的script标签之后和app块的script标签之前,就可以保证在vue运行前执行了我们生成好的那个分块了。

但是既然没有分块,所有代码都在app.js中,那么就需要延迟vue运行,以确保app.js标签后面的我们追加的script标签内容比vue先运行

否则vue-router会提前发现该组件是异步组件,然后它就自己帮我们调用webpackJsonp生成好async script标签了,这本来就是本文要避免的。

延迟app根实例的产生

const isProd = process.env.NODE_ENV === 'production'

const appOption = {
  router,
  store,
  ...App
}

let app
if (isProd === false) {
  app = new Vue(appOption)
}

let preFetchComponent = [
  Sidebar
]

export { app, appOption, router, store, preFetchComponent, isProd }

在服务端生成根实例

本文和服务端入口文件没关系,直接生成

import Vue from 'vue'
import { app, appOption, router, store, preFetchComponent, isProd } from './main'

const isDev = process.env.NODE_ENV !== 'production'

let realApp = isProd ? new Vue(appOption) : app

延迟客户端根实例的产生与挂载

import Vue from 'vue'
import { app, appOption, router, store, isProd } from './main'

const callback = isProd ? setTimeout : router.onReady.bind(router)
if (isProd) {
  store.state.isLoadingAsyncComponent = true
}

callback(() => {
  if (isProd) store.state.isLoadingAsyncComponent = false
  let realApp = isProd ? new Vue(appOption) : app

  window.__INITIAL_STATE__.route.hash = window.location.hash
  store.replaceState(window.__INITIAL_STATE__)
  realApp.$mount('#app')
})

router.onReady为vue-router 2.2.0的新增方法,在客户端异步组件加载完时触发,我们只在开发环境调用这个回调,用来确保客户端hydrate出来的HTML和SSR出来的一致。

到了正式环境,直接用setTimeout来延迟加载了。

简单修改异步组件

修改全局状态

添加如下的状态

isLoadingAsyncComponent: false

对应修改异步组件

  preFetch: fetchAchieves,
  beforeMount () {
    this.isLoadingAsyncComponent && this.$root._isMounted && fetchAchieves(this.$store, this.$route)
  }

不用这个状态来标志的话,客户端组件会在SSR数据已经存在的情况下再次获取组件,造成浪费

选择性优化客户端的router钩子

如果你使用了router的两个钩子,建议做如下处理

router.afterEach

router.afterEach((to, from) => {
  // should delay it to get the correct title generated by vue-meta
  from.name && clientGoogleAnalyse(to.path)
})

通过本文的延迟app挂载这种方法时,router.afterEach会在初始时被vue-router触发,博主猜测应当是vue处理异步组件后自行触发的,因此需要一个标志来避免触发。

由于本博客所有路由都是具名路由,而这种触发时from.name会为null,据此可以做过滤

router.beforeEach

异步组件的存在,导致数据获取的流程发生了变化,首先是异步组件被加载好,再触发异步组件的preFetch钩子进行数据获取,最后根据数据是否正常判断是否该进入路由。

如果不按这个流程来,会出现新路由数据尚未获取到就进入了该组件的情况,当数据拿到时会发生一次闪屏,不仅无法控制路由,而且用户体验非常差。

读者可以参考如下的数据获取流程

router.beforeEach((to, from, next) => {
  // 仅hash变化时,直接进入路由
  if (to.path === from.path && to.hash !== from.hash) {
    return next()
  }

  // Loading组件开始加载
  let loadingPromise = store.dispatch('START_LOADING')
  let endLoadingCallback = () => {
    return loadingPromise.then(interval => {
      clearInterval(interval)
      store.dispatch('SET_PROGRESS', 100)
      next()
    })
  }

  // 根据本博客的路由定义,一定有一个组件能够被匹配到
  let component = router.getMatchedComponents(to.fullPath)[0]

  // 如果是一个异步组件,我们手动resolve它,再接着调用它的preFetch接口,这样可以避免闪屏
  // 异步组件也是一个函数,但是它没有options这个值,可以据此做条件判断
  if (typeof component === 'function' && !component.options) {
    return new Promise((resolve, reject) => {
      // 一个resolve函数
      const _resolve = realComponent => {
        resolve(realComponent)
      }
      // 对普通异步组件来说,它会在第一层中被直接resolve,因此直接将resolve函数传给它调用
      let res = component(_resolve)
      // 对工厂模式的组件生成函数来说,它第一层中产生的只是工厂函数,组件是在第二步中产生的
      // 这种工厂函数是thenable的,因此将resolve函数用then传给第二层调用
      if (res && res.then) {
        res.then(_resolve)
      }
    }).then(component => {
      // 这里的component就是异步组件加载完毕后的真实组件了,开始调用数据获取函数
      return letsGo(component, store, to, endLoadingCallback)
    })
  }
  // 调用数据获取函数
  letsGo(component, store, to, endLoadingCallback)
})

// 数据获取函数
function letsGo (component, store, to, endLoadingCallback) {
  // 如果preFetch钩子存在,调用这个钩子,并将endLoadingCallback这个停止loading并进入路由的回调传给它
  if (component && component.preFetch) {
    return component.preFetch(store, to, endLoadingCallback).catch(err => {
      console.error(Date.now().toLocaleString(), err)
    })
  } else {
    // 如果preFetch钩子不存在,说明这是一个静态页面,直接导航进入。
    endLoadingCallback()
  }
}

这里给出preFetch的模板

function preFetch (store, { path: pathName, params, query }, callback) {
  return store.dispatch('FETCH_TAGS', {
    model: 'post',
    query: {
      conditions: {
        type: 'post',
        isPublic: true
      },
      select: {
        _id: 0,
        tags: 1
      }
    },
    callback
  })
}

这种模板既能让它在SSR中被调用,又可以在客户端中被路由权限控制调用,十分有用

最后,在action中手动调用callback这个参数,即可停止loading并进入路由了

结语

本文介绍的这种所有路由全部按需分块加载并完全不影响首屏速度的策略,是首屏优化中极少有人关注的。

然而,它的效果十分强大,按这种策略优化首屏之后,配合SSR的SPA可以任意扩展体积与功能,并且首屏渲染将和SPA只有首屏页面时几乎一模一样。例如,本博客现在就可以毫无顾忌地开始做搜索页面了。

最后,非常明显的是,本文的策略会带来一个问题,即非首屏的访问速度将会被拖慢,因为需要浏览器自行发起jsonp请求。解决办法其实也比较简单,只需要在首屏加载完成后让浏览器自行请求并缓存好对应的分块即可,这要求我们在构建时就必须得知分块的路径,并且需要搭配一个超强的请求拦截器,这也正是博主下一篇文章的内容。

本文链接:https://smallpath.me/post/vue2-ssr-on-demand-code-splitting-opti

-- EOF --