javascript之前端系列,知其然,知其所以然(一)

835 阅读1小时+

整理这份js知识体系的起因是受神三元灵魂之问系列的启发

面对着那么多不断迭代更替的新技术,总是感觉学习时间不够,效果不好而焦虑,是不是自己一开始自己的关注点就错了,关注点不应该在于眼花缭乱的技术,而在于自身知识体系的建设。

虽然每天都在写代码,自己写的到底是什么,很多概念听着好像很熟悉,但是又说不出所以然来。为了弄清楚这些困惑在自己心中的问题,所以开始了这份知识体系的建设。

js系列总共分两篇,这是本系列的第一篇,主要内容是基于执行上下文把知识延伸到数据类型,变量,作用域,this,闭包。接着又谈了面向对象思想模块化规范

正如灵魂之问对我的启发,也希望知其然系列的内容对你有所启发。另外,由于个人知识水平有限,如有理解不对的地方,请大家批评指正。

js知识体系梳理思路框架的思维导图如下

javascript知识体系思维导图

image.png

1. js数据类型

1. js是什么类型的语言

JavaScript 是一种弱类型的、动态的语言。

  • 动态,意味着你可以使用同一个变量保存不同类型的数据。
  • 弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量的值是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来。
我们把在使用之前就需要确认其变量数据类型的称为静态语言。相反地,我们把在运行过程中需要检查数据类型的语言称为动态语言。  
支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。

2. js有哪些数据类型

js数据类型一共有8种,7种基本类型,1种引用类型

不过我们要清楚js的变量是没有数据类型的,变量仅仅是一个保存值得占位符而已,可以随时持有任何类型的数据,值才有数据类型。

基本类型值指向简单的数据段,而引用类型值指那些可能由多个值构成的对象。

  • 值访问的区别

基本类型值是按值访问的,因为可以操作保存在变量中的实际的值。

引用类型的值是按引用访问,它是保存在内存中的对象,js中不能直接操作对象的内存空间,我们实际上操作的是对象的引用而不是实际的对象。

  • 值复制的区别

基本类型复制的是值的拷贝

引用类型复制的是值的引用

  • 基本类型

    Null - 只有一个值null,表示空对象指针
    Undefined - 只有一个值undefined,没有被赋值的变量的默认值是undefined
    Boolean - 只有true和false两个值
    Number - 表示整数和浮点数64位二进制的值
    BigInt - 大整数,可以以任意精度表示整数 ,在数字后加n表示
    String - 表示由零或多个16位Unicode字符组成的字符序列
    Symbol - 符号类型唯一的并且不可修改的,通常用来作为Object的key

Number数值范围

类型最小值最大值
普通数值Number.MIN_VALUE(≈5e-324)Number.MAX_VALUE(≈1.8e+308)
安全整数Number.MIN_SAFE_INTEGER(-2⁵³ + 1)Number.MAX_SAFE_INTEGER(2⁵³ - 1)
实际能表示的最大整数-2⁵³(不精确)2⁵³(不精确)
  • 引用类型

    Object 表示一组属性的集合

    js中的对象其实是一种数据和功能的集合。js有一些内置对象,提供了各子类型所特有的属性和方法。
    js中的内置对象有:
    Object,Array,Date,RegExp,Function,Error,Map/Set(ES6新增),Promise(异步操作)等
    基本包装类型的Boolean,Number,String,
    以及单体内置对象的Global,Math。

3. 基本包装类型

为了便于操作基本类型,js提供了3个特殊的引用类型:Boolean,Number,String。 它们具有与各自的基本类型相应的特殊行为。

看下面的示例

'javascript'.substring(4)//"script"

为什么字符串可以直接调取substring方法呢?实际上,每当读取一个基本类型值得时候,后台就会创建一个对应的基本包装类型的对象,从而让我们能够调用一些方法来操作这些数据。具体是怎么做的呢?

1、创建String类型的一个实例;
2、在实例上调用指定的方法;
3、销毁这个实例

var s = new String( 'javascript')
s.substring(4)
s=null

4. 整数VS浮点数存储

整数(Integer)

  • 整数也是用浮点数存储的,但若数值在 [ -2⁵³ + 1, 2⁵³ - 1 ] 范围内,可以精确表示

  • 安全整数范围Number.isSafeInteger() 可检测):

    • Number.MIN_SAFE_INTEGER = -9007199254740991(= -2⁵³ + 1)
    • Number.MAX_SAFE_INTEGER = 9007199254740991(= 2⁵³ - 1)
  • 超出安全范围的整数会丢失精度

    console.log(9007199254740992 === 9007199254740993); // true(超出精度)
    

浮点数(Float)

  • 可以表示小数,但存在精度问题(如 0.1 + 0.2 !== 0.3)。

  • 特殊值

    • Infinity(正无穷)
    • -Infinity(负无穷)
    • NaN(非数字,如 0/0

所谓浮点数值,就是该数值中必须包含一个小数点,必须小数点后面必须至少有一位数字。

浮点数的范围

最小值大约是5e-324(Number.MIN\_VALUE),
最大值大约是1.798e+308(Number.MAX\_VALUE)\

浮点数的最高精度是17位小数,但在进行算术计算的时候其精度远远不如整数。看下面示例运行结果为什么是false呢

0.1 + 0.2 === 0.3 //false

因为 0.1 和 0.2 在二进制浮点数中无法精确表示,导致计算误差:

主要是数字存储计算是采用的是二进制,计算完成后又变成十进制的,所以造成了浮点数误差。具体0.1和0.2在转换成二进制后会无限循环,由于标准位数的限制后面多余的位数会被截掉,此时就已经出现了精度的损失,相加后因浮点数小数位的限制而截断的二进制数字在转换为十进制就会变成0.30000000000000004。

那应该是怎么判断0.1+0.2和0.3相等呢

最常见的方法设置一个误差范围,通常称为“机器精度”,对js中的数字来说,这个值是2^-52,ES6该值定义在Number.EPSILON中。可以用该值判断两个值相等。

function closeEqual(n1,n2){
    return Math.abs(n1-n2)<Number.EPSILON
}
var a= 0.1+0.2
var b=0.3
closeEqual(a,b)//true

总结

  • JavaScript 的 Number 是 64 位双精度浮点数,可表示整数浮点数
  • 整数在 [ -2⁵³ + 1, 2⁵³ - 1 ] 范围内是精确的,超出会丢失精度
  • 浮点数存在精度问题(如 0.1 + 0.2 !== 0.3),需特殊处理。
  • 特殊值Infinity-InfinityNaN(需用 Number.isNaN() 检测)。

5. BigInt

是的,JavaScript 的 BigInt 是一种用于表示任意精度整数的数据类型,它解决了 Number 类型无法安全表示超大整数(超过 2⁵³ - 1)的问题。


1. BigInt 的基本用法

(1) 定义 BigInt

在数字后面加 n 或使用 BigInt() 构造函数:

const bigNum1 = 123456789012345678901234567890n; // 直接加 n
const bigNum2 = BigInt("123456789012345678901234567890"); // 使用 BigInt()
console.log(bigNum1 === bigNum2); // true

(2) 注意事项

  • 不能和 Number 直接运算,必须统一类型:

    console.log(10n + 20n); // 30n(正确)
    console.log(10n + 5);   // TypeError: Cannot mix BigInt and other types
    
  • 不能使用 Math 方法(如 Math.sqrt(9n) 会报错)。

  • 除法 / 会自动取整(因为 BigInt 只能表示整数):

    console.log(5n / 2n); // 2n(不是 2.5)
    

2. BigInt 的特点

特性NumberBigInt
存储方式64 位浮点数任意长度整数
最大安全整数2⁵³ - 19007199254740991无限制
运算精度可能丢失精度(如 9007199254740992n + 1n 正确)精确
支持运算符+ - * / % **+ - * / % **(除法取整)
特殊值InfinityNaN
JSON 兼容(需手动转换)

3. 使用场景

(1) 超大整数计算

const hugeNum = 1234567890123456789012345678901234567890n;
console.log(hugeNum * 2n); // 2469135780246913578024691357802469135780n

(2) 高精度金融计算(避免浮点误差)

const cents = 100n; // 1.00 美元(用分表示,避免 0.1 + 0.2 问题)
const total = cents * 3n / 2n; // 150n(1.50 美元,无精度丢失)

(3) 加密算法(大数运算)

const prime = 9576890767n;
const modResult = (2n ** 1000n) % prime; // 大数取模

4. 类型转换

(1) BigInt → Number(可能丢失精度)

console.log(Number(9007199254740993n)); // 9007199254740992(精度丢失)

(2) BigInt → String

console.log(String(12345678901234567890n)); // "12345678901234567890"

(3) JSON 处理(默认不支持,需自定义)

const data = { value: 12345678901234567890n };
const jsonStr = JSON.stringify(data, (_, v) => 
  typeof v === 'bigint' ? v.toString() : v
);
console.log(jsonStr); // {"value":"12345678901234567890"}

5. 总结

  • BigInt 用于表示任意大小的整数,在数字后加 n 或使用 BigInt() 创建。

  • 优势

    • 无精度限制,适合大整数运算
    • 适合金融计算、密码学等需要高精度的场景。
  • 限制

    • 不能和 Number 直接混合运算
    • 不支持 Math 方法
    • JSON 默认不支持,需手动转换。

适用场景推荐

场景推荐类型
普通整数(< 2⁵³)Number
超大整数或高精度计算BigInt
浮点数运算Number(注意精度问题)

如果涉及大数运算(如区块链、加密算法等),BigInt 是最佳选择!

2 js数据类型的检测

1. typeof

 //判断基本类型
 console.log(typeof null)//object
 console.log(typeof undefined)// undefined
 console.log(typeof 1)// number
 console.log(typeof '1')// string
 console.log(typeof true)// boolean
 console.log(typeof 1n)// bigint
 console.log(typeof Symbol())// symbol
 //判断引用类型
 console.log(typeof {})// object
 console.log(typeof [])// object
 console.log(typeof function(){})//function

对于基本类型来说,除了null检测为object其它都可以正确检测,
对于引用类型来说,除了function检测为function其它都可以正确检测。

2. instanceof

instanceof用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,主要的作用就是判断一个实例对象是否属于某种类型.

console.log("1" instanceof String)//false
console.log(1 instanceof Number)//false
console.log(true instanceof Boolean)//false
console.log([] instanceof Array)//true
console.log(function () {} instanceof Function)//true
console.log({} instanceof Object)//true

instanceof可以用于引用类型的检测,只要构造函数的prototype在实例的原型链上就为true,但对于基本类型是不生效的,另外,不能用于检测null和undefined。

  • instanceof能否判断基本类型
class MyNumber{
    static [Symbol.hasInstance](instance){
        return typeof instance === 'number'
    }
}
console.log(1 instanceof MyNumber)

Symbol.hasInstance用于判断某对象是否为某构造器的实例。可以自定义instanceof行为。

  • 手动实现instanceof 功能 其实 instanceof 主要的实现原理就是只要右边变量的 prototype 在左边变量的原型链上即可。因此,instanceof 在查找的过程中会遍历左边变量的原型链,直到找到右边变量的 prototype,如果查找失败,则会返回 false,告诉我们左边变量并非是右边变量的实例。
//left:当前实例对象 ,right:当前构造函数。
function myInstanceof(left, right) {
    //基本数据类型直接返回false
    if(typeof left !== 'object' || left === null) return false;
    //getProtypeOf是Object对象自带的一个方法,能够拿到参数(指定对象)的原型对象
    let proto = Object.getPrototypeOf(left);
    while(true) {
        //查找到尽头,还没找到
        if(proto == null) return false;
        //找到相同的原型对象
        if(proto == right.prototype) return true;
        proto = Object.getPrototypeOf(proto);
    }
}
console.log(myInstanceof("111", String)); //false
console.log(myInstanceof(new String("111"), String));//true

3. constructor

constructor是用来标识对象类型的。

console.log(("1").constructor === String);//true
console.log((1).constructor === Number);//true
console.log((true).constructor === Boolean);//true
console.log((Symbol()).constructor === Symbol);//true
console.log((1n).constructor === BigInt);//true
console.log(([]).constructor === Array);//true
console.log((function() {}).constructor === Function);//true
console.log(({}).constructor === Object);//true

除去null和undefined,似乎说constructor能用于检测js的基本类型和引用类型。但当涉及到原型和继承的时候,便出现了问题,如下:

function fun(){}
fun.prototype = new Array();
let f = new fun();
console.log(f.constructor===fun);//false
console.log(f.constructor===Array);//true

当对象的原型更改之后,constructor便失效了。

4. Object.prototype.toString.call()

var test = Object.prototype.toString;

console.log(test.call("str"));//[object String]
console.log(test.call(1));//[object Number]
console.log(test.call(true));//[object Boolean]
console.log(test.call(null));//[object Null]
console.log(test.call(1n));//[object BigInt]
console.log(test.call(Symbol()));//[object Symbol]
console.log(test.call(undefined));//[object Undefined]
console.log(test.call([]));//[object Array]
console.log(test.call(function() {}));//[object Function]
console.log(test.call({}));//[object Object]

console.log(test.call([]).slice(8,-1).toLowerCase())//array

可以看出,Object.prototype.toString.call()可用于检测js所有的数据类型,toString表示返回对象的字符串表示。具体可以这样用 Object.prototype.toString.call().slice(8,-1).toLowerCase()

3 js数据类型的转换

1. 转换为字符串

ES规范定义了一些抽象操作(即仅供内部使用的操作)和转换规则来进行强制类型转换,ToString 抽象操作就负责处理非字符串到字符串的强制类型转换。

转换规则:

  • null 转换为 'null'
  • undefined 转换为 undefined
  • true 转换为 'true'false 转换为 'false'
  • 数字转换遵循通用规则,极大极小的数字使用指数形式
  • 普通对象除非自定义 toString() 方法,否则返回内部属性 [[Class]],如上文提到的 [object Object]
  • 对象子类型的 toString() 被重新定义的则相应调用返回结果
console.log(String(null))//'null'
console.log(String(undefined)) //'undefined'
console.log(String(true)) //'true'
console.log(String(-0)) //'0'不是本身
console.log(String(0)) //'0'
console.log(String(+0)) //'0'不是本身
console.log(String(-Infinity)) //'-Infinity'
console.log(String(Symbol())) //'Symbol()'
console.log(String(1n)) //'1'
console.log(String({})) //'[object Object]'
console.log(String([1, [2, 3]])) //'1,2,3'不是本身
console.log(String(function () {})) //'function(){}'

2. 转换为数字

ToNumber 抽象操作负责处理非数字类型转换为数字类型。

转换规则:

  • null 转换为 0
  • undefined 转换为 NaN
  • true 转换为 1false 转换为 0
  • 字符串转换时遵循数字常量规则,转换失败返回 NaN
  • 对象类型会被转换为相应的基本类型值,如果得到的值类型不是数字,则遵循以上规则强制转换为数字

对象转原始类型,会调用内置的[ToPrimitive]函数,对于该函数而言,其逻辑如下:

  1. 如果Symbol.toPrimitive()方法,优先调用再返回
  2. 调用valueOf(),如果转换为原始类型,则返回
  3. 调用toString(),如果转换为原始类型,则返回
  4. 如果都没有返回原始类型,会报错
var obj = {
  value: 3,
  valueOf() {
    return 4;
  },
  toString() {
    return '5'
  },
  [Symbol.toPrimitive]() {
    return 6
  }
}
console.log(obj + 1); // 输出7

3. 转换为布尔值

ToBoolean 抽象操作负责处理非布尔类型转换为布尔类型。

转换规则:

  • 可以被强制强制类型转换为false的值:nullundefinedfalse+0-0NaN 和 ''
  • 假值列表以外的值都是真值

下面的情况会发生布尔值隐式强制类型转换。

  • if (..) 语句中的条件判断表达式。
  • for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。
  • while (..) 和 do..while(..) 循环中的条件判断表达式。
  • ? : 中的条件判断表达式。
  • 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

4. == 和 ===

===叫做严格相等,是指:左右两边不仅值要相等,类型也要相等,例如'1'===1的结果是false,因为一边是string,另一边是number。

==不像===那样严格,对于一般情况,只要值相等,就返回true,但==还涉及一些类型转换,它的转换规则如下:

  • 两边的类型是否相同,相同的话就比较值的大小,例如1==2,返回false
  • 判断的是否是null和undefined(其它值不和它们比较),是的话就返回true
  • 判断的类型是否是String和Number,是的话,把String类型转换成Number,再进行比较
  • 判断其中一方是否是Boolean,是的话就把Boolean转换成Number,再进行比较
  • 如果其中一方为Object,且另一方为String、Number或者Symbol,会将Object转换成字符串,再进行比较

image.png

console.log({a: 1} == true);//false
console.log({a: 1} == "[object Object]");//true
//注意
NaN===NaN//false
+0===-0//true

5. || 和 &&

|| 和 && 叫逻辑运算符,&& 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

var a = 42;
var b = "abc";
var c = null;
a || b; // 42
a && b; // "abc"
c || b; // "abc"
c && b; // null

|| 和 && 的运算流程大概如下

|| 和 && 首先会对第一个操作数(a 和 c)执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。

对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c)的值,如果为false 就返回第二个操作数(b)的值。

