前端开发核心知识进阶 5 前端工程化

前端工程化

模块化

  • 模块化,将大的应用拆分成功能单一且独立的文件,通过向外暴露数据或方法,与外部其他文件交互。

  • 模块化原则:可复用性、可组合性、中心化、独立性

  • 模块化发展历程:

    1. 早期假模块化时代
    2. 规范标准时代
    3. ES原生时代

1. 早期假模块化时代

  1. 函数模式

    • 借助函数作用域来模拟模块化,即将不同功能封装成不同的函数。
    • 缺点:各个函数在同一个文件中,混乱地互相调用,且存在命名冲突。
    function f1(){}
    
    function f2() {}
    
  2. 对象模式

    • 利用对象,实现命名空间的概念
    • 缺点:数据存储不安全,开发者可随意修改
    const module1 = {
        foo: 'bar',
        f11: function f11() {},
        f12: function f12() {}
    }
    
    const module2 = {
        data: 'data',
        f21: function f21() {},
        f22: function f22() {}
    }
    
    // 调用
    module1.foo
    module1.f11()
    
  3. IIFE模式

    • 闭包就是一个天生解决数据访问性问题的方案。通过立即执行函数,构造一个私有作用域,再通过闭包,将需要对外暴露的数据和接口输出。
    // 将立即执行函数与闭包结合使用
    const module = (function () {
        var foo = 'bar'
        var fn1 = function () {}
        var fn2 = function fn2() {}
        return {
            fn1: fn1,
            fn2: fn2
        }
    })()
    
    // 调用
    module.fn1()
    
  4. IIFE模式+window

     (function(window){
         var data = 'data'
         function foo(){
         console.log(data)
         }
         function bar () {
             data = 'modified data'
             console.log(data)
         }
         window.module1 = { foo, bar }
    
     })(window)
    
     // 调用
     module1.foo()
    
     // data完全做到了私有,外界无法修改data。若要访问data值,需要模块内部设计并暴露相关接口。
    

