带你领略前端模块化

139 阅读10分钟

导读


说在前面,本文主要是笔记的形式,不会过多深入细节,主要是从大局来认识前端模块化。本人菜狗大三学生一枚,初次发掘金,有错误的地方望指正。

认识模块


模块,在我们前端开发中,可以分为以下两个方面

  • 外部模块:指我们在项目中引入的第三方库(package),通常由多个文件组件,但会暴露出入口供我们使用
  • 内部模块:我们自己在项目开发中编写的文件,比如 .js.ts.vue 等等文件 除此之外,还可以分出一个新颖的发展方向,就是 source to source 的编译和转换,比如框架和一些高级语法等等,这里我们只分析前两个。

外部模块管理

在早期,我们如果在项目中使用某个库,我们会去官网或其他地方把文件下载下来,放到项目文件夹中,然后在根 html 中引入。有了 CDN 后,我们不用下载,可以通过请求去下载,省去了人工这个部分。

但是随着项目越来越庞大,文件夹会越来越臃肿,难以更新维护,各个库难以管理,缺少统一管理的机制。

npm 的出现打破了外部模块的管理方式。npm 是一个远程代码仓库,所有人都可以向仓库提交库,供其他人使用。同时,npm 内置了一套命令行工具,使我们快速下载或上传模块。为了统一,通过 npm 命令下载的库都会被放在 node_modules 下。当我们通过 npm 下载库时,它会自动帮我们生成一个 package.json 文件,这个文件记录了我们下载的模块和对应的版本信息。

这样,外部模块就有了一个统一的机制来管理三方库。

内部模块

书写时,将一个复杂的程序按照一定的规则封装为几个模块;运行时,将其进行组合。对于一个模块来说,数据和实现是私有的,不过可以对外暴露一些接口与外部其他模块进行通信。

在没有出现规范之前,是怎样模拟模块化代码的呢?

用一个个 .js 文件去模拟,然后在根 html 中加载这些文件。如果我们按照功能去划分文件,我们就会在根 html 加载一堆的 js 文件。而且如果有的文件依赖了其他文件的代码,就必须要考虑加载顺序。维护成本太大。

接下来我们介绍几种模拟模块的方式

namespace模式

简单的对象封装

let myModule = {
    data : 'Li',
    foo() {
        console.log('foo()调用');
    },
    bar() {
        console.log('bar()调用');
    }
}
myMoudule.data = 'Chen' //能直接修改模拟模块中的数据
  • 作用: 减少了全局变量,解决命名冲突
  • 缺点:数据不安全(外部可以直接修改模块内部的数据)

IIFE模式

利用闭包给传入的对象添加数据

(function(window) {
    let data = 'Li';
    function foo() {
        console.log('foo()调用');       
    }
    function bar() {
        console.log('bar()调用');       
    }
    // 内部私有函数      
    function other() { 
        
    }
    // 对外暴露
    window.myModule = {foo, bar};
})(window)
  • 作用:数据是私有的,外部只能操作模拟模块对外暴露的方法
  • 缺点:如果一个模块依赖另一个模块,没办法技术实现

IIFE模式的增强版

增加了传入变量的个数,引入依赖

(function(window, $) {
    let data = 'Li';
    function foo() {
        console.log('foo()调用');       
    }
    function bar() {
        console.log('bar()调用');       
    }
    // 内部私有函数
    function other() {
        $('body').css('background', 'red')
    }
    // 对外暴露
    window.myModule = {foo, bar};
})(window, jQuery)

需要注意的是,依赖的模块必须比当前模块提前引入。

在线处理阶段

所谓在线处理,就是我们要先引入支持模块化的polyfill,确保文件加载完毕后,才能进行模块解析,确定加载顺序和执行顺序,是在线上进行的。本质上有一定的风险。我们列举下早期社区主流的规范。

require.js与AMD规范

我们先来看一下 require.js 的基本使用

定义无依赖的暴露模块

define(function() {
    var add = function(a, b) {
        return a+b;
    }
    //对外暴露
    return {
        add : add
    }
})

定义有依赖的暴露模块

define(['module1', 'module2'], function(m1, m2) {
    // 这里 m1 代表 module1; m2 代表 module2
    var add = function(a, b) {
        return a+b;
    }
    //对外暴露
    return {
        add : add
    }
})

引入模块使用

require(['module1', 'module2'], function(m1, m2){
   //使用 m1 或 m2
})

需要引入依赖时,第一个参数代表依赖模块的路径,第二个参数为当前模块的内容。 我们可以设置一些基本的配置

