MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Webpack 代码分割的高级技巧

2024-08-133.5k 阅读

Webpack 代码分割简介

在前端开发中,随着项目规模的不断扩大,打包后的 JavaScript 文件体积也会变得越来越庞大。这不仅会导致页面加载时间变长,还会影响用户体验。Webpack 的代码分割功能可以有效地解决这个问题,它允许我们将代码分割成多个较小的 chunks,然后在需要的时候按需加载,从而提高应用的性能。

Webpack 提供了两种主要的代码分割方式:splitChunks 和动态导入(Dynamic Imports)。splitChunks 主要用于提取公共代码,而动态导入则侧重于实现按需加载。

使用 splitChunks 提取公共代码

splitChunks 是 Webpack 4 引入的一个强大功能,它可以自动将所有入口 chunk 中共享的模块提取到一个单独的 chunk 中。这样,当多个页面都需要用到这些公共模块时,只需要加载一次,大大减少了重复代码的加载。

基本配置

在 Webpack 的配置文件(通常是 webpack.config.js)中,我们可以通过 optimization.splitChunks 来配置 splitChunks。以下是一个基本的配置示例:

module.exports = {
  //...其他配置
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

在这个配置中,chunks: 'all' 表示对所有类型的 chunks(包括 initialasyncall)都进行代码分割。

更细致的配置

  1. 缓存组(Cache Groups): 缓存组允许我们更细粒度地控制如何分割代码。例如,我们可以将第三方库(如 lodashreact 等)提取到一个单独的 chunk 中。

    module.exports = {
      //...其他配置
      optimization: {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name:'vendors',
              chunks: 'all'
            }
          }
        }
      }
    };
    

    在这个配置中,test 正则表达式用于匹配 node_modules 中的模块,name 指定了提取出来的 chunk 的名称为 vendors

  2. 最小大小和最小块数: 我们还可以通过 minSizeminChunks 来控制代码分割的条件。

    module.exports = {
      //...其他配置
      optimization: {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name:'vendors',
              chunks: 'all',
              minSize: 30000, // 最小大小为 30kb
              minChunks: 1 // 至少被一个 chunk 引用
            }
          }
        }
      }
    };
    

    minSize 设置了提取出来的 chunk 的最小大小,小于这个大小的模块不会被提取。minChunks 表示模块至少被多少个 chunk 引用才会被提取。

  3. 按需加载的缓存组: 对于异步加载的模块,我们可以设置单独的缓存组。

    module.exports = {
      //...其他配置
      optimization: {
        splitChunks: {
          chunks: 'async',
          cacheGroups: {
            asyncVendor: {
              test: /[\\/]node_modules[\\/]/,
              name: 'async - vendors',
              minSize: 30000,
              minChunks: 1
            }
          }
        }
      }
    };
    

    这里 chunks: 'async' 表示只对异步加载的 chunks 进行分割,asyncVendor 缓存组用于提取异步加载的第三方库。

动态导入实现按需加载

动态导入是 ECMAScript 提案的一部分,Webpack 对其提供了很好的支持。通过动态导入,我们可以在代码运行时动态地加载模块,而不是在打包时将所有模块都包含进来。

基本语法

在 JavaScript 中,动态导入使用 import() 语法。例如,假设我们有一个 utils.js 文件,我们可以这样动态导入:

// 动态导入模块
import('./utils.js').then((module) => {
  // 使用导入的模块
  module.doSomething();
}).catch((error) => {
  console.error('Error loading module:', error);
});

当 Webpack 遇到 import() 时,它会将被导入的模块分割成一个单独的 chunk,并在运行时按需加载。

结合路由实现按需加载组件(以 React 为例)

在 React 应用中,我们经常使用路由来管理页面。通过动态导入,我们可以实现页面组件的按需加载,从而提高应用的初始加载性能。

首先,安装 react - router - dom

npm install react - router - dom

然后,在路由配置中使用动态导入:

import React from'react';
import { BrowserRouter as Router, Routes, Route } from'react - router - dom';

const Home = React.lazy(() => import('./components/Home'));
const About = React.lazy(() => import('./components/About'));

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<React.Suspense fallback={<div>Loading...</div>}><Home /></React.Suspense>} />
        <Route path="/about" element={<React.Suspense fallback={<div>Loading...</div>}><About /></React.Suspense>} />
      </Routes>
    </Router>
  );
}