2. 规范标准时代

  • CommonJS、AMD、CMD、UMD
  1. CommonJS

    • 概念:CommonJS,一种模块化规范,核心目标是让JS能够用于编写服务器端大型应用,解决全局作用域污染和依赖管理等问题。
    • 主要特点:
      • 每一个文件都是一个模块,拥有自己的作用域。
      • 通过module.exports或exports对外暴露模块的公共接口。
      • 通过require()函数同步地加载并执行其他模块,导入其暴露的接口。
      • 模块按照代码引入的顺序进行加载。
    • 导出模块:一个模块可以通过两种方式导出其功能
      1. 使用module.exports,模块系统真正导出的对象。可以直接给它赋值一个对象、函数、变量等。推荐
      2. exports,module.exports的一个引用,相当于let exports = module.exports,只能给exports添加属性,不能直接赋值。
    // math.js
    // 导出一个对象
    module.exports = {
        add: (a, b) => a + b,
        subtract: (a, b) => a - b,
        PI: 3.14159
    };
    
    // 或者直接导出一个函数
    module.exports = function(a, b) { return a + b; };
    
    // 或者导出一个类
    module.exports = class Calculator { ... };
    
    // math.js
    // 正确:给 exports 添加属性
    exports.add = (a, b) => a + b;
    exports.subtract = (a, b) => a - b;
    exports.PI = 3.14159;
    
    // 错误:直接给 exports 赋值会切断它与 module.exports 的联系,导致导出失败
    // exports = { add: (a, b) => a + b }; // ❌ 这样导出的对象是空的
    
    • 导入模块:使用require()函数来导入一个模块,require是同步加载的,会返回目标模块的module.exports对象。
    // main.js
    // 导入自定义模块 (需要提供路径)
    const math = require('./math.js'); // .js 扩展名可省略
    // 也可以 const { add, PI } = require('./math'); 使用解构赋值
    
    console.log(math.add(5, 3)); // 输出: 8
    console.log(math.PI);        // 输出: 3.14159
    
    // 导入Node.js核心模块或npm包 (不需要路径)
    const fs = require('fs'); // 核心模块
    const axios = require('axios'); // 第三方npm包
    
    • 核心特性与机制:同步加载、模块缓存(单例)、函数包装器
  2. AMD

    • 概念:AMD,一种在浏览器环境中使用的JS模块化规范。为了解决CommonJS同步加载模块在浏览器端不适用的问题。
    • 核心语法:define用于定义模块,require用于加载模块。
    // 1. 定义模块:define函数用于定义一个模块
    // 方法一:定义具名模块
    define('moduleName', ['dependency1', 'dependency2'], function(dep1, dep2) {
    // 模块工厂函数
    // 返回模块的值
    return {
        myMethod: function() {
        // 使用依赖 dep1, dep2
        return dep1.something() + dep2.something();
        }
    };
    });
    
    // 方法二:定义匿名模块
    // 语法: define(dependencies, factoryFunction);
    
    define(['jquery', 'lodash'], function($, _) {
    // 依赖列表中的模块('jquery', 'lodash')会异步加载
    // 加载完成后,它们会按顺序作为参数($, _)传入这个工厂函数
    
    // 模块内部的代码
    function privateFunc() {
        // ...
    }
    
    // 返回想要暴露给外部的对象
    return {
        publicMethod: function() {
        return $.ajax({ /* ... */ });
        },
        anotherMethod: function() {
        return _.map(/* ... */);
        }
    };
    });
    
    // 方法三:定义无依赖模块
    define(function() {
    let count = 0;
    return {
        increment: function() {
        return ++count;
        },
        getCount: function() {
        return count;
        }
    };
    });
    
    // 加载模块:require函数用于加载模块并使用它。用于入口点或非模块代码中
    // 语法: require(['module1', 'module2'], function(module1, module2) { ... });
    
    // 加载 jQuery 和我的自定义模块
    require(['jquery', './my-amd-module'], function($, myModule) {
    // 这个回调函数会在所有依赖都加载并执行完毕后才运行
    // $ 是 'jquery' 模块的导出值
    // myModule 是 './my-amd-module' 模块的导出值
    
    // 现在可以安全地使用它们了
    $(document).ready(function() {
        myModule.publicMethod();
    });
    });
    
    • AMD核心思想:异步加载,提前声明依赖。
    • AMD优点:
      1. 异步加载,不会阻塞浏览器渲染和后续脚本执行,用户体验更好
      2. 明确的依赖声明,依赖在模块定义之初就声明好,便于工具进行静态分析和优化
      3. 浏览器中直接使用,纯粹为浏览器环境设计的模块方案,无需打包工具
      4. 模块并行加载,可以同时加载多个模块,加快整体加载速度
      5. 支持非AMD库,通过shim配置可以很好地兼容传统的全局变量库
    • AMD缺点:
      1. 语法繁琐,相比ES Modules,define和require的回调函数语法显得臃肿,尤其依赖较多时
      2. 不是语言原生标准,是社区规范,需要引入额外的库来实现如RequireJS
      3. 运行时分析,依赖关系是在代码运行时确定的,不如ESM的静态化设计利于编译时优化
  3. CMD

    • 概念:CMD,一种用于浏览器环境的JS模块化规范。
    • 目标:提供一种更接近CommonJS书写风格的异步模块化方案。
    • 核心语法:CMD 推崇依赖就近(按需引入):在模块代码需要用到某个依赖时,再随时引入。CMD在加载模块时,可以通过同步的形式require,也可以通过异步的形式require.async。define定义模块。
    // CMD: 依赖可以在需要的地方随时引入
    define(function(require, exports, module) {
    
    // 现在只需要 dep1
    const dep1 = require('dep1');
    dep1.doSomething();
    
    // 很久以后,在某个条件分支下才需要 dep2
    if (true) { // 假设某个条件为真
        const dep2 = require('dep2'); // 此时才会去加载并执行 dep2
        dep2.doSomething();
    }
    
    // 另一个分支,不需要 dep3
    // if (false) {
    //   const dep3 = require('dep3'); // 这行不会执行,dep3 也就不会被加载
    // }
    });
    
    define(function(require, exports, module) {
    // 模块代码写在这里
    
    // 1. 使用 require(String) 引入依赖
    // 依赖是“就近”的,在需要的地方随时引入
    const $ = require('jquery');
    
    // 2. 使用 exports 对外提供接口
    exports.sayHello = function() {
        $('#app').html('<h1>Hello, CMD!</h1>');
    };
    
    // 或者使用 module.exports 提供整个接口
    // module.exports = { ... };
    
    // 3. 使用 require.async 进行异步加载(可选)
    if (someCondition) {
        require.async('./heavy-module', function(heavy) {
        // 这个回调函数会在模块加载完成后执行
        heavy.doSomething();
        });
    }
    });
    
    • CMD优点
      • 书写自然,风格接近于CommonJS
      • 按需加载,依赖就近声明
      • 模块定义简单,一个function包裹所有逻辑
    • CMD缺点
      • 静态分析困难
      • 依赖前置与依赖就近的性能争议
      • 生态和流行度
  4. UMD

    • 概念UMD,一段模式代码或一种包装技术。
    • 核心目标:让同一个模块文件,既能运行在CommonJS环境如Node.js,也能运行在AMD环境如RequireJS,还能直接被浏览器通过<script>标签引入。
    • 核心思想:利用立即执行函数根据环境来判断需要的参数类别,如果环境支持AMD,就会AMD定义模块,如果环境支持CommonJS,就用CommonJS导出,如果都不支持,就把模块挂载到全局对象上。
    • 语法:
    // 1. 在 AMD 环境 (如 RequireJS) 中使用
    // 配置路径
    require.config({
        paths: {
        'helper': 'path/to/helper',
        'math': 'path/to/math.umd'
        }
    });
    
    // 使用模块
    require(['math'], function(math) {
        console.log(math.add(2, 3)); // 输出: 5
    });
    
    // 2. 在 CommonJS 环境 (如 Node.js) 中使用
    // 假设 helper 是一个已安装的 npm 包
    const MyMathLib = require('./math.umd.js');
    console.log(MyMathLib.multiply(4, 5)); // 输出: 20
    
    // 3. 在浏览器全局环境中使用
    <!-- 先引入依赖 -->
    <script src="helper.js"></script>
    <!-- 再引入 UMD 模块 -->
    <script src="math.umd.js"></script>
    <script>
        // 模块被挂载到了 window.MyMathLib
        console.log(window.MyMathLib.add(5, 7)); // 输出: 12
        // 或者直接
        console.log(MyMathLib.multiply(3, 3)); // 输出: 9
    </script>
    
    • UMD优点
      • 通用性,一份代码,多种环境
      • 适合库开发
      • 无需转换
    • UMD缺点
      • 代码冗余
      • 调试困难
      • 逐渐过时

