多页应用增量更新静态资源Webpack打包方案

4,607 阅读5分钟

自从vue、react或者angular这类框架流行后,单页应用的数量也越来越多。但是限制于单页应用的一些缺点,比如:seo、首屏时间等因素,很多应用的结构还是保持了多页面结构。此篇讲述的是如何在多页面应用结构的基础上,利用webpack生成带hashcode文件名的方式实现静态资源的增量更新方案。

多页应用的结构在用户访问时往往会在当前页面加载一些公共资源和当前页面的js和css,可能有些应用还在用比较传统的:

https://url/[版本号]/xxx.[js|css]

https://url/xxx.js?r=xxx

的方式来保证当应用更新时客户端也能及时获取到最新的资源文件。而当前流行的前端的架构中单页应用在发布时,往往可以通过编译时在生成的资源文件名中加入文件的hashcode值来保证每个资源都有自己独立的"版本号"。客户端加载带有hashcode文件名的资源文件,当某个资源文件更新时也不会影响其他资源文件的名称,可以有效利用客户端的强缓存策略,增加资源文件的缓存命中率。

下面我们将实现在多页架构中如何实现静态文件名加入hashcode,并在服务端引用文件的例子:

1.Webpack编译生成文件追加hashcode

webpack.conf.js

{
    entry: './app.js',
    output: {
        filename: 'js/[name].[chunkhash:7].js',
        chunkFilename: 'js/[name].[chunkhash:7].js',
    }
}

配置结束!

就是这么简单,当然这样配置只会在webpack打包出来的js文件名中加入文件的hashcode值,如果应用中的css也需要hashcode的话则需要在mini-css-extract-plugin插件中配置:

webpack.conf.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

{
    entry: './app.js',
    output: {
        filename: 'js/[name].[chunkhash:7].js',
        chunkFilename: 'js/[name].[chunkhash:7].js',
    },
    plugins: [
        new MiniCssExtractPlugin({
          filename: 'css/[name].[contenthash:7].css'
        })
    ]
}

在output中定义filename和chunkFilename的命名规则,filename是指配置项中entry入口文件的输出命名规则,chunkFilename是指在代码中chunk包输出文件的命名规则,譬如:require.ensure或import导入的异步包名称

小伙伴们可以发现配置中既使用了chunkhash又使用了contenthash,那其中有什么区别呢?

其中的chunkhash是指webpack在打包chunk块时,根据chunk块内容生成的hashcode文件内容不改变hash值不变。而css是通过js模块导入的,所以理论上css也属于js的内容部分,所以css内容改变时js的hash也会变化,但是我们可以通过contenthash让js文件改变时css文件hash不变。

2.生成Manifest静态资源文件清单

hash文件打包之后我们需要一份原文件名和带hash文件名的映射关系。

接下来我们需要为编译后的N多带hash的文件生成一份manifest清单,webpack-manifest-plugin插件可以做到这件事情 ,具体可以参考:github.com/danethurber…

webpack.conf.js

const ManifestPlugin = require('webpack-manifest-plugin')

{
    entry: './app.js',
    output: {
        filename: 'js/[name].[chunkhash:7].js',
        chunkFilename: 'js/[name].[chunkhash:7].js',
    },
    plugins: [
        new MiniCssExtractPlugin({
          filename: 'css/[name].[contenthash:7].css'
        }),
        new ManifestPlugin({
            fileName: 'manifest-x.x.x.json'
        })
    ]
}

插件支持generate函数可以自定义生成manifest.json文件的内容,fileName可以自定义manifest文件名称,建议文件名和业务版本号绑定。

打包编译后:

经过自定义后的manifest.json:


{
  "common": {
    "vendors": {
      "js": "//cdn.xxx.cn/js/vendors.fda30d2.js"
    },
    "main": {
      "js": "//cdn.xxx.cn/js/main.eeb79b4.js",
      "css": "//cdn.xxx.cn/css/main.58eaf53.css"
    }
  },
  "pages": {
    "product": {
      "detail": {
        "js": "//cdn.xxx.cn/js/page.product.detail.1bfd90d.js",
        "css": "//cdn.xxx.cn/css/page.page.product.detail.19743f3.css"
      }
    }
  }
}

3.服务端引用Manifest文件

清单文件生成后,服务端需要引用清单文件并对页面js做映射加载实际的带hashcode名的资源文件(所以清单文件需要和服务端应用一同发布,不同构建环境有不同的实现方式)。

我们的服务端应用是Nodejs的express框架,handlebars作为模板渲染引擎。下面讲述我们实现服务端读取的方式。

在每个请求的业务逻辑处理完毕后我们都需要调用一次res.render函数来选择模板文件和传入渲染模板所需要的数据。除了页面需要的渲染数据,我们也会把当前这个页面所以需要引用的js和css文件名一同传递到页面中(如果进入页面逻辑之前就可以确定页面所引用资源名称那下面就不用这么复杂了)。

res.render('search/goods-list', {
    module: 'product',
    page: 'search-list',
    data: {
        pageData: {}, // 页面数据
        pageName: 'product/search-list' // js和css名称(页面名称)
    }
});