export default App;

在这个例子中,React.lazy 接受一个动态导入函数,React.Suspense 用于在组件加载时显示一个加载指示器。当用户访问 //about 路径时,对应的组件才会被加载。

预加载和预渲染

Webpack 支持对动态导入的模块进行预加载和预渲染,以进一步提高性能。

  1. 预加载: 预加载允许我们在浏览器空闲时提前加载模块,这样当用户需要使用时可以更快地获取到。在 Webpack 中,我们可以通过在动态导入中添加 /* webpackPreload: true */ 注释来实现预加载。

    import(/* webpackPreload: true */ './utils.js').then((module) => {
      module.doSomething();
    }).catch((error) => {
      console.error('Error loading module:', error);
    });
    

    这样,Webpack 会在生成的 HTML 中添加 <link rel="preload" as="script" href="utils.js"> 标签,告诉浏览器提前加载这个脚本。

  2. 预渲染: 预渲染则是在构建时就将模块渲染成静态 HTML,然后在运行时直接显示。这可以大大提高首屏渲染速度。Webpack 可以结合 html - webpack - prerender - plugin 等插件来实现预渲染。 首先,安装插件:

    npm install html - webpack - prerender - plugin
    

    然后,在 Webpack 配置中添加如下配置:

    const PrerenderPlugin = require('html - webpack - prerender - plugin');
    
    module.exports = {
      //...其他配置
      plugins: [
        new PrerenderPlugin({
          staticDir: path.join(__dirname, 'dist'),
          routes: ['/', '/about'],
          renderer: new PrerenderPlugin.PuppeteerRenderer()
        })
      ]
    };
    

    这个配置会在构建时使用 Puppeteer 对指定的路由进行预渲染,并生成静态 HTML 文件。

高级代码分割技巧

动态公共代码提取

在一些复杂的项目中,我们可能会有一些动态生成的公共代码,这些代码在不同的入口 chunk 中可能会被重复使用。例如,我们有一个基于用户角色动态加载不同功能模块的应用,不同角色可能会有一些共同的基础功能模块。

我们可以通过自定义的逻辑来实现动态公共代码的提取。首先,我们可以在 Webpack 的插件中定义一个自定义的代码分割规则。

class DynamicCommonChunkPlugin {
  constructor(options) {
    this.options = options;
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('DynamicCommonChunkPlugin', (compilation) => {
      compilation.hooks.chunkGroupCreation.tap('DynamicCommonChunkPlugin', (chunkGroup) => {
        // 这里我们可以根据 chunkGroup 的一些特性,如包含的模块等,来决定是否提取公共代码
        const commonModules = [];
        chunkGroup.chunks.forEach((chunk) => {
          chunk.modules.forEach((module) => {
            if (this.isCommonModule(module)) {
              commonModules.push(module);
            }
          });
        });
        if (commonModules.length > 0) {
          const commonChunk = compilation.addChunk('dynamic - common');
          commonModules.forEach((module) => {
            commonChunk.addModule(module);
          });
          chunkGroup.removeModules(commonModules);
        }
      });
    });
  }

  isCommonModule(module) {
    // 这里实现判断模块是否为公共模块的逻辑,例如根据模块路径等
    return module.resource.includes('common - utils');
  }
}

然后,在 Webpack 配置中使用这个插件:

module.exports = {
  //...其他配置
  plugins: [
    new DynamicCommonChunkPlugin()
  ]
};

这样,我们就可以在构建过程中动态地提取公共代码,即使这些公共代码的使用情况是动态变化的。

基于条件的代码分割

有时候,我们可能需要根据一些条件来决定是否进行代码分割。例如,根据环境变量或者用户的设备类型来分割代码。

假设我们有一个应用,在移动端和桌面端有不同的功能模块,我们可以根据用户代理字符串来决定加载不同的模块。

function getDeviceType() {
  const userAgent = navigator.userAgent.toLowerCase();
  if (userAgent.match(/android|iphone|ipad|ipod/)) {
    return'mobile';
  } else {
    return 'desktop';
  }
}

const deviceType = getDeviceType();

if (deviceType ==='mobile') {
  import('./mobile - specific - module.js').then((module) => {
    module.init();
  }).catch((error) => {
    console.error('Error loading mobile - specific module:', error);
  });
} else {
  import('./desktop - specific - module.js').then((module) => {
    module.init();
  }).catch((error) => {
    console.error('Error loading desktop - specific module:', error);
  });
}

