前端模块化(小白-白话文)

88 阅读7分钟

前端模块化

webpack是这样定义模块的:

在模块化编程中,开发者将程序分解成离散功能块(discrete chunks of functionality),并称之为模块。 每个模块具有比完整程序更小的接触面,使得校验、调试、测试轻而易举。 精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。

模块应该是职责单一、相互独立、低耦合的、高度内聚且可替换的离散功能块。

首先我们要知道为什么需要模块化?

  • 可维护性:根据代码职责,把代码拆分成一块一块积木,各司其职,更有利于维护
  • 命名空间:每个拆分出来的模块都有一个独立的命名空间,可以避免所有代码都在一个全局环境中互相污染
  • 重用代码:复用代码逻辑

前端模块化的进化史:

  • 通过函数隔离

    • 需要手动管理依赖顺序
    • 容易命名冲突
    • 维护成本高
    function fn1(){
      // ...
    }
    
    function fn2(){
      // ...
    }
    
  • 用对象模拟命名空间

    • 可以避免污染全局
    • 内部属性还是不安全(外界可篡改)
    var output = {
      _count: 0,
      fn1: function(){
        // ...
      }
    }
    
  • 闭包

    • 独立的作用域环境
    • 内存中只会存在一份copy
    • 避免了外界访问篡改
    • 可以避免污染全局

    这也是在ES6Module出现之前,通过webpack等打包工具实现前端模块化的基础原理

    var module = (function(){
      var _count = 0;
      var fn1 = function (){
        // ...
      }
      var fn2 = function fn2(){
        // ...
      }
      return {
        fn1: fn1,
        fn2: fn2
      }
    })()
    
    module.fn1();
    module._count; // undefined
    

大家口中的前端模块化是指?

历史上,JavaScript 一直没有模块体系,在ES6之前,社区制定了一些模块加载的方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。

我们现在提到的前端模块化,主要在聊CommonJS和ES6Module就够了,首先CommonJS是Node.js环境中默认就支持的,因此服务端主要就用CommonJS(所以我们聊的前端模块化是指前端JS开发人员接触到的模块化规范,而不仅仅指前端开发用的模块化);但是我们也可以用webpack、browserify实现在前端使用CommonJS,也可以直接使用ES6Module。

CommonJS

  • 语法:使用require()加载和module.exports输出

  • 目标环境:浏览器环境之外的 javascript 生态系统

  • 加载机制:运行时加载(什么时候用require引入,什么时候执行),同步加载(只有加载完成,才能执行后面的操作)

  • 导入方式:可动态导入

    let path = "./cmB";
    // 修改path
    const xxx = require(path); // 可根据上下文得到的变量作为导入路径(导入的内容可是动态的,因为是运行时加载)
    
  • 输出:值的拷贝(一个Module对象)

    // 在commonJs中,每个模式本质上就是一个Module对象
    console.log(module)
    // {
    //   id: ".",
    //   path: "...",
    //   exports: { },
    //   parent: null,
    //   filename: "...",
    //   loaded: false,
    //   children: [],
    //   paths: [
    //     ...
    //   ],
    // }
    
    // 其中exports初始值就是空对象 {}
    let value = 1;
    exports.value = value;  // 仔细想下,exports.value和该模块中的value变量还有关系吗?没有了,因此基本数据类型直接被拷贝了一份新的 给exports.value
    let obj = {name: 'obj'}
    exports.obj = obj;  // 再仔细想下,exports.obj和该模块中的obj变量还有关系吗?有,因为还是同一个引用地址
    
    console.log(module)
    // {
    //   id: ".",
    //   path: "...",
    //   exports: { value: 1, obj: {name: 'obj' } },
    //   parent: null,
    //   filename: "...",
    //   loaded: false,
    //   children: [],
    //   paths: [
    //     ...
    //   ],
    // }
    
    • 当这个模块首次被加载时,会整个执行一遍,并将需要到导出的所有东西 挂载到 module.exports这个空对象{} 上,并将整个module对象缓存下来

    • 之后每当该模块再次被导入时,直接**读取缓存中的module.exports**来使用

    • 上述过程中,将需要到导出的东西 挂载到 exports 这个空对象{} 上,这个行为好比是把 所有需要导出的东西浅拷贝到了这个空对象上,所以有人说:导出的是**值的拷贝**(在我看来这种说法有点容易产生误解~),理解这种表述的原因就好