3. ES原生时代

  • 概念:ESM,语言层面的官方标准。目标:为JS提供一个统一、跨平台、静态的模块化方案,无论是在浏览器还是Node.js环境中。ES模块的设计思想是尽量静态化,这样能保证在编译时就确定模块之间的依赖关系,每个模块的输入和输出变量也都是确定的。

    • 静态化优势:利于tree shaking,对无用代码进行清除,减小代码体积
      • 静态化限制:
        1. 只能在文件顶部引入依赖
        2. 导出的变量类型受到严格限制
        3. 变量不允许被重新绑定,引入的模块名只能是字符串常量,即不可以动态确定依赖。
  • 核心语法:export导出模块的对外接口;import导入其他模块

    • 导出模块export/export default
      1. export default会导出整体对象结果,不利于通过tree shaking进行分析
      2. export default导出的结果可以随意命名变量,不利于团队统一管理
     // 命名导出,一个模块可以有多个命名导出
     // lib.mjs 或 lib.js (在支持ESM的环境下)
     // 方式一:在声明前直接导出
     export const PI = 3.14159;
     export function add(a, b) {
         return a + b;
     }
     export class Calculator {
         // ...
     }
    
     // 方式二:在文件末尾统一导出
     const PI = 3.14159;
     function add(a, b) { return a + b; }
     class Calculator { /* ... */ }
    
     export { PI, add, Calculator }; // 导出已定义的变量
     export { PI as圆周率, add as相加 }; // 导出时可以使用别名 (as)
    
    // 默认导出,一个模块只能有一个默认导出,通常用于导出模块的主要功能
     // my-module.js
     // 方式一:直接导出匿名函数/类
     export default function(a, b) {
     return a + b;
     }
    
     // 方式二:导出已定义的变量为默认
     const myFunction = () => { ... };
     export default myFunction;
    
     // 方式三:导出对象字面量
     export default {
     version: '1.0',
     init() { ... }
     };
    
    • 导入模块import
      // 导入命名导出,需要使用与导出时相同的名称或使用as别名
      // 导入指定的命名导出
      import { PI, add, Calculator } from './lib.js';
      // 使用 as 取别名
      import { PI as圆周率, add as相加 } from './lib.js';
      // 导入整个模块的所有命名导出到一个命名空间对象
      import * as mathLib from './lib.js';
      console.log(mathLib.PI); // 通过对象属性访问
    
     // 导入默认导出
      // 导入默认导出,可以取任何名字
      import myAddFunction from './my-module.js';
      // 同时导入默认导出和命名导出
      import myAdd, { PI } from './my-module.js';
    
     // 仅执行模块,不导入任何内容
      import './some-polyfill.js'; // 执行该模块即可
    
      // 动态导入
      // import 关键字是静态的,必须在顶层作用域使用。为了支持按需加载,ES2020 引入了动态导入 import(),它返回一个 Promise。
    
      // 静态导入
      // import { someModule } from './some-module';
    
      // 动态导入:按需加载,在需要的时候才导入
      if (someCondition) {
          import('./some-module.js')
              .then(module => {
              module.doSomething();
              })
              .catch(err => {
              console.error('Module loading failed:', err);
              });
      }
    
      // 在 async 函数中使用
      async function loadModule() {
          const module = await import('./some-module.js');
          module.doSomething();
      }
    
    • 特点
      • 静态化,编译时解析,依赖关系声明时确定,利于tree shaking
      • ESM导出的是值的引用
      • 异步加载,不会阻塞浏览器渲染
    • 环境支持与使用
     <!-- 浏览器中使用 -->
     <!-- 直接使用 -->
     <script type="module">
     import { functionFromModule } from './my-module.js';
     functionFromModule();
     </script>
    
     <!-- 引入外部模块文件 -->
     <script type="module" src="app.js"></script>
    
     // Node.js中使用
     // 1. 使用.mjs扩展名
     // file: module.mjs
     export const value = 'Hello';
    
     // file: main.mjs
     import { value } from './module.mjs';
     console.log(value);
    
     // 2. 在 package.json 中设置 "type": "module":
     {
         "name": "my-app",
         "type": "module", // 所有 .js 文件都被视为ES模块
         "main": "index.js"
     }
    
