HtmlWebpackPlugin 的chunks插入方式,v3.2.0 和 v4.3.0 的差异(带源码分析)——vue-cli配置修改分片后为什么页面不渲染

6,579 阅读4分钟

最近碰到一个问题,项目是用vue-cli(v4.4.1)生成的webpack配置,修改splitChunks后,页面没有进行渲染(白屏),检查了一下以后发现有html的<srcipt>中少了一些chunck,再仔细检查以后发现锅在HtmlWebpackPlugin。这是一个生成html的webpack插件,会在模板渲染后将入口相关的chunks插入html中。

HtmlWebpackPlugin中有几个关键选项

{
    chunks: 'all', //数组或者'all',表示要将哪些chunks插入html
    chunksSortMode: 'auto', //chunks插入顺序。
}

(如果不想花时间,后面代码可以跳过不读,只看结论)

chunksSortMode

chunksSortMode在HtmlWebpackPlugin v3.2.0中的取值有:

  • Function:会执行Array.sort(chunksSortMode)来排序
  • none:不排序,即按compilation.entrypoints的原本顺序
  • manual:手动指定,按上面chunks的数组中的顺序
  • dependency:按照chunk依赖关系的拓扑排序
  • id:根据chunk id排序
  • auto:webpack版本大于1就是dependency,否则是id

在v4.3.0中只有Function、none、manual、auto(none),而且manual的行为和v3.2.0不一样。

// v3.2.0
// chunks是经过配置中的chunks规则过滤后的所有chunks,options是HtmlWebpackPlugin的配置对象
module.exports.manual = (chunks, options) => {
  const specifyChunks = options.chunks;
  const chunksResult = [];
  let filterResult = [];
  if (Array.isArray(specifyChunks)) {
    for (var i = 0; i < specifyChunks.length; i++) {
      filterResult = chunks.filter(chunk => {
        if (chunk.names[0] && chunk.names[0] === specifyChunks[i]) {
          return true;
        }
        return false;
      });
      filterResult.length > 0 && chunksResult.push(filterResult[0]);
    }
  }
  return chunksResult;
};

可以看到,如果HtmlWebpackPlugin配置项设为chunks: 'all',那么最后return的chunksResult是空数组,也就没有任何chunk会插入到html中。

// v4.3.0
// entryPointNames就是v3.2.0中的chunks,htmlWebpackPluginOptions就是v3.2.0中的options
module.exports.manual = (entryPointNames, compilation, htmlWebpackPluginOptions) => {
  const chunks = htmlWebpackPluginOptions.chunks;
  if (!Array.isArray(chunks)) {
    return entryPointNames;
  }
  // Remove none existing entries from
  // htmlWebpackPluginOptions.chunks
  return chunks.filter((entryPointName) => {
    return compilation.entrypoints.has(entryPointName);
  });
};

v4.3.0就没有这个问题,另外,在entrypoints中过滤而不是options.chunks中,后面会讲到。

chunks

这个选项在两个版本间的行为更加不同。

// v3.2.0
const allChunks = compilation.getStats().toJson(chunkOnlyConfig).chunks;
// Filter chunks (options.chunks and options.excludeCHunks)
let chunks = self.filterChunks(allChunks, self.options.chunks, self.options.excludeChunks);
// Sort chunks
chunks = self.sortChunks(chunks, self.options.chunksSortMode, compilation);
filterChunks (chunks, includedChunks, excludedChunks) {
return chunks.filter(chunk => {
  const chunkName = chunk.names[0];
  // This chunk doesn't have a name. This script can't handled it.
  if (chunkName === undefined) {
    return false;
  }
  // Skip if the chunk should be lazy loaded
  if (typeof chunk.isInitial === 'function') {
    if (!chunk.isInitial()) {
      return false;
    }
  } else if (!chunk.initial) {
    return false;
  }
  // Skip if the chunks should be filtered and the given chunk was not added explicity
  if (Array.isArray(includedChunks) && includedChunks.indexOf(chunkName) === -1) {
    return false;
  }
  // Skip if the chunks should be filtered and the given chunk was excluded explicity
  if (Array.isArray(excludedChunks) && excludedChunks.indexOf(chunkName) !== -1) {
    return false;
  }
  // Add otherwise
  return true;
});
}

可以看到逻辑很简单,就是在options.chunks里的chunk就保留,不然就抛弃,如果options.chunks不是数组就全保留(所有entries中的所有chunk)。 所以多入口的话设为'all'是会出错的。

// v4.3.0
const entryNames = Array.from(compilation.entrypoints.keys());
const filteredEntryNames = self.filterChunks(entryNames, self.options.chunks, self.options.excludeChunks);
const sortedEntryNames = self.sortEntryChunks(filteredEntryNames, this.options.chunksSortMode, compilation);
filterChunks (chunks, includedChunks, excludedChunks) {
return chunks.filter(chunkName => {
  // Skip if the chunks should be filtered and the given chunk was not added explicity
  if (Array.isArray(includedChunks) && includedChunks.indexOf(chunkName) === -1) {
    return false;
  }
  // Skip if the chunks should be filtered and the given chunk was excluded explicity
  if (Array.isArray(excludedChunks) && excludedChunks.indexOf(chunkName) !== -1) {
    return false;
  }
  // Add otherwise
  return true;
});
}

