Published on

基于 Webpack 实现文件预取

序言

日常开发中,我们经常会遇到一些性能优化的问题。其中,文件预取是一个非常重要的优化手段。在进行相应的方案设计时,我们需要对当前项目结构及组成有相应的认知,以便选择合适的优化策略。

什么是文件预取

文件预取是指在浏览器空闲时,提前加载下一个页面所需的资源。这样可以提高页面加载速度,提升用户体验。

基于 Webpack 项目实现文件预取

由于该优化项目是通过 webpack 进行构建的,所以我们一切的优化都需要基于 webpack 来进行。

在获取到文件预取需要我们首先获取到文件列表,然后对文件列表进行遍历以使用。

webpack-assets-manifest

(webpack-assets-manifest)[https://github.com/webdeveric/webpack-assets-manifest/blob/master/src/WebpackAssetsManifest.js] 可以直接获取到 webpack 的所有构建物,简单使用方式如下:

const path = require('path')
const WebpackAssetsManifest = require('webpack-assets-manifest')

module.exports = {
  entry: {
    // Your entry points
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name]-[hash].js',
    chunkFilename: '[id]-[chunkhash].js',
  },
  module: {
    // Your loader rules go here.
  },
  plugins: [
    new WebpackAssetsManifest({
      // Options go here
    }),
  ],
}

构建完成后可获取到的产出物类型如下:

type RouteManifest = Record<string, string>

webpack-route-manifest

(webpack-route-manifest)https://github.com/lukeed/webpack-route-manifest 可以基于路由返回 webpack 构建物列表,简单使用方式如下:

// webpack.config.js
const RouteManifest = require('webpack-route-manifest')

module.exports = {
  // ...
  plugins: [
    new RouteManifest({
      routes(str) {
        // Assume all entries are '../../../pages/Home' format
        let out = str.replace('../../../pages', '').toLowerCase()
        if (out === '/article') return '/blog/:title'
        if (out === '/home') return '/'
        return out
      },
    }),
  ],
}

构建完成后可获取到的产出物类型如下:

type RouteManifest = Record<
  string,
  {
    type: 'script' | 'style'
    href: string
  }[]
>

webpack optimization runtimeChunk

在 webpack 文档中,提供了 (分离 manifest)[https://survivejs.com/webpack/optimizing/separating-runtime/] 的文章,该文章初始对我产生了一定的误解,期望通过读取分离的 manifest 文件后对内容进行分析进行预取。

但在实际操作后,发现该文件返回的是一个 .js 文件,且无法在当次构建时读取该文件。

prefetch

prefetch & preload

prefetch 和 preload 的区别在于,preload 会在当前页面加载时立即加载资源,而 prefetch 会在浏览器空闲时加载资源。所以根据当前场景,使用 prefetch 更合适

方案 1 prefetch 所有文件

prefetch 部分的代码就相对简单,我们已经获取到了文件列表,遍历插入即可

import axios from 'axios'

import { delay } from './util'

const getAssetManifest = () => {
  return axios.get < Record < string, string >> `/asset-manifest.json?t=${Date.now()}`
}

const DELAY_TIME = 15e3

export const prefetchPath = async () => {
  /** 等待 DELAY_TIME 后再开始执行,错峰执行 */
  Promise.all([getAssetManifest(), delay(DELAY_TIME)]).then(([res]) => {
    Object.values(res.data).forEach((file) => {
      const link = document.createElement('link')

      link.rel = 'prefetch'

      // 判断文件后缀,css, js 提前加载
      if (file.endsWith('.css') || file.endsWith('.js')) {
        link.href = pathPrefix + file
        document.head.appendChild(link)
      }
    })
  })
}

方案 2 在链接 hover 时进行 prefetch

import axios from 'axios'
import rmanifest, { FileMap } from 'route-manifest'

// 可以考虑存储于 localStorage
let localRouterManifest: FileMap | null = null

const getRouterManifest = (): Promise<FileMap> => {
  if (localRouterManifest) {
    return Promise.resolve(localRouterManifest)
  }

  return axios.get(`/route-manifest.json?t=${Date.now()}`).then((res) => {
    localRouterManifest = res.data
    return res.data
  })
}

const getPathFiles = async (path: string) => {
  const routerManifest = await getRouterManifest()

  return ['/index.js', '/index.jsx', '/index.tsx', '.js', '.jsx', '.tsx']
    .map((suffix) => path + suffix)
    .flatMap((filePath) => rmanifest(routerManifest, filePath).files)
    .map((i) => ({
      type: i.type,
      href: pathPrefix + i.href,
    }))
}

const toPrefetch = new Set()

/**
 * 基于 [webpack-route-manifest](https://github.com/lukeed/webpack-route-manifest) 及 [quickLink](https://github.com/GoogleChromeLabs/quicklink) 实现的路由文件预取
 */
export const prefetchPath = async (path: string) => {
  const files = await getPathFiles(path)

  files.forEach((file) => {
    if (toPrefetch.has(file.href)) {
      return
    }

    toPrefetch.add(file.href)

    const link = document.createElement('link')

    link.rel = 'preload'
    link.href = file.href

    if (file.type === 'script') {
      link.as = 'script'
    } else if (file.type === 'style') {
      link.as = 'style'
    }

    // append the link tag to the head of the document
    document.head.appendChild(link)
  })
}

// 为 a 标签添加 hover 后执行 prefetchPath 的代码

方案对比

  • 方案 1 适用于所有文件的预取,但可能会导致一些不必要的资源浪费
  • 方案 2 适用于路由文件的预取,但对于部分没有路由的文件无法进行预取,例如通过 import() 异步加载的文件