&& 则相反,如果条件判断结果为 true 就返回第二个操作数(b)的值,如果为 false 就返回第一个操作数(a 和 c)的值。

4 执行上下文

1. 执行上下文包含内容

执行上下文(execution content)是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
执行上下文主要包含变量对象(VO),作用域链,this这些内容

2. 执行上下文的类型

哪些代码才会在执行之前就进行编译并创建执行上下文呢?一般有以下三种情况

  1. 全局执行上下文,当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。在浏览器中是 window 对象,Node.js 中是 global 对象
  2. 函数执行上下文,当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. eval执行上下文,当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

3. 执行上下文栈

JavaScript 引擎用来管理执行上下文的栈称为执行上下文栈或者叫调用栈。 每个函数都有自己的执行上下文。当执行流进入一个函数时,函数的执行上下文就会被压入一个调用栈中。而函数执行之后,调用栈将其执行上下文弹出,把控制权返回给之前的执行上下文。 调用栈是 JavaScript 引擎追踪函数执行的一个机制
那么,执行上下文的周期,分为两个阶段:

4.执行上下文周期

  • 创建阶段

    • 创建词法环境
    • 生成变量对象(VO)
      • 函数参数(函数上下文中)
      • 函数声明(整体提升)
      • 变量声明(var,初始化为 undefined)
    • 建立作用域链作用域链作用域链(重要的事说三遍)
    • 确认this指向,并绑定this
  • 执行阶段。这个阶段进行变量赋值,函数引用及执行代码。

image.png

理解执行上下文是掌握 JavaScript 核心机制的关键,它解释了:

  • 为什么函数可以访问外部变量
  • this 的动态绑定原理
  • 变量提升的实际表现
  • 闭包的工作机制

5 变量对象

变量对象(variable object)是执行上下文的一部分,它存储着该执行上下文中定义的所有的变量和函数
活动对象(active object) 当变量对象所处的上下文为 active EC 时(正在执行的函数的上下文),称为活动对象

varlet 和 const 特性

特性varletconst
作用域函数作用域块级作用域块级作用域
变量提升提升且初始化为undefined提升但不初始化(TDZ)提升但不初始化(TDZ)
全局属性会挂载到window不会挂载不会挂载
重复声明允许不允许不允许
初始值要求可不初始化可不初始化必须初始化
值可变性可修改可修改不可重新赋值(对象属性可修改)

暂时性死区(Temporal Dead Zone, TDZ)

在声明前访问 let/const 变量会报错:

console.log(a); // ReferenceError
let a = 1;
VM87:1 Uncaught ReferenceError: a is not defined
    at <anonymous>:1:13

为什么 const 对象可以修改属性?

  • const 只保证变量绑定不变(即不能指向新对象)
  • 对象内部状态可以改变
  • 如需完全不可变,可以使用 Object.freeze()

如何在全局使用 let/const

  • 全局作用域声明的 let/const 不会成为 window 属性
  • 如需显式设置全局变量,直接赋值 window.myVar = value

6 作用域

1. 作用域

作用域是指代码中定义变量的有效区域,变量的可访问范围。

2. 作用域的类型

  1. 全局作用域就是在全局中定义的变量和函数(全局执行上下文中的变量对象),全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  2. 函数作用域就是在函数内部定义的变量或者函数(函数执行上下文中的变量对象),并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
  3. 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

3. 作用域规则

作用域规则规定了变量存储在哪里,以及变量的生命周期,需要的时候如何去访问它们

4. 词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,javascript就是采用词法作用域规则的,通过它就能够预测代码在执行过程中如何查找标识符。
当代码在一个环境中执行时,会创建变量对象的一个作用域链

5. 作用域链本质

当 JavaScript 查找一个变量时,它会遵循一个特定的顺序,这个顺序就是作用域链

作用域链本质上是一个指向变量对象的指针列表,它包含自身的变量对象和父级的作用域链[[scope]],但它只 引用但不实际包含对象

6. 作用域链的用途

作用域链的用途是保证对执行上下文有权访问的所有变量和函数的有序访问。

7. 标识符解析

标识符解析是沿着作用域链一级一级地搜索标识符(变量和函数)的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止(如果找不到标识符,通常会导致错误发生).先在自己的变量对象(作用域)中搜索变量和函数,如果搜索不到则再搜索上一级作用域链中的变量对象(作用域),一直到沿着作用域链搜索到全局变量对象(作用域),直到搜索到为止,如果搜索不到,通常会报错,

7 this

this绑定规则:普通函数内部的this指向函数运行时所在的对象,this对象是在运行时基于函数的执行上下文绑定的,它引用的是函数执行的环境对象。也就是说this是在函数被调用时发生的绑定,它指向什么完全取决于函数调用的位置。this有以下几种绑定规则。

代码示例:

var a = 'global'
function foo() {
    console.log(this.a);
}
function bar(b){
    console.log(b)
}
var obj = {
    a: 'local',
    foo: foo
}
function doFoo(fn) {
    fn()
}
var barz = obj.foo;

