前端工程化浅析

278 阅读4分钟

工程化在前端开发中的重要性不言而喻,在如今前端应用复杂度指数级增长的大背景下,其是必然产物。原先的传统开发模式已经满足不了单页面应用(SPA)、组件化架构、跨端开发等场景的需要

前端工程化的意义

  • 工业化生产体系的构建
  • 规范化工程管理
  • 全流程质量管控

那么本文书接上回,将使用webpack作为elpis的项目构建工具(注意:要学会工程化底层原理而非学会使用工程化工具本身)

核心概念理解

入口与出口(Entry & Output)

  1. 入口

    入口配置顾名思义,将指导 webpack 应该从哪里开始解析文件,这是webpack构建依赖图谱的起点。

    module.exports = {
      entry: './path/to/my/entry/file.js',
    };
    
  2. 出口

    告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。可以在这里配置输出文件的名称格式

    output: {
      filename: '[name].[contenthash:8].js', // 哈希缓存策略
      path: path.resolve(__dirname, 'dist'),
      publicPath: '/assets/', // CDN路径配置
      chunkFilename: '[name].chunk.js' // 异步chunk命名
    }
    

模块处理(Module Rules)

典型Loader链配置:

rules: [
  {
    test: /\\.jsx?$/,
    exclude: /node_modules/,
    use: {
      loader: 'babel-loader',
      options: {
        presets: ['@babel/preset-env', '@babel/preset-react']
      }
    }
  },
  {
    test: /\\.scss$/,
    use: [
      'style-loader',
      {
        loader: 'css-loader',
        options: { modules: true } // CSS模块化
      },
      'sass-loader'
    ]
  }
]

在打包时,除了js文件之外,还会碰到其他格式的文件和资源,此时就需要对应的loader来处理这些文件,使其变为webpack可以理解的格式。以上述例子来说,其牵扯到:

  • Loader执行顺序:从右到左、从下到上处理(sass → css → style)
  • AST转换:Babel通过@babel/core生成AST(抽象语法树)进行操作
  • CSS模块化:通过唯一哈希类名实现样式隔离

插件配置(plugins)

常用插件示例:

plugins: [
	//处理vue文件,将定义的规则应用到.vue文件
	new VueLoaderPlugin(),
  //把第三方库暴露到window.context上,使得全局可用
  new webpack.ProvidePlugin({
      Vue: 'vue',
      axios:'axios',
      _: 'lodash',
  }),
  new HtmlWebpackPlugin({
	  filename: path.resolve(process.cwd(),'./app/public/dist/', `${entryName}.tpl`),//最终模板的输出路径
    template: './public/index.html',//使用指定的模板文件
    minify: { collapseWhitespace: true } // HTML压缩
    chunks:'entry.page1' //要注入的代码块(需要对应入口文件)
  }),
  new CleanWebpackPlugin(), // 构建前清理目录
  new MiniCssExtractPlugin({
    filename: '[name].[contenthash].css' // CSS文件分离
  }),
  //定义全局常量
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) // 环境变量注入
    __VUE_OPTIONS_API__: 'true',//启用vue3.0的options api
    __VUE_PROD_DEVTOOLS__: 'false',//禁用vue3.0的devtools
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',//禁用生产环境显示水合信息
  })
]

插件机制原理:

  • Tapable事件流:Webpack基于发布订阅模式的核心库
  • Hook拦截机制:通过compiler.hooks.emit.tap()等钩子介入构建过程
  • 生命周期阶段:compile → make → seal → emit 等关键阶段

分包

在工程化实践中,分包优化是提升应用性能的核心手段之一。

分包的黄金分割法则

  • 体积平衡:单个chunk建议控制在100-300KB(HTTP/2下可适当放宽)

  • 变更频率分层

    • 高频变更:业务代码
    • 低频变更:公共代码或者组件(common)
    • 永不变更:Runtime/Webpack注入代码;第三方库等(vendor)

