Webpack外掛是如何編寫的——prerender-spa-plugin原始碼解析

hjava發表於2021-10-08

概述

本文主要的內容是通過之前使用的prerender-spa-plugin外掛的原始碼閱讀,來看下我們應該如何編寫一個webpack的外掛,同時瞭解下預渲染外掛到底是如何實現的。

這個內容其實已經在使用prerender-spa-plugin裡面有所涉及了,這一章的內容算是對之前一篇文章的補充和擴充,詳細介紹下Webpack的外掛機制到底是如何執行的,之前寫的簡單的替換的外掛生效的原理到底是什麼。

如果大家還沒有看之前的如何使用prerender-spa-plugin外掛對頁面進行預渲染這篇文章,可以先去看看,瞭解下這個外掛到底是做什麼的,我們的外掛大概是什麼樣的。

外掛原始碼淺析

prerender-spa-plugin是開源的,原始碼在GitHub上面可以看到,大家如果有興趣的話,可以自己點選看下。

首先,我們讓我們來簡單回顧下這個外掛是如何使用的,這個對於我們瞭解其內部構造,有一定的幫助。我們就直接使用它官方文件上提供的一個例子。

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      // Required - The path to the webpack-outputted app to prerender.
      staticDir: path.join(__dirname, 'dist'),
      // Required - Routes to render.
      routes: [ '/', '/about', '/some/deep/nested/route' ],
    })
  ]
}

從上面這個例子來看,我們可以知道這個外掛需要初始化一個例項,然後傳入對應的引數如輸出的路徑staticDir、需要渲染的路由routes等。

接下來,讓我們來簡單介紹下他的原始碼結構。程式碼具體分塊如下:

function PrerenderSPAPlugin (...args) {
  ...
}

PrerenderSPAPlugin.prototype.apply = function (compiler) {
  const afterEmit = (compilation, done) => {
    ...
  }
  
  if (compiler.hooks) {
    const plugin = { name: 'PrerenderSPAPlugin' }
    compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
  } else {
    compiler.plugin('after-emit', afterEmit)
  }
}

整個prerender-spa-plugin的外掛是由2大部分構成:

  1. 一個function函式,主要用於初始化資料獲取與處理。在使用這個外掛的過程中,我們需要先進行初始化。這個函式可以用來進行一些資料的處理和解析。
  2. 一個原型上的apply函式,作為一個鉤子函式,主要用於處理Webpack觸發外掛執行後,相關邏輯的處理。

下面,我們就基於prerender-spa-plugin外掛,來一個一個部分的看下。

初始化function函式

首先讓我們來看下初始化的function函式。這個函式主要做的是一些初始化引數獲取後的處理。具體程式碼如下:

function PrerenderSPAPlugin (...args) {
  const rendererOptions = {} // Primarily for backwards-compatibility.

  this._options = {}

  // Normal args object.
  if (args.length === 1) {
    this._options = args[0] || {}

  // Backwards-compatibility with v2
  } else {
    console.warn("[prerender-spa-plugin] You appear to be using the v2 argument-based configuration options. It's recommended that you migrate to the clearer object-based configuration system.\nCheck the documentation for more information.")
    let staticDir, routes

    args.forEach(arg => {
      if (typeof arg === 'string') staticDir = arg
      else if (Array.isArray(arg)) routes = arg
      else if (typeof arg === 'object') this._options = arg
    })

    staticDir ? this._options.staticDir = staticDir : null
    routes ? this._options.routes = routes : null
  }

  // Backwards compatiblity with v2.
  if (this._options.captureAfterDocumentEvent) {
    console.warn('[prerender-spa-plugin] captureAfterDocumentEvent has been renamed to renderAfterDocumentEvent and should be moved to the renderer options.')
    rendererOptions.renderAfterDocumentEvent = this._options.captureAfterDocumentEvent
  }

  if (this._options.captureAfterElementExists) {
    console.warn('[prerender-spa-plugin] captureAfterElementExists has been renamed to renderAfterElementExists and should be moved to the renderer options.')
    rendererOptions.renderAfterElementExists = this._options.captureAfterElementExists
  }

  if (this._options.captureAfterTime) {
    console.warn('[prerender-spa-plugin] captureAfterTime has been renamed to renderAfterTime and should be moved to the renderer options.')
    rendererOptions.renderAfterTime = this._options.captureAfterTime
  }

  this._options.server = this._options.server || {}
  this._options.renderer = this._options.renderer || new PuppeteerRenderer(Object.assign({}, { headless: true }, rendererOptions))

  if (this._options.postProcessHtml) {
    console.warn('[prerender-spa-plugin] postProcessHtml should be migrated to postProcess! Consult the documentation for more information.')
  }
}