在 Webpack 配置中,我们可以通过 DefinePlugin 来传递环境变量,从而在代码中根据不同的环境进行更灵活的代码分割。

const webpack = require('webpack');

module.exports = {
  //...其他配置
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      'process.env.DEVICE_TYPE': JSON.stringify(getDeviceType())
    })
  ]
};

这样,在代码中我们就可以通过 process.env.DEVICE_TYPE 来获取设备类型,并根据这个变量进行代码分割和模块加载。

代码分割与懒加载策略优化

  1. 懒加载优先级控制: 在一个应用中,可能有多个模块需要懒加载。我们可以通过设置优先级来决定哪些模块优先被加载。例如,我们可以为不同的动态导入设置不同的优先级注释。

    import(/* webpackPrefetch: true */ './high - priority - module.js').then((module) => {
      module.doHighPriorityTask();
    }).catch((error) => {
      console.error('Error loading high - priority module:', error);
    });
    
    import(/* webpackPreload: true */ './medium - priority - module.js').then((module) => {
      module.doMediumPriorityTask();
    }).catch((error) => {
      console.error('Error loading medium - priority module:', error);
    });
    
    import('./low - priority - module.js').then((module) => {
      module.doLowPriorityTask();
    }).catch((error) => {
      console.error('Error loading low - priority module:', error);
    });
    

    webpackPrefetch 会将模块的加载优先级设置得比 webpackPreload 更高,浏览器会在空闲时优先加载带有 webpackPrefetch 注释的模块。

  2. 懒加载时机优化: 我们还可以优化懒加载的时机。例如,在页面滚动到某个位置时再加载相关模块。假设我们有一个图片懒加载的功能,当图片即将进入视口时加载图片相关的模块。

    <img data - src="image.jpg" class="lazy - load - img" />
    
    const lazyImages = document.querySelectorAll('.lazy - load - img');
    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          import('./image - load - module.js').then((module) => {
            module.loadImage(entry.target.dataset.src);
            observer.unobserve(entry.target);
          }).catch((error) => {
            console.error('Error loading image - load - module:', error);
          });
        }
      });
    });
    lazyImages.forEach((image) => {
      observer.observe(image);
    });
    

    这样,只有当图片即将进入视口时,才会加载图片加载相关的模块,进一步提高了应用的性能。

实战案例:大型前端项目的代码分割优化

项目背景

假设我们正在开发一个大型的企业级前端应用,包含多个功能模块,如用户管理、订单管理、报表生成等。每个功能模块都有自己的一组 JavaScript 文件和依赖。项目面临的问题是打包后的文件体积过大,导致首次加载时间过长。

分析与策略

  1. 分析依赖关系: 首先,我们使用 webpack - bundle - analyzer 插件来分析打包后的文件依赖关系和大小。

    npm install webpack - bundle - analyzer
    

    在 Webpack 配置中添加如下代码:

    const BundleAnalyzerPlugin = require('webpack - bundle - analyzer').BundleAnalyzerPlugin;
    
    module.exports = {
      //...其他配置
      plugins: [
        new BundleAnalyzerPlugin()
      ]
    };
    

    通过分析报告,我们发现一些第三方库(如 axiosmoment 等)在多个功能模块中被重复引用,同时一些功能模块内部的代码也有一些可以提取的公共部分。

  2. 策略制定

    • 使用 splitChunks 提取第三方库到一个单独的 vendors chunk 中。
    • 对于功能模块内部的公共代码,通过自定义插件进行提取。
    • 对于一些不常用的功能模块,采用动态导入实现按需加载。