1. 全局执行上下文

非严格模式下全局执行上下文this默认指向window,严格模式下指向undefined

2. 直接调用函数(默认绑定)

foo()//global
//以下是隐式丢失类型实际上它会应用默认绑定
barz(); //global
//回调函数丢失this绑定
doFoo(obj.foo) //global
setTimeout(obj.foo,1000)//global
setTimeout(function foo(){
    console.log(this.a,'set')
},1000)//global

// 立即执行函数
(function() {
  console.log(this); // 指向全局对象
})();

直接调用的this相当于全局执行上下文的情况,指向window。一个最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象或则undefined上,取决于是否是严格模式。

3. 对象.方法的形式调用(隐式绑定)

`obj.foo() //local`

对象.方法的this指向这个对象

4. 使用call,apply,bind绑定this(显示绑定)

foo.call(obj)//local
foo.apply(obj)//local
foo.bind(obj)()//local

/* 说明最后四行的执行结果及原因 */
var a = 3;
var obj = {
    a: 4,
    fn1: function() {
        return this.a;
    },
    fn2: () => {
        return this.a;
    }
}
 
var obj2 = {
    a: 5
}
 
obj.fn1();
obj.fn2();
obj.fn1.call(obj2);
obj.fn2.call(obj2);//3 箭头函数中的this一旦确定无法更改

this指向这个绑定的对象

5. DOM事件绑定

onclick和addEventerListener中 this 默认指向绑定事件的元素
IE8及以下版本不支持addEventerListener,使用attachEvent,里面的this默认指向window([object Window])

6. new+构造函数(new绑定)

`new bar('new')`//new

this指向这个实例对象

7. 箭头函数

箭头函数不会创建自己的this,而是根据当前的词法作用域决定当前的this,也就是箭头函数的定义生效的位置,它继承外层非箭头函数的this ,找不到就是window。

setTimeout(()=>{
    console.log(this.a,'arrow')
},1000)//global

function foo() {
  return () => {
    return () => {
      return () => {
        console.log('id:', this.id);
      };
    };
  };
}

var f = foo.call({id: 1});

var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1

箭头函数没有自己的this,所以当然也就不能用call()apply()bind()这些方法去改变this的指向

箭头函数实际上可以让this指向固定化,绑定this使得它不再可变,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。

var handler = {
  id: '123456',

  init: function() {
    document.addEventListener('click',
      event => this.doSomething(event.type), false);
  },

  doSomething: function(type) {
    console.log('Handling ' + type  + ' for ' + this.id);
  }
};

上面代码的init()方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。如果回调函数是普通函数,那么运行this.doSomething()这一行会报错,因为此时this指向document对象。

语法

  1. 基础形式
// 传统函数
const sum = function(a, b) {
  return a + b;
};

// 箭头函数
const sum = (a, b) => {
  return a + b;
};
  1. 简化形式
  • 单参数可省略括号:

    const square = x => {
      return x * x;
    };
    
  • 单行函数体可省略 {} 和 return

    const square = x => x * x;
    
  • 无参数需要空括号:

    const greet = () => console.log('Hello!');
    

特性

箭头函数有几个使用注意点。

(1)箭头函数没有自己的this对象。

(2)不可以当作构造函数,也就是说,不可以对箭头函数使用new命令,否则会抛出一个错误。

(3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

(4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

(5)无prototype 属性

最佳实践

  1. 回调函数优先使用箭头函数
  2. 需要绑定 this 的场景使用箭头函数
  3. 对象方法/构造函数使用普通函数
  4. 保持代码一致性:团队统一约定使用场景

8. 优先级

优先级:new>call、apply、bind>对象.方法>直接调用
new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定

9. 最佳实践

  1. 在需要动态 this 的场景使用普通函数
  2. 在需要固定 this 的场景使用箭头函数
  3. 避免在全局作用域滥用 this
  4. 使用 bind/call/apply 时明确指定 this
  5. 在类方法中优先使用箭头函数解决回调问题

8 闭包

1. 闭包是什么

内部函数引用了外部函数的变量,即形成了闭包,变量不会随外部函数的结束而被销毁,形成“延续的作用域”

闭包是指能够访问其他函数作用域中变量的函数,或者说:

  • 函数其周围状态(定义时词法作用域) 的组合
    • 指一个函数能够记住并访问它被创建时所处的词法作用域,即使该函数在其词法作用域之外执行
  • 即使外部函数已经执行完毕,内部函数仍能访问外部函数的变量
  • 红宝书:闭包指有权访问另一个函数作用域中的变量的函数。

  • 从理论和实践的角度去谈下闭包:

    • 理论角度:闭包指哪些能够访问自由变量的函数。

    自由变量指在函数中使用的,但既不是函数参数arguments也不是函数的局部变量的变量。全局变量也算自由变量,因为函数中访问全局变量也是访问自由变量,也就是说所有函数都可以理解为闭包。

    • 实践角度:以下函数才算闭包。

    1.即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2.在代码中引用了自由变量

  • 我个人的理解,闭包本质是一个变量对象,当函数可以记住并访问所在的词法作用域时,就产生了闭包,闭包是一种特殊的作用域,函数作用域链引用上级作用域中变量的集合会放到一个变量对象,这个变量对象存放在堆中的,其实这个变量对象就是闭包。

  • MDN

    一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure

扩展

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

2. 为什么js会有闭包

最根本的原因是,在js里,函数是一等公民,函数可以作为函数的返回值,可以作为函数的参数传入,也可以作为值赋值给变量。那么在函数调用时会出现funarg 问题,打破了基于栈的内存分配模式。而主流的js引擎都是惰性解析的,为了解决这个问题,引入闭包机制来解决这funarg问题。

  • 惰性解析

是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其编译,而仅仅会编译顶层代码。

  • Funarg问题
A functional argument (“Funarg”) — is an argument which value is a function.
函数式参数(“Funarg”) —— 是指值为函数的参数。

每个函数都有自己的执行上下文。当执行流进入一个函数时,函数的执行上下文就会被压入一个调用栈中。而函数执行之后,调用栈将其执行上下文弹出,把控制权返回给之前的执行上下文,函数的执行上下文会被销毁,相应的函数中的变量和函数也会被销毁。但是如果函数中有自由变量的时候,当访问这些自由变量的函数再次执行的时候,就会发生错误,因为这些自由变量不见了。

  • 通过下面的示例分析下闭包机制是怎么引入的
function foo() {
    var d = 20
    return function inner(a, b) {
        var c = a + b + d
        return c
    }
}
var f = foo()

我们可以分析下上面这段代码的执行过程:

  1. 当调用 foo 函数时,foo 函数会将它的内部函数 inner 返回给全局变量 f;
  2. 然后 foo 函数执行结束,执行上下文被 V8(js引擎) 销毁;
  3. 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d。

按照通用的做法,d 已经被 v8 销毁了,但是由于存活的函数 inner 依然引用了 foo 函数中的变量 d,这样就会带来两个问题:

  1. 当 foo 执行结束时,变量 d 该不该被销毁?如果不应该被销毁,那么应该采用什么策略?
  2. 如果采用了惰性解析,那么当执行到 foo 函数时,V8 只会解析 foo 函数,并不会解析内部的 inner 函数,那么这时候 V8 就不知道 inner 函数中是否引用了 foo 函数的变量 d。

所以正常的处理方式应该是 foo 函数的执行上下文虽然被销毁了,但是 inner 函数引用的 foo 函数中的变量却不能被销毁,那么 V8 就需要为这种情况做特殊处理,需要保证即便 foo 函数执行结束,但是 foo 函数中的 d 变量依然保持在内存中,不能随着 foo 函数的执行上下文被销毁掉。这就引入了闭包,闭包就是来解决这些问题的。

总的来说为什么js会有闭包的原因有三个

1、funArg问题
2、js引擎基于调用栈来管理执行上下文的。函数执行完毕后,函数内部的变量和函数会被销毁
3、js引擎采用惰性编译的。

3. 闭包是怎么实现的(形成机制)

  • 闭包实际上是通过js引擎的预解析器实现的

在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的 inner 函数,但是 V8 还是需要判断 inner 函数是否引用了 foo 函数中的变量,负责处理这个任务的模块叫做预解析器。

  • 预解析器具体是怎么实现闭包的

V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。

  1. 是判断当前函数是不是存在一些语法上的错误。
  2. 除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

对下面代码示例分析

image.png

在outer内第一行打断点调试,Scope中清晰的展示了,outer通过预解析后的情况。预解析器在解析outer时候会判断内部所有的函数是否引用了它的变量,检查到inner和foo引用了outer中的所有变量a1和b1,然后把a1和b1放入在堆中创建的Closure(outer)对象中,由于是预解析阶段,a1,b1还是undefined,到真正执行outer函数的时候,a1,b1才会赋值。

下图是闭包产生的过程。

image.png

  • 总结 由于 JavaScript 是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。

总的来说,闭包通过js引擎的预解析器实现产生闭包的核心有两步

  1. 第一步是需要预扫描内部函数
  2. 第二步是把内部函数引用的外部变量保存到堆中,即便当前函数执行结束之后,也不会释放该变量

4. 闭包与内存管理

有个耸人听闻的说法说闭包会造成内存泄漏,所以要尽量减少闭包的使用。我们分析下面的代码,看是否是这样的吗?

function foo(){
    var data = 1
    function bar(){
        console.log(data)
    }
    return bar
}
var doFoo=foo()
doFoo()

上述代码会形成覆盖foo内部作用域的闭包,由于引用闭包的doFoo是全局变量,全局变量会一直存在,所以闭包会一直存在。如果doFoo以后还会经常的使用到,data变量放在闭包中和放在全局作用域,对内存方面的影响是一样的,这里并不能说成内存泄漏。如果将来需要回收这些变量,将doFoo设置为null就可以了。

    function foo() {
        var data = 1
        function bar() {
            console.log(data)
        }
        return bar
    }
    function baz() {
        var getBar = foo();
        getBar()
    }
    baz();

上述代码也会形成覆盖foo内部作用域的闭包,由于引用闭包的getBar是局部变量,baz执行完毕后,getBar也销毁了,也不存在对闭包的引用了,闭包后续也会被垃圾回收掉,也不存在因为闭包造成内存泄漏的问题。

闭包其实就是函数作用域链对上级作用域中变量的引用,函数在,函数作用域链对闭包的引用就在,所以闭包也在,函数没人引用了,闭包也就随之被销毁。

如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,又没有解除引用的话就会造成内存泄漏。一旦数据不用的话,最后通过将其值设置为null来释放其引用。不过解除一个值得引用并不意味着自动回收该值所占的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾回收器下次运行时将其回收。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

总结
闭包会导致外部函数的变量无法被垃圾回收,因此需要:

  • 及时解除不再需要的闭包引用
  • 避免在循环中创建不必要的闭包
  • 使用开发者工具检查内存使用情况

5. 闭包的特性

  1. 函数内部可以定义新的函数
  2. 可以在内部函数中访问父函数中定义的变量
  3. 函数可以作为返回值输出

6. 闭包的表现形式及用途

1、函数作为返回值输出,可以参照闭包与内存管理中的示例。例如应用模块模式的jQuery,lodash,防抖,节流。

  • 实现私有变量/数据封装: 计数器例子,count 变量外部无法直接访问,只能通过返回的内部函数(即闭包)来间接操作,实现了数据的私有性。
  • 模块化: 在早期没有 ES Modules 或 CommonJS 模块系统时,闭包(特别是立即执行函数表达式 IIFE)被用来创建独立的模块,防止全局变量污染。

2、函数作为参数传递,可以参照在循环中创建闭包的示例。实际工作中,我们用到的定时器,事件监听器,Ajax请求,或者其他的异步任务,实际上就是在使用闭包(前提条件回调函数中引用了外部函数的变量)。

  • 事件处理器和回调函数: 当一个函数作为事件处理器或回调函数被传递时,它通常会形成一个闭包,记住其定义时环境中的变量。
  • 函数柯里化 (Currying) 或部分应用 (Partial Application): 创建一系列函数,每个函数都接收一部分参数,直到所有参数接收完毕才执行最终逻辑。

7. 闭包的优缺点

  • 优点
  1. 创建私有变量,实现封装
  2. 保持变量在内存中,可用于缓存
  3. 模块化开发的基础
  • 缺点
  1. 内存泄漏风险:闭包会使变量常驻内存,不当使用会增加内存消耗
  2. 性能考量:比普通函数稍慢,现代JavaScript引擎已优化

8. 如何解决循环中的闭包问题

多个子函数的[[scope]]都是同时指向父级,是完全共享的,他们访问的是同一个变量对象。因此当父级的变量对象被修改时,所有子函数都受到影响。

如何解决下面的循环输出问题

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, i * 1000)
}