因為我們的外掛使用的方式是例項化後新增(即new操作符例項化後使用),所以function函式的入參主要是將一些需要的引數繫結到this物件上,這樣例項化後,就可以獲取相關的引數。

很多的SDK或者說外掛相關的工具,因為能夠接受多種型別、不同長度的入參,因此會在一開始對引數型別進行判斷,確定傳入的引數型別到底是哪一種。

從程式碼中看,目前記錄的引數有輸出的引數staticDir、需要渲染的路由routes。如果自己定義了renderer函式,那麼也繫結儲存下來。同時,這個V3版本的程式碼還對V2版本進行了向前相容。

鉤子apply函式

說完了初始化的function,我們來看下最重要的apply函式。具體程式碼如下:

PrerenderSPAPlugin.prototype.apply = function (compiler) {
  const compilerFS = compiler.outputFileSystem

  // From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js
  const mkdirp = function (dir, opts) {
    return new Promise((resolve, reject) => {
      compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
    })
  }

  const afterEmit = (compilation, done) => {
    ...
  }

  if (compiler.hooks) {
    const plugin = { name: 'PrerenderSPAPlugin' }
    compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
  } else {
    compiler.plugin('after-emit', afterEmit)
  }
}

在說apply函式之前,我們先看下apply函式接收的引數compiler物件和mkdirp這個方法,以及生命週期繫結的程式碼。

complier物件

整個apply方法,接收的引數只有一個complier物件,詳細的內容我們可以看webpack中關於complier物件的描述,具體的原始碼可以見此處。我下面簡單介紹下:

complier物件是webpack提供的一個全域性的物件,這個物件上面掛載了一些在外掛生命週期中會使用到的功能和屬性,比如options、loader、plugin等。我們可以通過這個物件,在構建中獲取webpack相關的資料。

mkdirp方法

這個方法就是將執行mkdir -p方法的函式轉化成一個Promise物件。具體可以看程式碼上面的原文註釋。因為比較簡單,這裡我就不過多介紹了。

生命週期繫結

在最後,鉤子函式生命完成後,需要將其關聯到最近的生命週期上。這個外掛關聯的是afterEmit這個節點,大家如果想看下整個webpack相關構建流程的生命週期,可以參考這個文件

看完了簡單的部分,下面我們來看下最重點的鉤子函式。

鉤子函式

接下來,讓我們來看下這個外掛中最核心的鉤子函式。這個外掛的關聯的宣告週期是afterEmit這個節點,接下來我們來看下具體的程式碼。

