前端模块化 #116

简介: 前端模块化 #116

模块化的背景


Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 Javascript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。好消息是,最新的浏览器开始原生支持模块功能了,这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。


什么是模块化?


将一个复杂的程序依据一定的规则封装成代码块(文件), 并进行组合在一起,代码块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与其它模块通信


原生如何实现模块化


function 模式

优点

  • 将不同的功能封装成不同的全局函数
  • 天生的模块化

缺点

  • 污染全局命名空间, 易引起命名冲突
  • 模块成员之间看不出直接关系

例子

function function() {
    //...
}
function function2() {
    //...
}


namespace 模式

优点

减少了全局变量,解决命名冲突

缺点

数据不安全(外部可以直接修改模块内部的数据)

例子

let module = {
    name: 'Faker',
    getName() {
        console.log(`${this.name}`)
    }
}
module.name = 'other Name' // 能直接修改模块内部的数据
module.getName() // other Name


IIFE(立即调用函数表达式) 模式

优点

数据是私有的, 外部只能通过暴露的方法操作

缺点

如果当前这个模块依赖另一个模块怎么办?

引入 script

优点

相比于使用一个 js 文件,这种多个 js 文件实现最简单的模块化的思想是进步的。

缺点

请求过多,如果我们要依赖多个模块,那样就会发送多个请求,导致请求过多

依赖关系不明显,我们不知道他们的具体依赖关系是什么,因为不了解他们之间的依赖关系导致加载先后顺序出错。

难以维护,以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。

例子

<script src="jquery.js"></script>
<script src="jquery_scroller.js"></script>
<script src="main.js"></script>
<script src="other1.js"></script>
<script src="other2.js"></script>
<script src="other3.js"></script>


模块化规范


CommonJS

Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。

特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。
  • 对外抛出的变量,是一个值的地址,不是引用地址,内部修改的变量对外导出的变量不影响。

基本语法

  • 暴露模块:module.exports = valueexports.xxx = value
  • 引入模块:require(xxx), 如果是第三方模块,xxx 为模块名;如果是自定义模块,xxx 为模块文件路径

此处我们有个疑问:CommonJS 暴露的模块到底是什么? CommonJS 规范规定,每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即 module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。

// example.js
const count = 5;
const add = function () {
    return count;
};
module.exports.count = count;
module.exports.add = add;

上面代码通过 module.exports 输出变量 count 和函数 add

const example = require('./example.js');
console.log(example.count); // 5
console.log(example.add()); // 5

CommonJS 模块的加载机制

require 命令是 CommonJS 规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的 module.require 命令,而后者又调用 Node 的内部命令 Module.\_load

Module._load = function(request, parent, isMain) {
    // 1. 检查 Module._cache,是否缓存之中有指定模块
    // 2. 如果缓存之中没有,就创建一个新的Module实例
    // 3. 将它保存到缓存
    // 4. 使用 module.load() 加载指定的模块文件,
    //    读取文件内容之后,使用 module.compile() 执行文件代码
    // 5. 如果加载/解析过程报错,就从缓存删除该模块
    // 6. 返回该模块的 module.exports
};

上面的第 4 步,采用 module.compile()执行指定模块的脚本,逻辑如下。

Module.prototype._compile = function(content, filename) {
    // 1. 生成一个require函数,指向module.require
    // 2. 加载其他辅助方法到require
    // 3. 将文件内容放到一个函数之中,该函数可调用 require
    // 4. 执行该函数
};

上面的第 1 步和第 2 步,require 函数及其辅助方法主要如下。

  • require(): 加载外部模块
  • require.resolve():将模块名解析到一个绝对路径
  • require.main:指向主模块
  • require.cache:指向所有缓存的模块
  • require.extensions:根据文件的后缀名,调用不同的执行函数

一旦 require 函数准备完毕,整个所要加载的脚本内容,就被放到一个新的函数之中,这样可以避免污染全局环境。该函数的参数包括 requiremoduleexports,以及其他一些参数。

Module.\_compile 方法是同步执行的,所以 Module.\_load 要等它执行完成,才会向用户返回 module.exports 的值。

// main.js
let counter = require('./lib').counter;
let incCounter = require('./lib').incCounter;
console.log(counter); // 3
incCounter();
console.log(counter); // 3

上面代码说明,counter 输出以后,lib.js 模块内部的变化就影响不到 counter 了。这是因为 counter 是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

CommonJS 模块的缓存

第一次加载某个模块时,Node 会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports 属性。

require('./example.js');
require('./example.js').message = "hello";
require('./example.js').message
// "hello"

上面代码中,连续三次使用 require 命令,加载同一个模块。第二次加载的时候,为输出的对象添加了一个 message 属性。但是第三次加载的时候,这个 message 属性依然存在,这就证明 require 命令并没有重新加载模块文件,而是输出了缓存。

如果想要多次执行某个模块,可以让该模块输出一个函数,然后每次 require 这个模块的时候,重新执行一下输出的函数。

