內置類型
JS 中分為七種內置類型,七種內置類型又分為兩大類型:基本類型和對象(Object)。
基本類型有六種: null
,undefined
,boolean
,number
,string
,symbol
。
其中 JS 的數字類型是浮點類型的,沒有整型。并且浮點類型基于 IEEE 754標準實現,在使用中會遇到某些 Bug。NaN
也屬于 number
類型,并且 NaN
不等于自身。
對于基本類型來說,如果使用字面量的方式,那么這個變量只是個字面量,只有在必要的時候才會轉換為對應的類型
let a = 111 // 這只是字面量,不是 number 類型
a.toString() // 使用時候才會轉換為對象類型
對象(Object)是引用類型,在使用過程中會遇到淺拷貝和深拷貝的問題。
let a = { name: 'FE' }
let b = a
b.name = 'EF'
console.log(a.name) // EF
Typeof
typeof
對于基本類型,除了 null
都可以顯示正確的類型
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof b // b 沒有聲明,但是還會顯示 undefined
typeof
對于對象,除了函數都會顯示 object
typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'
對于 null
來說,雖然它是基本類型,但是會顯示 object
,這是一個存在很久了的 Bug
typeof null // 'object'
PS:為什么會出現這種情況呢?因為在 JS 的最初版本中,使用的是 32 位系統,為了性能考慮使用低位存儲了變量的類型信息,000
開頭代表是對象,然而 null
表示為全零,所以將它錯誤的判斷為 object
。雖然現在的內部類型判斷代碼已經改變了,但是對于這個 Bug 卻是一直流傳下來。
如果我們想獲得一個變量的正確類型,可以通過 Object.prototype.toString.call(xx)
。這樣我們就可以獲得類似 [object Type]
的字符串。
let a
// 我們也可以這樣判斷 undefined
a === undefined
// 但是 undefined 不是保留字,能夠在低版本瀏覽器被賦值
let undefined = 1
// 這樣判斷就會出錯
// 所以可以用下面的方式來判斷,并且代碼量更少
// 因為 void 后面隨便跟上一個組成表達式
// 返回就是 undefined
a === void 0
類型轉換
轉Boolean
在條件判斷時,除了 undefined
, null
, false
, NaN
, ''
, 0
, -0
,其他所有值都轉為 true
,包括所有對象。
對象轉基本類型
對象在轉換基本類型時,首先會調用 valueOf
然后調用 toString
。并且這兩個方法你是可以重寫的。
let a = {
valueOf() {
return 0
}
}
當然你也可以重寫 Symbol.toPrimitive
,該方法在轉基本類型時調用優先級最高。
let a = {
valueOf() {
return 0;
},
toString() {
return '1';
},
[Symbol.toPrimitive]() {
return 2;
}
}
1 + a // => 3
'1' + a // => '12'
四則運算符
只有當加法運算時,其中一方是字符串類型,就會把另一個也轉為字符串類型。其他運算只要其中一方是數字,那么另一方就轉為數字。并且加法運算會觸發三種類型轉換:將值轉換為原始值,轉換為數字,轉換為字符串。
1 + '1' // '11'
2 * '2' // 4
[1, 2] + [2, 1] // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'
對于加號需要注意這個表達式 'a' + + 'b'
'a' + + 'b' // -> "aNaN"
// 因為 + 'b' -> NaN
// 你也許在一些代碼中看到過 + '1' -> 1
==
操作符
上圖中的 toPrimitive
就是對象轉基本類型。
這里來解析一道題目 [] == ![] // -> true
,下面是這個表達式為何為 true
的步驟
// [] 轉成 true,然后取反變成 false
[] == false
// 根據第 8 條得出
[] == ToNumber(false)
[] == 0
// 根據第 10 條得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根據第 6 條得出
0 == 0 // -> true
比較運算符
- 如果是對象,就通過
toPrimitive
轉換對象 - 如果是字符串,就通過
unicode
字符索引來比較
原型
每個函數都有 prototype
屬性,除了 Function.prototype.bind()
,該屬性指向原型。
每個對象都有 __proto__
屬性,指向了創建該對象的構造函數的原型。其實這個屬性指向了 [[prototype]]
,但是 [[prototype]]
是內部屬性,我們并不能訪問到,所以使用 _proto_
來訪問。
對象可以通過 __proto__
來尋找不屬于該對象的屬性,__proto__
將對象連接起來組成了原型鏈。
如果你想更進一步的了解原型,可以仔細閱讀 深度解析原型中的各個難點。
new
- 新生成了一個對象
- 鏈接到原型
- 綁定 this
- 返回新對象
在調用 new
的過程中會發生以上四件事情,我們也可以試著來自己實現一個 new
function create() {
// 創建一個空的對象
let obj = new Object()
// 獲得構造函數
let Con = [].shift.call(arguments)
// 鏈接到原型
obj.__proto__ = Con.prototype
// 綁定 this,執行構造函數
let result = Con.apply(obj, arguments)
// 確保 new 出來的是個對象
return typeof result === 'object' ? result : obj
}
對于實例對象來說,都是通過 new
產生的,無論是 function Foo()
還是 let a = { b : 1 }
。
對于創建一個對象來說,更推薦使用字面量的方式創建對象(無論性能上還是可讀性)。因為你使用 new Object()
的方式創建對象需要通過作用域鏈一層層找到 Object
,但是你使用字面量的方式就沒這個問題。
function Foo() {}
// function 就是個語法糖
// 內部等同于 new Function()
let a = { b: 1 }
// 這個字面量內部也是使用了 new Object()
對于 new
來說,還需要注意下運算符優先級。
function Foo() {
return this;
}
Foo.getName = function () {
console.log('1');
};
Foo.prototype.getName = function () {
console.log('2');
};
new Foo.getName(); // -> 1
new Foo().getName(); // -> 2
從上圖可以看出,new Foo()
的優先級大于 new Foo
,所以對于上述代碼來說可以這樣劃分執行順序
new (Foo.getName());
(new Foo()).getName();
對于第一個函數來說,先執行了 Foo.getName()
,所以結果為 1;對于后者來說,先執行 new Foo()
產生了一個實例,然后通過原型鏈找到了 Foo
上的 getName
函數,所以結果為 2。
instanceof
instanceof
可以正確的判斷對象的類型,因為內部機制是通過判斷對象的原型鏈中是不是能找到類型的 prototype
。
我們也可以試著實現一下 instanceof
function instanceof(left, right) {
// 獲得類型的原型
let prototype = right.prototype
// 獲得對象的原型
left = left.__proto__
// 判斷對象的類型是否等于類型的原型
while (true) {
if (left === null)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
this
this
是很多人會混淆的概念,但是其實他一點都不難,你只需要記住幾個規則就可以了。
function foo() {
console.log(this.a)
}
var a = 1
foo()
var obj = {
a: 2,
foo: foo
}
obj.foo()
// 以上兩者情況 `this` 只依賴于調用函數前的對象,優先級是第二個情況大于第一個情況
// 以下情況是優先級最高的,`this` 只會綁定在 `c` 上,不會被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)
// 還有種就是利用 call,apply,bind 改變 this,這個優先級僅次于 new
以上幾種情況明白了,很多代碼中的 this
應該就沒什么問題了,下面讓我們看看箭頭函數中的 this
function a() {
return () => {
return () => {
console.log(this)
}
}
}
console.log(a()()())
箭頭函數其實是沒有 this
的,這個函數中的 this
只取決于他外面的第一個不是箭頭函數的函數的 this
。在這個例子中,因為調用 a
符合前面代碼中的第一個情況,所以 this
是 window
。并且 this
一旦綁定了上下文,就不會被任何代碼改變。
執行上下文
當執行 JS 代碼時,會產生三種執行上下文
- 全局執行上下文
- 函數執行上下文
- eval 執行上下文
每個執行上下文中都有三個重要的屬性
- 變量對象(VO),包含變量、函數聲明和函數的形參,該屬性只能在全局上下文中訪問
- 作用域鏈(JS 采用詞法作用域,也就是說變量的作用域是在定義時就決定了)
- this
var a = 10
function foo(i) {
var b = 20
}
foo()
對于上述代碼,執行棧中有兩個上下文:全局上下文和函數 foo
上下文。
stack = [
globalContext,
fooContext
]
對于全局上下文來說,VO 大概是這樣的
globalContext.VO === globe
globalContext.VO = {
a: undefined,
foo: <Function>,
}
對于函數 foo
來說,VO 不能訪問,只能訪問到活動對象(AO)
fooContext.VO === foo.AO
fooContext.AO {
i: undefined,
b: undefined,
arguments: <>
}
// arguments 是函數獨有的對象(箭頭函數沒有)
// 該對象是一個偽數組,有 `length` 屬性且可以通過下標訪問元素
// 該對象中的 `callee` 屬性代表函數本身
// `caller` 屬性代表函數的調用者
對于作用域鏈,可以把它理解成包含自身變量對象和上級變量對象的列表,通過 [[Scope]]
屬性查找上級變量
fooContext.[[Scope]] = [
globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
fooContext.VO,
globalContext.VO
]
接下來讓我們看一個老生常談的例子,var
b() // call b
console.log(a) // undefined
var a = 'Hello world'
function b() {
console.log('call b')
}
想必以上的輸出大家肯定都已經明白了,這是因為函數和變量提升的原因。通常提升的解釋是說將聲明的代碼移動到了頂部,這其實沒有什么錯誤,便于大家理解。但是更準確的解釋應該是:在生成執行上下文時,會有兩個階段。第一個階段是創建的階段(具體步驟是創建 VO),JS 解釋器會找出需要提升的變量和函數,并且給他們提前在內存中開辟好空間,函數的話會將整個函數存入內存中,變量只聲明并且賦值為 undefined,所以在第二個階段,也就是代碼執行階段,我們可以直接提前使用。
在提升的過程中,相同的函數會覆蓋上一個函數,并且函數優先于變量提升
b() // call b second
function b() {
console.log('call b fist')
}
function b() {
console.log('call b second')
}
var b = 'Hello world'
var
會產生很多錯誤,所以在 ES6中引入了 let
。let
不能在聲明前使用,但是這并不是常說的 let
不會提升,let
提升了聲明但沒有賦值,因為臨時死區導致了并不能在聲明前使用。
對于非匿名的立即執行函數需要注意以下一點
var foo = 1
(function foo() {
foo = 10
console.log(foo)
}()) // -> ? foo() { foo = 10 ; console.log(foo) }
因為當 JS 解釋器在遇到非匿名的立即執行函數時,會創建一個輔助的特定對象,然后將函數名稱作為這個對象的屬性,因此函數內部才可以訪問到 foo
,但是這個值又是只讀的,所以對它的賦值并不生效,所以打印的結果還是這個函數,并且外部的值也沒有發生更改。
specialObject = {};
Scope = specialObject + Scope;
foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
delete Scope[0]; // remove specialObject from the front of scope chain
閉包
閉包的定義很簡單:函數 A 返回了一個函數 B,并且函數 B 中使用了函數 A 的變量,函數 B 就被稱為閉包。
function A() {
let a = 1
function B() {
console.log(a)
}
return B
}
你是否會疑惑,為什么函數 A 已經彈出調用棧了,為什么函數 B 還能引用到函數 A 中的變量。因為函數 A 中的變量這時候是存儲在堆上的。現在的 JS 引擎可以通過逃逸分析辨別出哪些變量需要存儲在堆上,哪些需要存儲在棧上。
經典面試題,循環中使用閉包解決 var
定義函數的問題
for ( var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
首先因為 setTimeout
是個異步函數,所有會先把循環全部執行完畢,這時候 i
就是 6 了,所以會輸出一堆 6。
解決辦法兩種,第一種使用閉包
for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
第二種就是使用 setTimeout
的第三個參數
for ( var i=1; i<=5; i++) {
setTimeout( function timer(j) {
console.log( j );
}, i*1000, i);
}
第三種就是使用 let
定義 i
了
for ( let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
因為對于 let
來說,他會創建一個塊級作用域,相當于
{ // 形成塊級作用域
let i = 0
{
let ii = i
setTimeout( function timer() {
console.log( ii );
}, i*1000 );
}
i++
{
let ii = i
}
i++
{
let ii = i
}
...
}
深淺拷貝
let a = {
age: 1
}
let b = a
a.age = 2
console.log(b.age) // 2
從上述例子中我們可以發現,如果給一個變量賦值一個對象,那么兩者的值會是同一個引用,其中一方改變,另一方也會相應改變。
通常在開發中我們不希望出現這樣的問題,我們可以使用淺拷貝來解決這個問題。
淺拷貝
首先可以通過 Object.assign
來解決這個問題。
let a = {
age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1
當然我們也可以通過展開運算符(…)來解決
let a = {
age: 1
}
let b = {...a}
a.age = 2
console.log(b.age) // 1
通常淺拷貝就能解決大部分問題了,但是當我們遇到如下情況就需要使用到深拷貝了
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = {...a}
a.jobs.first = 'native'
console.log(b.jobs.first) // native
淺拷貝只解決了第一層的問題,如果接下去的值中還有對象的話,那么就又回到剛開始的話題了,兩者享有相同的引用。要解決這個問題,我們需要引入深拷貝。
深拷貝
這個問題通常可以通過 JSON.parse(JSON.stringify(object))
來解決。
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE
但是該方法也是有局限性的:
- 會忽略
undefined
- 會忽略
symbol
- 不能序列化函數
- 不能解決循環引用的對象
let obj = {
a: 1,
b: {
c: 2,
d: 3,
},
}
obj.c = obj.b
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c
let newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)
如果你有這么一個循環引用對象,你會發現你不能通過該方法深拷貝
在遇到函數、 undefined
或者 symbol
的時候,該對象也不能正常的序列化
let a = {
age: undefined,
sex: Symbol('male'),
jobs: function() {},
name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}
你會發現在上述情況中,該方法會忽略掉函數和 undefined
。
但是在通常情況下,復雜數據都是可以序列化的,所以這個函數可以解決大部分問題,并且該函數是內置函數中處理深拷貝性能最快的。當然如果你的數據中含有以上三種情況下,可以使用 lodash 的深拷貝函數。
如果你所需拷貝的對象含有內置類型并且不包含函數,可以使用 MessageChannel
function structuralClone(obj) {
return new Promise(resolve => {
const {port1, port2} = new MessageChannel();
port2.onmessage = ev => resolve(ev.data);
port1.postMessage(obj);
});
}
var obj = {a: 1, b: {
c: b
}}
// 注意該方法是異步的
// 可以處理 undefined 和循環引用對象
(async () => {
const clone = await structuralClone(obj)
})()
模塊化
在有 Babel 的情況下,我們可以直接使用 ES6 的模塊化
// file a.js
export function a() {}
export function b() {}
// file b.js
export default function() {}
import {a, b} from './a.js'
import XXX from './b.js'
CommonJS
CommonJs
是 Node 獨有的規范,瀏覽器中使用就需要用到 Browserify
解析了。
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1
// b.js
var module = require('./a.js')
module.a // -> log 1
在上述代碼中,module.exports
和 exports
很容易混淆,讓我們來看看大致內部實現
var module = require('./a.js')
module.a
// 這里其實就是包裝了一層立即執行函數,這樣就不會污染全局變量了,
// 重要的是 module 這里,module 是 Node 獨有的一個變量
module.exports = {
a: 1
}
// 基本實現
var module = {
exports: {} // exports 就是個空對象
}
// 這個是為什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
// 導出的東西
var a = 1
module.exports = a
return module.exports
};
再來說說 module.exports
和 exports
,用法其實是相似的,但是不能對 exports
直接賦值,不會有任何效果。
對于 CommonJS
和 ES6 中的模塊化的兩者區別是:
前者支持動態導入,也就是
require(${path}/xx.js)
,后者目前不支持,但是已有提案前者是同步導入,因為用于服務端,文件都在本地,同步導入即使卡住主線程影響也不大。而后者是異步導入,因為用于瀏覽器,需要下載文件,如果也采用同步導入會對渲染有很大影響
前者在導出時都是值拷貝,就算導出的值變了,導入的值也不會改變,所以如果想更新值,必須重新導入一次。但是后者采用實時綁定的方式,導入導出的值都指向同一個內存地址,所以導入值會跟隨導出值變化
后者會編譯成
require/exports
來執行的
AMD
AMD 是由 RequireJS
提出的
// AMD
define(['./a', './b'], function(a, b) {
a.do()
b.do()
})
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
var b = require('./b')
b.doSomething()
})
防抖
你是否在日常開發中遇到一個問題,在滾動事件中需要做個復雜計算或者實現一個按鈕的防二次點擊操作。
這些需求都可以通過函數防抖動來實現。尤其是第一個需求,如果在頻繁的事件回調中做復雜計算,很有可能導致頁面卡頓,不如將多次計算合并為一次計算,只在一個精確點做操作。
PS:防抖和節流的作用都是防止函數多次調用。區別在于,假設一個用戶一直觸發這個函數,且每次觸發函數的間隔小于wait,防抖的情況下只會調用一次,而節流的 情況會每隔一定時間(參數wait)調用函數。
我們先來看一個袖珍版的防抖理解一下防抖的實現:
// func是用戶傳入需要防抖的函數
// wait是等待時間
const debounce = (func, wait = 50) => {
// 緩存一個定時器id
let timer = 0
// 這里返回的函數是每次用戶實際調用的防抖函數
// 如果已經設定過定時器了就清空上一次的定時器
// 開始一個新的定時器,延遲執行用戶傳入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
// 不難看出如果用戶調用該函數的間隔小于wait的情況下,上一次的時間還未到就被清除了,并不會執行函數
這是一個簡單版的防抖,但是有缺陷,這個防抖只能在最后調用。一般的防抖會有immediate選項,表示是否立即調用。這兩者的區別,舉個栗子來說:
- 例如在搜索引擎搜索問題的時候,我們當然是希望用戶輸入完最后一個字才調用查詢接口,這個時候適用
延遲執行
的防抖函數,它總是在一連串(間隔小于wait的)函數觸發之后調用。 - 例如用戶給interviewMap點star的時候,我們希望用戶點第一下的時候就去調用接口,并且成功之后改變star按鈕的樣子,用戶就可以立馬得到反饋是否star成功了,這個情況適用
立即執行
的防抖函數,它總是在第一次調用,并且下一次調用必須與前一次調用的時間間隔大于wait才會觸發。
下面我們來實現一個帶有立即執行選項的防抖函數
// 這個是用來獲取當前時間戳的
function now() {
return +new Date()
}
/**
* 防抖函數,返回函數連續調用時,空閑時間必須大于或等于 wait,func 才會執行
*
* @param {function} func 回調函數
* @param {number} wait 表示時間窗口的間隔
* @param {boolean} immediate 設置為ture時,是否立即調用函數
* @return {function} 返回客戶調用函數
*/
function debounce (func, wait = 50, immediate = true) {
let timer, context, args
// 延遲執行函數
const later = () => setTimeout(() => {
// 延遲函數執行完畢,清空緩存的定時器序號
timer = null
// 延遲執行的情況下,函數會在延遲函數中執行
// 使用到之前緩存的參數和上下文
if (!immediate) {
func.apply(context, args)
context = args = null
}
}, wait)
// 這里返回的函數是每次實際調用的函數
return function(...params) {
// 如果沒有創建延遲執行函數(later),就創建一個
if (!timer) {
timer = later()
// 如果是立即執行,調用函數
// 否則緩存參數和調用上下文
if (immediate) {
func.apply(this, params)
} else {
context = this
args = params
}
// 如果已有延遲執行函數(later),調用的時候清除原來的并重新設定一個
// 這樣做延遲函數會重新計時
} else {
clearTimeout(timer)
timer = later()
}
}
}
整體函數實現的不難,總結一下。
- 對于按鈕防點擊來說的實現:如果函數是立即執行的,就立即調用,如果函數是延遲執行的,就緩存上下文和參數,放到延遲函數中去執行。一旦我開始一個定時器,只要我定時器還在,你每次點擊我都重新計時。一旦你點累了,定時器時間到,定時器重置為
null
,就可以再次點擊了。 - 對于延時執行函數來說的實現:清除定時器ID,如果是延遲調用就調用函數
節流
防抖動和節流本質是不一樣的。防抖動是將多次執行變為最后一次執行,節流是將多次執行變成每隔一段時間執行。
/**
* underscore 節流函數,返回函數連續調用時,func 執行頻率限定為 次 / wait
*
* @param {function} func 回調函數
* @param {number} wait 表示時間窗口的間隔
* @param {object} options 如果想忽略開始函數的的調用,傳入{leading: false}。
* 如果想忽略結尾函數的調用,傳入{trailing: false}
* 兩者不能共存,否則函數不能執行
* @return {function} 返回客戶調用函數
*/
_.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
// 之前的時間戳
var previous = 0;
// 如果 options 沒傳則設為空對象
if (!options) options = {};
// 定時器回調函數
var later = function() {
// 如果設置了 leading,就將 previous 設為 0
// 用于下面函數的第一個 if 判斷
previous = options.leading === false ? 0 : _.now();
// 置空一是為了防止內存泄漏,二是為了下面的定時器判斷
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
// 獲得當前時間戳
var now = _.now();
// 首次進入前者肯定為 true
// 如果需要第一次不執行函數
// 就將上次時間戳設為當前的
// 這樣在接下來計算 remaining 的值時會大于0
if (!previous && options.leading === false) previous = now;
// 計算剩余時間
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 如果當前調用已經大于上次調用時間 + wait
// 或者用戶手動調了時間
// 如果設置了 trailing,只會進入這個條件
// 如果沒有設置 leading,那么第一次會進入這個條件
// 還有一點,你可能會覺得開啟了定時器那么應該不會進入這個 if 條件了
// 其實還是會進入的,因為定時器的延時
// 并不是準確的時間,很可能你設置了2秒
// 但是他需要2.2秒才觸發,這時候就會進入這個條件
if (remaining <= 0 || remaining > wait) {
// 如果存在定時器就清理掉否則會調用二次回調
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 判斷是否設置了定時器和 trailing
// 沒有的話就開啟一個定時器
// 并且不能不能同時設置 leading 和 trailing
timeout = setTimeout(later, remaining);
}
return result;
};
};
繼承
在 ES5 中,我們可以使用如下方式解決繼承的問題
function Super() {}
Super.prototype.getNumber = function() {
return 1
}
function Sub() {}
let s = new Sub()
Sub.prototype = Object.create(Super.prototype, {
constructor: {
value: Sub,
enumerable: false,
writable: true,
configurable: true
}
})
以上繼承實現思路就是將子類的原型設置為父類的原型
在 ES6 中,我們可以通過 class
語法輕松解決這個問題
class MyDate extends Date {
test() {
return this.getTime()
}
}
let myDate = new MyDate()
myDate.test()
但是 ES6 不是所有瀏覽器都兼容,所以我們需要使用 Babel 來編譯這段代碼。
如果你使用編譯過得代碼調用 myDate.test()
你會驚奇地發現出現了報錯
因為在 JS 底層有限制,如果不是由 Date
構造出來的實例的話,是不能調用 Date
里的函數的。所以這也側面的說明了:ES6 中的 class
繼承與 ES5 中的一般繼承寫法是不同的。
既然底層限制了實例必須由 Date
構造出來,那么我們可以改變下思路實現繼承
function MyData() {
}
MyData.prototype.test = function () {
return this.getTime()
}
let d = new Date()
Object.setPrototypeOf(d, MyData.prototype)
Object.setPrototypeOf(MyData.prototype, Date.prototype)
以上繼承實現思路:先創建父類實例 => 改變實例原先的 _proto__
轉而連接到子類的 prototype
=> 子類的 prototype
的 __proto__
改為父類的 prototype
。
通過以上方法實現的繼承就可以完美解決 JS 底層的這個限制。
call, apply, bind 區別
首先說下前兩者的區別。
call
和 apply
都是為了解決改變 this
的指向。作用都是相同的,只是傳參的方式不同。
除了第一個參數外,call
可以接收一個參數列表,apply
只接受一個參數數組。
let a = {
value: 1
}
function getValue(name, age) {
console.log(name)
console.log(age)
console.log(this.value)
}
getValue.call(a, 'yck', '24')
getValue.apply(a, ['yck', '24'])
模擬實現 call 和 apply
可以從以下幾點來考慮如何實現
- 不傳入第一個參數,那么默認為
window
- 改變了 this 指向,讓新的對象可以執行該函數。那么思路是否可以變成給新的對象添加一個函數,然后在執行完以后刪除?
Function.prototype.myCall = function (context) {
var context = context || window
// 給 context 添加一個屬性
// getValue.call(a, 'yck', '24') => a.fn = getValue
context.fn = this
// 將 context 后面的參數取出來
var args = [...arguments].slice(1)
// getValue.call(a, 'yck', '24') => a.fn('yck', '24')
var result = context.fn(...args)
// 刪除 fn
delete context.fn
return result
}
以上就是 call
的思路,apply
的實現也類似
Function.prototype.myApply = function (context) {
var context = context || window
context.fn = this
var result
// 需要判斷是否存儲第二個參數
// 如果存在,就將第二個參數展開
if (arguments[1]) {
result = context.fn(...arguments[1])
} else {
result = context.fn()
}
delete context.fn
return result
}
bind
和其他兩個方法作用也是一致的,只是該方法會返回一個函數。并且我們可以通過 bind
實現柯里化。
同樣的,也來模擬實現下 bind
Function.prototype.myBind = function (context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
var _this = this
var args = [...arguments].slice(1)
// 返回一個函數
return function F() {
// 因為返回了一個函數,我們可以 new F(),所以需要判斷
if (this instanceof F) {
return new _this(...args, ...arguments)
}
return _this.apply(context, args.concat(...arguments))
}
}
Promise 實現
Promise 是 ES6 新增的語法,解決了回調地獄的問題。
可以把 Promise 看成一個狀態機。初始是 pending
狀態,可以通過函數 resolve
和 reject
,將狀態轉變為 resolved
或者 rejected
狀態,狀態一旦改變就不能再次變化。
then
函數會返回一個 Promise 實例,并且該返回值是一個新的實例而不是之前的實例。因為 Promise 規范規定除了 pending
狀態,其他狀態是不可以改變的,如果返回的是一個相同實例的話,多個 then
調用就失去意義了。
對于 then
來說,本質上可以把它看成是 flatMap
// 三種狀態
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
// promise 接收一個函數參數,該函數會立即執行
function MyPromise(fn) {
let _this = this;
_this.currentState = PENDING;
_this.value = undefined;
// 用于保存 then 中的回調,只有當 promise
// 狀態為 pending 時才會緩存,并且每個實例至多緩存一個
_this.resolvedCallbacks = [];
_this.rejectedCallbacks = [];
_this.resolve = function (value) {
if (value instanceof MyPromise) {
// 如果 value 是個 Promise,遞歸執行
return value.then(_this.resolve, _this.reject)
}
setTimeout(() => { // 異步執行,保證執行順序
if (_this.currentState === PENDING) {
_this.currentState = RESOLVED;
_this.value = value;
_this.resolvedCallbacks.forEach(cb => cb());
}
})
};
_this.reject = function (reason) {
setTimeout(() => { // 異步執行,保證執行順序
if (_this.currentState === PENDING) {
_this.currentState = REJECTED;
_this.value = reason;
_this.rejectedCallbacks.forEach(cb => cb());
}
})
}
// 用于解決以下問題
// new Promise(() => throw Error('error))
try {
fn(_this.resolve, _this.reject);
} catch (e) {
_this.reject(e);
}
}
MyPromise.prototype.then = function (onResolved, onRejected) {
var self = this;
// 規范 2.2.7,then 必須返回一個新的 promise
var promise2;
// 規范 2.2.onResolved 和 onRejected 都為可選參數
// 如果類型不是函數需要忽略,同時也實現了透傳
// Promise.resolve(4).then().then((value) => console.log(value))
onResolved = typeof onResolved === 'function' ? onResolved : v => v;
onRejected = typeof onRejected === 'function' ? onRejected : r => throw r;
if (self.currentState === RESOLVED) {
return (promise2 = new MyPromise(function (resolve, reject) {
// 規范 2.2.4,保證 onFulfilled,onRjected 異步執行
// 所以用了 setTimeout 包裹下
setTimeout(function () {
try {
var x = onResolved(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (reason) {
reject(reason);
}
});
}));
}
if (self.currentState === REJECTED) {
return (promise2 = new MyPromise(function (resolve, reject) {
setTimeout(function () {
// 異步執行onRejected
try {
var x = onRejected(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (reason) {
reject(reason);
}
});
}));
}
if (self.currentState === PENDING) {
return (promise2 = new MyPromise(function (resolve, reject) {
self.resolvedCallbacks.push(function () {
// 考慮到可能會有報錯,所以使用 try/catch 包裹
try {
var x = onResolved(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (r) {
reject(r);
}
});
self.rejectedCallbacks.push(function () {
try {
var x = onRejected(self.value);
resolutionProcedure(promise2, x, resolve, reject);
} catch (r) {
reject(r);
}
});
}));
}
};
// 規范 2.3
function resolutionProcedure(promise2, x, resolve, reject) {
// 規范 2.3.1,x 不能和 promise2 相同,避免循環引用
if (promise2 === x) {
return reject(new TypeError("Error"));
}
// 規范 2.3.2
// 如果 x 為 Promise,狀態為 pending 需要繼續等待否則執行
if (x instanceof MyPromise) {
if (x.currentState === PENDING) {
x.then(function (value) {
// 再次調用該函數是為了確認 x resolve 的
// 參數是什么類型,如果是基本類型就再次 resolve
// 把值傳給下個 then
resolutionProcedure(promise2, value, resolve, reject);
}, reject);
} else {
x.then(resolve, reject);
}
return;
}
// 規范 2.3.3.3.3
// reject 或者 resolve 其中一個執行過得話,忽略其他的
let called = false;
// 規范 2.3.3,判斷 x 是否為對象或者函數
if (x !== null && (typeof x === "object" || typeof x === "function")) {
// 規范 2.3.3.2,如果不能取出 then,就 reject
try {
// 規范 2.3.3.1
let then = x.then;
// 如果 then 是函數,調用 x.then
if (typeof then === "function") {
// 規范 2.3.3.3
then.call(
x,
y => {
if (called) return;
called = true;
// 規范 2.3.3.3.1
resolutionProcedure(promise2, y, resolve, reject);
},
e => {
if (called) return;
called = true;
reject(e);
}
);
} else {
// 規范 2.3.3.4
resolve(x);
}
} catch (e) {
if (called) return;
called = true;
reject(e);
}
} else {
// 規范 2.3.4,x 為基本類型
resolve(x);
}
}
以上就是根據 Promise / A+ 規范來實現的代碼,可以通過 promises-aplus-tests
的完整測試
Generator 實現
Generator 是 ES6 中新增的語法,和 Promise 一樣,都可以用來異步編程
// 使用 * 表示這是一個 Generator 函數
// 內部可以通過 yield 暫停代碼
// 通過調用 next 恢復執行
function* test() {
let a = 1 + 2;
yield 2;
yield 3;
}
let b = test();
console.log(b.next()); // > { value: 2, done: false }
console.log(b.next()); // > { value: 3, done: false }
console.log(b.next()); // > { value: undefined, done: true }
從以上代碼可以發現,加上 *
的函數執行后擁有了 next
函數,也就是說函數執行后返回了一個對象。每次調用 next
函數可以繼續執行被暫停的代碼。以下是 Generator 函數的簡單實現
// cb 也就是編譯過的 test 函數
function generator(cb) {
return (function() {
var object = {
next: 0,
stop: function() {}
};
return {
next: function() {
var ret = cb(object);
if (ret === undefined) return { value: undefined, done: true };
return {
value: ret,
done: false
};
}
};
})();
}
// 如果你使用 babel 編譯后可以發現 test 函數變成了這樣
function test() {
var a;
return generator(function(_context) {
while (1) {
switch ((_context.prev = _context.next)) {
// 可以發現通過 yield 將代碼分割成幾塊
// 每次執行 next 函數就執行一塊代碼
// 并且表明下次需要執行哪塊代碼
case 0:
a = 1 + 2;
_context.next = 4;
return 2;
case 4:
_context.next = 6;
return 3;
// 執行完畢
case 6:
case "end":
return _context.stop();
}
}
});
}
Map、FlatMap 和 Reduce
Map
作用是生成一個新數組,遍歷原數組,將每個元素拿出來做一些變換然后 append
到新的數組中。
[1, 2, 3].map((v) => v + 1)
// -> [2, 3, 4]
Map
有三個參數,分別是當前索引元素,索引,原數組
['1','2','3'].map(parseInt)
// parseInt('1', 0) -> 1
// parseInt('2', 1) -> NaN
// parseInt('3', 2) -> NaN
FlatMap
和 map
的作用幾乎是相同的,但是對于多維數組來說,會將原數組降維。可以將 FlatMap
看成是 map
+ flatten
,目前該函數在瀏覽器中還不支持。
[1, [2], 3].flatMap((v) => v + 1)
// -> [2, 3, 4]
如果想將一個多維數組徹底的降維,可以這樣實現
const flattenDeep = (arr) => Array.isArray(arr)
? arr.reduce( (a, b) => [...a, ...flattenDeep(b)] , [])
: [arr]
flattenDeep([1, [[2], [3, [4]], 5]])
Reduce
作用是數組中的值組合起來,最終得到一個值
function a() {
console.log(1);
}
function b() {
console.log(2);
}
[a, b].reduce((a, b) => a(b()))
// -> 2 1
async 和 await
一個函數如果加上 async
,那么該函數就會返回一個 Promise
async function test() {
return "1";
}
console.log(test()); // -> Promise {<resolved>: "1"}
可以把 async
看成將函數返回值使用 Promise.resolve()
包裹了下。
await
只能在 async
函數中使用
function sleep() {
return new Promise(resolve => {
setTimeout(() => {
console.log('finish')
resolve("sleep");
}, 2000);
});
}
async function test() {
let value = await sleep();
console.log("object");
}
test()
上面代碼會先打印 finish
然后再打印 object
。因為 await
會等待 sleep
函數 resolve
,所以即使后面是同步代碼,也不會先去執行同步代碼再來執行異步代碼。
async 和 await
相比直接使用 Promise
來說,優勢在于處理 then
的調用鏈,能夠更清晰準確的寫出代碼。缺點在于濫用 await
可能會導致性能問題,因為 await
會阻塞代碼,也許之后的異步代碼并不依賴于前者,但仍然需要等待前者完成,導致代碼失去了并發性。
下面來看一個使用 await
的代碼。
var a = 0
var b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
a = (await 10) + a
console.log('3', a) // -> '3' 20
}
b()
a++
console.log('1', a) // -> '1' 1
對于以上代碼你可能會有疑惑,這里說明下原理
- 首先函數
b
先執行,在執行到await 10
之前變量a
還是 0,因為在await
內部實現了generators
,generators
會保留堆棧中東西,所以這時候a = 0
被保存了下來 - 因為
await
是異步操作,遇到await
就會立即返回一個pending
狀態的Promise
對象,暫時返回執行代碼的控制權,使得函數外的代碼得以繼續執行,所以會先執行console.log('1', a)
- 這時候同步代碼執行完畢,開始執行異步代碼,將保存下來的值拿出來使用,這時候
a = 10
- 然后后面就是常規執行代碼了
Proxy
Proxy 是 ES6 中新增的功能,可以用來自定義對象中的操作
let p = new Proxy(target, handler);
// `target` 代表需要添加代理的對象
// `handler` 用來自定義對象中的操作
可以很方便的使用 Proxy 來實現一個數據綁定和監聽
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};
let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
value = v
}, (target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // bind `value` to `2`
p.a // -> Get 'a' = 2
為什么 0.1 + 0.2 != 0.3
因為 JS 采用 IEEE 754 雙精度版本(64位),并且只要采用 IEEE 754 的語言都有該問題。
我們都知道計算機表示十進制是采用二進制表示的,所以 0.1
在二進制表示為
// (0011) 表示循環
0.1 = 2^-4 * 1.10011(0011)
那么如何得到這個二進制的呢,我們可以來演算下
小數算二進制和整數不同。乘法計算時,只計算小數位,整數位用作每一位的二進制,并且得到的第一位為最高位。所以我們得出 0.1 = 2^-4 * 1.10011(0011)
,那么 0.2
的演算也基本如上所示,只需要去掉第一步乘法,所以得出 0.2 = 2^-3 * 1.10011(0011)
。
回來繼續說 IEEE 754 雙精度。六十四位中符號位占一位,整數位占十一位,其余五十二位都為小數位。因為 0.1
和 0.2
都是無限循環的二進制了,所以在小數位末尾處需要判斷是否進位(就和十進制的四舍五入一樣)。
所以 2^-4 * 1.10011...001
進位后就變成了 2^-4 * 1.10011(0011 * 12次)010
。那么把這兩個二進制加起來會得出 2^-2 * 1.0011(0011 * 11次)0100
, 這個值算成十進制就是 0.30000000000000004
下面說一下原生解決辦法,如下代碼所示
parseFloat((0.1 + 0.2).toFixed(10))
正則表達式
元字符
元字符 | 作用 |
---|---|
. | 匹配任意字符除了換行符和回車符 |
[] | 匹配方括號內的任意字符。比如 [0-9] 就可以用來匹配任意數字 |
^ | ^9,這樣使用代表匹配以 9 開頭。[^ 9],這樣使用代表不匹配方括號內除了 9 的字符 |
{1, 2} | 匹配 1 到 2 位字符 |
(yck) | 只匹配和 yck 相同字符串 |
| | 匹配 | 前后任意字符 |
\ | 轉義 |
* | 只匹配出現 0 次及以上 * 前的字符 |
+ | 只匹配出現 1 次及以上 + 前的字符 |
? | ? 之前字符可選 |
修飾語
修飾語 | 作用 |
---|---|
i | 忽略大小寫 |
g | 全局搜索 |
m | 多行 |
字符簡寫
簡寫 | 作用 |
---|---|
\w | 匹配字母數字或下劃線 |
\W | 和上面相反 |
\s | 匹配任意的空白符 |
\S | 和上面相反 |
\d | 匹配數字 |
\D | 和上面相反 |
\b | 匹配單詞的開始或結束 |
\B | 和上面相反 |
V8 下的垃圾回收機制
V8 實現了準確式 GC,GC 算法采用了分代式垃圾回收機制。因此,V8 將內存(堆)分為新生代和老生代兩部分。
新生代算法
新生代中的對象一般存活時間較短,使用 Scavenge GC 算法。
在新生代空間中,內存空間分為兩部分,分別為 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閑的。新分配的對象會被放入 From 空間中,當 From 空間被占滿時,新生代 GC 就會啟動了。算法會檢查 From 空間中存活的對象并復制到 To 空間中,如果有失活的對象就會銷毀。當復制完成后將 From 空間和 To 空間互換,這樣 GC 就結束了。
老生代算法
老生代中的對象一般存活時間較長且數量也多,使用了兩個算法,分別是標記清除算法和標記壓縮算法。
在講算法前,先來說下什么情況下對象會出現在老生代空間中:
- 新生代中的對象是否已經經歷過一次 Scavenge 算法,如果經歷過的話,會將對象從新生代空間移到老生代空間中。
- To 空間的對象占比大小超過 25 %。在這種情況下,為了不影響到內存分配,會將對象從新生代空間移到老生代空間中。
老生代中的空間很復雜,有如下幾個空間
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不變的對象空間
NEW_SPACE, // 新生代用于 GC 復制算法的空間
OLD_SPACE, // 老生代常駐對象空間
CODE_SPACE, // 老生代代碼對象空間
MAP_SPACE, // 老生代 map 對象
LO_SPACE, // 老生代大空間對象
NEW_LO_SPACE, // 新生代大空間對象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情況會先啟動標記清除算法:
- 某一個空間沒有分塊的時候
- 空間中被對象超過一定限制
- 空間不能保證新生代中的對象移動到老生代中
在這個階段中,會遍歷堆中所有的對象,然后標記活的對象,在標記完成后,銷毀所有沒有被標記的對象。在標記大型對內存時,可能需要幾百毫秒才能完成一次標記。這就會導致一些性能上的問題。為了解決這個問題,2011 年,V8 從 stop-the-world 標記切換到增量標志。在增量標記期間,GC 將標記工作分解為更小的模塊,可以讓 JS 應用邏輯在模塊間隙執行一會,從而不至于讓應用出現停頓情況。但在 2018 年,GC 技術又有了一個重大突破,這項技術名為并發標記。該技術可以讓 GC 掃描和標記對象時,同時允許 JS 運行,你可以點擊 該博客 詳細閱讀。
清除對象后會造成堆內存出現碎片的情況,當碎片超過一定限制后會啟動壓縮算法。在壓縮過程中,將活的對象像一端移動,直到所有對象都移動完成然后清理掉不需要的內存。