const afterEmit = (compilation, done) => {
  const PrerendererInstance = new Prerenderer(this._options);

  PrerendererInstance.initialize()
    .then(() => {
      return PrerendererInstance.renderRoutes(this._options.routes || []);
    })
    // Backwards-compatibility with v2 (postprocessHTML should be migrated to postProcess)
    .then((renderedRoutes) =>
      this._options.postProcessHtml
        ? renderedRoutes.map((renderedRoute) => {
            const processed = this._options.postProcessHtml(renderedRoute);
            if (typeof processed === "string") renderedRoute.html = processed;
            else renderedRoute = processed;

            return renderedRoute;
          })
        : renderedRoutes
    )
    // Run postProcess hooks.
    .then((renderedRoutes) =>
      this._options.postProcess
        ? Promise.all(
            renderedRoutes.map((renderedRoute) =>
              this._options.postProcess(renderedRoute)
            )
          )
        : renderedRoutes
    )
    // Check to ensure postProcess hooks returned the renderedRoute object properly.
    .then((renderedRoutes) => {
      const isValid = renderedRoutes.every((r) => typeof r === "object");
      if (!isValid) {
        throw new Error(
          "[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?"
        );
      }

      return renderedRoutes;
    })
    // Minify html files if specified in config.
    .then((renderedRoutes) => {
      if (!this._options.minify) return renderedRoutes;

      renderedRoutes.forEach((route) => {
        route.html = minify(route.html, this._options.minify);
      });

      return renderedRoutes;
    })
    // Calculate outputPath if it hasn't been set already.
    .then((renderedRoutes) => {
      renderedRoutes.forEach((rendered) => {
        if (!rendered.outputPath) {
          rendered.outputPath = path.join(
            this._options.outputDir || this._options.staticDir,
            rendered.route,
            "index.html"
          );
        }
      });

      return renderedRoutes;
    })
    // Create dirs and write prerendered files.
    .then((processedRoutes) => {
      const promises = Promise.all(
        processedRoutes.map((processedRoute) => {
          return mkdirp(path.dirname(processedRoute.outputPath))
            .then(() => {
              return new Promise((resolve, reject) => {
                compilerFS.writeFile(
                  processedRoute.outputPath,
                  processedRoute.html.trim(),
                  (err) => {
                    if (err)
                      reject(
                        `[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`
                      );
                    else resolve();
                  }
                );
              });
            })
            .catch((err) => {
              if (typeof err === "string") {
                err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(
                  processedRoute.outputPath
                )} for route ${processedRoute.route}. \n ${err}`;
              }

              throw err;
            });
        })
      );

      return promises;
    })
    .then((r) => {
      PrerendererInstance.destroy();
      done();
    })
    .catch((err) => {
      PrerendererInstance.destroy();
      const msg = "[prerender-spa-plugin] Unable to prerender all routes!";
      console.error(msg);
      compilation.errors.push(new Error(msg));
      done();
    });
};

在這個方法中,又出現了一個新的compilation物件。這個方法詳細的介紹可以看Webpack compilation物件,具體的原始碼可以見此處。下面我簡單介紹下:這個物件代表的是一次檔案資源的構建。每次有檔案變化時,就會建立一個新的物件。這個檔案主要包含了當前資源構建和變化過程中的一些屬性和資訊。

另一個done引數,代表著當前外掛執行完後執行下一步的一個觸發器,和我們常見的Node框架中的next()一樣。

接下來,我們來簡單說下這個函式執行的邏輯:

  1. 初始化了一個Prerenderer的例項。這個例項是用於對頁面進行預渲染的一個工具,具體的程式碼可以見GitHub
  2. 例項初始化後,針對每一個路由,進行了一次預渲染操作。
  3. 根據拿到的預渲染相關的資料,對有效性進行檢查。
  4. 如果指定了壓縮,那麼對預渲染資料進行相關的壓縮處理。
  5. 最終將預渲染相關的資料輸出到指定路徑上。
  6. 銷燬Prerenderer例項。

這個就是一個外掛執行的完整流程。

總結

通過prerender-spa-plugin這個外掛,大家應該能夠了解到我們現行的一個外掛到底是如何運轉的,我們編寫一個外掛需要的核心部件:

  • 一個初始化的function函式。
  • 一個原型鏈上的apply方法。

    - 一個鉤子函式。

    - 一個繫結生命週期的程式碼。

有了這些東西,我們的一個Webpack的外掛就完成了。

希望通過一個外掛原始碼的示例,能夠讓大家瞭解下我們日常使用的看似很複雜的Webpack外掛,到底是怎麼實現的。

附錄

  1. Webpack官方:如何編寫一個外掛
  2. Webpack Complier鉤子
  3. Webpack Compilation物件
  4. Webpack鉤子

相關文章