所有缓存的模块保存在 require.cache 之中,如果想删除模块的缓存,可以像下面这样写。

// 删除指定模块的缓存
delete delete require.cache[require.resolve("./a.js")];
// 删除所有模块的缓存
Object.keys(require.cache).forEach(function(key) {
    delete delete require.cache[require.resolve(key)];
})

注意,缓存是根据绝对路径识别模块的,如果同样的模块名,但是保存在不同的路径,require 命令还是会重新加载该模块。

CommonJS 模块的循环加载

如果发生模块的循环加载,即 A 加载 B,B 又加载 A,则 B 将加载 A 的不完整版本。

// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';
// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';
// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

上面代码是三个 JavaScript 文件。其中,a.js 加载了 b.js,而 b.js 又加载 a.js。这时,Node 返回 a.js 的不完整版本,所以执行结果如下。

$ node main.js
b.js a1
a.js b2
main.js a2
main.js b2

修改 main.js,再次加载 a.jsb.js

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

执行上面代码,结果如下。

$ node main.js
b.js  a1
a.js  b2
main.js  a2
main.js  b2
main.js  a2
main.js  b2

上面代码中,第二次加载 a.jsb.js 时,会直接从缓存读取 exports 属性,所以 a.jsb.js 内部的 console.log 语句都不会执行了。


ES6 模块化

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

特点

  • ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

基本语法

export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。

export

let basicNum = 0;
let add = function(a, b) {
    return a + b;
};
export { basicNum, add };

import

import { basicNum, add } from './math';
function test(ele) {
    ele.textContent = add(99 + basicNum);
}

如上例所示,使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到 export default 命令,为模块指定默认输出。

export default function () {
    console.log('foo');
}


ES6 模块与 CommonJS 模块的差异


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

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


总结


CommonJS 规范主要用于服务端编程,加载模块是同步的,并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的。

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

目录
相关文章
|
2月前
|
资源调度 前端开发 JavaScript
构建高效前端项目:现代包管理器与模块化的深度解析
【2月更文挑战第21天】 在当今快速演变的前端开发领域,高效的项目管理和代码组织已成为成功交付复杂Web应用的关键。本文将深入探讨现代前端包管理器如npm, yarn和pnpm的工作原理,以及它们如何与模块化编程实践(例如CommonJS、ES6模块)协同工作以优化开发流程。我们将剖析这些工具的内部机制,了解它们如何解决依赖冲突,提高安装速度,并保证项目的健壮性。同时,本文还将介绍模块化编程的最佳实践,包括代码拆分、重用和版本控制,帮助开发者构建可维护且性能卓越的前端项目。
|
2月前
|
前端开发 JavaScript jenkins
构建高效前端项目:从模块化到自动化
【2月更文挑战第13天】 随着Web技术的不断进步,前端项目的复杂性日益增加。为了确保可维护性和性能,前端工程师必须采用模块化和自动化的策略来优化开发流程。本文将探讨如何使用现代前端工具和最佳实践来构建一个高效的前端项目架构,包括模块打包、代码分割和持续集成等方面。
|
2天前
|
JavaScript 前端开发 开发者
【Web 前端】JS模块化有哪些?
【4月更文挑战第22天】【Web 前端】JS模块化有哪些?
|
23天前
|
前端开发 JavaScript 安全
前端模块化发展
前端模块化发展
|
2月前
|
前端开发 JavaScript 内存技术
Node-前端模块化
Node-前端模块化
22 0
Node-前端模块化
|
2月前
|
前端开发 JavaScript 测试技术
构建高效前端项目:模块化与组件化策略
【2月更文挑战第25天】 在现代网页开发中,随着用户对于网页性能和交互体验的期待不断提升,前端项目的复杂性也随之增加。为了应对这种复杂性并提高开发效率,本文将探讨模块化与组件化在前端项目中的应用策略。通过分析这两种方法的优势与适用场景,我们将揭示如何利用它们来优化项目结构,提升代码复用率,以及加快开发流程。
32 4
|
2月前
|
资源调度 前端开发 JavaScript
构建高效前端项目:模块化与组件化的最佳实践
【2月更文挑战第13天】在现代前端开发的浪潮中,模块化和组件化已经成为提升项目可维护性和开发效率的核心原则。本文深入探讨了如何通过合理的模块划分、组件设计以及工具选择来优化前端项目结构,同时确保代码的复用性和可测试性。我们将从理论出发,结合实例分析,为前端开发者提供一套行之有效的最佳实践指南。
|
4月前
|
自然语言处理 JavaScript 前端开发
前端模块化的前世今生(下)
前端模块化的前世今生(下)
|
4月前
|
缓存 JSON 前端开发
前端模块化的前世今生(上)
前端模块化的前世今生(上)
|
7月前
|
前端开发 JavaScript 开发者
带你读《现代Javascript高级教程》九、前端模块化(1)
带你读《现代Javascript高级教程》九、前端模块化(1)