我们对这段代码的预期是每秒一次的频率输出1-5。但实际上,这段代码会以每秒一次的频率输出五次6。
因为setTimeout为宏任务,由于JS中单线程eventLoop机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,5个回调函数的当前作用域中没有i,往上一级全局作用域查找,发现了i,此时循环已经结束了,i变成了6。它们共享了全局作用域中i,因此会全部输出6。

解决方法

1、使用IIFE(自执行函数)创建闭包
在每次迭代时候,使用IIFE(自执行函数)创建一个包含i的新的作用域,由于定时器中的回调函数持有IIFE中i的引用,其实也就是同时创建了覆盖了IIFE作用域的的闭包,回调函数执行的时候就会访问各自闭包中的i的值。

for (var i = 1; i <= 5; i++) {
    (function (j) {
        setTimeout(function timer() {
            console.log(j)
        }, j * 1000)
    })(i)
}

我们在timer打断点调式,一步步执行for循环代码可以看到具体Closure中i的变化过程

image.png

2、使用let块级作用域
使用let,在for循环中每次迭代let都会声明一个块作用域。我们使用IIFE在每次迭代时都创建一个新作用域。也就是说,每次迭代我们创建一个块作用域就可以解决循环输出的问题了。

for (let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, i * 1000)
}

//使用foreEach也可以 其本质也是for循环实现的

// 核心逻辑 注意是let声明的块级作用域 
for (let i = 0; i < length; i++) {
    if (i in array) {
      var element = array[i];
      callback(element, i, array);
    }
}

;[1,2,3,4,5].forEach((item,index)=>{
  setTimeout(function timer() {
      console.log(index+1,'setindex')
  }, (index+1) * 1000)
})

我们在timer打断点调式,一步步执行for循环代码可以看到具体Block中i的变化过程

image.png

3、给定时器传入第3个参数
这个参数会作为参数传递给timer。由于函数的参数是按值传递的,即使全局作用域的i发生了变化,传递给回调函数中的i也不会变化的。

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer(j) {
        console.log(j)
    }, i * 1000,i)
}

我们在timer打断点调式,一步步执行for循环代码可以看到具体Local中i的变化过程

image.png

9 JS面向对象

1. 理解面向对象编程

什么是面向对象

什么是面向对象呢,用java中的一句经典语句来说就是:万事万物皆对象。面向对象的思想主要是以对象为主,将一个问题抽象出具体的对象,并且将抽象出来的对象和对象的属性和方法封装成一个类

面向对象是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。

面向对象和面向过程的区别

面向对象和面向过程是两种不同的编程思想,我们经常会听到两者的比较,刚开始编程的时候,大部分应该都是使用的面向过程的编程,但是随着我们的成长,还是面向对象的编程思想比较好一点

其实面向对象和面向过程并不是完全相对的,也并不是完全独立的。

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达,对象以方法的形式包含了过程。

我认为面向对象和面向过程的主要区别是面向过程主要是以动词为主解决问题的方式是按照顺序一步一步调用不同的函数
面向对象主要是以名词为主,将问题抽象出具体的对象,而这个对象有自己的属性和方法,在解决问题的时候是将不同的对象组合在一起使用

所以说面向对象的好处就是可扩展性更强一些,解决了代码重用性的问题。

  • 面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。
  • 面向对象是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。

具体的实现我们看一下最经典的“把大象放冰箱”这个问题

面向过程的解决方法

在面向过程的编程方式中实现“把大象放冰箱”这个问题答案是耳熟能详的,一共分三步:

  1. 开门(冰箱);
  2. 装进(冰箱,大象);
  3. 关门(冰箱)。
面向对象的解决方法
  1. 冰箱.开门()
  2. 冰箱.装进(大象)
  3. 冰箱.关门()

可以看出来面向对象和面向过程的侧重点是不同的,面向过程是以动词为主,完成一个事件就是将不同的动作函数按顺序调用。
面向对象是以主谓为主。将主谓看成一个一个的对象,然后对象有自己的属性和方法。比如说,冰箱有自己的id属性,有开门的方法。然后就可以直接调用冰箱的开门方法给其传入一个参数大象就可以了。

简单的例子面向对象和面向过程的好处还不是很明显。请看下面购物车的列子

面向对象实战思想

购物车例子

万物皆对象,所以,任何事物都是有特征(属性)和动作(方法)的,一般拿到一份需求分档,或者你浏览一个网页看到一个画面的时候,脑子里就要有提炼出来的属性和方法的能力,那你才是合格的。

image.png

做任何东西,先宏观思考* ,然后再去处理细节,然后组装起来,就好像组装汽车的道理一样。例如上图,红色的就是属性,黄色的就是方法,抽象出属性和方法,其他都是死的。

面向过程思想实现

假如是刚学前端的同学,可能就会用这种全局化的变量,也叫面向函数编程,缺点就是很乱,代码冗余

//商品属性
var name = 'macbook pro'
var description = ''var price = 0;
//商品方法
addOne:funcion(){alert('增加一件商品')},
reduceOne:function(){alert('减少一件商品')},

//购物车属性
var card = ['macbook pro' ,'dell']
var sum = 2,
var allPrice = 22000,
//购物车方法
function addToCart:function(){
        alert('添加到购物车')
    }


addToCart()
单例模式思想实现

假如是单例模式的思想,可能会这样做,但这样还是不太好。对象太多,可能造成变量重复,项目小还可以接受

var product={
        name:'macbook pro',
        description:'',
        price:6660,
        addOne:funcion(){},
        reduceOne:function(){},
        addToCart:function(){
            alert('添加到购物车')
        }
    }

    /*购物车*/
    var cart={
        name:'购物车',
        products:[],
        allPrice:5000,
        sum:0
    }
面向对象思想实现

假如是有一定经验的人,可能会这样子做。

function Product(name,price,des) {
        /*属性 行为 可以为空或者给默认值*/
        this.name = name;
        this.price = price;
        this.description = des;
    }
Product.prototype={
    addToCart:function(){
        alert('添加到购物车')
    }
    addOne:funcion(){},
    reduceOne:function(){},
     /*绑定元素*/
    bindDom:function(){
    //在这里进行字符串拼接,
    //例如
    var str = ''
    str +='<div>价格是:'+this.privce+'</div>'
    return str
    },

}

function Card(products,allPrice,sum) {
        /*属性 行为 可以为空或者给默认值*/
        this.products = products;
        this.allPrice = allPrice;
        this.sum = sum
    }
Product.prototype={
    getAllPrice:function(){
        alert('计算购物车内商品总价')
    }
}

通过创建各种对象例如macbook

//后台给的数据
var products= [
    {name:'macbook',price:21888},
    {name:'dell',price:63999}
]

var str = ''
for(var i = 0,len=products.length;i<len;i++) {
    var curName = products[i].name
    var curName = new Product()
    curName.name=products[i].name;
    curName.price=products[i].price;
    str+= curName.bindDom()
}
MVVM模式思想实现

MVVM的核心是数据驱动即ViewModel,ViewModel是View和Model的关系映射。ViewModel类似中转站(Value Converter),负责转换Model中的数据对象,使得数据变得更加易于管理和使用,该层向上与视图层进行双向数据绑定,向下与 Model 层通过接口请求进行数据交互,起呈上启下作用MVVM本质就是基于操作数据来操作视图进而操作DOM,借助于MVVM无需直接操作DOM,开发者只需完成包含声明绑定的视图模板,编写ViewModel中有业务,具体包含数据模型和展示逻辑,使得View完全实现自动化。

image.png

例如vue,他们不需要获取dom,那么渲染的时候,定义好一个一个的组件就行了。属性全部在data定义好,方法在methods中定义,剩下的就是vue来解决了。

data:{
        name ='',
        price='',
        description = ''
},
methods:{
     addToCart:function(){
            alert('添加到购物车')
        }
    addOne:funcion(){},
    reduceOne:function(){},  
}

然后page级组件引入这个产品组件,然后循环这个产品组件就好了。一个组件也是一个对象,其本质还是面向对象的思想。

2. 原型和原型链

原型和原型链是构建js面向对象系统的基础,我们先了解下原型和原型链

理解原型,构造函数,实例之间的关系

我们创建的每个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。

在JavaScript中,每当定义一个函数数据类型(普通函数、类)时候,就会根据一组特定规则为该函数创建一个prototype(原型)属性,这个属性指向函数的原型对象。原型对象上包含可以由特定类型的所有实例共享的属性和方法。另外,在默认情况下,所有原型对象都会自动获得一个constructor(构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。

当函数经过new调用时,这个函数就成为了构造函数,返回一个全新的实例对象,这个实例对象有一个__proto__属性,指向构造函数的原型对象

function Foo(name){
    this.name=name
}

var newFoo = new Foo('dongnan')
console.log(newFoo)//打印结果如下图

image.png

构造函数Foo,Foo的原型(prototype),Foo的实例newFoo的关系如下图所示。

image.png

原型链

javascript对象通过__proto__指向当前对象的原型对象,当前对象的原型对象再通过__proto__指向父类原型对象,直到指向Object对象的原型对象为止,这样就形成了一个指向原型指向的链条,即原型链。

平时我们访问一个对象的属性和方法时候,就是沿着原型链查找的。查找顺序,1)当前实例,2)当前实例的原型对象,3)父类的原型对象,N)Object的原型对象。

image.png

 检查原型关系的方法

// 检查实例与构造函数的关系
console.log(dog instanceof Dog); // true

// 检查原型链
console.log(Dog.prototype.isPrototypeOf(dog)); // true

// 获取对象的原型
console.log(Object.getPrototypeOf(dog));

