挑战100天跳槽大厂计划--day01 前端模块化

1 阅读4分钟

我是一名前端工程师,最近在系统梳理前端知识体系。今天第1篇,我们来聊聊前端模块化。

一、模块化:大厂面试的“第一道题”

面试官可能不会直接问“什么是模块化”,但一定会通过这些方式考察:

  • script 标签的 async 和 defer 有什么区别?
  • CommonJS 和 ES6 Module 的区别是什么?
  • 为什么 Tree Shaking 只在 ES6 Module 下生效?

今天,我们从实际业务场景出发,把模块化的演进史彻底搞懂。

二、业务场景:三个典型困境

某文玩电商头部企业,面临以下问题:

  1. 多页面/多技术栈共存:jQuery、Vue 2、Vue 3 项目并存,如何让不同“模块”和谐共处?
  2. 第三方 SDK 加载:直播 SDK、图片鉴定 SDK、IM SDK,体积大且依赖复杂
  3. 首屏性能:商品图片鉴定页需要加载多张大图,JS 阻塞渲染影响体验

带着这些问题,看看模块化如何一步步解决。

三、理论深度解析

阶段1:无模块化时代 —— script 标签的“野蛮生长”

三种加载模式对比:

模式执行顺序是否阻塞HTML解析适用场景
默认(无属性)按顺序,立即执行会阻塞有严格依赖关系
async谁先下载完谁执行会打断无依赖的独立脚本
defer按顺序,HTML解析完后执行不会阻塞需要保证顺序,不阻塞渲染

html

<!-- 1. 默认:阻塞模式,保证顺序 -->
<script src="jquery.js"></script>
<script src="plugin.js"></script>

<!-- 2. async:异步加载,适合无依赖的大文件 -->
<script async src="https://cdn.xxx.com/large-sdk.js"></script>

<!-- 3. defer:延迟执行,推荐用于页面主逻辑 -->
<script defer src="app.js"></script>

带来的问题:

  • 全局作用域污染,变量挂在 window 上容易冲突
  • 依赖管理混乱,手动保证顺序,稍有不慎就报错

阶段2:成长期 —— IIFE(立即执行函数)

javascript

var moduleA = (function() {
    var privateName = '我是私有的';
    function publicMethod() {
        console.log('这是公开方法');
    }
    return { sayHello: publicMethod };
})();

moduleA.sayHello();  // 输出:这是公开方法
console.log(moduleA.privateName); // undefined

优点:创建独立作用域,初步实现模块化
缺点:无法解决依赖管理,仍需保证 script 顺序

阶段3:成熟期 —— CommonJS

Node.js 的出现让 JS 走向服务端,对同步加载有天然需求。

javascript

// math.js
module.exports = { add: (a, b) => a + b };

// main.js
const { add } = require('./math.js');
console.log(add(1, 2));

优点:从框架层面解决模块化和依赖管理
缺点:同步加载不适合浏览器

阶段4:百花齐放 —— AMD、CMD、UMD

AMD(require.js):依赖前置

javascript

define(['jquery'], function($) {
    return { doSomething: () => $('#app').hide() };
});

CMD(sea.js):依赖就近

javascript

define(function(require, exports) {
    var $ = require('jquery');
    exports.doSomething = () => $('#app').show();
});

UMD:通用模块定义,自动适配 AMD、CJS 或全局变量

阶段5:大一统 —— ES6 Module

javascript

// math.js
export const add = (a, b) => a + b;

// main.js
import { add } from './math.js';
console.log(add(1, 2));

// 动态导入(代码分割、按需加载)
import('./heavy-module.js').then(module => {
    module.doSomething();
});

四、面试必考:CommonJS vs ES6 Module

维度CommonJSES6 Module
运行时 vs 编译时运行时加载,require可写在if里编译时输出静态接口,import必须在顶层
值的拷贝 vs 引用输出值的拷贝,模块内部变化不影响已加载值输出值的只读引用,模块内部变化外部能感知
加载方式同步加载异步加载
Tree Shaking不支持支持

CommonJS 值拷贝验证:

javascript

// lib.js
let count = 1;
function inc() { count++; }
module.exports = { count, inc };

// main.js
const { count, inc } = require('./lib.js');
console.log(count);  // 1
inc();
console.log(count);  // 1(还是1,因为是拷贝)

ES6 Module 引用验证:

javascript

// lib.js
export let count = 1;
export function inc() { count++; }

// main.js
import { count, inc } from './lib.js';
console.log(count);  // 1
inc();
console.log(count);  // 2(引用,值变了)

五、实践踩坑记录

  1. async/defer 误用:接入 IM SDK 时误用 async,导致 SDK 未加载完成业务代码就执行。解决:改用 defer 或动态 import()
  2. ESM 迁移策略:老项目从 CJS 迁移到 ESM,使用 esbuild 或 @babel/preset-env 转换,同时在 package.json 中用 exports 字段同时输出 CJS 和 ESM
  3. Tree Shaking 失效:打包产物包含大量未使用代码,排查发现 babel 配置把 ESM 转成了 CJS。解决:设置 modules: false

六、手写代码:简单模块加载器

javascript

const MyModule = (function() {
    const modules = {};
    
    function define(name, deps, factory) {
        modules[name] = { deps, factory };
    }
    
    function require(name) {
        const module = modules[name];
        if (!module) throw new Error(`Module ${name} not found`);
        
        const args = module.deps.map(dep => require(dep));
        const exports = module.factory.apply(null, args);
        return exports;
    }
    
    return { define, require };
})();

// 使用示例
MyModule.define('math', [], () => ({ add: (a,b) => a+b }));
MyModule.define('main', ['math'], (math) => {
    console.log(math.add(2, 3));  // 5
});

MyModule.require('main');

七、思考题

下面这段代码有什么问题?如何用函数式编程思想改造?

javascript

let discount = 0.9;
let price = 100;
function calculate() {
  if (user.isVip) {
    discount = 0.8;
  }
  return price * discount;
}

欢迎在评论区留下你的答案和思考。