以下是有关变更频率、公共模块提取、运行分离的优化配置

    optimization: {
        splitChunks: {
            chunks: "all", //对同步和异步模块都进行分割
            maxAsyncRequests: 10, //最多同时加载的异步请求数
            maxInitialRequests: 10,//入口点的最大并行请求数
            //针对不同类型的模块进行规配置,如果打包内容同时符合多个规则,则按照优先级进行打包
            cacheGroups: {
                vendor:{
                    test: /[\\\\/]node_modules[\\\\/]/, //打包第三方库
                    name: 'vendor', //模块名称
                    priority: 20, //优先级,越大越优先打包,
                    enforce: true, //强制打包成单独的chunk
                    reuseExistingChunk: true, //是否复用已经打包的模块
                },
                common:{
                    name: 'common', //模块名称
                    minChunks: 2,//被两次或以上次数引用的模块,才打包成公共模块
                    minSize: 1,//最小分割文件大小,单位为byte
                    priority: 10, //优先级,越大越优先打包,
                    reuseExistingChunk: true, //是否复用已经打包的模块
                },
            }

        },
        //将webpack运行时的代码打包到runtime.js
        runtimeChunk: true
    }

分包维度选择

  1. 按模块类型分割:

    • node_modules
    • src业务代码
    • 公共工具库
  2. 按功能模块分割:

    • 基础框架(React/Vue)
    • UI组件库
    • 路由模块
    • 数据状态管理
  3. 按加载时序分割:

    • 首屏核心包
    • 异步加载包
    • 预加载包

原理级优化

哈希策略优化

output: {
  filename: '[name].[contenthash:8].js',
  chunkFilename: '[name].[contenthash:8].chunk.js'
}
  • contenthash:根据文件内容生成哈希,最大化缓存利用率
  • 哈希长度:8位哈希在碰撞概率与体积间取得平衡

Tree Shaking深度优化

Webpack生产模式自动启用Terser压缩

optimization: {
  usedExports: true,
  minimize: true,
  minimizer: [
    new TerserPlugin({
      parallel: true,
      terserOptions: {
        compress: {
          pure_funcs: ['console.log'] // 移除指定函数
        }
      }
    })
  ]
}

预编译优化

// DLL预编译配置
new webpack.DllPlugin({
	context: __dirname,
  path: path.join(__dirname, 'dll', '[name]-manifest.json'),
  name: '[name]_[hash]'
})

// 配合DllReferencePlugin使用
new webpack.DllReferencePlugin({
  manifest: require('./dll/vendor-manifest.json')
})

开发服务器(DevServer)

目标是实现在开发模式下的代码热更新

const DEV_SERVER_CONFIG = {
    HOST:'127.0.0.1',
    PORT:9002,
    HMR_PATH:'__webpack_hmr',//官方规定的热更新路径
    TIMEOUT:20000,
}