3. 封装

面向对象有三大特性,封装、继承和多态。对于ES5来说,没有class的概念,并且由于js的函数级作用域(在函数内部的变量在函数外访问不到),所以我们就可以模拟 class的概念,在es5中,类其实就是保存了一个函数的变量,这个函数有自己的属性和方法。将属性和方法组成一个类的过程就是封装。

封装:把客观事物封装成抽象的对象,隐藏属性和方法的实现细节,仅对外公开接口。
具体的事物抽象化

工厂模式

用函数来封装以特定接口创建对象的细节,这种创建对象的模式叫做工厂模式

function createPerson(name, age, job) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function () {
        alert(this.name);
    };
    return o;
}
var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");

这种对象的不足之处是不知道对象的类型,因为对象都是通过Object创建的。

构造函数模式

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        alert(this.name);
    };
}
var newperson1 = new Person("Nicholas", 29, "Software Engineer");
var newperson2 = new Person("Greg", 27, "Doctor");

这种方式与工厂模式的不同之处

  • 没有显示的创建对象
  • 直接将属性和方法赋值给this对象
  • 没有return语句

要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 4 个步骤:

(1) 创建一个新对象;
(2) 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
(3) 执行构造函数中的代码(为这个新对象添加属性);
(4) 返回新对象。

构造函数模式解决了对象识别的问题,但是使用构造函数的主要问题,就是每个方法都要在每个实例上重新创建一遍。说明白些,以这种方式创建函数,会导致不同的作用域链和标识符解析,但创建 Function 新实例的机制仍然是相同的。因此,不同实例上的同名函数是不相等的。怎么解决这个问题

alert(newperson1.sayName == newperson2.sayName); //false

原型模式

通过原型对象创建对象

function Person(){}
Person.prototype.name='dongnan'
Person.prototype.age='21'
Person.prototype.sayName=function(){
    console.log(this.name)
}

var person1=new Person()
person1.sayName()//dongnan

var person2 = new Person()
person2.sayName()//dongnan

原型模式的不足之处,通过原型共享方法很合适,但是共享属性不符合创建实例对象的初衷(实例一般都需要有自己的全部属性的)

组合使用构造函数模式和原型模(推荐)

创建自定义类型的最常见方式,是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参数;可谓是集两种模式之长。

function Person(name,age){
    this.name=name
    this.age=age
    this.friends=['zk','zh']
}
Person.prototype={
    constructor:Person,
    sayName:function(){
        console.log(this.name)
    }
}
var person1 = new Person('js',29)
var person2 = new Person('vue',10)
person1.friends.push('react')
console.log(person1.friends)//["zk", "zh", "react"]
console.log(person2.friends)//["zk", "zh"]
console.log(person1.friends===person2.friends)//false
console.log(person1.sayName===person2.sayName)//true

这是定义引用类型的一种默认模式

动态原型模式

有其他 OO 语言经验的开发人员在看到独立的构造函数和原型时,很可能会感到非常困惑。动态原型模式正是致力于解决这个问题的一个方案,它把所有信息都封装在了构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。换句话说,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。来看一个例子。

function Person(name, age, job) {
    //属性
    this.name = name;
    this.age = age;
    this.job = job;
    //方法
    if (typeof this.sayName != "function") {
        console.log(1)
        Person.prototype.sayName = function () {
            console.log(this.name);
        };

    }
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();

寄生构造函数模式

在这个例子中,Person 函数创建了一个新对象,并以相应的属性和方法初始化该对象,然后又返回了这个对象。除了使用 new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数的末尾添加个 return 语句,可以重写调用构造函数时返回的值。这个模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改 Array 构造函数,因此可以使用这个模式。

function SpecialArray() {
    //创建数组
    var values = new Array();
    //添加值
    values.push.apply(values, arguments);
    //添加方法
    values.toPipedString = function () {
        return this.join("|");
    };

    //返回数组
    return values;
}
var colors = new SpecialArray("red", "blue", "green");
alert(colors.toPipedString()); //"red|blue|green"
console.log(colors,'colors')

返回的对象与构造函数或者与构造函数的原型属性之间没有关系,在可以使用其它模式的情况下不推荐使用。

稳妥构造函数模式

道格拉斯·克罗克福德(Douglas Crockford)发明了 JavaScript 中的稳妥对象(durable objects)这 个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用 this 和 new),或者在防止数据被其他应用程序(如 Mashup程序)改动时使用。稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建对象的实例方法不引用 this;二是不使用 new 操作符调用构造函数。按照稳妥构造函数的要求,可以将前面的 Person 构造函数重写如下。

function Person(name,age){
    // 创建要返回的对象
    var o = new Object()
    // 定义私有变量和方法
    // 添加方法
    o.sayName = function(){
        console.log(name)
    }
    // 返回对象
    return o
}
var friend =Person('dongnan',21)
friend.sayName()

稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境。

4. 继承

继承:一个对象继承另一个对象的所有功能,并且对这些功能进行扩展。继承的过程,就是从一般到特殊的过程。

原型链

基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。

function SuperType() {
    this.property = true;
}
SuperType.prototype.getSuperValue = function () {
    return this.property;
};

function SubType() {
    this.subproperty = false;
}
//继承了 SuperType 
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () {
    return this.subproperty;
};
var instance = new SubType();
alert(instance.getSuperValue()); //true

原型链虽然很强大,可以用它来实现继承,但它也存在一些问题。其中,最主要的问题来自包含引用类型值的原型,包含引用类型值的原型属性会被所有实例共享。原型链的第二个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。一般不会单独使用。

借用构造函数

在解决原型中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数(constructor stealing)的技术(有时候也叫做伪造对象或经典继承)。这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。

function SuperType(name){
    this.name=name
}
function SubType(name){
    // 继承了SubperType 同时还传递了参数
    SuperType.call(this,name)
    // 实例属性
    this.age=21
}
var instance = new SubType('dongnan')
console.log(instance.name)//dongnan
console.log(instance.age)//21

该模式存在的问题,方法都在构造函数中定义,因此函数复用就无从谈起了。而且,在超类型的原型中定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。很少单独使用

组合继承

组合继承(combination inheritance),有时候也叫做伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。

function SuperType(name){
    this.name=name
    this.colors=['red','blue','green']
}

SuperType.prototype.sayName=function(){
    console.log(this.name)
}

function SubType(name,age){
    // 继承属性
    SuperType.call(this,name)//第一次调用SuperType
    this.age=age
}
// 继承方法
SubType.prototype=new SuperType()//第二次调用SuperType
SubType.prototype.constructor=SubType
SubType.prototype.sayAge=function(){
    console.log(this.age)
}

var instance1 = new SubType('dongnan',21)
instance1.colors.push('black')
console.log(instance1.colors)//["red", "blue", "green", "black"]
instance1.sayAge()//21
instance1.sayName()//dongnan

var instance2 = new SubType('fusheng',29)
console.log(instance2.colors)// ["red", "blue", "green"]
instance2.sayAge()//29
instance2.sayName()//fusheng

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继承模式。而且,instanceof 和 isPrototypeOf()也能够用于识别基于组合继承创建的对象。

组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。没错,子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。

原型试继承

借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。

var person ={
    name:"dongnan",
    friends:['df1','df2']
}

var anotherPerson =Object.create(person)
anotherPerson.name='fusheng'
anotherPerson.friends.push('df3')

var yetAnotherPerson = Object.create(person)
yetAnotherPerson.name='liuji'
yetAnotherPerson.friends.push('df4')

console.log(person.friends)// ["df1", "df2", "df3", "df4"]

Object.create内部实现的简化版

function Object(o){
    function F(){}
    F.prototype=o
    return new F()
}

寄生试继承

寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

function createAnother(original){
    var clone = Object.create(original)
    clone.sayHi = function(){
        console.log('hi')
    }
    return clone
}

var person ={
    name:"dongnan",
    friends:['df1','df2']
}
var anotherPerson=createAnother(person)
anotherPerson.sayHi()//hi

寄生组合式继承(推荐)

这种继承方式对组合继承进行了优化,组合继承缺点在于继承父类函数时调用了构造函数,我们只需要优化掉这点就行了。

function SuperType(name) {
    this.name = name
    this.colors = ['red', 'blue', 'green']
}

SuperType.prototype.sayName = function () {
    console.log(this.name)
}

function SubType(name, age) {
    // 继承属性
    SuperType.call(this, name)
    this.age = age
}
// 继承SupType的原型
//**`Object.create()`**  方法创建一个新对象,使用现有的对象来提供新创建的对象的 __proto__。
//描述符默认值汇总
//拥有布尔值的键 `configurable`、`enumerable` 和 `writable` 的默认值都是 `false`。
//属性值和函数的键 `value`、`get` 和 `set` 字段的默认值为 `undefined`。
SubType.prototype = Object.create(SuperType.prototype,{
    constructor:{//定义数据属性
        value:SubType,
        configurable:true//描述符是否可以配置
        enumerable:false,//属性是否可以枚举
        writable:true,//属性是否可以改写
    }
})
SubType.prototype.sayAge = function () {
    console.log(this.age)
}

//Child.prototype = Object.create(Parent.prototype); // 原型链继承
//Child.prototype.constructor = Child;

var instance = new SubType('dongnan','21')

instance.sayName()//dognnan
instance.sayAge()//21
console.log(instance instanceof SubType)//true
console.log(instance instanceof SuperType)//true

image.png

该继承实现的核心就是将父类的原型赋值给了子类,并且将构造函数设置为子类,这样父类只调用了一次,并且因此解决了无用的父类属性问题,还能正确的找到子类的构造函数。

5. Babel 如何编译 ES6 Class 的

es6的继承可以直接使用 class 来实现继承。
但是 class 毕竟是 ES6 的东西,为了能更好地兼容浏览器,我们通常都会通过 Babel 去编译 ES6 的代码。接下来我们就来了解下通过 Babel 编译后的代码是怎么样的。

function _possibleConstructorReturn(self, call) {
    // ...
    return call && (typeof call === 'object' || typeof call === 'function') ? call : self;
}

function _inherits(subClass, superClass) {
    // ...
    //看到没有
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}


var Parent = function Parent() {
    // 验证是否是 Parent 构造出来的 this
    _classCallCheck(this, Parent);
};

var Child = (function (_Parent) {
    _inherits(Child, _Parent);

    function Child() {
        _classCallCheck(this, Child);

        return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
    }

    return Child;
}(Parent));

以上代码就是编译出来的部分代码,隐去了一些非核心代码,我们先来阅读 _inherits 函数。

设置子类原型部分的代码其实和寄生组合继承是一模一样的,侧面也说明了这种实现方式是最好的。但是这部分的代码多了一句 Object.setPrototypeOf(subClass, superClass),其实这句代码的作用是为了继承到父类的静态方法,之前我们实现的两种继承方法都是没有这个功能的。

然后 Child 构造函数这块的代码也基本和之前的实现方式类似。所以总的来说 Babel 实现继承的方式还是寄生组合继承,无非多实现了一步继承父类的静态方法。

6. 从设计思想上谈谈继承

继承存在的问题

假如现在有不同品牌的车,每辆车都有drive、music、addOil这三个方法。

