前言
ECMAScript 6.0(簡稱ES6),作為下一代JavaScript的語言標準正式釋出於2015 年 6 月,至今已經發布3年多了,但是因為蘊含的語法之廣,完全消化需要一定的時間,這裡我總結了部分ES6,以及ES6以後新語法的知識點,使用場景,希望對各位有所幫助
本文講著重是對ES6語法特性的補充,不會講解一些API層面的語法,更多的是發掘背後的原理,以及ES6到底解決了什麼問題
如有錯誤,歡迎指出,將在第一時間修改,歡迎提出修改意見和建議
話不多說開始ES6之旅吧~~~
let/const(常用)
let,const用於宣告變數,用來替代老語法的var關鍵字,與var不同的是,let/const會建立一個塊級作用域(通俗講就是一個花括號內是一個新的作用域)
這裡外部的console.log(x)拿不到前面2個塊級作用域宣告的let:
在日常開發中多存在於使用if/for關鍵字結合let/const建立的塊級作用域,值得注意的是使用let/const關鍵字宣告變數的for迴圈和var宣告的有些不同
for迴圈分為3部分,第一部分包含一個變數宣告,第二部分包含一個迴圈的退出條件,第三部分包含每次迴圈最後要執行的表示式,也就是說第一部分在這個for迴圈中只會執行一次var i = 0,而後面的兩個部分在每次迴圈的時候都會執行一遍
而使用使用let/const關鍵字宣告變數的for迴圈,除了會建立塊級作用域,let/const還會將它繫結到每個迴圈中,確保對上個迴圈結束時候的值進行重新賦值
什麼意思呢?簡而言之就是每次迴圈都會宣告一次(對比var宣告的for迴圈只會宣告一次),可以這麼理解let/const中的for迴圈
給每次迴圈建立一個塊級作用域:
暫時性死區
使用let/const宣告的變數,從一開始就形成了封閉作用域,在宣告變數之前是無法使用這個變數的,這個特點也是為了彌補var的缺陷(var宣告的變數有變數提升)
在預編譯的階段,JS編譯器會先解析一遍判斷是否有let/const宣告的變數,如果在一個花括號中存在使用let/const宣告的變數,則ES6規定這些變數在沒宣告前是無法使用的,隨後再是進入執行階段執行程式碼
這裡當滿足if的條件時,進入true的邏輯,這裡因為使用了let宣告瞭變數name,在一開始就”劫持了這個作用域”,使得任何在let宣告之前使用name的操作都會報錯
使用var宣告的變數,因為會有變數提升,同樣也是發生在預編譯階段,var會提升到當前函式作用域的頂部並且預設賦值為undefined,如果這幾行程式碼是在全域性作用域下,則name變數會直接提升到全域性作用域,隨後進入執行階段執行程式碼,name被賦值為”abc”,並且可以成功列印出字串abc
相當於這樣
暫時性死區其實是為了防止ES5以前在變數宣告前就使用這個變數,這是因為var的變數提升的特性導致一些不熟悉var原理的開發者習以為常的以為變數可以先使用在宣告,從而埋下一些隱患
關於JS預編譯和JS的3種作用域(全域性,函式,塊級)這裡也不贅述了,否則又能寫出幾千字的部落格,有興趣的朋友自行了解一下,同樣也有助於瞭解JavaScript這門語言
const
使用const關鍵字宣告一個常量,常量的意思是不會改變的變數,const和let的一些區別是
- const宣告變數的時候必須賦值,否則會報錯,同樣使用const宣告的變數被修改了也會報錯
- const宣告變數不能改變,如果宣告的是一個引用型別,則不能改變它的記憶體地址(這裡牽扯到JS引用型別的特點,有興趣可以看我另一篇部落格物件深拷貝和淺拷貝)
有些人會有疑問,為什麼日常開發中沒有顯式的宣告塊級作用域,let/const宣告的變數卻沒有變為全域性變數
這個其實也是let/const的特點,ES6規定它們不屬於頂層全域性變數的屬性,這裡用chrome除錯一下
可以看到使用let宣告的變數x是在一個叫script作用域下的,而var宣告的變數因為變數提升所以提升到了全域性變數window物件中,這使我們能放心的使用新語法,不用擔心汙染全域性的window物件
建議
在日常開發中,我的建議是全面擁抱let/const,一般的變數宣告使用let關鍵字,而當宣告一些配置項(類似介面地址,npm依賴包,分頁器預設頁數等一些一旦宣告後就不會改變的變數)的時候可以使用const,來顯式的告訴專案其他開發者,這個變數是不能改變的(const宣告的常量建議使用全大寫字母標識,單詞間用下劃線),同時也建議瞭解var關鍵字的缺陷(變數提升,汙染全域性變數等),這樣才能更好的使用新語法
箭頭函式(常用)
ES6 允許使用箭頭(=>)定義函式
箭頭函式對於使用function關鍵字建立的函式有以下區別
-
箭頭函式沒有arguments(建議使用更好的語法,剩餘運算子替代)
-
箭頭函式沒有prototype屬性,不能用作建構函式(不能用new關鍵字呼叫)
-
箭頭函式沒有自己this,它的this是詞法的,引用的是上下文的this,即在你寫這行程式碼的時候就箭頭函式的this就已經和外層執行上下文的this繫結了(這裡個人認為並不代表完全是靜態的,因為外層的上下文仍是動態的可以使用call,apply,bind修改,這裡只是說明了箭頭函式的this始終等於它上層上下文中的this)
因為setTimeout會將一個匿名的回撥函式推入非同步佇列,而回撥函式是具有全域性性的,即在非嚴格模式下this會指向window,就會存在丟失變數a的問題,而如果使用箭頭函式,在書寫的時候就已經確定它的this等於它的上下文(這裡是makeRequest的函式執行上下文,相當於將箭頭函式中的this繫結了makeRequest函式執行上下文中的this)因為是controller物件呼叫的makeRequest函式,所以this就指向了controller物件中的a變數
箭頭函式的this指向即使使用call,apply,bind也無法改變(這裡也驗證了為什麼ECMAScript規定不能使用箭頭函式作為建構函式,因為它的this已經確定好了無法改變)
建議
箭頭函式替代了以前需要顯式的宣告一個變數儲存this的操作,使得程式碼更加的簡潔
ES5寫法:
ES6箭頭函式:
再來看一個例子
值得注意的是makeRequest後面的function不能使用箭頭函式,因為這樣它就會再使用上層的this,而再上層是全域性的執行上下文,它的this的值會指向window,所以找不到變數a返回undefined
在陣列的迭代中使用箭頭函式更加簡潔,並且省略了return關鍵字
不要在可能改變this指向的函式中使用箭頭函式,類似Vue中的methods,computed中的方法,生命週期函式,Vue將這些函式的this繫結了當前元件的vm例項,如果使用箭頭函式會強行改變this,因為箭頭函式優先順序最高(無法再使用call,apply,bind改變指向)
在把箭頭函式作為日常開發的語法之前,個人建議是去了解一下箭頭函式的是如何繫結this的,而不只是當做省略function這幾個單詞拼寫,畢竟那才是ECMAScript真正希望解決的問題
iterator迭代器
iterator迭代器是ES6非常重要的概念,但是很多人對它瞭解的不多,但是它卻是另外4個ES6常用特性的實現基礎(解構賦值,剩餘/擴充套件運算子,生成器,for of迴圈),瞭解迭代器的概念有助於瞭解另外4個核心語法的原理,另外ES6新增的Map,Set資料結構也有使用到它,所以我放到前面來講
對於可迭代的資料解構,ES6在內部部署了一個[Symbol.iterator]屬性,它是一個函式,執行後會返回iterator物件(也叫迭代器物件,也叫iterator介面),擁有[Symbol.iterator]屬性的物件即被視為可迭代的
陣列中的Symbol.iterator方法預設部署在陣列原型上:
預設具有iterator介面的資料結構有以下幾個,注意普通物件預設是沒有iterator介面的(可以自己建立iterator介面讓普通物件也可以迭代)
- Array
- Map
- Set
- String
- TypedArray(類陣列)
- 函式的 arguments 物件
- NodeList 物件
iterator迭代器是一個物件,它具有一個next方法所以可以這麼呼叫
next方法返回又會返回一個物件,有value和done兩個屬性,value即每次迭代之後返回的值,而done表示是否還需要再次迴圈,可以看到當value為undefined時,done為true表示迴圈終止
梳理一下
- 可迭代的資料結構會有一個[Symbol.iterator]方法
- [Symbol.iterator]執行後返回一個iterator物件
- iterator物件有一個next方法
- next方法執行後返回一個有value,done屬性的物件
這裡簡要概述了以下iterator的概念,有興趣可以去看阮一峰老師的《ECMAScript 6 入門》
解構賦值(常用)
解構賦值可以直接使用物件的某個屬性,而不需要通過屬性訪問的形式使用,物件解構原理個人認為是通過尋找相同的屬性名,然後原物件的這個屬性名的值賦值給新物件對應的屬性
這裡左邊真正宣告的其實是titleOne,titleTwo這兩個變數,然後會根據左邊這2個變數的位置尋找右邊物件中title和test[0]中的title對應的值,找到字串abc和test賦值給titleOne,titleTwo(如果沒有找到會返回undefined)
陣列解構的原理其實是消耗陣列的迭代器,把生成物件的value屬性的值賦值給對應的變數
陣列解構的一個用途是交換變數,避免以前要宣告一個臨時變數值儲存值
ES6交換變數:
建議
同樣建議使用,因為解構賦值語意化更強,對於作為物件的函式引數來說,可以減少形參的宣告,直接使用物件的屬性(如果巢狀層數過多我個人認為不適合用物件解構,不太優雅)
一個常用的例子是Vuex中actions中的方法會傳入2個引數,第一個引數是個物件,你可以隨意命名,然後使用<名字>.commit的方法呼叫commit函式,或者使用物件解構直接使用commit
不使用物件解構:
使用物件解構:
另外可以給使用axios的響應結果進行解構(axios預設會把真正的響應結果放在data屬性中)
剩餘/擴充套件運算子(常用)
剩餘/擴充套件運算子同樣也是ES6一個非常重要的語法,使用3個點(…),後面跟著一個含有iterator介面的資料結構
擴充套件運算子
以陣列為例,使用擴充套件運算子使得可以”展開”這個陣列,可以這麼理解,陣列是存放元素集合的一個容器,而使用擴充套件運算子可以將這個容器拆開,這樣就只剩下元素集合,你可以把這些元素集合放到另外一個陣列裡面
擴充套件運算子可以代替ES3中陣列原型的concat方法
這裡將arr1,arr2通過擴充套件運算子展開,隨後將這些元素放到一個新的陣列中,相對於concat方法語義化更強
剩餘運算子
剩餘運算子最重要的一個特點就是替代了以前的arguments
訪問函式的arguments物件是一個很昂貴的操作,以前的arguments.callee,arguments.caller都被廢止了,建議在支援ES6語法的環境下不要在使用arguments物件,使用剩餘運算子替代(箭頭函式沒有arguments,必須使用剩餘運算子才能訪問引數集合)
剩餘運算子可以和陣列的解構賦值一起使用,但是必須放在最後一個,因為剩餘運算子的原理其實是利用了陣列的迭代器,它會消耗3個點後面的陣列的所有迭代器,讀取所有迭代器生成物件的value屬性,剩運算子後不能在有解構賦值,因為剩餘運算子已經消耗了所有迭代器,而陣列的解構賦值也是消耗迭代器,但是這個時候已經沒有迭代器了,所以會報錯
這裡first會消耗右邊陣列的一個迭代器,…arr會消耗剩餘所有的迭代器,而第二個例子…arr直接消耗了所有迭代器,導致last沒有迭代器可供消耗了,所以會報錯,因為這是毫無意義的操作
剩餘運算子和擴充套件運算子的區別就是,剩餘運算子會收集這些集合,放到右邊的陣列中,擴充套件運算子是將右邊的陣列拆分成元素的集合,它們是相反的
在物件中使用擴充套件運算子
這個是ES9的語法,ES9中支援在物件中使用擴充套件運算子,之前說過陣列的擴充套件運算子原理是消耗所有迭代器,但物件中並沒有迭代器,我個人認為可能是實現原理不同,但是仍可以理解為將鍵值對從物件中拆開,它可以放到另外一個普通物件中
其實它和另外一個ES6新增的API相似,即Object.assign,它們都可以合併物件,但是還是有一些不同Object.assign會觸發目標物件的setter函式,而物件擴充套件運算子不會,這個我們放到後面討論
建議
使用擴充套件運算子可以快速的將類陣列轉為一個真正的陣列
合併多個陣列
函式柯里化
物件屬性/方法簡寫(常用)
物件屬性簡寫
es6允許當物件的屬性和值相同時,省略屬性名
需要注意的是
- 省略的是屬性名而不是值
- 值必須是一個變數
物件屬性簡寫經常與解構賦值一起使用
結合上文的解構賦值,這裡的程式碼會其實是宣告瞭x,y,z變數,因為bar函式會返回一個物件,這個物件有x,y,z這3個屬性,解構賦值會尋找等號右邊表示式的x,y,z屬性,找到後賦值給宣告的x,y,z變數
方法簡寫
es6允許當一個物件的屬性的值是一個函式(即是一個方法),可以使用簡寫的形式
在Vue中因為都是在vm物件中書寫方法,完全可以使用方法簡寫的方式書寫函式
for … of迴圈
for … of是作為ES6新增的遍歷方式,允許遍歷一個含有iterator介面的資料結構並且返回各項的值,和ES3中的for … in的區別如下
-
for … of遍歷獲取的是物件的鍵值,for … in 獲取的是物件的鍵名
-
for … in會遍歷物件的整個原型鏈,效能非常差不推薦使用,而for … of只遍歷當前物件不會遍歷原型鏈
-
對於陣列的遍歷,for … in會返回陣列中所有可列舉的屬性(包括原型鏈上可列舉的屬性),for … of只返回陣列的下標對應的屬性值
for … of迴圈的原理其實也是利用了遍歷物件內部的iterator介面,將for … of迴圈分解成最原始的for迴圈,內部實現的機制可以這麼理解
可以看到只要滿足第二個條件(iterator.next()存在且res.done為true)就可以一直迴圈下去,並且每次把迭代器的next方法生成的物件賦值給res,然後將res的value屬性賦值給for … of第一個條件中宣告的變數即可,res的done屬性控制是否繼續遍歷下去
for… of迴圈同時支援break,continue,return(在函式中呼叫的話)並且可以和物件解構賦值一起使用
arr陣列每次使用for … of迴圈都返回一物件({a:1},{a:2},{a:3}),然後會經過物件解構,尋找屬性為a的值,賦值給obj.a,所以在每輪迴圈的時候obj.a會分別賦值為1,2,3
Promise(常用)
Promise作為ES6中推出的新的概念,改變了JS的非同步程式設計,現代前端大部分的非同步請求都是使用Promise實現,fetch這個web api也是基於Promise的,這裡不得簡述一下之前統治JS非同步程式設計的回撥函式,回撥函式有什麼缺點,Promise又是怎麼改善這些缺點
回撥函式
眾所周知,JS是單執行緒的,因為多個執行緒改變DOM的話會導致頁面紊亂,所以設計為一個單執行緒的語言,但是瀏覽器是多執行緒的,這使得JS同時具有非同步的操作,即定時器,請求,事件監聽等,而這個時候就需要一套事件的處理機制去決定這些事件的順序,即Event Loop(事件迴圈),這裡不會詳細講解事件迴圈,只需要知道,前端發出的請求,一般都是會進入瀏覽器的http請求執行緒,等到收到響應的時候會通過回撥函式推入非同步佇列,等處理完主執行緒的任務會讀取非同步佇列中任務,執行回撥
在《你不知道的JavaScript》下卷中,這麼介紹
使用回撥函式處理非同步請求相當於把你的回撥函式置於了一個黑盒,使用第三方的請求庫你可能會這麼寫
收到響應後,執行後面的回撥列印字串,但是如果這個第三方庫有類似超時重試的功能,可能會執行多次你的回撥函式,如果是一個支付功能,你就會發現你扣的錢可能就不止1000元了-.-
另外一個眾所周知的問題就是,在回撥函式中再巢狀回撥函式會導致程式碼非常難以維護,這是人們常說的“回撥地獄”
你使用的第三方ajax庫還有可能並沒有提供一些錯誤的回撥,請求失敗的一些錯誤資訊可能會被吞掉,而你確完全不知情
總結一下回撥函式的一些缺點
-
多重巢狀,導致回撥地獄
-
程式碼跳躍,並非人類習慣的思維模式
-
信任問題,你不能把你的回撥完全寄託與第三方庫,因為你不知道第三方庫到底會怎麼執行回撥(多次執行)
-
第三方庫可能沒有提供錯誤處理
-
不清楚回撥是否都是非同步呼叫的(可以同步呼叫ajax,在收到響應前會阻塞整個執行緒,會陷入假死狀態,非常不推薦)
xhr.open("GET","/try/ajax/ajax_info.txt",false); //通過設定第三個async為false可以同步呼叫ajax
複製程式碼
Promise
針對回撥函式這麼多缺點,ES6中引入了一個新的概念Promise,Promise是一個建構函式,通過new關鍵字建立一個Promise的例項,來看看Promise是怎麼解決回撥函式的這些問題
Promise並不是回撥函式的衍生版本,而是2個概念,所以需要將之前的回撥函式改為支援Promise的版本,這個過程成為”提升”,或者”promisory”,現代MVVM框架常用的第三方請求庫axios就是一個典型的例子,另外nodejs中也有bluebird,Q等
- 多重巢狀,導致回撥地獄
Promise在設計的時候引入了鏈式呼叫的概念,每個then方法同樣也是一個Promise,因此可以無限鏈式呼叫下去
配合箭頭函式,明顯的比之前回撥函式的多層巢狀優雅很多
- 程式碼跳躍,並非人類習慣的思維模式
Promise使得能夠同步思維書寫程式碼,上述的程式碼就是先請求3000埠,得到響應後再請求3001,再請求3002,再請求3003,而書寫的格式也是符合人類的思維,從先到後
- 信任問題,你不能把你的回撥完全寄託與第三方庫,因為你不知道第三方庫到底會怎麼執行回撥(多次執行)
Promise本身是一個狀態機,具有pending(等待),fulfilled(成功),rejected(拒絕)這3個狀態,當請求傳送沒有得到響應的時候為pending狀態,得到響應後會resolve(決議)當前這個Promise例項,將它變為fulfilled/rejected(大部分情況會變為fulfilled),當請求發生錯誤後會執行reject(拒絕)將這個Promise例項變為rejected狀態.一個Promise例項的狀態只能從pending => fulfilled 或者從 pending => rejected,即當一個Promise例項從pending狀態改變後,就不會再改變了(不存在fulfilled => rejected 或 rejected => fulfilled)
而Promise例項必須主動呼叫then方法,才能將值從Promise例項中取出來(前提是Promise不是pending狀態),這一個“主動”的操作就是解決這個問題的關鍵,即第三方庫做的只是把改變Promise的狀態,而響應的值怎麼處理,這是開發者主動控制的,這裡就實現了控制反轉,將原來第三方庫的控制權轉移到了開發者上
- 第三方庫可能沒有提供錯誤處理
Promise的then方法會接受2個函式,第一個函式是這個Promise例項被resolve時執行的回撥,第二個函式是這個Promise例項被reject時執行的回撥,而這個也是開發者主動呼叫的
使用Promise在非同步請求傳送錯誤的時候,即使沒有捕獲錯誤,也不會阻塞主執行緒的程式碼
- 不清楚回撥是否都是非同步呼叫的
Promise在設計的時候保證所有響應的處理回撥都是非同步呼叫的,不會阻塞程式碼的執行,Promise將then方法的回撥放入一個叫微任務的佇列中(MicroTask),保證這些回撥任務都在同步任務執行完再執行,這部分同樣也是事件迴圈的知識點,有興趣的朋友可以深入研究一下
對於第三個問題中,為什麼說執行了resolve函式後”大部分情況”會進入fulfilled狀態呢?考慮以下情況
(這裡用一個定時器在下輪事件迴圈中列印這個Promise例項的狀態,否則會是pending狀態)
很多人認為promise中呼叫了resolve函式則這個promise一定會進入fulfilled狀態,但是這裡可以看到,即使呼叫了resolve函式,仍返回了一個拒絕狀態的Promise,原因是因為如果在一個promise的resolve函式中又傳入了一個Promise,會展開傳入的這個promise
這裡因為傳入了一個拒絕狀態的promise,resolve函式展開這個promise後,就會變成一個拒絕狀態的promise,所以把resolve理解為決議比較好一點
等同於這樣
建議
在日常開發中,建議全面擁抱新的Promise語法,其實現在的非同步程式設計基本也都使用的是Promise
建議使用ES7的async/await進一步的優化Promise的寫法,async函式始終返回一個Promise,await可以實現一個”等待”的功能,async/await被成為非同步程式設計的終極解決方案,即用同步的形式書寫非同步程式碼,並且能夠更優雅的實現非同步程式碼順序執行,詳情可以看阮老師的ES6標準入門
關於Promise還有很多很多需要講的,包括它的靜態方法all,race,resolve,reject,Promise的執行順序,Promise巢狀Promise,thenable物件的處理等,礙於篇幅這裡只介紹了一下為什麼需要使用Promise。但很多開發者在日常使用中只是瞭解這些API,卻不知道Promise內部具體是怎麼實現的,遇到複雜的非同步程式碼就無從下手,非常建議去了解一下Promise A+的規範,自己實現一個Promise
ES6 Module(常用)
在ES6 Module出現之前,模組化一直是前端開發者討論的重點,面對日益增長的需求和程式碼,需要一種方案來將臃腫的程式碼拆分成一個個小模組,從而推出了AMD,CMD和CommonJs這3種模組化方案,前者用在瀏覽器端,後面2種用在服務端,直到ES6 Module出現
ES6 Module預設目前還沒有被瀏覽器支援,需要使用babel,在日常寫demo的時候經常會顯示這個錯誤
可以在script標籤中使用tpye=”module”在同域的情況下可以解決(非同域情況會被同源策略攔截,webstorm會開啟一個同域的伺服器沒有這個問題,vscode貌似不行)
ES6 Module使用import關鍵字匯入模組,export關鍵字匯出模組,它還有以下特點
-
ES6 Module是靜態的,也就是說它是在編譯階段執行,和var以及function一樣具有提升效果(這個特點使得它支援tree shaking)
-
自動採用嚴格模式(頂層的this返回undefined)
-
ES6 Module支援使用export {<變數>}匯出具名的介面,或者export default匯出匿名的介面
module.js匯出:
a.js匯入:
這兩者的區別是,export {<變數>}匯出的是一個變數的引用,export default匯出的是一個值
什麼意思呢,就是說在a.js中使用import匯入這2個變數的後,在module.js中因為某些原因x變數被改變了,那麼會立刻反映到a.js,而module.js中的y變數改變後,a.js中的y還是原來的值
module.js:
a.js:
可以看到給module.js設定了一個一秒後改變x,y變數的定時器,在一秒後同時觀察匯入時候變數的值,可以發現x被改變了,但y的值仍是20,因為y是通過export default匯出的,在匯入的時候的值相當於只是匯入數字20,而x是通過export {<變數>}匯出的,它匯出的是一個變數的引用,即a.js匯入的是當前x的值,只關心當前x變數的值是什麼,可以理解為一個”活連結”
export default這種匯出的語法其實只是指定了一個命名匯出,而它的名字叫default,換句話說,將模組的匯出的名字重新命名為default,也可以使用import <變數> from <路徑> 這種語法匯入
module.js匯出:
a.js匯入:
但是由於是使用export {<變數>}這種形式匯出的模組,即使被重新命名為default,仍然匯出的是一個變數的引用
這裡再來說一下目前為止主流的模組化方案ES6 Module和CommonJs的一些區別
-
CommonJs輸出的是一個值的拷貝,ES6 Module通過export {<變數>}輸出的是一個變數的引用,export default輸出的是一個值
-
CommonJs執行在伺服器上,被設計為執行時載入,即程式碼執行到那一行才回去載入模組,而ES6 Module是靜態的輸出一個介面,發生在編譯的階段
-
CommonJs在第一次載入的時候執行一次並且會生成一個快取,之後載入返回的都是快取中的內容
import()
關於ES6 Module靜態編譯的特點,導致了無法動態載入,但是總是會有一些需要動態載入模組的需求,所以現在有一個提案,使用把import作為一個函式可以實現動態載入模組,它返回一個Promise,Promise被resolve時的值為輸出的模組
使用import方法改寫上面的a.js使得它可以動態載入(使用靜態編譯的ES6 Module放在條件語句會報錯,因為會有提升的效果,並且也是不允許的),可以看到輸出了module.js的一個變數x和一個預設輸出
Vue中路由的懶載入的ES6寫法就是使用了這個技術,使得在路由切換的時候能夠動態的載入元件渲染檢視
函式預設值
ES6允許在函式的引數中設定預設值
ES5寫法:
ES6寫法:
相比ES5,ES6函式預設值直接寫在引數上,更加的直觀
如果使用了函式預設引數,在函式的引數的區域(括號裡面),它會作為一個單獨的作用域,並且擁有let/const方法的一些特性,比如暫時性死區,塊級作用域,沒有變數提升等,而這個作用域在函式內部程式碼執行前
這裡當執行func的時候,因為沒有傳引數,使用函式預設引數,y就會去尋找x的值,在沿著詞法作用域在外層找到了值為1的變數x
再來看一個例子
這裡同樣沒有傳引數,使用函式的預設賦值,x通過詞法作用域找到了變數w,所以x預設值為2,y同樣通過詞法作用域找到了剛剛定義的x變數,y的預設值為3,但是在解析到z = z + 1這一行的時候,JS直譯器先會去解析z+1找到相應的值後再賦給變數z,但是因為暫時性死區的原因(let/const”劫持”了這個塊級作用域,無法在宣告之前使用這個變數,上文有解釋),導致在let宣告之前就使用了變數z,所以會報錯
這樣理解函式的預設值會相對容易一些
當傳入的引數為undefined時才使用函式的預設值(顯式傳入undefined也會觸發使用函式預設值,傳入null則不會觸發)
在舉個例子:
這裡借用阮一峰老師書中的一個例子,func的預設值為一個函式,執行後返回foo變數,而在函式內部執行的時候,相當於對foo變數的一次變數查詢(LHS查詢),而查詢的起點是在這個單獨的作用域中,即JS直譯器不會去查詢去函式內部查詢變數foo,而是沿著詞法作用域先檢視同一作用域(前面的函式引數)中有沒有foo變數,再往函式的外部尋找foo變數,最終找不到所以報錯了,這個也是函式預設值的一個特點
函式預設值配合解構賦值
第一行給func函式傳入了2個空物件,所以函式的第一第二個引數都不會使用函式預設值,然後函式的第一個引數會嘗試解構物件,提取變數x,因為第一個引數傳入了一個空物件,所以解構不出變數x,但是這裡又在內層設定了一個預設值,所以x的值為10,而第二個引數同樣傳了一個空物件,不會使用函式預設值,然後會嘗試解構出變數y,發現空物件中也沒有變數y,但是y沒有設定預設值所以解構後y的值為undefined
第二行第一個引數顯式的傳入了一個undefined,所以會使用函式預設值為一個空物件,隨後和第一行一樣嘗試解構x發現x為undefined,但是設定了預設值所以x的值為10,而y和上文一樣為undefined
第三行2個引數都會undefined,第一個引數和上文一樣,第二個引數會呼叫函式預設值,賦值為{y:10},然後嘗試解構出變數y,即y為10
第四行和第三行相同,一個是顯式傳入undefined,一個是隱式不傳引數
第五行直接使用傳入的引數,不會使用函式預設值,並且能夠順利的解構出變數x,y
Proxy
Proxy作為一個”攔截器”,可以在目標物件前架設一個攔截器,他人訪問物件,必須先經過這層攔截器,Proxy同樣是一個建構函式,使用new關鍵字生成一個攔截物件的例項,ES6提供了非常多物件攔截的操作,幾乎覆蓋了所有可能修改目標物件的情況(Proxy一般和Reflect配套使用,前者攔截物件,後者返回攔截的結果,Proxy上有的的攔截方法Reflect都有)
Object.definePropery
提到Proxy就不得不提一下ES5中的Object.defineProperty,這個api可以給一個物件新增屬性以及這個屬性的屬性描述符/訪問器(這2個不能共存,同一屬性只能有其中一個),屬性描述符有configurable,writable,enumerable,value這4個屬性,分別代表是否可配置,是否只讀,是否可列舉和屬性的值,訪問器有configurable,enumerable,get,set,前2個和屬性描述符功能相同,後2個都是函式,定義了get,set後對元素的讀寫操作都會執行後面的getter/setter函式,並且覆蓋預設的讀寫行為
定義了obj中a屬性的表示為只讀,且不可列舉,obj2定義了get,但沒有定義set表示只讀,並且讀取obj2的b屬性返回的值是getter函式的返回值
ES5中的Object.defineProperty這和Proxy有什麼關係呢?個人理解Proxy是Object.defineProperty的增強版,ES5只規定能夠定義屬性的屬性描述符或訪問器.而Proxy增強到了13種,具體太多了我就不一一放出來了,這裡我舉幾個比較有意思的例子
handler.apply
apply可以讓我們攔截一個函式(JS中函式也是物件,Proxy也可以攔截函式)的執行,我們可以把它用在函式節流中
呼叫攔截後的函式:
handler.contruct
contruct可以攔截通過new關鍵字呼叫這個函式的操作,我們可以把它用在單例模式中
這裡通過一個閉包儲存了instance變數,每次使用new關鍵字呼叫被攔截的函式後都會檢視這個instance變數,如果存在就返回閉包中儲存的instance變數,否則就新建一個例項,這樣可以實現全域性只有一個例項
handler.defineProperty
defineProperty可以攔截對這個物件的Object.defineProerty操作
注意物件內部的預設的[[SET]]操作(即對這個物件的屬性賦值)會間接觸發defineProperty和getOwnPropertyDescriptor這2個攔截方法
這裡有幾個知識點
- 這裡使用了遞迴的操作,當需要訪問物件的屬性時候,會判斷代理的物件屬性的值仍是一個可以代理的物件就遞迴的進行代理,否則通過錯誤捕獲執行預設的get操作
- 定義了defineProperty的攔截方法,當對這個代理物件的某個屬性進行賦值的時候會執行物件內部預設的[[SET]]操作進行賦值,這個操作會間接觸發defineProperty這個方法,隨後會執行定義的callback函式
這樣就實現了無論物件巢狀多少層,只要有屬性進行賦值就會觸發get方法,對這層物件進行代理,隨後觸發defineProperty執行callback回撥函式
其他的使用場景
Proxy另外還有很多功能,比如在實現驗證器的時候,可以將業務邏輯和驗證器分離達到解耦,通過defineProperty設定一些私有變數,攔截物件做日誌記錄等
Vue
尤大預計2019年下半年釋出Vue3.0,其中一個核心的功能就是使用Proxy替代Object.defineProperty
我相信瞭解過一點Vue響應式原理的人都知道Vue框架在物件攔截上的一些不足
<template>
<div>
<div>{{arr}}</div>
<div>{{obj}}</div>
<button @click="handleClick">修改arr下標</button>
<button @click="handleClick2">建立obj的屬性</button>
</div>
</template>
<script>
export default {
name: "index",
data() {
return {
arr:[1,2,3],
obj:{
a:1,
b:2
}
}
},
methods: {
handleClick() {
this.arr[0] = 10
console.log(this.arr)
},
handleClick2() {
this.obj.c = 3
console.log(this.obj)
}
},
}
</script>
複製程式碼
可以看到這裡資料改變了,控制檯列印出了新的值,但是檢視沒有更新,這是因為Vue內部使用Object.defineProperty進行的資料劫持,而這個API無法探測到物件根屬性的新增和刪除,以及直接給陣列下標進行賦值,所以不會通知渲染watcher進行檢視更新,而理論上這個API也無法探測到陣列的一系列方法(push,splice,pop),但是Vue框架修改了陣列的原型,使得在呼叫這些方法修改資料後會執行檢視更新的操作
//原始碼位置:src/core/observer/array.js
methodsToPatch.forEach(function (method) {
// cache original method
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case `push`:
case `unshift`:
inserted = args;
break
case `splice`:
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
// notify change
ob.dep.notify(); //這一行就會主動呼叫notify方法,會通知到渲染watcher進行檢視更新
return result
});
});
複製程式碼
在掘金翻譯的尤大Vue3.0計劃中寫到
3.0 將帶來一個基於 Proxy 的 observer 實現,它可以提供覆蓋語言 (JavaScript——譯註) 全範圍的響應式能力,消除了當前 Vue 2 系列中基於 Object.defineProperty 所存在的一些侷限,如:
對屬性的新增、刪除動作的監測
對陣列基於下標的修改、對於 .length 修改的監測
對 Map、Set、WeakMap 和 WeakSet 的支援
Proxy就沒有這個問題,並且還提供了更多的攔截方法,完全可以替代Object.defineProperty,唯一不足的也就是瀏覽器的支援程度了(IE:誰在說我?)
所以要想深入瞭解Vue3.0實現機制,學會Proxy是必不可少的
Object.assign
這個ES6新增的Object靜態方法允許我們進行多個物件的合併
可以這麼理解,Object.assign遍歷需要合併給target的物件(即sourece物件的集合)的屬性,用等號進行賦值,這裡遍歷{a:1}將屬性a和值數字1賦值給target物件,然後再遍歷{b:2}將屬性b和值數字2賦值給target物件
這裡羅列了一些這個API的需要注意的知識點
-
Object.assign是淺拷貝,對於值是引用型別的屬性,拷貝仍舊的是它的引用
-
可以拷貝Symbol屬性
-
不能拷貝不可列舉的屬性
-
Object.assign保證target始終是一個物件,如果傳入一個基本型別,會轉為基本包裝型別,null/undefined沒有基本包裝型別,所以傳入會報錯
-
source引數如果是不可列舉的資料型別會忽略合併(字串型別被認為是可列舉的,因為內部有iterator介面)
-
因為是用等號進行賦值,如果被賦值的物件的屬性有setter函式會觸發setter函式,同理如果有getter函式,也會呼叫賦值物件的屬性的getter函式(這就是為什麼Object.assign無法合併物件屬性的訪問器,因為它會直接執行對應的getter/setter函式而不是合併它們,如果需要合併物件屬性的getter/setter函式,可以使用ES7提供的Object.getOwnPropertyDescriptors和Object.defineProperties這2個API實現)
可以看到這裡成功的複製了obj物件中a屬性的getter/setter
為了加深瞭解我自己模擬了Object.assign的實現,可供參考
這裡有一個坑不得不提,對於target引數傳入一個字串,內部會轉換為基本包裝型別,而字串基本包裝型別的屬性是隻讀的(屬性描述符的writable屬性為false),這裡感謝木易楊的專欄
列印物件屬性的屬性描述符可以看到下標屬性的值都是隻讀的,即不能再次賦值,所以嘗試以下操作會報錯
字串abc會轉為基本包裝型別,然後將字串def合併給這個基本包裝型別的時候會將字串def展開,分別將字串def賦值給基本包裝型別abc的0,1,2屬性,隨後就會在賦值的時候報錯(非嚴格模式下會只會靜默處理,ES6的Object.assign預設開啟了嚴格模式)
和ES9的物件擴充套件運算子對比
ES9支援在物件上使用擴充套件運算子,實現的功能和Object.assign相似,唯一的區別就是在含有getter/setter函式的物件的屬性上有所區別
(最後一個字串get可以忽略,這是控制檯為了顯示a變數觸發的getter函式)
分析一下這個例子
ES9:
- 會合並2個物件,並且只觸發2個物件對應屬性的getter函式
- 相同屬性的後者覆蓋了前者,所以a屬性的值是第二個getter函式return的值
ES6:
- 同樣會合併這2個物件,並且只觸發了obj上a屬性的setter函式而不會觸發它的getter函式(結合上述Object.assgin的內部實現理解會容易一些)
- obj上a屬性的setter函式替代預設的賦值行為,導致obj2的a屬性不會被複制過來
除去物件屬性有getter/setter的情況,Object.assgin和物件擴充套件運算子功能是相同的,兩者都可以使用,兩者都是淺拷貝,使用ES9的方法相對簡潔一點
建議
- Vue中重置data中的資料
這個是我最常用的小技巧,使用Object.assign可以將你目前元件中的data物件和元件預設初始化狀態的data物件中的資料合併,這樣可以達到初始化data物件的效果
在當前元件的例項中$data屬性儲存了當前元件的data物件,而$options是當前元件例項初始化時的一些屬性,其中有個data方法,即在在元件中寫的data函式,執行後會返回一個初始化的data物件,然後將這個初始化的data物件合併到當前的data來初始化所有資料
- 給物件合併需要的預設屬性
可以封裝一個函式,外層宣告一個DEFAULTS常量,options為每次傳入的動態配置,這樣每次執行後會合併一些預設的配置項
- 在傳參的時候可以多個資料合併成一個物件傳給後端
參考資料
-
你不知道的JavaScript下卷