特性 ES Modules (ESM) 传统规范 (CJS/AMD/CMD)
性质 语言官方标准 社区规范
语法 import / export require() / module.exports 或 define
加载方式 异步(浏览器),静态分析 同步(CJS)或异步(AMD/CMD)
关键优势 Tree Shaking,静态优化,官方标准 简单(CJS),浏览器兼容(AMD/CMD)
环境 浏览器和 Node.js 原生支持 需要运行时(如 Node.js)或库(如 RequireJS)

webpack

1. webpack编译产物

  • Webpack 的产出物是一个或多个 JavaScript 打包文件。其核心是将项目中分散的、相互依赖的模块(ESM, CommonJS, AMD 等)打包合并成一个或少数几个文件,并用一个自定义的运行时环境将它们包裹起来,以确保模块间的依赖关系和执行顺序是正确的。
  • webpack打包输出的结果就是一个IIFE,通过这个IIFE及webpack_require来支持各种模块化打包方案。

2. webpack工作原理

  • 工作流程:

    1. 首先,初始化,webpack读取配置文件及参数,webpack插件实例化,在webpack事件流上挂载插件钩子
    2. 接着,编译,根据入口文件开始进行依赖收集,对所有依赖的文件进行编译,这个编译过程依赖loaders,不同类型文件用不同loader解析。编译好的内容解析生成抽象语法树,分析文件依赖关系,将不同模块化语法替换为__webpack_require__,即使用webpack自己的加载器进行模块化实现。整个过程插件会在合适时机执行插件任务。
    3. 最后,输出结果,将结果打包到相应目录。
    • 模块会经历加载loaded、封存sealed、优化optimized、分块chunked、哈希hashed、重新创建restored
  • AST,抽象语法树,以树状形式描述节点信息,方便开发者提取模块文件中的关键信息,然后就知晓开发者到底鞋了什么东西,方便进行分析和扩展。

  • compiler对象,它的实例包含了完整的webpack配置,且全局只有一个compiler实例。当插件被实例化时,就会收到一个compiler对象,通过这个对象可以访问webpack的内部环境。

  • compilation对象,当webpack以开发模式运行时,每当检测到文件变化时,一个新的compilation对象就会被创建。这个对象包含了当前的模块资源、编译生成资源、变化的文件等信息。即所有构建过程中产生的构建数据都会被存储在该对象上,它掌控着构建过程的每一个环节。该对象还提供了很多事件回调供插件做扩展。

  • webpack的构建过程是通过compiler控制流程,通过compilation进行代码解析的。在开发插件时,可以从compiler对象中得到所有与webpack主环境相关的内容,包括事件钩子。