class Car{
  constructor(id) {
    this.id = id;
  }
  drive(){
    console.log("wuwuwu!");
  }
  music(){
    console.log("lalala!")
  }
  addOil(){
    console.log("哦哟!")
  }
}
class otherCar extends Car{}

现在可以实现车的功能,并且以此去扩展不同的车。

但是问题来了,新能源汽车也是车,但是它并不需要addOil(加油)。

如果让新能源汽车的类继承Car的话,也是有问题的,俗称"大猩猩和香蕉"的问题。大猩猩手里有香蕉,但是我现在明明只需要香蕉,却拿到了一只大猩猩。也就是说加油这个方法,我现在是不需要的,但是由于继承的原因,也给到子类了。

继承的最大问题在于:无法决定继承哪些属性,所有属性都得继承。

当然你可能会说,可以再创建一个父类啊,把加油的方法给去掉,但是这也是有问题的,一方面父类是无法描述所有子类的细节情况的,为了不同的子类特性去增加不同的父类,代码势必会大量重复,另一方面一旦子类有所变动,父类也要进行相应的更新,代码的耦合性太高,维护性不好。

如何解决继承的问题

继承更多的是去描述一个东西是什么,描述的不好就会出现各种各样的问题,那么我们是否有办法去解决这些问题呢?答案是组合。

什么是组合呢?你可以把这个概念想成是,你拥有各种各样的零件,可以通过这些零件去造出各种各样的产品,组合更多的是去描述一个东西能干什么。

现在我们把之前那个车的案例通过组合的方式来实现。

function drive(){
  console.log("wuwuwu!");
}
function music(){
  console.log("lalala!")
}
function addOil(){
  console.log("哦哟!")
}

let car = compose(drive, music, addOil);
let newEnergyCar = compose(drive, music);
复制代码

从上述伪代码中想必你也发现了组合比继承好的地方。无论你想描述任何东西,都可以通过几个函数组合起来的方式去实现。代码很干净,也很利于复用。

用组合,这也是当今编程语法发展的趋势,比如golang完全采用的是面向组合的设计方式。

7. ES6 Class

1. Class 基本语法

1. 基本类定义
class Person {
  // 构造函数
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  
  // 实例方法
  greet() {
    return `你好,我是${this.name},今年${this.age}岁`;
  }
  
  // 静态方法
  static info() {
    return '这是一个Person类';
  }
}

