多頁應用增量更新靜態資源Webpack打包方案

陳峰163發表於2018-09-12

自從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

相關文章