但这里有一个小问题页面名称是在每个具体的页面业务逻辑中定义的(只有在调用render时才会传入),我们希望在业务逻辑之前添加一个读取清单文件的中间件,可在业务逻辑之前还没有确定页面名称。在业务逻辑之后的话,因为调用了res.render后续中间件也不会被执行,最后在具体业务逻辑中去调用读取清单文件更不合适。所以重写express的render方法,并在实际输出渲染内容之前以事件的方式把页面参数emit出来。

res.emit('beforeRender', {module, page, others});

这样我们可以在实际业务逻辑之前的中间件就可以注册这个事件,获取到页面名称后通过require的方式加载清单文件json,并找到页面映射的资源文件实际地址,最后把实际资源地址merge到渲染数据中,最后在handlebars中加载,下面示例仅供参考实际实际场景会更复杂一些:

middleware.js


const _ = require('lodash');
const manifest = require('path/manifest.json');

function getStatic(path) {
    return _.get(manifest, path);
}

module.exports = (req, res, next) => {
    res.on('beforeRender', (params) => {
        const {pageName} = params;

        res.renderData.statics = {
            name: `${pageName}`,
            styles: [
                getStatic(`common.main.css`),
                getStatic(`pages.${pageName}.css`)
            ],
            javascripts: [
                getStatic('common.vendors.js'),
                getStatic('common.main.js'),
                getStatic(`pages.${pageName}.js`)
            ]
        };
    });
    return next();
};

页面渲染layout模板:

layout.hbs

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title></title>
  {{#statics.preloads}}
    <link rel="preload" href="{{url}}" as="{{as}}">
  {{/statics.preloads}}
  {{#statics.styles}}
      <link rel="stylesheet" media="all" href="{{.}}">
  {{/statics.styles}}
</head>
<body>
  {{{body}}}
  {{#statics.javascripts}}
      <script src="{{.}}" crossorigin="anonymous"></script>
  {{/statics.javascripts}}
</body>
</html>

*可以在文档头部通过preload预先加载,提高资源加载速度。

到此为止我们已经实现了静态资源打包生成文件hashcode,node加载hashcode清单文件输出页面加载脚本了。

4.ServiceWorker的precache资源文件的hashcode问题

ps: 使用Service Worker技术的话极力推荐google的workbox框架:developers.google.com/web/tools/w… 可以更方便、更简单的解决Service Worker绝大多数问题。

预缓存代码:

sw.js:

self.workbox.precaching.precacheAndRoute([
    '/common.offline.js',
    '/common.offline.css'
]);

按照之前的构建方式这么写没问题,但是现有构建模式中文件名已经和文件hashcode绑定了,这里的文件名应该是带有hashcode的文件地址。我们也可以在sw.js中读取manifest.json文件来加载实际的文件地址,但这样显然不合适。

幸好workbox提供了webpack的workbox-webpack-plugin插件,可以通过其中的InjectManifest插件声明需要注入的chunks,生成一份precache-manifest清单,最后通过importScripts导入到现有的sw.js文件中:

webpack配置:

const {InjectManifest} = require('workbox-webpack-plugin');
const suffix = isDev ? 'dev' : 'prod';

new InjectManifest({
    importWorkboxFrom: 'disabled',
    swSrc: path.join(__dirname, 'path/sw.js'),
    swDest: isDev ? 'sw.js' : path.join(__dirname, 'dist/sw.js'),
    chunks: ['page.common.offline'],
    importScripts: [
        'https://cdn.xxx.cn/workbox/workbox-sw.js',
        `https://cdn.xxx.cn/workbox/workbox-core.${suffix}.js`,
        `https://cdn.xxx.cn/workbox/workbox-precaching.${suffix}.js`,
        `https://cdn.xxx.cn/workbox/workbox-routing.${suffix}.js`,
        `https://cdn.xxx.cn/workbox/workbox-cache-expiration.${suffix}.js`]
})

生成的precache-manifest.js文件:

self.__precacheManifest = [
  {
    "revision": "52d9fa25e9a080052ab2",
    "url": "//cdn.xxx.cn/js/page.common.offline.52d9fa2.js"
  },
  {
    "revision": "52d9fa25e9a080052ab2",
    "url": "//cdn.xxx.cn/css/page.common.offline.241a79d.css"
  }
];

sw.js文件中只需要一句:

self.workbox.precaching.precacheAndRoute(self.__precacheManifest);

最后编译的结果:


importScripts("https://cdn.xxx.cn/workbox/workbox-sw.js", "https://cdn.xxx.cn/workbox/workbox-core.prod.js", "https://cdn.xxx.cn/workbox/workbox-precaching.prod.js", "https://cdn.xxx.cn/workbox/workbox-routing.prod.js", "https://cdn.xxx.cn/workbox/workbox-strategies.prod.js", "https://cdn.xxx.cn/workbox/workbox-cache-expiration.prod.js", "https://cdn.xxx.cn/workbox/workbox-cacheable-response.prod.js", "//cdn.xxx.cn/precache-manifest.6f42fce0d1707a193aaa90b5f613205f.js");

self.workbox.precaching.precacheAndRoute(self.__precacheManifest);
/* 
some codes 
...
*/

站点资源的强缓存策略 All done!

下图可以大概说明目前的静态资源架构:

image-20180907103326142