require.config({
    baseUrl : 'js/',
    paths: {
        // 自定义模块
        dataService: './service/dataService',
        
        // 配置第三方模块
      	jquery: './libs/jquery-1.10.1'
    }
}

require.js 为全局添加了 define 函数和 require 函数,我们只需要按照这种规定书写即可。对应地,其实就是 AMD 模块化规范。

sea.js与CMD规范

我们先来看一下 sea.js 的基本使用

定义无依赖的暴露模块

define(function(require, exports, module) {
    console.log('加载 add 模块');
    var add = function(a, b) {
        return a+b;
    }
    module.exports = {
        add: add
    }
})

定义有依赖的暴露模块

define(function(require, exports, module) {
    console.log('加载 add 模块');
    // 引入依赖模块 (同步)
    var module1 = require('module1');
    // 引入依赖模块 (异步)
    require.async('module2', function(m2) {
        
    })
    var add = function(a, b) {
        return a+b;
    }
    module.exports = {
        add: add
    }
})

引入模块

define(function(require) {
    var m1 = require('module1');
    var m2 = require('module2');
    m1.add;
    m2.total;
})

响应地,这些规定就对应着 CMD 规范。

AMD与CMD的区别

AMD 是依赖前置,js很方便的就知道要加载的是哪个模块了,因为已经在 definedependencies 参数中就定义好了,会立即加载它。

CMD 是依赖就近,需要使用把模块变为字符串解析一遍才知道依赖了那些模块。

AMD 是将需要使用的模块先加载完再执行代码,而 CMD 是在 require 的时候才去加载模块文件,加载完再接着执行。

commonJs

AMDCMD 都是用于浏览器端的模块规范,对于服务端的 Node.js 来说,采用的是 CommonJS 规范。

我们先来看一下基本使用

定义暴露模块

// add.js
var add = function(a, b) {
    return a+b;
}
module.exports.add = add;

引入模块

var add = require('add');
console.log(add.add(1, 2));

每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。

commonJs与AMD的区别

commonJs 模块规范中,每个文件就是一个模块,有自己的作用域。

commonJs 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。

AMD 规范则是非同步加载模块,允许指定回调函数。

由于 Node.js 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 commonJs 规范比较适用。

如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用 AMD 规范。此外 AMD 规范比 commonJs 规范在浏览器端实现要来着早。

ES6模块化

ES module 的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJSAMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。我们先看一下它的基本使用

导出模块

// profile.js
export var name = 'Li';
export var age = 18;
export var sex = 'n';

//还有一种写法
 var name = 'Li';
 var age = 18;
 var sex = '男';
export {
    name,
    age,
    sex
 };

引入模块方式

import {name, age, sex} from './profile';
console.log(`我叫${},是一个${age}岁的${sex}孩`)

es module与commonJs的区别

  • commonJs 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • commonJs 模块是运行时加载,ES6 模块是编译时输出接口

第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。 对于第一个差异,我们举个例子

counter.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
    counter: counter,
    incCounter: incCounter,
};
var mod = require('./counter');

console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

上述代码我们通过 commonJs 的规范去书写的。counter.js 模块加载以后,它的内部变化就影响不到输出的 mod.counter 了。这是因为 mod.counter 是一个原始类型的值,会被缓存。

对于 es module 模块来说, ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6import 有点像 Unix 系统的“符号连接”,原始值变了,import 加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

总结

  • commonJs 规范主要用于服务端,因为是同步加载模块,并不适合浏览器环境

  • AMD 规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD 规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅

  • CMD 规范与 AMD 规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js 中运行。不过,依赖 SPM 打包,模块的加载逻辑偏重

  • es module 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 commonJsAMD 规范,成为浏览器和服务器通用的模块解决方案

预编译

之前我们说的方案,主要是在线处理,等到用户加载完文件后,再进行模块相关的分析和处理。这样带来明显的问题是

  • 延长用户,因为要在线处理模块,如果处理的很慢,网站可能会暂时失去交互行为
  • 在加载过程中会发出海量的请求,降低页面性能 于是存在这样一个需求,希望有一个工具,可以在代码部署上线前就把代码模块组织好,并且把代码进行合并,由多个 script 代码合并到少数 script 甚至一个script 中,减少 http 请求次数。

在这种迫切的需求下,一系列模块预处理的工具出现了,其中最出名、活跃最久的当属 webpack

webpack 是一个将 JavaScript 应用程序打包工具,将模块依赖打包生成静态资源。

我们只需要简单配置下出口和入口,就可以完成基本的需求——打包。

const path = require('path')
module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist')
        filename: 'bundle.js'
    }
}

然后我们在根 html 引入这个 bundle.js 就好了。

当然这种方式也会存在弊端,最主要的问题就是打包后文件体积过大的问题。这样会导致首屏加载速度减慢。 webpack 通过代码分割 code splitting 来解决这个问题。

code splitting 一共有两种方面的优化

  • 业务代码和三方库代码分离加载:业务代码更新快,三方库的更新频率相对较低,分离后可以通过浏览器的缓存机制加载三方库的代码,提升效率
  • 按序加载:在代码中通过 import 动态加载的资源,比如路由对应的组件,会单独打包,等到对应的触发时刻再去加载,这样就减少了首屏加载的资源量

最后

本文只记录一些模块化相关的知识。对于 webpack 来说,他的作用远不止打包压缩这么简单,以 webpack 为核心,有一套自动化构建的生态,其目的是,更好地区分开发环境生成环境。关于 webpack 更详细的功能,这里不再讨论(给自己加个 flag,出个 webpack 相关的文章)。

参考

mp.weixin.qq.com/s?__biz=Mzg…