(兩萬多字,建議精讀)知其然,知其所以然,前端系列之javascript(一)
theme: juejin
整理這份js知識體系的起因是受神三元
靈魂之問系列的啟發
面對著那麼多不斷迭代更替的新技術,總是感覺學習時間不夠,效果不好而焦慮,是不是自己一開始自己的關注點就錯了,關注點不應該在於眼花繚亂的技術,而在於自身知識體系的建設。
雖然每天都在寫程式碼,自己寫的到底是什麼,很多概念聽著好像很熟悉,但是又說不出所以然來。為了弄清楚這些困惑在自己心中的問題,所以開始了這份知識體系的建設。
js系列總共分兩篇,這是本系列的第一篇,主要內容是基於執行上下文
把知識延伸到資料型別,變數,作用域,this,閉包
。接著又談了面向物件思想
和模組化規範
。
正如靈魂之問對我的啟發,也希望知其然系列的內容對你有所啟發。另外,由於個人知識水平有限,如有理解不對的地方,請大家批評指正。
js知識體系梳理思路框架的思維導圖如下
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 - 大整數,可以以任意精度表示整數
String - 表示由零或多個16位Unicode字元組成的字元序列
Symbol - 符號型別唯一的並且不可修改的,通常用來作為Object的key
- 引用型別
Object 表示一組屬性的集合
js中的物件其實是一種資料和功能的集合。js有一些內建物件,提供了各子型別所特有的屬性和方法。
js中的內建物件有:
Object,Array,Date,RegExp,Function,Error等
基本包裝型別的Boolean,Number,String,
以及單體內建物件的Global,Math。
3. 基本包裝型別
為了便於操作基本型別,js提供了3個特殊的引用型別:Boolean,Number,String。 它們具有與各自的基本型別相應的特殊行為。
看下面的示例
js
'javascript'.substring(4)//"script"
為什麼字串可以直接調取substring方法呢?實際上,每當讀取一個基本型別值得時候,後臺就會建立一個對應的基本包裝型別的物件,從而讓我們能夠呼叫一些方法來操作這些資料。具體是怎麼做的呢?
1、建立String型別的一個例項;
2、在例項上呼叫指定的方法;
3、銷燬這個例項
js
var s = new String( 'javascript')
s.substring(4)
s=null
4. 浮點數
所謂浮點數值,就是該數值中必須包含一個小數點,必須小數點後面必須至少有一位數字。
浮點數的範圍 最小值大約是5e-324(Number.MIN_VALUE),最大值大約是1.798e+308(Number.MAX_VALUE)
浮點數的最高精度是17位小數,但在進行算術計算的時候其精度遠遠不如整數。看下面示例執行結果為什麼是false呢
js
0.1 + 0.2 === 0.3 //false
主要是數字儲存計算是採用的是二進位制,計算完成後又變成十進位制的,所以造成了浮點數誤差。具體0.1和0.2在轉換成二進位制後會無限迴圈,由於標準位數的限制後面多餘的位數會被截掉,此時就已經出現了精度的損失,相加後因浮點數小數位的限制而截斷的二進位制數字在轉換為十進位制就會變成0.30000000000000004。
那應該是怎麼判斷0.1+0.2和0.3相等呢
最常見的方法設定一個誤差範圍,通常稱為“機器精度”,對js中的數字來說,這個值是2^-52,ES6該值定義在Number.EPSILON
中。可以用該值判斷兩個值相等。
js
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
2 js資料型別的檢測
1. typeof
js
//判斷基本型別
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
屬性是否出現在某個例項物件的原型鏈上,主要的作用就是判斷一個例項物件是否屬於某種型別.
js
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能否判斷基本型別
js
class MyNumber{
static [Symbol.hasInstance](instance){
return typeof instance === 'number'
}
}
console.log(1 instanceof MyNumber)
Symbol.hasInstance
用於判斷某物件是否為某構造器的例項。可以自定義instanceof行為。
- 手動實現instanceof 功能
其實
instanceof
主要的實現原理就是隻要右邊變數的prototype
在左邊變數的原型鏈上即可。因此,instanceof
在查詢的過程中會遍歷左邊變數的原型鏈,直到找到右邊變數的prototype
,如果查詢失敗,則會返回 false,告訴我們左邊變數並非是右邊變數的例項。
js
//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是用來標識物件型別的。
js
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的基本型別和引用型別。但當涉及到原型和繼承的時候,便出現了問題,如下:
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()
```js 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()
被重新定義的則相應呼叫返回結果
js
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
轉換為1
,false
轉換為0
- 字串轉換時遵循數字常量規則,轉換失敗返回
NaN
- 物件型別會被轉換為相應的基本型別值,如果得到的值型別不是數字,則遵循以上規則強制轉換為數字
物件轉原始型別,會呼叫內建的[ToPrimitive]函式,對於該函式而言,其邏輯如下:
- 如果Symbol.toPrimitive()方法,優先呼叫再返回
- 呼叫valueOf(),如果轉換為原始型別,則返回
- 呼叫toString(),如果轉換為原始型別,則返回
- 如果都沒有返回原始型別,會報錯
```js var obj = { value: 3, valueOf() { return 4; }, toString() { return '5' }, Symbol.toPrimitive { return 6 } } console.log(obj + 1); // 輸出7
```
3. 轉換為布林值
ToBoolean 抽象操作負責處理非布林型別轉換為布林型別。
轉換規則:
- 可以被強制強制型別轉換為false的值:
null
、undefined
、false
、+0
、-0
、NaN
和''
- 假值列表以外的值都是真值
下面的情況會發生布爾值隱式強制型別轉換。
- 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轉換成字串,再進行比較
js
console.log({a: 1} == true);//false
console.log({a: 1} == "[object Object]");//true
//注意
NaN===NaN//false
+0===-0//true
5. || 和 &&
|| 和 && 叫邏輯運算子,&& 和 || 運算子的返回值並不一定是布林型別,而是兩個運算元其中一個的值。
js
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. 執行上下文的型別
哪些程式碼才會在執行之前就進行編譯並建立執行上下文呢?一般有以下三種情況
- 全域性執行上下文,當 JavaScript 執行全域性程式碼的時候,會編譯全域性程式碼並建立全域性執行上下文,而且在整個頁面的生存週期內,全域性執行上下文只有一份。
- 函式執行上下文,當呼叫一個函式的時候,函式體內的程式碼會被編譯,並建立函式執行上下文,一般情況下,函式執行結束之後,建立的函式執行上下文會被銷燬。
- eval執行上下文,當使用 eval 函式的時候,eval 的程式碼也會被編譯,並建立執行上下文。
3. 執行上下文棧
JavaScript 引擎用來管理執行上下文的棧稱為執行上下文棧或者叫呼叫棧
。
每個函式都有自己的執行上下文。當執行流進入一個函式時,函式的執行上下文就會被壓入一個呼叫棧中。而函式執行之後,呼叫棧將其執行上下文彈出,把控制權返回給之前的執行上下文。
呼叫棧是 JavaScript 引擎追蹤函式執行的一個機制
那麼,執行上下文的週期,分為兩個階段:
4.執行上下文週期
-
建立階段
- 建立詞法環境
- 生成變數物件(
VO
),建立作用域鏈、作用域鏈、作用域鏈(重要的事說三遍) - 確認
this
指向,並繫結this
-
執行階段
。這個階段進行變數賦值,函式引用及執行程式碼。
5 變數物件
變數物件(variable object)是執行上下文的一部分,它儲存著該執行上下文中定義的所有的變數和函式
活動物件(active object) 當變數物件所處的上下文為 active EC 時(正在執行的函式的上下文),稱為活動物件
6 作用域
1. 作用域
作用域是指程式原始碼中定義變數的區域,變數的有效範圍。
2. 作用域的型別
- 全域性作用域就是在全域性中定義的變數和函式(全域性執行上下文中的變數物件),全域性作用域中的物件在程式碼中的任何地方都能訪問,其生命週期伴隨著頁面的生命週期。
- 函式作用域就是在函式內部定義的變數或者函式(函式執行上下文中的變數物件),並且定義的變數或者函式只能在函式內部被訪問。函式執行結束之後,函式內部定義的變數會被銷燬。
- 塊級作用域就是使用一對大括號包裹的一段程式碼,比如函式、判斷語句、迴圈語句,甚至單獨的一個{}都可以被看作是一個塊級作用域。
3. 作用域規則
作用域規則規定了變數儲存在哪裡,以及變數的生命週期,需要的時候如何去訪問它們
4. 詞法作用域
詞法作用域就是指作用域是由程式碼中函式宣告
的位置來決定的,所以詞法作用域是靜態的作用域,javascript就是採用詞法作用域規則的,通過它就能夠預測程式碼在執行過程中如何查詢識別符號。
當代碼在一個環境中執行時,會建立變數物件的一個作用域鏈。
5. 作用域鏈本質
作用域鏈本質上是一個指向變數物件的指標列表,它包含自身的變數物件和父級的作用域鏈[[scope]],但它只 引用但不實際包含物件
6. 作用域鏈的用途
作用域鏈的用途是保證對執行上下文有權訪問的所有變數和函式的有序訪問。
7. 識別符號解析
識別符號解析是沿著作用域鏈一級一級地搜尋識別符號(變數和函式)的過程。搜尋過程始終從作用域鏈的前端開始,然後逐級地向後回溯,直至找到識別符號為止(如果找不到識別符號,通常會導致錯誤發生).先在自己的變數物件(作用域)中搜索變數和函式,如果搜尋不到則再搜尋上一級作用域鏈中的變數物件(作用域),一直到沿著作用域鏈搜尋到全域性變數物件(作用域),直到搜尋到為止,如果搜尋不到,通常會報錯,
7 this
this繫結規則:普通函式內部的this指向函式執行時所在的物件,this物件是在執行時基於函式的執行上下文繫結的,它引用的是函式執行的環境物件。也就是說this是在函式被呼叫時發生的繫結,它指向什麼完全取決於函式呼叫
的位置。this有以下幾種繫結規則。
程式碼示例:
js
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. 直接呼叫函式(預設繫結)
js
foo()//global
//以下是隱式丟失型別實際上它會應用預設繫結
barz(); //global
//回撥函式丟失this繫結
doFoo(obj.foo) //global
setTimeout(obj.foo,1000)//global
setTimeout(function foo(){
console.log(this.a,'set')
},1000)//global
直接呼叫的this相當於全域性執行上下文的情況,指向window。一個最常見的this繫結問題就是被隱式繫結的函式會丟失繫結物件,也就是說它會應用預設繫結,從而把this繫結到全域性物件或則undefined上,取決於是否是嚴格模式。
3. 物件.方法的形式呼叫(隱式繫結)
`obj.foo() //local`
物件.方法的this指向這個物件
4. 使用call,apply,bind繫結this(顯示繫結)
```js 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。
```js 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)箭頭函式沒有自己的this
物件。
(2)不可以當作建構函式,也就是說,不可以對箭頭函式使用new
命令,否則會丟擲一個錯誤。
(3)不可以使用arguments
物件,該物件在函式體內不存在。如果要用,可以用 rest 引數代替。
(4)不可以使用yield
命令,因此箭頭函式不能用作 Generator 函式。
8. 優先順序
優先順序:new>call、apply、bind>物件.方法>直接呼叫
8 閉包
1. 閉包是什麼
-
紅寶書:閉包指有權訪問另一個函式作用域中的變數的函式。
-
從理論和實踐的角度去談下閉包:
- 理論角度:閉包指哪些能夠訪問自由變數的函式。
自由變數指在函式中使用的,但既不是函式引數arguments也不是函式的區域性變數的變數。全域性變數也算自由變數,因為函式中訪問全域性變數也是訪問自由變數,也就是說所有函式都可以理解為閉包。
- 實踐角度:以下函式才算閉包。
1.即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回)
2.在程式碼中引用了自由變數 -
我個人的理解,閉包本質是一個變數物件,當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,閉包是一種特殊的作用域,函式作用域鏈引用上級作用域中變數的集合會放到一個變數物件,這個變數物件存放在堆中的,其實這個變數物件就是閉包。
擴充套件
在 JavaScript 中,根據詞法作用域的規則,內部函式總是可以訪問其外部函式中宣告的變數,當通過呼叫一個外部函式返回一個內部函式後,即使該外部函式已經執行結束了,但是內部函式引用外部函式的變數依然儲存在記憶體中,我們就把這些變數的集合稱為閉包。
2. 為什麼js會有閉包
最根本的原因是,在js裡,函式是一等公民,函式可以作為函式的返回值,可以作為函式的引數傳入,也可以作為值賦值給變數。那麼在函式呼叫時會出現funarg 問題,打破了基於棧的記憶體分配模式。而主流的js引擎都是惰性解析的,為了解決這個問題,引入閉包機制來解決這funarg問題。
- 惰性解析
是指解析器在解析的過程中,如果遇到函式宣告,那麼會跳過函式內部的程式碼,並不會為其編譯,而僅僅會編譯頂層程式碼。
- Funarg問題
A functional argument (“Funarg”) — is an argument which value is a function.
函式式引數(“Funarg”) —— 是指值為函式的引數。
每個函式都有自己的執行上下文。當執行流進入一個函式時,函式的執行上下文就會被壓入一個呼叫棧中。而函式執行之後,呼叫棧將其執行上下文彈出,把控制權返回給之前的執行上下文,函式的執行上下文會被銷燬,相應的函式中的變數和函式也會被銷燬。但是如果函式中有自由變數的時候,當訪問這些自由變數的函式再次執行的時候,就會發生錯誤,因為這些自由變數不見了。
- 通過下面的示例分析下閉包機制是怎麼引入的
js
function foo() {
var d = 20
return function inner(a, b) {
var c = a + b + d
return c
}
}
var f = foo()
我們可以分析下上面這段程式碼的執行過程:
- 當呼叫 foo 函式時,foo 函式會將它的內部函式 inner 返回給全域性變數 f;
- 然後 foo 函式執行結束,執行上下文被 V8(js引擎) 銷燬;
- 雖然 foo 函式的執行上下文被銷燬了,但是依然存活的 inner 函式引用了 foo 函式作用域中的變數 d。
按照通用的做法,d 已經被 v8 銷燬了,但是由於存活的函式 inner 依然引用了 foo 函式中的變數 d,這樣就會帶來兩個問題:
- 當 foo 執行結束時,變數 d 該不該被銷燬?如果不應該被銷燬,那麼應該採用什麼策略?
- 如果採用了惰性解析,那麼當執行到 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 引入預解析器,比如當解析頂層程式碼的時候,遇到了一個函式,那麼預解析器並不會直接跳過該函式,而是對該函式做一次快速的預解析,其主要目的有兩個。
- 是判斷當前函式是不是存在一些語法上的錯誤。
- 除了檢查語法錯誤之外,預解析器另外的一個重要的功能就是檢查函式內部是否引用了外部變數,如果引用了外部的變數,預解析器會將棧中的變數複製到堆中,在下次執行到該函式的時候,直接使用堆中的引用,這樣就解決了閉包所帶來的問題。
對下面程式碼示例分析
在outer內第一行打斷點除錯,Scope中清晰的展示了,outer通過預解析後的情況。預解析器在解析outer時候會判斷內部所有的函式是否引用了它的變數
,檢查到inner和foo引用了outer中的所有變數a1和b1,然後把a1和b1放入在堆中建立的Closure(outer)物件中,由於是預解析階段,a1,b1還是undefined,到真正執行outer函式的時候,a1,b1才會賦值。
下圖是閉包產生的過程。
- 總結
由於 JavaScript 是一門天生支援閉包的語言,由於閉包會引用當前函式作用域之外的變數,所以當 V8 解析一個函式的時候,還需要判斷該函式的內部函式是否引用了當前函式內部宣告的變數,如果引用了,那麼需要將該變數存放到堆中,即便當前函式執行結束之後,也不會釋放該變數。
總的來說,產生閉包的核心有兩步:
- 第一步是需要預掃描內部函式;
- 第二步是把內部函式引用的外部變數儲存到堆中。
4. 閉包與記憶體管理
有個聳人聽聞的說法說閉包會造成記憶體洩漏,所以要儘量減少閉包的使用。我們分析下面的程式碼,看是否是這樣的嗎?
js
function foo(){
var data = 1
function bar(){
console.log(data)
}
return bar
}
var doFoo=foo()
doFoo()
上述程式碼會形成覆蓋foo內部作用域的閉包,由於引用閉包的doFoo是全域性變數,全域性變數會一直存在,所以閉包會一直存在。如果doFoo以後還會經常的使用到,data變數放在閉包中和放在全域性作用域,對記憶體方面的影響是一樣的,這裡並不能說成記憶體洩漏。如果將來需要回收這些變數,將doFoo設定為null就可以了。
js
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. 閉包的特性
- 函式內部可以定義新的函式
- 可以在內部函式中訪問父函式中定義的變數
- 函式可以作為返回值輸出
6. 閉包的表現形式及應用
1、函式作為返回值輸出,可以參照閉包與記憶體管理中的示例。例如應用模組模式的jQuery,lodash,防抖,節流。
2、函式作為引數傳遞,可以參照在迴圈中建立閉包的示例。實際工作中,我們用到的定時器,事件監聽器,Ajax請求,或者其他的非同步任務,實際上就是在使用閉包(前提條件回撥函式中引用了外部函式的變數)。
7. 閉包的優缺點
閉包的優點
- 希望一個變數長期儲存記憶體中;
- 私有成員的存在,避免全域性變數汙染;
閉包的缺點
- 常駐記憶體,增加記憶體使用量;
- 使用不當造成記憶體洩漏。
8. 在迴圈中建立閉包
多個子函式的[[scope]]
都是同時指向父級,是完全共享的,他們訪問的是同一個變數物件。因此當父級的變數物件被修改時,所有子函式都受到影響。
如何解決下面的迴圈輸出問題
js
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(自執行函式)建立一個包含i的新的作用域,由於定時器中的回撥函式持有IIFE中i的引用,其實也就是同時建立了覆蓋了IIFE作用域的的閉包,回撥函式執行的時候就會訪問各自閉包中的i的值。
js
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j)
}, j * 1000)
})(i)
}
我們在timer打斷點調式,一步步執行for迴圈程式碼可以看到具體Closure中i的變化過程
2、使用let,在for迴圈中每次迭代let都會宣告一個塊作用域。我們使用IIFE在每次迭代時都建立一個新作用域。也就是說,每次迭代我們建立一個塊作用域就可以解決迴圈輸出的問題了。 ```js 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的變化過程
3、給定時器傳入第3個引數,這個引數會作為引數傳遞給timer。由於函式的引數是按值傳遞的,即使全域性作用域的i發生了變化,傳遞給回撥函式中的i也不會變化的。
js
for (var i = 1; i <= 5; i++) {
setTimeout(function timer(j) {
console.log(j)
}, i * 1000,i)
}
我們在timer打斷點調式,一步步執行for迴圈程式碼可以看到具體Local中i的變化過程
9 JS面向物件
1. 理解面向物件程式設計
什麼是面向物件
什麼是面向物件呢,用java中的一句經典語句來說就是:萬事萬物皆物件。面向物件的思想主要是以物件為主,將一個問題抽象出具體的物件,並且將抽象出來的物件和物件的屬性和方法封裝成一個類。
面向物件是把構成問題事務分解成各個物件,建立物件的目的不是為了完成一個步驟,而是為了描敘某個事物在整個解決問題的步驟中的行為。
面向物件和麵向過程的區別
面向物件和麵向過程是兩種不同的程式設計思想,我們經常會聽到兩者的比較,剛開始程式設計的時候,大部分應該都是使用的面向過程的程式設計,但是隨著我們的成長,還是面向物件的程式設計思想比較好一點
其實面向物件和麵向過程並不是完全相對的,也並不是完全獨立的。
過程與資料的結合是形容面向物件中的“物件”時經常使用的表達,物件以方法的形式包含了過程。
我認為面向物件和麵向過程的主要區別是面向過程主要是以動詞為主,解決問題的方式是按照順序一步一步呼叫不同的函式。\ 而面向物件主要是以名詞為主,將問題抽象出具體的物件,而這個物件有自己的屬性和方法,在解決問題的時候是將不同的物件組合在一起使用。
所以說面向物件的好處就是可擴充套件性更強一些,解決了程式碼重用性的問題。
- 面向過程就是分析出解決問題所需要的步驟,然後用函式把這些步驟一步一步實現,使用的時候一個一個依次呼叫就可以了。
- 面向物件是把構成問題事務分解成各個物件,建立物件的目的不是為了完成一個步驟,而是為了描敘某個事物在整個解決問題的步驟中的行為。
具體的實現我們看一下最經典的“把大象放冰箱”這個問題
面向過程的解決方法
在面向過程的程式設計方式中實現“把大象放冰箱”這個問題答案是耳熟能詳的,一共分三步:
- 開門(冰箱);
- 裝進(冰箱,大象);
- 關門(冰箱)。
面向物件的解決方法
- 冰箱.開門()
- 冰箱.裝進(大象)
- 冰箱.關門()
可以看出來面向物件和麵向過程的側重點是不同的,面向過程是以動詞為主,完成一個事件就是將不同的動作函式按順序呼叫。\ 面向物件是以主謂為主。將主謂看成一個一個的物件,然後物件有自己的屬性和方法。比如說,冰箱有自己的id屬性,有開門的方法。然後就可以直接呼叫冰箱的開門方法給其傳入一個引數大象就可以了。
簡單的例子面向物件和麵向過程的好處還不是很明顯。請看下面購物車的列子
面向物件實戰思想
購物車例子
萬物皆物件,所以,任何事物都是有特徵(屬性)和動作(方法)的,一般拿到一份需求分檔,或者你瀏覽一個網頁看到一個畫面的時候,腦子裡就要有提煉出來的屬性和方法的能力,那你才是合格的。
做任何東西,先巨集觀思考* ,然後再去處理細節,然後組裝起來,就好像組裝汽車的道理一樣。例如上圖,紅色的就是屬性,黃色的就是方法,抽象出屬性和方法,其他都是死的。
面向過程思想實現
假如是剛學前端的同學,可能就會用這種全域性化的變數,也叫面向函式程式設計,缺點就是很亂,程式碼冗餘
```js //商品屬性 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() ```
單例模式思想實現
假如是單例模式的思想,可能會這樣做,但這樣還是不太好。物件太多,可能造成變數重複,專案小還可以接受
```js var product={ name:'macbook pro', description:'', price:6660, addOne:funcion(){}, reduceOne:function(){}, addToCart:function(){ alert('新增到購物車') } }
/*購物車*/
var cart={
name:'購物車',
products:[],
allPrice:5000,
sum:0
}
```
面向物件思想實現
假如是有一定經驗的人,可能會這樣子做。
```js 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 +='
}
function Card(products,allPrice,sum) { /屬性 行為 可以為空或者給預設值/ this.products = products; this.allPrice = allPrice; this.sum = sum } Product.prototype={ getAllPrice:function(){ alert('計算購物車內商品總價') } } ``` 通過建立各種物件例如macbook
```js //後臺給的資料 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中的資料物件,使得資料變得更加易於管理和使用。MVVM
本質就是基於操作資料來操作檢視進而操作DOM,藉助於MVVM無需直接操作DOM,開發者只需完成包含宣告繫結的檢視模板,編寫ViewModel中有業務,具體包含資料模型和展示邏輯,使得View完全實現自動化。
例如vue,他們不需要獲取dom,那麼渲染的時候,定義好一個一個的元件就行了。屬性全部在data定義好,方法在methods中定義,剩下的就是vue來解決了。
js
data:{
name ='',
price='',
description = ''
},
methods:{
addToCart:function(){
alert('新增到購物車')
}
addOne:funcion(){},
reduceOne:function(){},
}
然後page級元件引入這個產品元件,然後迴圈這個產品元件就好了。一個元件也是一個物件,其本質還是面向物件的思想。
2. 原型和原型鏈
原型和原型鏈是構建js面向物件系統的基礎,我們先了解下原型和原型鏈
理解原型,建構函式,例項之間的關係
我們建立的每個函式都有一個 prototype(原型)屬性,這個屬性是一個指標,指向一個物件,而這個物件的用途是包含可以由特定型別的所有例項共享的屬性和方法。如果按照字面意思來理解,那麼 prototype 就是通過呼叫建構函式而建立的那個物件例項的原型物件。
在JavaScript中,每當定義一個函式資料型別(普通函式、類)時候,就會根據一組特定規則為該函式建立一個prototype(原型)屬性,這個屬性指向函式的原型物件。原型物件上包含可以由特定型別的所有例項共享的屬性和方法。另外,在預設情況下,所有原型物件都會自動獲得一個constructor(建構函式)屬性,這個屬性包含一個指向 prototype 屬性所在函式的指標。
當函式經過new呼叫時,這個函式就成為了建構函式,返回一個全新的例項物件,這個例項物件有一個__proto__屬性,指向建構函式的原型物件。
```js function Foo(name){ this.name=name }
var newFoo = new Foo('dongnan')
console.log(newFoo)//列印結果如下圖
```
建構函式Foo,Foo的原型(prototype),Foo的例項newFoo的關係如下圖所示。
原型鏈
javascript物件通過__proto__指向當前物件的原型物件,當前物件的原型物件再通過__proto__指向父類原型物件,直到指向Object物件的原型物件為止,這樣就形成了一個指向原型指向的鏈條,即原型鏈。
平時我們訪問一個物件的屬性和方法時候,就是沿著原型鏈查詢的。查詢順序,1)當前例項,2)當前例項的原型物件,3)父類的原型物件,N)Object的原型物件。
3. 封裝
面向物件有三大特性,封裝、繼承和多型。對於ES5來說,沒有class
的概念,並且由於js的函式級作用域(在函式內部的變數在函式外訪問不到),所以我們就可以模擬 class
的概念,在es5中,類其實就是儲存了一個函式的變數,這個函式有自己的屬性和方法。將屬性和方法組成一個類的過程就是封裝。
封裝:把客觀事物封裝成抽象的物件,隱藏屬性和方法的實現細節,僅對外公開介面。
具體的事物抽象化
工廠模式
用函式來封裝以特定介面建立物件的細節,這種建立物件的模式叫做工廠模式
js
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建立的。
建構函式模式
js
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 新例項的機制仍然是相同的。因此,不同例項上的同名函式是不相等的。怎麼解決這個問題
js
alert(newperson1.sayName == newperson2.sayName); //false
原型模式
通過原型物件建立物件
```js 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 ```
原型模式的不足之處,通過原型共享方法很合適,但是共享屬性不符合建立例項物件的初衷(例項一般都需要有自己的全部屬性的)
組合使用建構函式模式和原型模(推薦)
建立自定義型別的最常見方式,是組合使用建構函式模式與原型模式。建構函式模式用於定義例項屬性,而原型模式用於定義方法和共享的屬性。結果,每個例項都會有自己的一份例項屬性的副本,但同時又共享著對方法的引用,最大限度地節省了記憶體。另外,這種混成模式還支援向建構函式傳遞引數;可謂是集兩種模式之長。
js
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 語言經驗的開發人員在看到獨立的建構函式和原型時,很可能會感到非常困惑。動態原型模式正是致力於解決這個問題的一個方案,它把所有資訊都封裝在了建構函式中,而通過在建構函式中初始化原型(僅在必要的情況下),又保持了同時使用建構函式和原型的優點。換句話說,可以通過檢查某個應該存在的方法是否有效,來決定是否需要初始化原型。來看一個例子。 ```js 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 建構函式,因此可以使用這個模式。
```js 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 建構函式重寫如下。
js
function Person(name,age){
// 建立要返回的物件
var o = new Object()
// 定義私有變數和方法
// 新增方法
o.sayName = function(){
console.log(name)
}
// 返回物件
return o
}
var friend =Person('dongnan',21)
friend.sayName()
穩妥建構函式模式提供的這種安全性,使得它非常適合在某些安全執行環境。
4. 繼承
繼承:一個物件繼承另一個物件的所有功能,並且對這些功能進行擴充套件。繼承的過程,就是從一般到特殊的過程。
原型鏈
基本思想是利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。
```js 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)的技術(有時候也叫做偽造物件或經典繼承)。這種技術的基本思想相當簡單,即在子型別建構函式的內部呼叫超型別建構函式。
js
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),有時候也叫做偽經典繼承,指的是將原型鏈和借用建構函式的技術組合到一塊,從而發揮二者之長的一種繼承模式。其背後的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用建構函式來實現對例項屬性的繼承。這樣,既通過在原型上定義方法實現了函式複用,又能夠保證每個例項都有它自己的屬性。
```js 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()也能夠用於識別基於組合繼承建立的物件。
組合繼承最大的問題就是無論什麼情況下,都會呼叫兩次超型別建構函式:一次是在建立子型別原型的時候,另一次是在子型別建構函式內部。沒錯,子型別最終會包含超型別物件的全部例項屬性,但我們不得不在呼叫子型別建構函式時重寫這些屬性。
原型試繼承
藉助原型可以基於已有的物件建立新物件,同時還不必因此建立自定義型別。
```js 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內部實現的簡化版
js
function Object(o){
function F(){}
F.prototype=o
return new F()
}
寄生試繼承
寄生式繼承的思路與寄生建構函式和工廠模式類似,即建立一個僅用於封裝繼承過程的函式,該函式在內部以某種方式來增強物件,最後再像真地是它做了所有工作一樣返回物件。
```js 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 ```
寄生組合式繼承(推薦)
這種繼承方式對組合繼承進行了優化,組合繼承缺點在於繼承父類函式時呼叫了建構函式,我們只需要優化掉這點就行了。
```js 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。
SubType.prototype = Object.create(SuperType.prototype,{
constructor:{
value:SubType,
enumerable:false,
writable:true,
configurable:true
}
})
SubType.prototype.sayAge = function () {
console.log(this.age)
}
var instance = new SubType('dongnan','21')
instance.sayName()//dognnan
instance.sayAge()//21
console.log(instance instanceof SubType)//true
console.log(instance instanceof SuperType)//true
```
該繼承實現的核心就是將父類的原型賦值給了子類,並且將建構函式設定為子類,這樣父類只調用了一次,並且因此解決了無用的父類屬性問題,還能正確的找到子類的建構函式。
5. Babel 如何編譯 ES6 Class 的
es6的繼承可以直接使用 class
來實現繼承。
但是 class
畢竟是 ES6 的東西,為了能更好地相容瀏覽器,我們通常都會通過 Babel 去編譯 ES6 的程式碼。接下來我們就來了解下通過 Babel 編譯後的程式碼是怎麼樣的。
```js 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這三個方法。
js
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完全採用的是面向組合的設計方式。
10 JS模組化規範
1. 模組化要解決什麼問題以及怎麼實現模組化
從 1995 年釋出 JavaScript 開始,瀏覽器端載入 JS 模組就是使用簡單的 script 標籤。早在 1996 年,就湧現了很多 伺服器端 JavaScript 實現, 例如 2009 年釋出的 Nodejs。無論是瀏覽器端還是服務端 JavaScript, 在 ES6 規範提出之前,JavaScript 本身一直沒有模組體系。
什麼是模組化
模組化就是將一個複雜的系統分解成多個獨立的模組的程式碼組織方式。優秀的作者把他們的書分成章節,優秀的程式設計師把他們的程式分成模組。好的模組是高度獨立的,具有特定功能的,可以根據需要對它們進行修改,刪除或新增,而不會破壞整個系統。
模組化有什麼好處
模組化帶來的好處主要是這些:
- 名稱空間
在 JavaScript 中,每個 JS 檔案的介面都暴露在全域性作用域中,每個人都可以訪問它們,並且容易造成命名衝突,汙染全域性。模組化可以為變數建立私有空間來避免名稱空間汙染。
- 可複用性
有沒有曾經在某個時候將之前編寫的程式碼複製到新的專案中呢?如果將此程式碼模組化,則可以反覆使用,且在需要修改時只需要修改此模組,而不需要在專案中的每個此程式碼處做修改。
- 可維護性
模組應該是獨立的,一個設計良好的模組應儘可能減少對部分程式碼庫的依賴,從而使其能夠獨立地刪減和修改。當模組與其他程式碼片段分離時,更新單個模組要容易得多,還可以對每次修改的內容做版本管理。
模組化雖然有很多好處,但是要真正的實現模組化開發並不容易
傳統的模組化開發方式
- 命名衝突
當多個 JS 檔案為變數和方法取相同名稱而造成命名衝突時,可以採用 Java 中的名稱空間的方式。
```js // 程式碼來自:http://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 等其他程式語言中使用類的方式,可以將公共以及私有的方法和變數儲存在單個物件中。將要公開給全域性作用域的方法寫在閉包外,將私有變數和方法封裝在閉包範圍內,這樣就可以解決變數都暴露在全域性作用域的問題。
js
// 全域性作用局可訪問
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 釋出
-
模組化的歷史圖譜
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 規範。它有四個重要的環境變數為模組化的實現提供支援:module
、exports
、require
、global
。實際使用時,用module.exports
定義當前模組對外輸出的介面(不推薦直接用exports
),用require
載入模組。
```js // 定義模組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()
並規定專案中用到的基礎模組。
```js / 網頁中引入require.js及main.js /
/ 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()
的第一引數。
```js // 定義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推廣過程中產生的。
```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 用於引入模組。
```js // 匯入單個介面 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 傳遞的是引用,便不會有這些問題
其他還有對未來可能新增的標準(巨集、型別系統等)更相容等。
ES6 Module in Browser
在 ES Module 標準出來之前,儘管社群實現的 Loader 一籮筐,但瀏覽器自身一直沒有選定模組方案,支援 ES Module 對瀏覽器來說還是比較少顧慮的。
由於 ES Module 的執行環境和普通指令碼不同,瀏覽器選擇增加 <script type="module">
,只有 <script type="module">
中的指令碼(和 import
進來的指令碼)才是 module
模式。也只有 module
模式執行的指令碼,才可以宣告 import
。也就是說,下面這種程式碼是不行的:
```js
```
目前,幾大常青瀏覽器都已支援 ES Module。最後一個支援的是 Firefox,2018 年 5 月 8 日釋出的 Firefox 60 正式支援 ES Module。
此外,考慮到向後相容,瀏覽器還增加 <script nomodule>
標籤。開發者可以使用 <script nomodule>
標籤相容不支援 ES Module 的瀏覽器:
```js // 在瀏覽器中,import 語句只能在聲明瞭 type="module" 的 script 的標籤中使用。
// 在 script 標籤中使用 nomodule 屬性,可以確保向後相容。
```
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
,export
和import()
- 在
.mjs
中不可以使用require
- 在
.js
中可以使用require
和import()
- 在
.js
中不可以使用import
和export
也就是兩套模組系統完全獨立。此外,依賴查詢方式也有變化,原本 require.extensions
是:
```js { '.js': [Function], '.json': [Function], '.node': [Function] }
```
如今(需要開啟 --experimental-modules
選項)則是:
js
{ '.js': [Function],
'.json': [Function],
'.node': [Function],
'.mjs': [Function] }
但兩套獨立的模組系統也導致第二個糾結的方面,模組系統彼此之間如何互通?對瀏覽器來說這不是問題,但對 Node.js 來說,npm 中海量的 CommonJS 模組是它不得不考慮的。
- ES6 Module 載入 CommonJS
最終確定的方案倒也簡單,在 .mjs
裡,開發者可以 import
CommonJS(雖然只能 import default
):
js
//正確
import foo from './foo'
//錯誤
import {method} from './foo'
ES6 模組的import
命令可以載入 CommonJS 模組,但是隻能整體載入,不能只加載單一的輸出項。
這是因為 ES6 模組需要支援靜態程式碼分析,而 CommonJS 模組的輸出介面是module.exports
,是一個物件,無法被靜態分析,所以只能整體載入。
- CommonJS 載入ES6 Module
在 .js
裡,開發者自然不能 import
ES Module,但他們可以 import()
:
```js import('./foo').then(foo => { // use foo })
(async function() { const bar = await import('./bar') // use bar })() ```
require()
不支援 ES6 模組的一個原因是,它是同步載入,而 ES6 模組內部可以使用頂層await
命令,導致無法被同步載入。
注意,和瀏覽器以引入方式判斷執行模式不同,Node.js 中指令碼的執行模式是和副檔名繫結的。也就是說,依賴的查詢方式會有所不同:
- 在
.js
中require('./foo')
找的是./foo.js
或者./foo/index.js
- 在
.mjs
中import './bar'
找的是./bar.mjs
或者./bar/index.mjs
善用這些特性,我們現在就可以將已有的 npm 模組升級成 ES Module,並且仍然支援 CommonJS 方式。
Dynamic import
靜態型的 import
是初始化載入依賴項的最優選擇,使用靜態 import
更容易從程式碼靜態分析工具和 tree shaking中受益。但當希望按照一定的條件或者按需載入模組的時候,需要動態引入依賴,例如:
```js 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()
標準[提案。
js
//這是一個處於第三階段的提案。
var promise = import("module-name");
除了可以用來處理動態依賴,HTML 中的 script 標籤不需要宣告 type="module"
。
```js
```
在 Node.js 中(.js
檔案)還可以使用 import() 引入使用 import 的 ES Module :
js
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:
js
import defaultExport from "module-name";
import * as name from "module-name";
import { export } from "module-name";
這便是構建工具帶來的好處了,發揮空間遠比傳統瀏覽器 Loader 來得大,可以輕鬆加入像 Babel、Traceur 等 transpiler 支援。
6. ES6 Module 與 CommmonJS 的區別
載入方式
-
CommonJS 模組是執行時載入,ES6 模組是編譯時輸出介面。
- 執行時載入: CommonJS 模組載入的是一個物件,該物件只有在
指令碼執行完才會生成
;即在輸入時是先載入整個模組,生成一個物件,然後再從這個物件上面讀取方法,這種載入稱為“執行時載入”。即使再次執行require
命令,也不會再次執行該模組,而是到快取之中取值。也就是說,CommonJS 模組無論載入多少次,都只會在第一次載入時執行一次,以後再載入,就返回第一次執行的結果,除非手動清除系統快取。 - 編譯時載入: ES6 模組不是物件,它的對外介面只是一種靜態定義,
JS引擎對指令碼靜態解析階段就會生成
。而是通過export
命令顯式指定輸出的程式碼,import
時採用靜態命令的形式。即在import
時可以指定載入某個輸出值,而不是載入整個模組,這種載入稱為“編譯時載入”。
- 執行時載入: CommonJS 模組載入的是一個物件,該物件只有在
CommonJS 載入的是一個物件(即module.exports
屬性),該物件只有在指令碼執行完才會生成。而 ES6 模組不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成。
同步/非同步
CommonJS 模組的require()
是同步載入模組,ES6 模組的import
命令是非同步載入,有一個獨立的模組依賴的解析階段。
輸出值的方式
-
CommonJS 模組輸出的是一個值的拷貝,ES6 模組輸出的是值的引用。
- CommonJS 模組輸出的是值的拷貝,也就是說,一旦輸出一個值,模組內部的變化就影響不到這個值。
- ES6 模組的執行機制與 CommonJS 不一樣。JS 引擎對指令碼靜態分析的時候,遇到模組載入命令
import
,就會生成一個只讀引用。等到指令碼真正執行時,再根據這個只讀引用,到被載入的那個模組裡面去取值。換句話說,ES6 的import
有點像 Unix 系統的“符號連線”,原始值變了,import
載入的值也會跟著變。因此,ES6 模組是動態引用,並且不會快取值,模組裡面的變數繫結其所在的模組。
```js // 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
js
// 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
的時候,就會全部執行。一旦出現某個模組被"迴圈載入",就只輸出已經執行的部分,還未執行的部分不會輸出 - ES6 處理“迴圈載入”與 CommonJS 有本質的不同。ES6 模組是動態引用,如果使用
import
從一個模組載入變數(即import foo from 'foo'
),那些變數不會被快取,而是成為一個指向被載入模組的引用,需要開發者自己保證,真正取值的時候能夠取到值。本質上說,相互匯入,加上檢驗兩個import語句的有效性的靜態驗證,虛擬組合了連個模組的空間(通過繫結),這個樣A模組可以呼叫B模組,反過來也一樣,這和它們本來宣告在一個作用域中是對稱的。
7. 模組化之間的差異
8. 模組化的現狀
正如玉伯在 前端模組化開發那點歷史 中所說: 隨著 W3C 等規範、以及瀏覽器的飛速發展,前端的模組化開發會逐步成為基礎設施。一切終究都會成為歷史。
我們現在開發中不必再糾結使用哪種模組化方案, ES6 在語言標準層面為我們解決了這個問題。 - CommonJS - Nodejs 已成為服務端 JavaScript 標準
-
Module Loader(模組載入器已成過去式)
- RequireJS 已經不維護了
- seajs 已經不維護了。作者2015年就釋出微博: 應該給 Sea.js 樹一塊墓碑了。
-
ES6 Module
- 語法在主流瀏覽器和Nodejs8.5版本以上都已支援。
-
Module Bundler
- webpack [GitHub] 現在最火的打包工具
- browserify [GitHub] 現在更新頻率比較少
參考資料
《瀏覽器工作原理與實踐》 極客時間
《圖解 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非同步處理(一)——理解非同步