3. webpack loader

  • loader的工作:接收源文件,对源文件进行处理,并返回编译后的文件。

  • 执行顺序:

    • loader的执行顺序和配置顺序是相反的,即配置的最后一个loader最先执行,第一个loader最后执行。
    • 第一个执行的loader接收源文件中的内容作为参数,其他loadeer接收前一个执行的loader返回值作为参数。最后执行的loader会返回最终结果。
  • loader的本质就是函数,一个基于CommonJS规范的函数模块,接收内容,并返回新的内容。

import loaderUtils = require('loader-utils')
module.exports = function(source){
    const options = loaderUtils.getOptions(this)
    // ....
    // return content
    this.callback(null, content)
}

// 开发一个loader时,只需要关心输入和输出,但需注意保持其职责的单一性。还要根据开发者配置的options信息进行构建定制化处理,以输出最后的结果。

// 当使用this.callback返回内容时,该loader必须返回undefined,这样webpack知道该loader返回结果在this.callback中,而不在return中。

4. webpack plugin

  • webpack事件流机制,在webpack构建的生命周期中,webpack会广播许多事件。在该机制下,开发者注册的各种插件可以根据需要监听与自身相关的事件。插件捕获事件后,可以在合适的时机通过webpack提供的API去改变编译输出结果。
  • compiler暴露了和webpack整个生命周期相关的钩子,而且暴露了与模块和依赖相关的、粒度更小的钩子。
  • 编写plugin:要清楚当前插件要解决什么问题,根据问题找到相应的钩子事件,在相关事件中进行操作,改变输出结果。
    • 一个自定义plugin,就是一个带有apply方法的类
    • 定义一个JS的class函数或在函数原型中定义一个以compiler对象为参数的apply方法。
    • 在apply方法中通过compiler插入指定的事件钩子,并在钩子回调中获取compilation对象。
    • 使用compilation修改webpack打包的内容。

5. webpack rollup

  • Rollup,下一代打包方案
    • 依赖解析,打包构建
    • 仅支持ES Next模块
    • 内置支持tree shaking功能
  • webpack,目前最流行的打包方案
  • 建库用Rollup,其他场景用webpack。非绝对。

