1

Overview

The main content of this article is to read the source code of the prerender-spa-plugin plug-in used before to see how we should write a webpack plug-in, and understand how the pre-rendering plug-in is implemented.

This content has actually been involved in the use of prerender-spa-plugin. The content of this chapter is a supplement and expansion to the previous article. It introduces in detail how the plug-in mechanism of Webpack works, and it is simple to write before. What is the principle of the effective replacement of the plug-in.

If you haven’t read the previous how to use the prerender-spa-plugin plugin to pre-render the page this article, you can go and take a look first to understand what this plugin does and what our plugin looks like .

Analysis of plug-in source code

prerender-spa-plugin is open source. The source code GitHub . If you are interested, you can click to read it yourself.

First of all, let us briefly review how this plug-in is used. This is helpful for us to understand its internal structure. We will directly use an example provided in its official documentation.

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' ],
    })
  ]
}

From the above example, we can know that this plug-in needs to initialize an instance, and then pass in the corresponding parameters such as the output path staticDir , the route to be rendered routes etc.

Next, let us briefly introduce his source code structure. The specific code blocks are as follows:

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)
  }
}

The entire prerender-spa-plugin plug-in is composed of 2 parts:

  1. A function function is mainly used to initialize data acquisition and processing. In the process of using this plug-in, we need to initialize it first. This function can be used to process and analyze some data.
  2. The apply function on a prototype, as a hook function, is mainly used to process the relevant logic after the Webpack triggers the execution of the plugin.

Below, we will look at each part one by one based on the prerender-spa-plugin plug-in.

Initialization function

First, let us look at the initialized function function. This function mainly does some processing after the initialization parameters are obtained. The specific code is as follows:

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.')
  }
}

Because the way our plug-in uses is to add after instantiation (that is, use after instantiation of the new operator), the input parameters of the function function are mainly to bind some required parameters to the this object, so that after instantiation, you can Get related parameters.

Many SDK or plug-in-related tools can accept input parameters of multiple types and different lengths, so they will judge the parameter type at the beginning to determine which type of parameter is passed in.

From the code, the currently recorded parameters include the output parameter staticDir and the route that needs to be rendered routes . If you define the renderer function yourself, then bind it and store it. At the same time, this V3 version of the code is also forward compatible with the V2 version.

Hook apply function

After talking about the initialization function, let's look at the most important apply function. The specific code is as follows:

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)
  }
}

Before say apply function, we look at the function parameters apply received compiler objects and mkdirp this method, as well as the life cycle of the code binding.

complier object

Apply the entire method, received only one parameter complier objects, we can see detailed contents WebPACK complier for a description of the object , the specific source can see here . Let me briefly introduce it below:

complier object is a global object provided by webpack. This object is mounted with some functions and attributes that will be used in the plug-in life cycle, such as options, loader, plugin, etc. We can use this object to obtain webpack-related data during construction.

mkdirp method

This method is to convert mkdir -p method into a Promise object. For details, see the original comment above the code. Because it's relatively simple, I won't introduce too much here.

Life cycle binding

In the end, after the life of the hook function is completed, it needs to be associated with the most recent life cycle. This plug-in is associated with the afterEmit node. If you want to see the life cycle of the entire webpack-related build process, you can refer to the document .

After reading the simple part, let's take a look at the most important hook function.

Hook function

Next, let's take a look at the core hook function in this plug-in. The associated life cycle of this plug-in is the node afterEmit. Next, let's look at the specific code.

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();
    });
};

In this method, a new compilation object appears. For a detailed introduction to this method, see Webpack compilation object . For the specific source code, see here . Let me briefly introduce it below: This object represents the construction of a file resource. Every time a file changes, a new object is created. This file mainly contains some attributes and information in the current resource construction and change process.

The other done parameter represents a trigger to execute the next step after the current plug-in is executed, which is the same as next()

Next, let's briefly talk about the logic executed by this function:

  1. Initialized an instance of Prerenderer This example is a tool for pre-rendering the page. The specific code can be found on GitHub .
  2. After the instance is initialized, a pre-rendering operation is performed for each route.
  3. Check the validity according to the data related to the pre-rendering.
  4. If compression is specified, the pre-rendered data will be compressed accordingly.
  5. Finally, the data related to the pre-rendering is output to the specified path.
  6. Destroy the Prerenderer instance.

This is the complete process of a plug-in execution.

Summarize

Through the prerender-spa-plugin plug-in, everyone should be able to understand how our current plug-in works, and we write the core components that a plug-in needs:

  • An initialized function function.
  • An apply method on the prototype chain.

-A hook function.

-A code that binds the life cycle.

With these things, one of our Webpack plugins is complete.

I hope that through an example of the plug-in source code, everyone can understand how the seemingly complex Webpack plug-in we use every day is implemented.

appendix

  1. Official Webpack: How to write a plug-in
  2. Webpack Complier hook
  3. Webpack Compilation object
  4. Webpack hook

hjava
1.9k 声望525 粉丝

字节客服平台电商前端负责人,欢迎投简历来撩:huangjue.hjava@bytedance.com