看上去没啥区别,因为区别不在这,而在后面执行到计算assets文件路径的时候。

// v3.2.0
// Get assets
const assets = self.htmlWebpackPluginAssets(compilation, chunks);
htmlWebpackPluginAssets (compilation, chunks) {
  const self = this;
  ...
  const assets = {
    ...
    chunks: {},
    js: [],
    css: [],
    ...
  };
  ...
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i];
    const chunkName = chunk.names[0];
    assets.chunks[chunkName] = {};
    
    // chunkFiles就是chunk.files文件名加上publicPath、hash等
    const js = chunkFiles.find(chunkFile => /.js($|\?)/.test(chunkFile));
    if (js) {
      assets.chunks[chunkName].size = chunk.size;
      assets.chunks[chunkName].entry = js;
      assets.chunks[chunkName].hash = chunk.hash;
      assets.js.push(js);
    }
    
    // Gather all css files
    const css = chunkFiles.filter(chunkFile => /.css($|\?)/.test(chunkFile));
    assets.chunks[chunkName].css = css;
    assets.css = assets.css.concat(css);
  }
  ...
  return assets;
}

v3.2.0的assets中放的是options.chunks里经过过滤和排序后,各个chunk对应的js、css文件(chunk.files中的文件),也就是你写了哪些chunk,最多保留这些chunk中的文件。

// v4.3.0
// childCompilationOutputName不是重点不用管
// 通过之前的代码可以知道sortedEntryNames是options.chunks过滤和排序后的数组
const assets = self.htmlWebpackPluginAssets(compilation, childCompilationOutputName, sortedEntryNames);
htmlWebpackPluginAssets (compilation, childCompilationOutputName, entryNames) {
  ...
  const assets = {
    ...
    js: [],
    css: [],
    ...
  };
  for (let i = 0; i < entryNames.length; i++) {
    const entryName = entryNames[i];
    // 关键是这里,Files是从entrypoints中获取的
    const entryPointUnfilteredFiles = compilation.entrypoints.get(entryName).getFiles();
    ...
    // 过滤热加载模块,文件名加上publicPath、hash等
    ...
    entryPointPublicPaths.forEach((entryPointPublicPath) => {
      ...
      // ext是文件类型,js或css
      assets[ext].push(entryPointPublicPath);
    });
  }
  return assets;
}

所以v4.3.0的HtmlWebpackPlugin的chunks选项的作用是将对应入口(把chunks中的每一项当作entry)的所有文件插入html中

名为chunks,实为entries

举个例子,entryA要加载的js文件是entryA.js、entryAentryB.js、vendorsentryA~entryB.js,chunks选项设为['entryA']。

在v3.2.0中最后插入html的只有entryA.js,因为entryA这个chunk中只有entryA.js。

在v4.3.0中最后插入html的有是entryA.js、entryAentryB.js、vendorsentryA~entryB.js,因为为entryA这个entry中包含了这些文件。

运行效果

我们在代码中打个断点看看

下面是在v3.2.0中

下面是在v4.3.0中

开头的问题

回过头来看开头的问题,vue-cli最新版用的也是v3.2.0的HtmlWebpackPlugin,然后vue-cli的入口配置中

pages: {
  index: {
    ...
    // 在这个页面中包含的块,默认情况下会包含
    // 提取出来的通用 chunk 和 vendor chunk。
    // 这个chunks就是HtmlWebpackPlugin配置中的chunks
    chunks: ['chunk-vendors', 'chunk-common', 'index']
  },
  ...
}

如果chunks不写,就默认是['chunk-vendors', 'chunk-common', page],因此如果分片后入口的chunk有变化,比如多了个pageA~pageB,这个chunk不会被插入到html中,造成缺失部分代码,所以页面没有渲染。 如果chunks设为'all',就会把所有chunk(包括其它入口的)插到html中,造成执行出错。所以怎么写都不对。如果你chunks设为'all'的同时chunksSortMode还设为manual,那么一个chunk都不会插到html中。

这样必须在chunks选项设置好该入口的所有chunks,但是执行分片前又不知道有哪些chunks。而且HtmlWebpackPlugin的所有hook都在计算assets之后。所以在v3.2.0中无解。。。

只能单独升级HtmlWebpackPlugin。

npm install --save-dev html-webpack-plugin

然后再vue.config.js中插入

const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  ...
  configureWebpack: {
    ...
    plugins: [
      ...
      ...Object.keys(pages).map(page =>
        new HtmlWebpackPlugin({
          filename: `${page}.html`,
          chunks: [page],
          template: pages[page].template,
          // 模块热替换时依旧会使用vue-cli内置的HtmlWebpackPlugin,
          // 疑似是vue-cli的问题,开发环境下加入hash,让其识别文件变化
          hash: process.env.NODE_ENV === 'development',
        })),
    ]
  }
}

如果有其它好办法的话,欢迎告诉我。

相关链接:

vue-cli文档

HtmlWebpackPlugin 仓库