具体实现

  1. 提取第三方库: 在 Webpack 配置中添加如下 splitChunks 配置:
    module.exports = {
      //...其他配置
      optimization: {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name:'vendors',
              chunks: 'all'
            }
          }
        }
      }
    };
    
  2. 提取功能模块公共代码: 我们创建一个自定义插件 FunctionModuleCommonChunkPlugin 来提取功能模块内部的公共代码。
    class FunctionModuleCommonChunkPlugin {
      constructor(options) {
        this.options = options;
      }
    
      apply(compiler) {
        compiler.hooks.compilation.tap('FunctionModuleCommonChunkPlugin', (compilation) => {
          compilation.hooks.chunkGroupCreation.tap('FunctionModuleCommonChunkPlugin', (chunkGroup) => {
            const functionModuleChunks = [];
            chunkGroup.chunks.forEach((chunk) => {
              if (chunk.name.startsWith('function - module -')) {
                functionModuleChunks.push(chunk);
              }
            });
            if (functionModuleChunks.length > 1) {
              const commonModules = [];
              functionModuleChunks.forEach((chunk) => {
                chunk.modules.forEach((module) => {
                  if (this.isCommonModule(module)) {
                    commonModules.push(module);
                  }
                });
              });
              if (commonModules.length > 0) {
                const commonChunk = compilation.addChunk('function - module - common');
                commonModules.forEach((module) => {
                  commonChunk.addModule(module);
                });
                functionModuleChunks.forEach((chunk) => {
                  chunk.removeModules(commonModules);
                });
              }
            }
          });
        });
      }
    
      isCommonModule(module) {
        return module.resource.includes('function - module - common - utils');
      }
    }
    
    在 Webpack 配置中使用这个插件:
    module.exports = {
      //...其他配置
      plugins: [
        new FunctionModuleCommonChunkPlugin()
      ]
    };
    
  3. 动态导入不常用功能模块: 以订单管理模块中的订单导出功能为例,假设这个功能不常用。我们可以这样实现动态导入:
    const exportButton = document.getElementById('export - order - button');
    exportButton.addEventListener('click', () => {
      import('./order - export - module.js').then((module) => {
        module.exportOrder();
      }).catch((error) => {
        console.error('Error loading order - export - module:', error);
      });
    });
    

效果评估

经过上述优化后,再次使用 webpack - bundle - analyzer 分析打包结果,我们发现打包后的文件体积明显减小。同时,通过页面加载性能测试工具(如 Google Lighthouse)测试,页面的首次加载时间和交互时间都得到了显著改善,大大提高了用户体验。

代码分割中的注意事项

模块依赖顺序

在代码分割过程中,要注意模块之间的依赖顺序。如果依赖关系处理不当,可能会导致代码运行出错。例如,A 模块依赖 B 模块,而 B 模块又依赖 C 模块。在动态导入时,如果先导入 A 模块,然后再导入 C 模块,可能会因为 C 模块的某些初始化操作在 A 模块之后执行而导致错误。

为了避免这种情况,我们在编写代码时要确保依赖关系清晰。在 Webpack 打包过程中,Webpack 会根据模块的导入顺序和依赖关系来处理模块加载,但我们自己也要从代码逻辑上保证依赖的正确性。

缓存控制

当我们进行代码分割后,不同的 chunk 可能会有不同的缓存策略。如果缓存控制不当,可能会导致用户在更新应用时无法及时获取到新的代码。

对于提取出来的公共 chunk(如 vendors chunk),由于其内容相对稳定,可以设置较长的缓存时间。而对于一些动态导入的 chunk,因为它们可能会随着功能的更新而频繁变化,所以缓存时间可以设置得较短。

在 Webpack 配置中,我们可以通过 output.publicPathhtml - webpack - plugin 等相关配置来控制资源的缓存。例如:

module.exports = {
  output: {
    publicPath: '/static/',
    filename: '[name].[contenthash].js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      hash: true
    })
  ]
};

这里通过在 filename 中使用 [contenthash],当文件内容发生变化时,文件名也会改变,从而避免浏览器使用旧的缓存。html - webpack - plugin 中的 hash 设置为 true 会在生成的 HTML 中给每个资源链接添加一个哈希值,同样可以起到控制缓存的作用。

兼容性问题

虽然动态导入是 ECMAScript 提案的一部分,但在一些较老的浏览器中可能不支持。为了确保应用在各种浏览器中都能正常运行,我们需要进行兼容性处理。

一种常见的方法是使用 Babel 进行转译。首先,安装相关的 Babel 插件:

npm install @babel/core @babel/preset - env babel - loader

然后,在 Webpack 配置中添加 Babel 加载器:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel - loader',
          options: {
            presets: ['@babel/preset - env']
          }
        }
      }
    ]
  }
};

@babel/preset - env 会根据目标浏览器的配置自动将动态导入等新语法转译为兼容老浏览器的代码。同时,我们还可以通过 polyfill 来提供一些缺失的 API 支持,以确保应用在各种浏览器中都能正常运行。