您现在的位置是:首页 > 文章详情

前端模块化详解

日期:2022-01-07点击:390

前言

随着前端技术的发展,模块化开发已经是前端开发通用解决方案。

本文主要介绍了模块化的概念、由来、优点以及前端开发中常见的模块化规范。

一、认识模块化

模块概念?

对于一个复杂的程序,将其按照一定的规范封装成几个文件块,每一块向外暴露一些接口, 但是块的内部数据是私有的, 块与块之间通过暴露的接口进行通信,这个过程称为模块化。

一个模块具有的基本特征:

  • 代码封装,避免全局污染
  • 具有唯一标识
  • 暴露部分数据或者api方法供外部使用
  • 模块使用方便快捷

为什么会有模块化

模块化的由来,需要从早期的开发模式说起。

无封装无模块

早期的js只是作为一个浏览器脚本的身份,来实现一些简单的交互操作。

由于代码量少,所以直接将代码放在<script>标签中即可。

<script> document.getElementById('hello').onClick = function () { alert('hello'); } document.getElementById('submit-btn').onClick = function () { var username = document.getElementById('username').value; var password = document.getElementById('password').value; if (!username) { alert('请输入用户名'); return; } if (!password) { alert('请输入密码'); return; } // 提交表单 console.log('提交表单'); } </script> 复制代码

随着前端技术的发展,js的广泛应用,代码量日益增加。原始的代码堆砌方式会有很多弊端:

  • 产生大量重复的代码,同样功能的代码到处复制粘贴
  • 逻辑混乱,不利于阅读和维护
  • 很容易出现bug。

于是便将一部分功能封装到函数中,通过调用函数来执行。

封装全局功能函数

通过将不同的功能封装成不同的函数,进行调用

// 张三定义了setValue函数 function setValue(name){ document.getElementById('username').value = name; } // 张三定义了getValue函数 function getValue(){ return document.getElementById('username').value; } // 李四定义了setValue函数 function setValue(name){ document.getElementById('phone').value = name; } // 李四定义了getValue函数 function getValue(){ return document.getElementById('phone').value; } 复制代码

张三定义了setValuegetValue方法,实现了自己的功能,测试了下没有问题

第二天李四增加了功能,也定义了setValuegetValue方法,测试了下自己的功能,也没有问题

第三天,测试给张三提bug了。

所以,这种方式也有弊端:会污染全局命名空间, 容易引起命名冲突,而且模块成员之间看不出依赖

命名空间

声明一个命名空间对象,将数据和方法封装到对象中,减少了全局变量,解决命名冲突

const tool = { id: 'tool_1', type: 'input', value: '123', getType() { console.log(`type-${this.type}`); return this.type; } getValue() { console.log(`value-${this.value}`); return this.value; }, } tool.type = 'checkbox' // 直接修改模块内部的数据 tool.getType() // 'checkbox' 复制代码

这样的写法会暴露所有模块成员,内部状态可以被外部改写,导致数据安全问题。

立即执行函数、依赖注入

将数据和方法封装到一个函数内部, 通过给window添加属性来向外暴露api接口

// tool.js文件 (function(window, $) { let id = '#tool_1'; let type = 'input'; let value = '123'; let count = 0; // 函数 function getType() { console.log(`type-${this.type}`); return type; } function getValue() { console.log(`value-${$(id).val()}`); return $(id).val(); } function setValue(val) { value = val; } function increase() { count++; } // 私有方法 function resetValue() { value = '123'; } // 私有方法 function resetCount() { count = 0; } function resetHandler() { console.log('resetHandler'); resetValue(); resetCount(); } // 暴露方法 window.tool = { getType, getValue, setValue, increase, resetHandler } })(window, jQuery) 复制代码

引入js时必须保证顺序正确 (index.html文件)

<script type="text/javascript" src="jquery-1.7.2.js"></script> <script type="text/javascript" src="tool.js"></script> <script type="text/javascript"> tool.setValue('567'); </script> 复制代码

上面例子通过jquery方法获取input框的值,所以必须先引入jQuery库,当作参数传入。

原始开发方案局限性:

script标签 + 函数封装 + 命名空间 + 立即执行函数,这些方式有很大的局限性

  • 1、全局空间污染
  • 2、需手动管理依赖,不具备可扩展性
  • 3、重复加载与循环引用的问题
  • 3、无法实现按需加载
  • 4、产生大量的http请求

模块化的优点

  • 避免命名冲突(减少命名空间污染)
  • 功能分离, 按需加载
  • 可复用性
  • 可维护性

接下来介绍开发中常用的commonjs, AMD, CMD, ES6规范。

二、模块化规范

CommonJS

CommonJS概述

一个文件代表一个模块,有自己的作用域。模块中定义的变量、函数、类,都是私有的,通过暴露变量和api方法给外部使用。

Node采用了CommonJS模块规范,但并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性。

CommonJS特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。

  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。

  • 模块加载的顺序,按照其在代码中出现的顺序。

  • 运行时同步加载

CommonJS基本用法

模块定义和导出

// moduleA.js let count = 0; module.exports.increase = () => { count++; }; module.exports.getValue = () => { return count; } 复制代码

模块引入

require命令用于加载模块文件,如果没有发现指定模块,会报错。

可以在一个文件中引入模块并导出另一个模块。

// moduleB.js // 如果参数字符串以“./”或者“../”开头,则表示加载的是一个相对路径的文件 const { getValue, increase } = require('./moduleA'); increase(); let count = getValue(); console.log(count); module.exports.add = (val) => { return val + count; } 复制代码

模块标识

模块标识就是require(moduleName)函数的参数moduleName,参数需符合规范:

  • 必须是字符串
    • 如果是第三方模块,则moduleName为模块名
    • 如果是自定义模块,moduleName为模块文件路径; 可以是相对路径或者绝对路径
  • 可以省略后缀名

CommonJS模块规范的意义在于将变量和方法等限制在私有的作用域中,每个模块具有独立的空间,它们互不干扰,同时支持导入和导出来衔接上下游模块。

CommonJS模块的加载机制

一个模块除了自己的函数作用域之外,最外层还有一个模块作用域,module代表这个模块,是一个对象,exportsmodule的属性,是对外暴露的接口。

require也在这个模块的上下文中,用来引入外部模块,其实就是加载其他模块的module.exports属性。

接下来分析下CommonJS模块的大致加载流程

function loadModule(filename, module, require, __filename, __dirname) { const wrappedSrc = `(function (module, exports, require, __filename, __dirname) { ${fs.readFileSync(filename, "utf8")} // 使用的是fs.readFileSync,同步读取 })(module, module.exports, require, __filename, __dirname)`; eval(wrappedSrc); } 复制代码

这里只是为了概述加载的流程,很多边界及安全问题都不予考虑,如:

这里我们只是简单的使用 eval来我们的JS代码,实际上这种方式会有很多安全问题,所以真实代码中应该使用 vm来实现。

源代码中还有额外两个参数: __filename__dirname,这就是为什么我们在写代码的时候可以直接使用这两个变量的原因。

require实现

function require(moduleName) { // 通过require.resolve解析补全模块路径,得到一个绝对路径字符串 const id = require.resolve(moduleName); // 先查询下该id路径是否已经缓存到require.cache中,如果已经缓存过了,则直接读缓存 if (require.cache[id]) { return require.cache[id].exports; } // module 元数据 const module = { exports: {}, id, }; // 新加载模块后,将模块路径添加到缓存中,方便后续通过id路径直接读缓存 require.cache[id] = module; // 加载模块 // loadModule(id, module, require); // 直接将上面loadModule方法整合进来 (function (filename, module, require) { (function (module, exports, require) { fs.readFileSync(filename, "utf8"); })(module, module.exports, require); })(id, module, require); // 返回 module.exports  return module.exports; } require.cache = {}; require.resolve = (moduleName) => { /* 解析补全模块路径,得到一个绝对路径字符串 */ return '绝对路径字符串'; }; 复制代码

上面的模块加载时,将module.exports对象传入内部自执行函数中,模块内部将数据或者方法挂载到module.exports对象上,最后返回这个module.exports对象。

以前面的moduleA.jsmoduleB.js模块为例:

  • moduleA 模块中将 increasegetValue 方法挂载到 上下文的 module.exports对象上

    // moduleA.js let count = 0; module.exports.increase = () => { count++; }; module.exports.getValue = () => { return count; } 复制代码
  • moduleB 模块中 requiremoduleA,并return 挂载了increasegetValue方法的module.exports对象;这个对象经过结构赋值,最终被moduleB中的increasegetValue变量接收。

    // moduleB.js const { getValue, increase } = require('./moduleA'); //等价于 // let m = require('./moduleA'); // const getValue = m.getValue; // const increase = m.increase; increase(); let count = getValue(); console.log(count); module.exports.add = (val) => { return val + count; } 复制代码

require.resolve加载策略

在前面我们已经知道了resolverequire的方法属性。它的作用就是把传递进来的路径进行补全得到一个绝对路径的字符串。

function require(moduleName) { ...... // 返回 module.exports  return module.exports; } require.resolve = (moduleName) => { /* 解析补全模块路径,得到一个绝对路径字符串 */ return '绝对路径字符串'; }; 复制代码

在实际项目中,我们经常使用的方式有:

  • 导入自己写的模块文件
  • 导入nodejs提供的核心模块
  • 导入node_modules里的包模块

我们可以简单地概括下加载策略:

  • 首先判断是否为核心模块,在nodejs自身提供的模块列表中进行查找,如果是就直接返回
  • 判断参数 moduleName 是否以./或者../开头,如果是就统一转换成绝对路径进行加载后返回
  • 如果前两步都没找到,就当做是包模块,去最近的node_moudles目录中查找

由于moduleName是可以省略后缀名的,所以应该遵循一个后缀名判断规则,不同后缀名判断的优先级顺序如下:

  • 如果moduleName是带有后缀名的文件,则直接返回;
  • 如果moduleName是不带后缀名的路径,则按照一下顺序加载
    • moduleName.js
    • moduleName.json
    • moduleName.node
    • moduleName/index.js
    • moduleName/index.json
    • moduleName/index.node
  • 如果是加载的是包模块的话,就会按照包模块中package.json文件的main字段属性的值来加载

Nodejs的模块化实现

Nodejs模块在实现中并非完全按照CommonJS来,进行了取舍,增加了一些自身的的特性。

Nodejs中一个文件是一个模块: module, 一个模块就是一个Module的实例

Nodejs中Module构造函数:

function Moduleid, parent{ this.id = id; this.exports = {}; this.parent = parent; if(parent && parent.children) { parent.children.push(this); } this.filename = null; this.loaded = false; this.children = []; } //实例化一个模块 var module = new Module(filename, parent); 复制代码

其中id是模块id,exports是这个模块要暴露出来的api接口,parent是父级模块,loaded表示这个模块是否加载完成。

AMD

AMD(Asynchronous Module Definition),异步模块定义:主要用于浏览器,由于该规范不是原生js支持的,使用AMD规范进行开发的时候需要引入第三方的库函数,也就是流行的RequireJS RequireJS是AMD规范的一种实现。其实也可以说AMD是RequireJS在推广过程中对模块定义的规范化产出。

AMD是一个异步模块加载规范,它与CommonJS的主要区别就是异步加载,允许指定回调函数。模块加载过程中即使require的模块还没有获取到,也不会影响后面代码的执行。

由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑异步加载的方式,所以CommonJS规范会比较适用。但是,浏览器环境,要从服务器端下载模块文件,这时就必须采用异步加载,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS规范在浏览器端实现要早。

AMD规范基本语法

RequireJS定义了一个define函数,用来定义模块

语法

define([id], [dependencies], factory) 复制代码

参数

  • id:可选,字符串类型,定义模块标识,如果没有提供参数,默认为文件名
  • dependencies:可选,字符串数组,即当前模块所依赖的其他模块,AMD 推崇依赖前置
  • factory:必需,工厂方法,初始化模块需要执行的函数或对象。如果为函数,它只被执行一次。如果是对象,此对象会作为模块的输出值

模块定义和导出

  • 定义没有依赖的独立模块

    // module1.js define({ increase: function() {}, getValue: function() {}, }); // 或者 define(function(){ return { increase: function() {}, getValue: function() {}, } }); 复制代码
  • 定义有依赖的模块

    // module2.js define(['jQuery', 'tool'], function($, tool){ return { clone: $.extend, getType: function() { return tool.getType(); } } }); 复制代码
  • 定义具名模块

    define('module1', ['jQuery', 'tool'], function($, tool){ return { clone: $.extend, getType: function() { return tool.getType(); } } }); 复制代码

引入使用模块

require(['module1', 'module2'], function(m1, m2){ m1.getValue(); m2.getType(); }) 复制代码

require()函数加载依赖模块是异步加载,这样浏览器就不会失去响应

AMD规范和CommonJS规范对比

  • CommonJS一般用于服务端,AMD一般用于浏览器客户端
  • CommonJSAMD都是运行时加载

什么是运行时加载?

  • CommonJSAMD模块都只能在运行时确定模块之间的依赖关系
  • require一个模块的时候,模块会先被执行,并返回一个对象,并且这个对象是整体加载的

小结:AMD模块定义的方法能够清晰地显示依赖关系,不会污染全局环境。AMD模式可以用于浏览器环境,允许异步加载模块,也可以根据需要动态加载模块。

CMD

CMD(Common Module Definition),通用模块定义,它解决的问题和AMD规范是一样的,只不过在模块定义方式和模块加载时机上不同,CMD也需要额外的引入第三方的库文件,SeaJS CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。

CMD规范基本语法

define 是一个全局函数,用来定义模块

语法

define([id], [dependencies], factory) 复制代码

参数

  • id:可选,字符串类型,定义模块标识,如果没有提供参数,默认为文件名
  • dependencies:可选,字符串数组,即当前模块所依赖的其他模块,CMD 推崇依赖就近
  • factory:必需,工厂方法,初始化模块需要执行的函数或对象。如果为函数,它只被执行一次。如果是对象,此对象会作为模块的输出值

模块定义和导出

除了给 exports 对象增加成员,还可以使用 return 直接向外提供接口

  • 定义没有依赖的模块

    define(function(require, exports, module) { module.exports = { count: 1, increase: function() {}, getValue: function() {} }; }) // 或者 define(function(require, exports, module) { return { count: 1, increase: function() {}, getValue: function() {} }; }) 复制代码
  • 定义有依赖的模块

    define(function(require, exports, module){ // 引入依赖模块(同步) const module1 = require('./module1'); // 引入依赖模块(异步) require.async('./tool', function (tool) { tool.getType(); }) // 暴露模块 module.exports = { value: 1 }; }) 复制代码

引入使用模块

define(function (require) { var m1 = require('./module1'); var m2 = require('./module2'); m1.getValue(); m2.getType(); }) 复制代码

CMD规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。CMD规范整合了CommonJS和AMD规范的特点。

ES6模块化

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及导入和导出的变量。CommonJS 和 AMD 模块,都只能在运行时才能确定。

ES6模块化语法

export命令用于暴露模块的对外接口,import命令用于导入其他模块。

模块定义和导出

// moduleA.js let count = 0; export const increase = () => { count++; }; export const getValue = () => { return count; } 复制代码

模块引入

// moduleB.js import { getValue, increase } from './moduleA.js'; increase(); let count = getValue(); console.log(count); export function add(val) { return val + count; } 复制代码

导入模块时可以给变量或方法指定别名,需要使用as关键字来定义别名

// moduleB.js import { getValue as getCountValue, increase as increaseHandler } from './moduleA.js'; increaseHandler(); let count = getCountValue(); console.log(count); 复制代码

如上例所示,使用import命令的时候,需要知道所要加载的变量名或函数名,否则无法加载。

为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

模块默认导出后, 其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。

// add.js export default function (a, b) { return a + b; } // demo.js import add from './add'; console.log(add(1, 2)); // 3 复制代码

如果想在一条import语句中,同时导入默认方法和其他变量、方法,可以写成下面这样。

// moduleA.js let count = 0; export const increase = () => { count++; }; export const getValue = () => { return count; } export default { a: 1 } // moduleB.js import _, { getValue, increase } from './moduleA.js'; increase(); let count = getValue(); console.log(count); console.log(_); 复制代码

这种用法在react项目中随处可见

import React, { useState } from 'react'; function Hello() { let [ count, setCount ] = useState(0); return ( <div> <p>You click { count } times</p> <button onClick={() => setCount(count + 1)}>设置count</button> </div> ) } 复制代码

整体导入

除了指定加载某些输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

// moduleB.js // import { getValue, increase } from './moduleA.js'; import * as handler from './moduleA.js'; handler.increase(); let count = handler.getValue(); console.log(count); 复制代码

其他情况

import命令具有提升效果,会提升到整个模块的头部,首先执行。

foo(); import { foo } from './foo.js'; 复制代码

上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。

由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

import { 'f' + 'oo' } from './foo'; // 报错 let module = './foo'; import { foo } from module; // 报错 // 报错 if (x === 1) { import { foo } from './foo1'; } else { import { foo } from './foo2'; } 复制代码

上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是无法得到值的。

import语句会执行所加载的模块,因此可以有下面的写法。

import './initData'; 复制代码

initData.js中会自执行初始化数据的方法,并不需要导出变量和方法。所以只需要import这个模块,执行初始化操作即可。

ES6 模块与 CommonJS 模块的差异

它们有两个重大差异:

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

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

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

小结

由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如类型检验(type system)等只能靠静态分析实现的功能。

三、总结

  • CommonJS规范主要用于服务端编程,加载模块是同步的;在浏览器环境中,同步加载会导致阻塞,所以不适合这个规范,因此有了AMDCMD规范。

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

  • CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。

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

最后

如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激 !

PHP学习手册:https://doc.crmeb.com
技术交流论坛:https://q.crmeb.com

原文链接:https://my.oschina.net/u/5079097/blog/5393346
关注公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。

持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。

转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。

文章评论

共有0条评论来说两句吧...

文章二维码

扫描即可查看该文章

点击排行

推荐阅读

最新文章