// 使用类
const zhangSan = new Person('张三', 25);
console.log(zhangSan.greet()); // "你好,我是张三,今年25岁"
console.log(Person.info());    // "这是一个Person类"
2. 类表达式
const Animal = class {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} 发出声音`);
  }
};

2. 核心特性

1. 继承(extends)
class Student extends Person {
  constructor(name, age, grade) {
    super(name, age); // 调用父类构造函数
    this.grade = grade;
  }
  
  study() {
    return `${this.name}正在学习${this.grade}年级课程`;
  }
  
  // 方法重写
  greet() {
    return `${super.greet()},我是${this.grade}年级学生`;
  }
}

const liSi = new Student('李四', 18, '高三');
console.log(liSi.study()); // "李四正在学习高三年级课程"
console.log(liSi.greet()); // "你好,我是李四,今年18岁,我是高三年级学生"
2. 静态属性和方法
class MathUtils {
  // 静态属性
  static PI = 3.1415926;
  
  // 静态方法
  static sum(...nums) {
    return nums.reduce((a, b) => a + b, 0);
  }
}

console.log(MathUtils.PI);         // 3.1415926
console.log(MathUtils.sum(1,2,3)); // 6
3. Getter 和 Setter
class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }
  
  // Getter
  get fahrenheit() {
    return this.celsius * 1.8 + 32;
  }
  
  // Setter
  set fahrenheit(value) {
    this.celsius = (value - 32) / 1.8;
  }
}

const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77
temp.fahrenheit = 100;
console.log(temp.celsius);    // 37.777...
4. 私有字段和方法(ES2022)
class BankAccount {
  // 私有字段(以#开头)
  #balance = 0;
  
  // 私有方法
  #validate(amount) {
    return amount > 0;
  }
  
  deposit(amount) {
    if (this.#validate(amount)) {
      this.#balance += amount;
    }
  }
  
  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount();
account.deposit(100);
console.log(account.getBalance()); // 100
console.log(account.#balance);     // 报错:私有字段无法外部访问

3. Class 与传统构造函数的区别

特性ES6 ClassES5 构造函数
语法更清晰,类似传统OOP语言函数形式
继承extends 关键字手动设置原型链
静态方法static 关键字直接添加到构造函数
私有字段# 前缀(ES2022)闭包或命名约定(如 _ 前缀)
提升不存在提升(TDZ)函数提升
本质语法糖(底层仍是原型继承)直接使用原型链

4. 注意事项

  1. 类声明不会提升

    new Person(); // 报错
    class Person {}
    
  2. 类方法不可枚举

    class Person {
      method() {}
    }
    console.log(Object.keys(new Person())); // []
    
  3. 必须使用 new 调用

    class Person {}
    const p = Person(); // 报错
    
  4. 类内部默认严格模式

    class Strict {
      constructor() {
        a = 1; // 报错(未声明的变量)
      }
    }
    

10 JS模块化规范

1. 模块化要解决什么问题以及怎么实现模块化

从 1995 年发布 JavaScript 开始,浏览器端加载 JS 模块就是使用简单的 script 标签。早在 1996 年,就涌现了很多 服务器端 JavaScript 实现, 例如 2009 年发布的 Nodejs。无论是浏览器端还是服务端 JavaScript, 在 ES6 规范提出之前,JavaScript 本身一直没有模块体系。

什么是模块化

模块化就是将一个复杂的系统分解成多个独立的模块的代码组织方式。优秀的作者把他们的书分成章节,优秀的程序员把他们的程序分成模块。好的模块是高度独立的,具有特定功能的,可以根据需要对它们进行修改,删除或添加,而不会破坏整个系统。

模块化有什么好处

模块化带来的好处主要是这些:

  • 命名空间

在 JavaScript 中,每个 JS 文件的接口都暴露在全局作用域中,每个人都可以访问它们,并且容易造成命名冲突,污染全局。模块化可以为变量创建私有空间来避免命名空间污染

  • 可复用性

有没有曾经在某个时候将之前编写的代码复制到新的项目中呢?如果将此代码模块化,则可以反复使用,且在需要修改时只需要修改此模块,而不需要在项目中的每个此代码处做修改。

  • 可维护性

模块应该是独立的,一个设计良好的模块应尽可能减少对部分代码库的依赖,从而使其能够独立地删减和修改。当模块与其他代码片段分离时,更新单个模块要容易得多,还可以对每次修改的内容做版本管理。

模块化虽然有很多好处,但是要真正的实现模块化开发并不容易

传统的模块化开发方式

  • 命名冲突

当多个 JS 文件为变量和方法取相同名称而造成命名冲突时,可以采用 Java 中的命名空间的方式。

// 代码来自:https://github.com/seajs/seajs/issues/547
var org = {};
org.CoolSite = {};
org.CoolSite.Utils = {};

org.CoolSite.Utils.each = function (arr) {
  // 实现代码
};

org.CoolSite.Utils.log = function (str) {
  // 实现代码
};

类似于 Java 或 Python 等其他编程语言中使用类的方式,可以将公共以及私有的方法和变量存储在单个对象中。将要公开给全局作用域的方法写在闭包外,将私有变量和方法封装在闭包范围内,这样就可以解决变量都暴露在全局作用域的问题。

// 全局作用局可访问
var global = 'Hello World';
(function() {
  // 只能在闭包内访问
   var a = 2;
})()
  • 繁琐的文件依赖

虽然这种方法有其好处,但也有其缺点。

  • 通过立即执行的工厂函数定义的模块(IIFE: Immediately Invoked Function Expression)。
  • 对依赖项的引用是通过通过HTML脚本标记加载的全局变量名完成的。
  • 依赖关系是非常弱的:开发人员需要知道正确的依赖顺序。例如,使用 Backbone 的文件不能在 jQuery 标记之前。
  • 需要额外的工具来将一组脚本标记替换为一个标记以优化部署。

这在大型项目上很难管理,特别是当脚本以重叠和嵌套的方式具有许多依赖关系时。手写脚本标记的可伸缩性不高,而且它没有按需加载脚本的能力。

那是否有方法,可以不用在全局范围内请求依赖的模块,而是在模块内部请求依赖的模块呢?CommonJS、AMD、CMD、UMD等应运而生,通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。这些模块化规范告诉开发者:

  • 如何引入模块的依赖(imports  )
  • 如何定义模块(code  )
  • 如何导出模块的接口(exports)

从模块化开发思想提出以来,无论是浏览器端还是服务端 Javascript 开发,开发者们一直在探索满足实际需求的模块化规范及其实现,它们要解决的问题是相同的,即模块化开发和模块依赖的问题,但它们发起的原因却各有不同。

模块化的发展历程

  • 模块化的历史进程

    •  2009 年,美国程序员 Ryan Dahl 创造了node.js 项目,node.js 的模块系统就是参照CommonJS的模块规范写的。
    • 但是 CommonJS 规范中的 require 是同步的,这在浏览器端是不能接受的。所以后来就有了 AMD 规范,2010 年,RequireJS 实现了是 AMD 规范。
    • 2012 年来玉伯觉得 RequireJS 不够完善,给 RequireJS 团队提的很多意见都不被采纳,就自己写了 Sea.js,并制定了CMD 规范,Sea.js 遵循 CMD 规范。
    •  2015 年 6 月正式发布了 ECMAScript6 标准,在语言标准层面实现了模块功能,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。(这是未来)
    • 2015 年 10 月,UMD 出现,整合了 CommonJS 和 AMD 两个模块定义规范的方法。这时候 ES6 模块标准才刚出来,很多浏览器还不支持 ES6 模块化规范。
    • 2016 年 browserify 发布
    • 2017 年 webpack 发布
  • 模块化的历史图谱

image.png

2. CommonJS

Mozilla工程师 Kevin Dangoor 于 2009 年 1 月发起 ServerJS 项目,旨在规范化 JavaScript 在服务端使用时的模块化,以及 Filesystem API、I/O Streams、Socket IO 等服务端开发领域所涉及内容的标准化。

并希望这些在尽可能多的操作系统和解释器上工作,包括三个主要的操作系统(Windows、Mac、Linux)和四个主要的解释器(SpiderMonkey、Rhino、v8、JavaScriptCore),另外还有"浏览器"(本身就是一个独特的环境)。

为了展示其定义的 API 可以广泛适用,在 2009 年 8 月 ,ServerJS 被改名为 CommonJS。后来的很多开发吐槽,认为 CommonJS 的模块格式对浏览器很不友好(不支持异步写法),把浏览器当第二类公民,它更适合 ServerJS 这个名称。

NodeJS

同年 5 月 31,美国程序员 Ryan Dahl 实现了 Node.js 项目,并在同年 11 月 8 日在 JSConf 大会上首次介绍 Node.js。

直接使用 CommonJS 规范实现模块体系的 Node.js 广受欢迎,相信绝大部分 Web 开发者至今都管 Node.js 的模块体系叫 CommonJS 规范。它有四个重要的环境变量为模块化的实现提供支持:moduleexportsrequireglobal。实际使用时,用module.exports定义当前模块对外输出的接口(不推荐直接用exports),用require加载模块。

// 定义模块math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在这里写上需要向外暴露的函数、变量
  add: add,
  basicNum: basicNum
}

//引入模块index.js
// 引用自定义的模块时,参数包含路径,可省略.js
var math = require('./math');
math.add(2, 5);

// 引用核心模块时,不需要带路径
var http = require('http');
http.createService(...).listen(3000);

commonJS用同步的方式加载模块。在服务端,模块文件都存在本地磁盘,读取非常快,所以这样做不会有问题。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。

3. Module Loader

回到 2009 年,网页开发者们正对着一堆 <script> 标签发愁。如何在浏览器中管理依赖,是一个很让人头疼的问题。YUI 2 和 Google Closure Library 都提出过基于 namespace 的方案,但治标不治本,仍然需要人肉确保脚本的加载、打包顺序。

CommonJS 致力于 JavaScript 的服务端生态,模块同步加载,语法非常简洁,对服务端开发很友好。但这在浏览器端是无法接受的,从网络上读取一个模块比从磁盘上读取要花费更长的时间,只要加载模块的脚本正在运行,就会阻止浏览器运行其他,直到模块加载完成。

在 CommonJS 的论坛 中,Kevin Dangoor 发起过 关于异步加载 Commonjs 模块的讨论 以及 征集浏览器端的模块加载方案。论坛中也出现了很多关于如何在浏览器中异步加载 Commonjs 模块的帖子。

  • 有提出 transport方案的,在浏览器上运行前,先通过转换工具将模块转换为符合 Transport 规范的代码.
  • 有提出 XHR 加载模块代码文本,再在浏览器中使用 eval 或者 new Function 执行的;
  • 有提出应当直接改良 CommonJS,推出纯异步的模块加载方案的;

第三种方案的提出者 James Burke 认为:CommonJS 的模块格式不支持浏览器端的异步加载,需要通过 XHR 等其他方式加载 CommonJS 的模块,对 web 前端开发者很不友好。提出者认为浏览器端开发的最佳实践是:每个页面只加载一个模块。

RequireJS in AMD

James Burke 于 09 年 12 月在 CommonJS in the browser中写了很长的篇幅阐述了直接改良 CommonJS 的模块格式以适应浏览器端开发的诉求,但是 CommonJS 的发起者 Kevin Dangoor 并不同意此方案,这也就催生了 RequireJS

James Burke 制定了 AMD 规范,并在 2010 年实现了遵循 AMD 规范的模块加载器 RequireJS。

AMD规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。这里介绍用require.js实现AMD规范的模块化:用require.config()指定引用路径等,用define()定义模块,用require()加载模块。

首先我们需要引入require.js文件和一个入口文件main.js。main.js中配置require.config()并规定项目中用到的基础模块。

/** 网页中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>

/** main.js 入口文件/主模块 **/
// 首先用config()指定各模块路径和引用名
require.config({
  baseUrl: "js/lib",
  paths: {
    "jquery": "jquery.min",  //实际路径为js/lib/jquery.min.js
    "underscore": "underscore.min",
  }
});
// 执行基本操作
require(["jquery","underscore"],function($,_){
  // some code here
});

引用模块的时候,我们将模块名放在[]中作为reqiure()的第一参数;如果我们定义的模块本身也依赖其他模块,那就需要将它们放在[]中作为define()的第一参数。

// 定义math.js模块
define(function () {
    var basicNum = 0;
    var add = function (x, y) {
        return x + y;
    };
    return {
        add: add,
        basicNum :basicNum
    };
});
// 定义一个依赖underscore.js的模块
define(['underscore'],function(_){
  var classify = function(list){
    _.countBy(list,function(num){
      return num > 30 ? 'old' : 'young';
    })
  };
  return {
    classify :classify
  };
})

// 引用模块,将模块放在[]内
require(['jquery', 'math'],function($, math){
  var sum = math.add(10,20);
  $("#sum").html(sum);
});

总的来说AMD是依赖前置,异步加载,先全部导入再执行,

SeaJS in CMD

玉伯 认为 RequireJS 不够完善:

  • 执行时机有异议

    • Reqiurejs 模块加载完毕后是立即执行, Seajs 在模块加载完毕后保存 factory 函数,在执行到 require 时再执行模块对应的 factory 函数返回模块的导出结果。
  • 模块书写风格有争议

    • AMD 风格下,通过参数传入依赖模块的导出,破坏了 就近声明 原则。

玉伯开发了一个新的 Module Loader: SeaJS, 于 2011 年 11 月在 CommonJS Group 中宣布公开( Announcing SeaJS: A Module Loader for the Web),Sea.js 遵循 CMD 规范。

CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

/** AMD写法 **/
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
     // 等于在最前面声明并初始化了(加载并提前执行了)要用到的所有模块
    a.doSomething();//此处只是获取模块a的exports
    if (false) {
        // 即便没用到某个模块 b,但 b 还是已经下载好,并且提前执行了
        b.doSomething()
    } 
});

/** CMD写法 **/
define(function(require, exports, module) {
    var a = require('./a'); //在需要时申明
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

4. Es6 Module

在2015 年 6 月, ECMAScript6 标准正式发布,其中的  ES 模块化规范的提出目标是整合 CommonJS、AMD 等已有模块方案,在语言标准层面实现模块化,成为浏览器和服务器通用的模块解决方案。

模块功能由 export 和 import 两个命令完成。export 对外输出模块,import 用于引入模块。

// 导入单个接口
import {myExport} from '/modules/my-module.js';
// 导入多个接口
import {foo, bar} from '/modules/my-module.js';

// 导出早前定义的函数
export { myFunction }; 

// 导出常量
export const foo = Math.sqrt(2);

ES Module 与 CommonJS 及 Loaders 等方案的区别主要在以下方面:

  • 声明式而非命令式,或者说 import 是声明语句 Declaration 而非表达式 Statement,在 ES Module 中无法使用 import 声明带变量的依赖、或者动态引入依赖:
  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • import 是预先解析、预先加载的,不像 RequireJS 等是执行到点了再发一个请求

对务实主义的 Node.js 开发者来说,这些区别都让 npm 所营造出来的海量社区代码陷入一种尴尬的境地,无论是升级还是兼容都需要大量的工作。对此,David Herman 撰文解释,ES Module 所带来的好处远大于不便:

  • 静态 import 能确保被编译成变量引用,这些引用在当前执行环境运行时能被解析器(通过 JIT 编译 )优化,执行更有效率
  • 静态 export 能让变量检测更准确,在 JSHint、ESLint 等代码检测工具中,变量是否定义是个非常受欢迎的功能,而静态 export 能让这一检测更具准确性
  • 更完备的循环依赖处理,在 Node.js 等已有的 CommonJS 实现中,循环依赖是通过传递未完成的 exports 对象解决的,对于直接引用 exports.foo 或者父模块覆盖 module.exports 的情况,传统方式无从解决,而因为 ES Module 传递的是引用,便不会有这些问题

其他还有对未来可能新增的标准(宏、类型系统等)更兼容等。

ES Modules 核心语法

1. 导出方式
// 命名导出(多个)
export const PI = 3.14;
export function circleArea(r) { return PI * r * r; }

// 默认导出(单个)
export default class Calculator { /* ... */ }

// 统一导出
export { PI, circleArea };

// 重命名导出
export { PI as PiValue };
2. 导入方式
// 导入命名导出
import { PI, circleArea } from './math.js';

// 导入默认导出
import Calculator from './calculator.js';

// 混合导入
import React, { useState } from 'react';

// 重命名导入
import { PI as PiValue } from './math.js';

// 整体导入
import * as math from './math.js';
math.circleArea(5);
3. 动态导入(按需加载)
// 返回Promise
import('./module.js')
  .then(module => {
    module.doSomething();
  });

// 在async函数中使用
async function loadModule() {
  const module = await import('./module.js');
}

模块特性

  1. 严格模式:模块默认在严格模式下执行
  2. 作用域隔离:每个模块有自己的顶级作用域
  3. 单例模式:模块只会被执行/加载一次
  4. 静态结构:导入/导出必须在顶层(动态导入除外)
  5. 延迟执行:模块会延迟执行直到被导入

ES6 Module in Browser

在 ES Module 标准出来之前,尽管社区实现的 Loader 一箩筐,但浏览器自身一直没有选定模块方案,支持 ES Module 对浏览器来说还是比较少顾虑的。

由于 ES Module 的执行环境和普通脚本不同,浏览器选择增加 <script type="module"> ,只有 <script type="module"> 中的脚本(和 import 进来的脚本)才是 module 模式。也只有 module 模式执行的脚本,才可以声明 import 。也就是说,下面这种代码是不行的:

<script>
import foo from "./foo.js"
</script>

<script type="javascript">
import bar from "./bar.js"
</script>

目前,几大常青浏览器都已支持 ES Module。最后一个支持的是 Firefox,2018 年 5 月 8 日发布的 Firefox 60 正式支持 ES Module。

此外,考虑到向后兼容,浏览器还增加 <script nomodule> 标签。开发者可以使用 <script nomodule> 标签兼容不支持 ES Module 的浏览器:

// 在浏览器中,import 语句只能在声明了 type="module" 的 script 的标签中使用。
<script type="module" src="./app.js"></script>
// 在 script 标签中使用 nomodule 属性,可以确保向后兼容。
<script nomodule src="./app.bundle.js"></script>

ES6 Module in Node.js

但在 Node.js 这边,ES Module 遭遇的声音要大很多。前 Node.js 领导者 Isaacs Schlutuer 甚至认为 ES Module 太过阳春白雪且不考虑实际情况,毫无价值。

首先纠结的是如何支持 module 执行模式,是自动检测,还是 'use module' ,还是在 package.json 里增加 module 属性作为专门的入口,还是干脆增加一个新的扩展名?

最终 Node.js 选择增加新的扩展名 .mjs

  • .mjs 中可以自如使用 import , exportimport()
  • .mjs 中不可以使用 require
  • .js 中可以使用 requireimport()
  • .js 中不可以使用 importexport

也就是两套模块系统完全独立。此外,依赖查找方式也有变化,原本 require.extensions 是:

{ '.js': [Function],
  '.json': [Function],
  '.node': [Function] }

如今(需要开启 --experimental-modules 选项)则是:

{ '.js': [Function],
  '.json': [Function],
  '.node': [Function],
  '.mjs': [Function] }

但两套独立的模块系统也导致第二个纠结的方面,模块系统彼此之间如何互通?对浏览器来说这不是问题,但对 Node.js 来说,npm 中海量的 CommonJS 模块是它不得不考虑的。

  • ES6 Module 加载 CommonJS

最终确定的方案倒也简单,在 .mjs 里,开发者可以 import CommonJS(虽然只能 import default):

//正确
import foo from './foo'
//错误
import {method} from './foo'

ES6 模块的import命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。

这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是module.exports,是一个对象,无法被静态分析,所以只能整体加载。

  • CommonJS 加载ES6 Module

在 .js 里,开发者自然不能 import ES Module,但他们可以 import() :

import('./foo').then(foo => {
  // use foo
})

(async function() {
  const bar = await import('./bar')
  // use bar
})()

require()不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层await命令,导致无法被同步加载。

注意,和浏览器以引入方式判断运行模式不同,Node.js 中脚本的运行模式是和扩展名绑定的。也就是说,依赖的查找方式会有所不同

  • .jsrequire('./foo') 找的是 ./foo.js 或者 ./foo/index.js
  • .mjsimport './bar' 找的是 ./bar.mjs 或者 ./bar/index.mjs

善用这些特性,我们现在就可以将已有的 npm 模块升级成 ES Module,并且仍然支持 CommonJS 方式。

Dynamic import

静态型的 import 是初始化加载依赖项的最优选择,使用静态 import 更容易从代码静态分析工具和 tree shaking中受益。但当希望按照一定的条件或者按需加载模块的时候,需要动态引入依赖,例如:

if (process.env.NODE_ENV !== 'production') {
  require('./cjs/react.development.js')
} else {
  require('./cjs/react.production.js')
}

if (process.env.BROWSER) {
  require('./browser.js')
}
复制代码

为此,Domenic Denicola 起草 import() 标准[提案。

//这是一个处于第三阶段的提案。
var promise = import("module-name");

除了可以用来处理动态依赖,HTML 中的 script 标签不需要声明 type="module" 。

<script>
import('./foo.js').then(foo => {
  // use foo
})
</script>

在 Node.js 中(.js 文件)还可以使用 import() 引入使用 import 的 ES Module :

import('./foo.mjs').then(foo => {
  // use foo
})

使用 ES Module 编写浏览器、Node.js 通用的 JavaScript 模块化代码已经完全可行,我们还需要编译或者打包工具吗?

5. Module Bundler

在浏览器端使用模块加载器也存在很多弊端。例如 RequireJS 编码方式不友好、加载其他规范的模块比较麻烦、提前执行等, SeaJS 规则一直变化导致升级出现各种问题等,而 CommonJS 在服务端的使用就很方便稳定,引用第三方库只需:

  • npm install 安装模块
  • 直接使用 require 引入

那能否在浏览器中也使用 CommonJS 规范的方式引入模块并可以很方便调用其他规范的模块呢?

一种解决办法就是预编译,我们用 CommonJS 规范的方式书写代码定义和引入模块,然后将模块和依赖编译成一个 js 文件,我们都叫它 bundlejs。

Browserify和 webpack都是这种预编译的模块化方案, 最终都是 build 生成一个 bundle 文件,在这个 build 的过程里进行依赖关系的解析。

Browserify

Node.js 社区早期活跃成员 substack 开发Browserify的初衷非常简单: Browserify可以让你使用类似于 node 的 require() 的方式来组织浏览器端的 Javascript 代码,通过预编译让前端 Javascript 可以直接使用 Node NPM 安装的一些库, 也可以引入非 CommonJS 模块,但需要使用 transform(browserify.transform  配置转换插件)。

Browserify 的 require 与 Node.js 保持一致,不支持异步加载。社区希望Browserify支持异步加载的呼声一直很高 ,但作者坚持认为 Browserify 的 require 应当和 Node.js 保持一致。

Webpack

晚于 Browserify 一年发布的 Webpack结合了 CommonJS 和 AMD 的优缺点,开发时可按照 CommonJS 的编写方式,支持编译后按需加载和异步加载所有资源。

Webpack 最出色的特性一是它的模块解析粒度以及因此带来的强大打包能力,二是它的可扩展性,相关转换工具(Babel、PostCSS、CSS Modules)可以变成插件快速接入,还能自定义 Loader。这些特性加在一起,无往而不利。 而且它还支持 ES Module:

import defaultExport from "module-name";
import * as name from "module-name";
import { export } from "module-name";

这便是构建工具带来的好处了,发挥空间远比传统浏览器 Loader 来得大,可以轻松加入像 Babel、Traceur 等 transpiler 支持。

6. ES6 Module 与 CommmonJS 的区别

核心差异解析

特性CommonJSES6 Module
加载时机运行时加载 (同步)编译时静态解析 (异步可能)
模块类型动态模块静态模块
值引用值的拷贝 (导出基本类型时)动态绑定 (实时引用)
循环依赖处理支持但可能产生不一致静态分析保证一致性
顶层作用域非严格模式 (默认)严格模式 (强制)
this指向指向当前模块exports对象undefined
动态导入require() 任意位置import() 动态导入函数
性能优化难以静态分析支持Tree Shaking

加载时机和本质

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    CommonJS:
  • 运行时加载模块
  • 模块是一个对象 (module.exports)
  • 同步加载 (Node.js 中)
// 可以动态require
let modulePath;
if (condition) {
  modulePath = './moduleA';
} else {
  modulePath = './moduleB';
}
const myModule = require(modulePath);

ES6 Module:

  • 编译时静态解析
  • 模块不是对象,是静态定义
  • 异步加载 (浏览器中)
// 不能动态导入 (静态语法)
if (condition) {
  import './moduleA'; // 语法错误
}

// 必须使用动态import()函数
const modulePath = condition ? './moduleA' : './moduleB';
import(modulePath).then(...);
  • 运行时加载: CommonJS 模块加载的是一个对象,该对象只有在脚本运行完才会生成;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
  • 编译时加载: ES6 模块不是对象,它的对外接口只是一种静态定义,JS引擎对脚本静态解析阶段就会生成。而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。

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

加载方式 同步/异步

** CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。**

输出值的方式

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

    • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
    • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
        // export.js
        var foo = 'bar';
        module.exports={foo}
        setTimeout(() => foo = 'baz', 500);

        // common.js
        import {foo} from './export.js';
        console.log(foo);//bar
        setTimeout(() => console.log(foo), 500);//bar

common.js 中500ms后foo的值没发生变化,还是bar

        // export.js
        export var foo = 'bar';
        setTimeout(() => foo = 'baz', 500);

        // es6.js
        import {foo} from './export.js';
        console.log(foo);//bar
        setTimeout(() => console.log(foo), 500);//baz

es6.js 中500ms后foo的值发生了变化,变为baz

循环加载的处理方式

CommonJS 部分执行+缓存

模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出

1. 核心原理

CommonJS 采用运行时加载值拷贝的机制:

  • 模块在第一次被 require 时立即执行整个脚本
  • 执行过程中遇到 require 会暂停当前模块,先加载并执行被依赖模块
  • 模块导出的是值的拷贝,而非引用
  • 使用缓存机制避免重复执行12
2. 循环依赖处理特点

当发生循环依赖时,CommonJS 会:

  1. 部分执行:只输出已经执行完成的部分
  2. 缓存优先:后续引用直接从缓存读取
  3. 值冻结:后续模块内部的变化不会影响已导出的值3
3. 经典案例分析
// a.js
exports.done = false;  // 已执行部分
const b = require('./b.js');  // 发生循环依赖
console.log('在a.js中,b.done =', b.done);
exports.done = true;  // 未执行部分
// b.js
exports.done = false;  
const a = require('./a.js');  // 从缓存获取a.js已执行部分
console.log('在b.js中,a.done =', a.done);  // 输出false
exports.done = true;

执行顺序和输出:

  1. a.js开始执行 → 设置done=false
  2. a.js加载b.js → 暂停a.js执行
  3. b.js开始执行 → 加载a.js时获取缓存中已执行部分(done=false)
  4. b.js完成执行 → 返回到a.js继续执行
  5. a.js完成执行 → 最终所有模块done=true
4. 潜在问题
  • 状态不一致:被依赖模块只能获取到已执行部分的导出值
  • 时序敏感:模块内部状态变更不会影响已导出的拷贝值
  • 调试困难:执行顺序不符合直观的代码顺序
ES6 引用绑定+动态解析

处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。本质上说,相互导入,加上检验两个import语句的有效性的静态验证,虚拟组合了连个模块的空间(通过绑定),这个样A模块可以调用B模块,反过来也一样,这和它们本来声明在一个作用域中是对称的。

1. 核心原理

ES6 Module 采用静态分析动态引用的机制:

  • 模块在编译阶段进行依赖分析
  • import 语句只创建只读引用,不立即执行模块
  • 实际值在运行时按需获取
  • 导出的是值的引用,而非拷贝
2. 循环依赖处理特点

当发生循环依赖时,ES6 Module 会:

  1. 引用绑定:建立模块间的引用关系
  2. 按需取值:运行时才获取最终值
  3. 动态更新:模块内部变化会反映到所有引用处
3. 经典案例分析
// a.js
import { bLoaded } from './b.js';
export let aLoaded = false;
console.log('在a中,b.loaded =', bLoaded);
aLoaded = true;

// b.js
import { aLoaded } from './a.js';
export let bLoaded = false;
console.log('在b中,a.loaded =', aLoaded);
bLoaded = true;

// 由于静态分析,可以保证行为一致性

执行特点:

  1. 编译阶段建立aLoadedbLoaded的引用关系
  2. 运行时按调用链动态获取最新值引用
4. 优势体现
  • 状态一致性:所有引用指向同一内存地址
  • 时序无关:总是获取最新值
  • 递归支持:适合需要相互调用的算法实现

7. 模块化之间的差异

image.png

8. 模块化的现状

正如玉伯在 前端模块化开发那点历史 中所说: 随着 W3C 等规范、以及浏览器的飞速发展,前端的模块化开发会逐步成为基础设施。一切终究都会成为历史。

我们现在开发中不必再纠结使用哪种模块化方案, ES6 在语言标准层面为我们解决了这个问题。

  • CommonJS

    • Nodejs 已成为服务端 JavaScript 标准
  • Module Loader(模块加载器已成过去式)

    • RequireJS 已经不维护了
    • seajs 已经不维护了。作者2015年就发布微博: 应该给 Sea.js 树一块墓碑了。
  • ES6 Module

    • 语法在主流浏览器和Nodejs8.5版本以上都已支持。
  • Module Bundler

参考资料

《浏览器工作原理与实践》 极客时间
《图解 Google V8》极客时间
《JavaScript高级程序设计(第三版)》
《你不知道的JavaScript(上 中卷)》
《JavaScript设计模式与开发实践》
《ECMAScript入门》

(建议收藏)原生JS灵魂之问, 请问你能接得住几个?(上)
(建议精读)原生JS灵魂之问(中),检验自己是否真的熟悉JavaScript?
2万字 | 前端基础拾遗90问 玩转 JavaScript 之数据类型
前端进阶之道

js:面向对象编程,带你认识封装、继承和多态
JavaScript 面向对象实战思想

前端模块化:CommonJS,AMD,CMD,ES6
前端高频面试题整理 前端两年-月入30K | 掘金技术征文
你可能不知道的 JavaScript 模块化野史
前端模块化开发那点历史
前端模块化开发的价值 前端模块的历史沿革 前端模块的现状
前端面试必备 JavaScript模块化全面解析

JavaScript 运行机制详解:再谈Event Loop
深入理解 JavaScript 异步
尝试用通俗的方式解释协程
js异步处理(一)——理解异步