为什么CommonJS用于服务端
  • 服务端加载一个模块,无需经过网络,直接读取硬盘或内存,消耗时间成本低
  • CommonJS是同步的,需要阻塞后面代码的执行,从而会阻塞浏览器渲染页面,容易让页面出现假死状态。

ES6Module

  • 语法:使用import加载和export输出

    <script type="module">
      import xxx from 'xxx'
    </script>
    
  • 目标环境:浏览器环境

  • 加载机制:编译时加载(在静态编译时分析出所有依赖关系,在编译时,从最深的依赖先执行)

  • 导入方式:静态导入

    import xxx from `xxx.js`;  // 导入必须是静态的路径(因为静态编译时就要加载执行)
    
  • 输出:值的引用(严格意义讲,不算是引用,而是一种绑定,也可以理解为一个对外的接口)

    例如,当我们导入两个模块a、b,这两个模块又都各自导入模块c时,

    • 我们修改模块c中的变量

    • 即使变量值是基本数量类型,模块a、b中导入的c也会直指模块中的变量c的最新值

    由此可以看出,export { c, changeC } 并不是简单的导出一个对象,而是对模块导出的变量进行一种绑定

    这就是一个与CommonJS模块化导出表现的最大不同点

    // c.js
    let c = 1;
    
    function changeC() {
      c++;
    }
    
    export { c, changeC };
    
    
    // b.js
    import { c, changeC } from "./mC.js";
    changeC();
    console.log(c);  // 输出:3 
    
    
    // a.js 
    import { c, changeC } from "./mC.js";
    changeC();
    console.log(c);  // 输出:2
    
    <script type="module">
      import './a.js'
      import './b.js'
    </script>
    
注意:

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";

严格模式主要有以下限制:

  • 禁止this指向全局对象(尤其注意,ES6模块之中,顶层 this 指向 undefined
  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
  • 不能使用with语句
  • 增加了保留字(比如protectedstaticinterface
  • 等等

什么是运行时加载,什么是编译时加载

CommonJS运行时加载

注意:虽然是运行时加载,但也不是每次加载都会执行的,首次加载完成会缓存一个

cmB.js

const b = "b";
console.log("执行cmB");

module.exports = {
  b,
};

cmA.js

const a = "a";
console.log("执行cmA"); // 这里先执行

const { b } = require("./cmB");

module.exports = {
  a,
  b,
};

app.js

const { a, b } = require("./cmA");

console.log("commonJS: ", a, b);

// 执行输出顺序:

// 执行cmA
// 执行cmB
// commonJS:  a b
ES6Module编译时加载

mB.js

const b = "b";
console.log("执行mB"); // 通过静态依赖分析,最深的依赖最先执行

export { b };

mA.js

const a = "a";
console.log("执行mA");

import { b } from "./mB.js";

export { a, b };

index.html

<body>
  <script type="module">
    import { a, b } from './mA.js'
    console.log("ES6 Module: ", a, b);
    // 执行输出顺序:
    // 执行cmB
    // 执行cmA
    // ES6 Module:  a b
  </script>
</body>
使用browserify打包实现前端使用CommonJS (与Node环境下的效果一致)

cmB.js

const b = "b";
console.log("执行cmB");

module.exports = {
  b,
};

cmA.js

const a = "a";
console.log("执行cmA");

const { b } = require("./cmB.js");

module.exports = {
  a,
  b,
};

app.js

const { a, b } = require("./cmA.js");

console.log("commonJS in browserify: ", a, b);

安装 npm i browserify -g ,通过 browserify src/app.js -o src/dist/bundle.js 打包出产物:

bundle.js

(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
const { a, b } = require("./cmA.js");

console.log("commonJS in browserify: ", a, b);

},{"./cmA.js":2}],2:[function(require,module,exports){
const a = "a";
console.log("执行cmA");

const { b } = require("./cmB.js");

module.exports = {
  a,
  b,
};

},{"./cmB.js":3}],3:[function(require,module,exports){
const b = "b";
console.log("执行cmB");

module.exports = {
  b,
};

},{}]},{},[1]);

index.html引入使用

<body>
  <script src="./src/dist/bundle.js"></script>
</body>
// 执行输出顺序:

// 执行cmA
// 执行cmB
// commonJS in browserify:  a b

面试题:

有人问过你CommonJS和ES6Module哪个是拷贝、哪个是引用吗?

人家想听到的是:CommonJS是拷贝,ES6 Module 是引用,尽管这种讲法其实很粗俗。