抖音“哲玄前端”《大前端全栈实践》webpack5-工程化实践

101 阅读3分钟

什么是前端工程化

前端工程化是指将软件工程的方法和原则应用到前端开发中,通过系统化、规范化、标准化的方法提升前端开发效率和质量的一套完整实践体系。 前端工程化主要包含以下几个方面:

  1. 模块化开发

    • JavaScript模块化(ES Module, CommonJS等)
    • CSS模块化(CSS Modules, CSS-in-JS等)
    • 组件化开发(React/Vue/Angular组件)
  2. 自动化构建

    • 代码编译(如Babel转译)
    • 打包工具(Webpack, Rollup, Vite等)
    • 任务自动化(Gulp, Grunt等)
  3. 规范化体系

    • 代码规范(ESLint, Stylelint)
    • 提交规范(Git Commit Message)
    • 目录结构规范
    • 文档规范
  4. 性能优化

    • 代码分割(Code Splitting)
    • 按需加载
    • 缓存策略
    • 资源压缩
  5. 质量保障

    • 单元测试(Jest, Mocha)
    • E2E测试(Cypress, Playwright)
    • 代码覆盖率
    • 类型检查(TypeScript)
  6. 部署与监控

    • CI/CD流程
    • 错误监控(Sentry)
    • 性能监控
    • 日志收集

这次的重点在第二部分,

const glob = require("glob");
const path = require("path");
const webpack = require("webpack");
const { VueLoaderPlugin } = require("vue-loader");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CleanWebpackPlugin = require("clean-webpack-plugin");

// 动态构造entry和HtmlWebpackPlugin
const pageEntries = {};
const HtmlWebpackPluginList = [];

//获取app/pages目录下的所有入口文件(entry.xx.js)
const entryList = path.resolve(process.cwd(), "./app/pages/**/entry.*.js");
glob.sync(entryList).forEach((file) => {
  const entryName = path.basename(file, ".js");
  // 构造entry
  pageEntries[entryName] = file;
  // 构造最终渲染的页面文件
  HtmlWebpackPluginList.push(
    new HtmlWebpackPlugin({
      filename: path.resolve(
        process.cwd(),
        `./app/public/dist/${entryName}.tpl`
      ), // 输出文件路径
      template: path.resolve(process.cwd(), `./app/view/entry.tpl`), // 模板文件路径
      chunks: [entryName], // 引入的chunk
    })
  );
});

/**
 * webpack 配置文件基础配置
 */
module.exports = {
  // 入口配置
  entry: pageEntries,
  // 模块解析配置(决定了要加载解析哪些模块,以及用什么方式解析)
  module: {
    rules: [
      // 针对vue文件的解析规则
      {
        test: /\.vue$/,
        use: {
          loader: "vue-loader",
        },
      },
      // 针对js文件的解析规则
      {
        test: /\.js$/,
        include: [
          // 只对业务代码进行babel转换,减少编译时间,加快打包速度
          path.resolve(process.cwd(), "./app/pages"),
        ],
        use: {
          loader: "babel-loader",
        },
      },
      // 针对图片文件的解析规则
      {
        test: /\.(png|jpg|gif|jpeg|webp)(\?.+)?$/,
        use: {
          loader: "url-loader",
          options: {
            limit: 300,
            esModule: false,
          },
        },
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.less$/,
        use: ["style-loader", "css-loader", "less-loader"],
      },
      // 针对字体文件的解析规则
      {
        test: /\.(eot|svg|ttf|woff|woff2)(\?\S*)?$/,
        use: ["file-loader"],
      },
    ],
  },
  // 产物输出路径,开发和生产环境的输出不一致,所以各自配置
  output: {},
  // 配置模块解析的具体行为(定义webpack在打包,如何找到并解析具体模块路径,构建依赖图谱)
  resolve: {
    extensions: [".js", ".vue", ".less", ".css"],
    alias: {
      $pages: path.resolve(process.cwd(), "./app/pages"), // 业务代码目录
      $common: path.resolve(process.cwd(), "./app/pages/common"), // 公共组件目录
      $widgets: path.resolve(process.cwd(), "./app/pages/widgets"), // 组件目录
      $store: path.resolve(process.cwd(), "./app/pages/store"), // 状态管理目录
    },
  },
  // 配置webpack插件
  plugins: [
    // 处理.vue文件,这个插件是必须的 他的职能是将你定义的其他规则复制并应用到.vue文件里
    // 例如,如果你有一条规则,/\.js$/, 那么它会应用到 .vue 文件里的 <script> 块
    new VueLoaderPlugin(),
    // 把第三方库暴露到window context下
    new webpack.ProvidePlugin({
      Vue: "vue",
      axios: "axios",
      _: "lodash",
    }),
    // 定义全局常量
    new webpack.DefinePlugin({
      __VUE_OPTIONS_API__: "true", // 启用选项API
      __VUE_PROD_DEVTOOLS__: "false", // 禁用Vue Devtools
      __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false", // 禁用生产环境显式’水合‘信息
    }),

    // 显式打包进度
    new webpack.ProgressPlugin(),

    // 每次build前,删除dist目录
    new CleanWebpackPlugin(["public/dist"], {
      root: path.resolve(process.cwd(), "./app/"),
      exclude: [],
      verbose: true, // 开启在控制台输出信息
      dry: false, // 启用删除文件
    }),

    //构建最终渲染的页面模板
    ...HtmlWebpackPluginList,
  ],
  // 配置打包输出优化(代码分割,模块合并,缓存,Tree Shaking,压缩等优化策略)
  optimization: {
    splitChunks: {
      chunks: "all", //对同步和异步代码都进行分割
      maxAsyncRequests: 10, // 每次异步加载的最大并行请求数量
      maxInitialRequests: 10, // 初始加载的最大并行请求数量
      cacheGroups: {
        // 第三方依赖
        vendor: {
          test: /[\\/]node_modules[\\/]/, // 匹配node_modules目录下的模块
          name: "vendor", // 打包后的文件名
          priority: 20, // 优先级,数字越大,优先级越高
          enforce: true, // 强制分割,即使模块没有被引用,也会被分割
          reuseExistingChunk: true, // 复用已有的公共chunk
        },
        // 公共组件
        common: {
          name: "common", // 打包后的文件名
          minChunks: 2, // 模块至少被引用的次数,被引用2次以上才会被分割
          minSize: 1, // 模块的最小大小,单位是字节,小于1字节的模块不会被分割
          priority: 10, // 优先级,数字越大,优先级越高
          reuseExistingChunk: true, // 复用已有的公共chunk
        },
      },
    },
    runtimeChunk: true, // 运行时代码单独打包
  },
};