项目组织设计

  • 管理组织代码的两种主要方式:monorepo、multirepo

    • multirepo,分而治之。将应用模块分别在不同的仓库中进行管理。
    • monorepo,集中管理,将应用中所有的模块全部放在一个仓库中,不需要单独发包、测试,所有代码都在一个项目中管理,一同部署上线,能够在开发阶段更早地复现bug,暴露问题。如Babel、React都是monorepo。
  • multirepo缺点:

    • 开发调试及版本更新效率低下
    • 团队技术选型分散,不同库的实现风格可能存在较大差异
    • changelog梳理困难,Issues管理混乱
  • monorepo缺点:

    • 库体积超大,目录结构复杂度上升。
    • 需要使用维护monorepo的工具,学习成本比较高。
  • 使用Lerna实现monorepo

    • Lerna,是Babel管理自身项目的开源工具,一个管理多包共存问题的JavaScript项目工具。

    • 避免在每个子包(package)下产生重复的 node_modules,从而节省磁盘空间并优化安装速度。主要通过两种方式来实现这一点:Hoisting(提升):默认行为。使用符合 Node.js 解析规则的包管理器:如 npm、yarn、pnpm,并利用其 Workspace 功能。

    • Fixed/Locked模式:开发者执行lerna publish命令后,Lerna会在lerna.json中找到指定的版本号。若这一次发布包含某个项目的更新,则会自动更新版本号。任何一个项目进行大版本升级,其他项目的大版本号也都会更新。

    • Independent模式:各个项目相互独立,开发者需要独立管理多个包的版本更新。即具体到更新每个包的版本,每次发布时,Lerna都会配合Git检查包文件的改动,只发布有改动的包。

// 安装依赖
// 在 monorepo 根目录或指定子包目录下,使用 pnpm 安装:
pnpm add vue --filter demo1
// 或者进入子包目录安装:
cd packages/demo1
pnpm add vue

代码规范

  • prettier,格式化、规范化代码,使其更加工整。
  • ESLint,基于静态分析代码原理找出代码反模式的过程,使得开发者在代码执行前就发现代码错误或者不合理的写法
  • linter和prettier
    • 格式化规则:max-len、no-mixed-spaces-and-tabs,代码格式方面的规范,prettier阶段会被纠正
    • 代码质量规则:no-unused-vars、no-extra-bind、no-implicit-globals、prefer-promise-reject-errors,需要linter保障
  • husky是通过git命令的钩子,在git命令进行到某一时段时,可以被交给开发者完成某些特定操作。
  • 整个项目运行lint会很慢,所以一般只对更改的文件进行检查,需要用到lint-staged。
// package.json
"scripts": {
   "lint": "eslint src/",
    "lint:fix": "eslint src/ --fix",
    "lint:debug": "eslint --debug src/",
     "prettier": "prettier --write src/**/*.js"
},
"husky": {
   "hooks": {
       "pre-commit": "lint-staged",
       "pre-push": "",
    }
},
"lint-staged": {
    "*.(js|jsx)": ["pnpm run lint:fix", "pnpm run prettier","git add"]
}

// prettier.config.js
// 根目录下创建prettier.config.js文件,并在文件中添加prettier规则
module.exports = {
    printWidth: 100,
    singleQuote: true,
    trailingComma: 'all',
    bracketSpacing: true,
    jsxBracketSameLine: false,
    tabWidth: 2,
     semi: true,
}

// .eslintrc(传统配置)
{
  "env": {
    "browser": true,
    "es2021": true
  },
  "extends": ["eslint:recommended"],
  "plugins": ["vue"],
  "rules": {
    "semi": ["error", "always"]
  }
}

// eslint.config.mjs(Flat Config 新格式)
import js from "@eslint/js";
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
import { defineConfig } from "eslint/config";

export default defineConfig([
  { 
    files: ["**/*.{js,mjs,cjs,vue}"], 
    plugins: { js }, 
    extends: ["js/recommended"], 
    languageOptions: { globals: globals.browser } 
  },
  pluginVue.configs["flat/essential"],
]);



参考&感谢各路大神

  • [前端开发核心知识进阶-侯策]
posted @ 2025-04-15 10:44  安静的嘶吼  阅读(5)  评论(0)    收藏  举报