//开发阶段的entry需要加入HRM
Object.keys(baseConfig.entry).forEach( v => { //拿到基础配置中的所有入口文件并添加额外配置
    //第三方包不作为hmr的入口文件
    if( v !== 'vendor') {
        baseConfig.entry[v] =[
            //主入口文件
            baseConfig.entry[v],
            //hmr更新入口,官方指定的hmr路径
            //目的是为了打包的文件中能注入HMR的代码
            `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
        ]
    }
})

const webpackConfig = merge.smart(baseConfig, {  //这里使用merge方法合并基础配置,类似于接口中的继承
    // 指定开发环境
    mode: 'development',
    //source-map,呈现代码映射关系,方便开发过程中调试代码
    devtool: 'eval-cheap-module-source-map',
    output:{
        filename: 'js/[name]_[chunkhash:8].bundle.js',
        path: path.resolve(process.cwd(), './app/public/dist/dev/'), //输出文件的存储路径
        publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, //外部资源的访问路径
        globalObject:'this', //支持多环境打包
    },
    //开发阶段的插件
    plugins: [
        //热更新(HMR)插件,使得程序在运行时就可以更新最新代码
        new webpack.HotModuleReplacementPlugin({
            multiStep: false,
        }),
    ],

})

开发环境打包的启动文件配置:

//本地开发启动 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中获取webpackConfig和devServer配置
const {
    webpackConfig,
    DEV_SERVER_CONFIG
} = require('./config/webpack.dev')

const app = express();

const compiler = webpack(webpackConfig);

//指定静态文件目录
app.use(express.static(path.join(__dirname, '../public/dist')))
//引入devMiddleware,监听文件改动
app.use(devMiddleware(compiler, {
    //指定落地文件,即写入磁盘的文件,出此之外的文件都是放到内存中
    writeToDisk:(filePath) => {
       return  filePath.endsWith('.tpl')
    },
    //资源路径
    publicPath: webpackConfig.output.publicPath, //对应webpack.dev.js中的第37行
    //header配置
    headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
        'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
    },
    stats: {
        colors: true,
    }
}));

//引入hotMiddleware,实现热更新
app.use(hotMiddleware(compiler,{
    path:`/${DEV_SERVER_CONFIG.HMR_PATH}`,
    log:() => {}
}));

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

//监听文件改动
consoler.info('等待webpack初次编译完成...')

const port = DEV_SERVER_CONFIG.PORT;

app.listen(port, () => {
    consoler.info(`app listening on port ${port}`)
})

热更新流程:

用户开发完业务文件后,被devServe(选择express)的监控模块(webpack-dev-middleware)所监听到,通知解析引擎进行解析编译->模块分包->压缩优化。生成的模板文件会保存,而其他的js,css文件会放到devServe的内存中。同时当js,css文件发生变化时(不存在到存在也是变化)会通过devServe的通知模块(webpack-hot-middleware)会与HMR客户端建立联系,让浏览器请求这些资源并注入相应的js、css文件到模板文件中,随后刷新页面。从而实现热更新。

image.png

这个过程和vue中的数据响应式原理类似。监控模块相当于Observe 部件,使得内容改变都能被其所感知到;通知模块相当于Dep部件的派发更新功能dep.notify() ,而HMR客户端拉取更新代码的操作相当于watcher部件在接收到Dep的派发更新后重行运行其对应的render函数一样,最终实现代码的热更新。

需要掌握的基本底层原理

模块解析机制

  • resolve.alias:路径别名转换
  • resolve.extensions:自动扩展名匹配顺序
  • resolve.modules:模块搜索目录优先级

代码生成策略

  • Runtime代码:包含模块加载、缓存等基础逻辑
  • SourceMap生成:通过devtool配置不同映射模式
  • Tree Shaking:基于ES Module静态分析实现无用代码消除

性能优化原理

  • 缓存策略

    • cache: { type: 'filesystem' } 持久化缓存
    • babel-loader?cacheDirectory=true 编译缓存
  • 并行处理

    • thread-loader 多进程编译
    • TerserWebpackPlugin.parallel 并行压缩

常见问题解决矩阵

现象可能原因解决方案
打包后文件过大未做代码分割配置 SplitChunks
修改代码不生效缓存未清除禁用 cache/clean 输出目录
样式不生效loader 顺序错误检查 loader 执行顺序
动态导入失效未配置 babel 插件添加 @babel/plugin-syntax-dynamic-import

总结

前端工程化需要根据项目的需求进行多元化配置,在我看来这就像css的庞大数量的各种属性一样,要根据具体布局选取最佳的配置方案。以webpack为例,种类多样的loader和plugin也是如此。选择的越契合那么我们项目的开发效率就会越高。而要实现这样的效果,理解工程化背后的本质及底层原理是至关重要的,至于具体的实现工具则依项目实际情况而定。我认为在前端工程化配置方面没有固定的配置模板,不见得搜到的高赞的方案就一定适合你。好的方案永远是能做到效果最大化、平衡项目业务与开发情况的。希望大家都能在项目中综合选择最适合自己的打包方案~