这个文件实现的功能,首先会根据路径,读取到app/pages下所有的entry.*.js文件,作为入口文件,根据对应的规则,进行打包,打包出来的结果又会构建最终渲染成最终渲染的页面

  new HtmlWebpackPlugin({
      filename: path.resolve(
        process.cwd(),
        `./app/public/dist/${entryName}.tpl`
      ), // 输出文件路径
      template: path.resolve(process.cwd(), `./app/view/entry.tpl`), // 模板文件路径
      chunks: [entryName], // 引入的chunk
    })

chunks是对打包出来的结果,template对应注入的模版文件,把这两个结合起来生成最终的文件,也就是这样

<script defer="defer" src="/dist/prod/js/runtime~entry.page1_ae875b58.bundle.js" crossorigin="anonymous"></script>
<script defer="defer" src="/dist/prod/js/vendor_7eb0dbae.bundle.js" crossorigin="anonymous"></script>
<script defer="defer" src="/dist/prod/js/entry.page1_dec2ddb7.bundle.js" crossorigin="anonymous"></script>

其中还引入了代码分包,这里采用的策略是,第三方依赖打个包,公共组件打个包,业务组件打个包,对应的实现是这里

  // 配置打包输出优化(代码分割,模块合并,缓存,Tree Shaking,压缩等优化策略)
  optimization: {
    splitChunks: {
      chunks: "all", //对同步和异步代码都进行分割
      maxAsyncRequests: 10, // 每次异步加载的最大并行请求数量
      maxInitialRequests: 10, // 初始加载的最大并行请求数量
      cacheGroups: {
        // 第三方依赖
        vendor: {
          test: /[\\/]node_modules[\\/]/, // 匹配node_modules目录下的模块
          name: "vendor", // 打包后的文件名
          priority: 20, // 优先级,数字越大,优先级越高
          enforce: true, // 强制分割,即使模块没有被引用,也会被分割
          reuseExistingChunk: true, // 复用已有的公共chunk
        },
        // 公共组件
        common: {
          name: "common", // 打包后的文件名
          minChunks: 2, // 模块至少被引用的次数,被引用2次以上才会被分割
          minSize: 1, // 模块的最小大小,单位是字节,小于1字节的模块不会被分割
          priority: 10, // 优先级,数字越大,优先级越高
          reuseExistingChunk: true, // 复用已有的公共chunk
        },
      },
    },
    runtimeChunk: true, // 运行时代码单独打包
  },

这样就完成了生产环境的打包

我们都知道我们在开发环境中,不只是需要打包,我们还需要在开发环境的时候对代码进行调试,下面就涉及到了我们在开发环境下的热更新

// 本地开发启动devServer
const express = require("express");
const path = require("path");
const consoler = require("consoler");
const webpack = require("webpack");
const devMiddleware = require("webpack-dev-middleware");
const hotMiddleware = require("webpack-hot-middleware");

//从webpack.dev.js中获取webpack配置和devServer配置
const { webpackConfig, DEV_SERVER_CONFIG } = require("./config/webpack.dev.js");
const { PORT, HRM_PATH } = DEV_SERVER_CONFIG;

const app = express();
const compiler = webpack(webpackConfig);

// 指定静态文件目录
app.use(express.static(path.join(__dirname, "../public/dist")));

// 引用devMiddleware中间件 (监控文件改动)
app.use(
  devMiddleware(compiler, {
    // 落地文件
    writeToDisk: (filePath) => filePath.endsWith(".tpl"),

    // 资源路径
    publicPath: webpackConfig.output.publicPath,

    // headers 配置
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,PATH,OPTIONS",
      "Access-Control-Allow-Headers":
        "X-Requested-With,content-type,Authorization",
    },
    stats: {
      colors: true,
    },
  })
);

// 引用hotMiddleware中间件 (实现热更新通讯)
app.use(
  hotMiddleware(compiler, {
    path: `/${HRM_PATH}`,
    log: () => {},
  })
);

consoler.info("请等待webpack初次构建完成提示...");

// 启动devServer
app.listen(PORT, () => {
  console.log(`app listening on port ${PORT}`);
});

开发环境下的热更新,采取的策略是,模板打包,js,css部分,使用express启动一个服务,服务监听文件的改动,改动后实时更新服务上的打包结果,页面重新获取服务上的打包结果,实现文件一动 => 重新打包 => 页面重新获取