2019 JavaScript面試題詳解(基礎+進階)

Coderfei發表於2019-04-28
本篇內容包括:JS 基礎一、二,ES6,JS進階,非同步程式設計 等等,這些知識是我在一本書上看到的,書的名字我也忘了,並非我自己的原創。我只是整理了一下。並非我不想加這個書名,我真的忘了。。。


JS 基礎知識點及常考面試題(一)

JS 對於每位前端開發都是必備技能,在小冊中我們也會有多個章節去講述這部分的知識。首先我們先來熟悉下 JS 的一些常考和容易混亂的基礎知識點。

原始(Primitive)型別

涉及面試題:原始型別有哪幾種?null 是物件嘛?

在 JS 中,存在著 6 種原始值,分別是:

  • boolean
  • null
  • undefined
  • number
  • string
  • symbol

首先原始型別儲存的都是值,是沒有函式可以呼叫的,比如 undefined.toString()

2019 JavaScript面試題詳解(基礎+進階)此時你肯定會有疑問,這不對呀,明明 '1'.toString() 是可以使用的。其實在這種情況下,'1' 已經不是原始型別了,而是被強制轉換成了 String 型別也就是物件型別,所以可以呼叫 toString 函式。

除了會在必要的情況下強轉型別以外,原始型別還有一些坑。

其中 JS 的 number 型別是浮點型別的,在使用中會遇到某些 Bug,比如 0.1 + 0.2 !== 0.3,但是這一塊的內容會在進階部分講到。string 型別是不可變的,無論你在 string 型別上呼叫何種方法,都不會對值有改變。

另外對於 null 來說,很多人會認為他是個物件型別,其實這是錯誤的。雖然 typeof null 會輸出 object,但是這只是 JS 存在的一個悠久 Bug。在 JS 的最初版本中使用的是 32 位系統,為了效能考慮使用低位儲存變數的型別資訊,000 開頭代表是物件,然而 null 表示為全零,所以將它錯誤的判斷為 object 。雖然現在的內部型別判斷程式碼已經改變了,但是對於這個 Bug 卻是一直流傳下來。

物件(Object)型別

涉及面試題:物件型別和原始型別的不同之處?函式引數是物件會發生什麼問題?

在 JS 中,除了原始型別那麼其他的都是物件型別了。物件型別和原始型別不同的是,原始型別儲存的是值,物件型別儲存的是地址(指標)。當你建立了一個物件型別的時候,計算機會在記憶體中幫我們開闢一個空間來存放值,但是我們需要找到這個空間,這個空間會擁有一個地址(指標)。

const a = []複製程式碼

對於常量 a 來說,假設記憶體地址(指標)為 #001,那麼在地址 #001 的位置存放了值 [],常量 a存放了地址(指標) #001,再看以下程式碼

const a = []
const b = a
b.push(1)複製程式碼

當我們將變數賦值給另外一個變數時,複製的是原本變數的地址(指標),也就是說當前變數 b 存放的地址(指標)也是 #001,當我們進行資料修改的時候,就會修改存放在地址(指標) #001 上的值,也就導致了兩個變數的值都發生了改變。

接下來我們來看函式引數是物件的情況

function test(person) { 
    person.age = 26 person = {
        name: 'yyy',
        age: 30
    }
    return person}const p1 = {
        name: 'yck',
        age: 25
    }
const p2 = test(p1)
console.log(p1) // -> ?
console.log(p2) // -> ?複製程式碼

對於以上程式碼,你是否能正確的寫出結果呢?接下來讓我為你解析一番:

  • 首先,函式傳參是傳遞物件指標的副本
  • 到函式內部修改引數的屬性這步,我相信大家都知道,當前 p1 的值也被修改了
  • 但是當我們重新為 person 分配了一個物件時就出現了分歧

所以最後 person 擁有了一個新的地址(指標),也就和 p1 沒有任何關係了,導致了最終兩個變數的值是不相同的。

typeof vs instanceof

涉及面試題:typeof 是否能正確判斷型別?instanceof 能正確判斷物件的原理是什麼?

typeof 對於原始型別來說,除了 null 都可以顯示正確的型別
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof 對於物件來說,除了函式都會顯示 object,所以說 typeof 並不能準確判斷變數到底是什麼型別
typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'複製程式碼

如果我們想判斷一個物件的正確型別,這時候可以考慮使用 instanceof,因為內部機制是通過原型鏈來判斷的,在後面的章節中我們也會自己去實現一個 instanceof。

const Person = function() {}
const p1 = new Person()
p1 instanceof
Person // true
var str = 'hello world'
str instanceof
String // false
var str1 = new String('hello world')
str1 instanceof
String // true複製程式碼

對於原始型別來說,你想直接通過 instanceof 來判斷型別是不行的,當然我們還是有辦法讓 instanceof 判斷原始型別的

class PrimitiveString {
    static [Symbol.hasInstance](x) {
        return typeof x === 'string'
    }
}
console.log('hello world' instanceof PrimitiveString) // true複製程式碼

你可能不知道 Symbol.hasInstance 是什麼東西,其實就是一個能讓我們自定義 instanceof 行為的東西,以上程式碼等同於 typeof 'hello world' === 'string',所以結果自然是 true 了。這其實也側面反映了一個問題, instanceof 也不是百分之百可信的。

型別轉換

涉及面試題:該知識點常在筆試題中見到,熟悉了轉換規則就不懼怕此類題目了。

首先我們要知道,在 JS 中型別轉換隻有三種情況,分別是:

  • 轉換為布林值
  • 轉換為數字
  • 轉換為字串

我們先來看一個型別轉換表格,然後再進入正題

Boolean

在條件判斷時,除了 undefined, null, false, NaN, '', 0, -0,其他所有值都轉為 true,包括所有物件。

物件轉原始型別

物件在轉換型別的時候,會呼叫內建的 [[ToPrimitive]] 函式,對於該函式來說,演算法邏輯一般來說如下:

· 如果已經是原始型別了,那就不需要轉換了

· 呼叫 x.valueOf(),如果轉換為基礎型別,就返回轉換的值

· 呼叫 x.toString(),如果轉換為基礎型別,就返回轉換的值

· 如果都沒有返回原始型別,就會報錯

當然你也可以重寫 Symbol.toPrimitive ,該方法在轉原始型別時呼叫優先順序最高。

let a = {
    valueOf() {
        return 0
    },
    toString() {
        return '1'
    },
    [Symbol.toPrimitive]() {
        return 2
    }
}
1 + a // => 3複製程式碼

四則運算子

加法運算子不同於其他幾個運算子,它有以下幾個特點:

· 運算中其中一方為字串,那麼就會把另一方也轉換為字串

· 如果一方不是字串或者數字,那麼會將它轉換為數字或者字串

1 + '1' // '11'
true + true // 2
4 + [1,2,3] //
"41,2,3"複製程式碼

如果你對於答案有疑問的話,請看解析:

  • 對於第一行程式碼來說,觸發特點一,所以將數字 1 轉換為字串,得到結果 '11'
  • 對於第二行程式碼來說,觸發特點二,所以將 true 轉為數字 1
  • 對於第三行程式碼來說,觸發特點二,所以將陣列通過 toString 轉為字串 1,2,3,得到結果 41,2,3

另外對於加法還需要注意這個表示式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"複製程式碼

因為 + 'b' 等於 NaN,所以結果為 "aNaN",你可能也會在一些程式碼中看到過 + '1' 的形式來快速獲取 number 型別。

那麼對於除了加法的運算子來說,只要其中一方是數字,那麼另一方就會被轉為數字

4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN複製程式碼

比較運算子

1. 如果是物件,就通過 toPrimitive 轉換物件

2. 如果是字串,就通過 unicode 字元索引來比較

let a = {
    valueOf() {
        return 0
    },
    toString() {
        return '1'
    }
}
a > -1 // true
在以上程式碼中,因為 a 是物件,所以會通過 valueOf 轉換為原始型別再比較值。複製程式碼

this

涉及面試題:如何正確判斷 this?箭頭函式的 this 是什麼?

this 是很多人會混淆的概念,但是其實它一點都不難,只是網上很多文章把簡單的東西說複雜了。在這一小節中,你一定會徹底明白 this 這個概念的。

我們先來看幾個函式呼叫的場景

function foo() {
    console.log(this.a)
}
var a = 1
foo()
const obj = {
    a: 2,
    foo: foo
}
obj.foo()
const c = new foo()複製程式碼

接下來我們一個個分析上面幾個場景

  • 對於直接呼叫 foo 來說,不管 foo 函式被放在了什麼地方,this 一定是 window
  • 對於 obj.foo() 來說,我們只需要記住,誰呼叫了函式,誰就是 this,所以在這個場景下 foo函式中的 this 就是 obj 物件
  • 對於 new 的方式來說,this 被永遠繫結在了 c 上面,不會被任何方式改變 this

說完了以上幾種情況,其實很多程式碼中的 this 應該就沒什麼問題了,下面讓我們看看箭頭函式中的 this

function a() {
    return () => {
        return () => {
            console.log(this)
        }
    }
}
console.log(a()()())複製程式碼

首先箭頭函式其實是沒有 this 的,箭頭函式中的 this 只取決包裹箭頭函式的第一個普通函式的 this。在這個例子中,因為包裹箭頭函式的第一個普通函式是 a,所以此時的 this 是 window。另外對箭頭函式使用 bind 這類函式是無效的。

最後種情況也就是 bind 這些改變上下文的 API 了,對於這些函式來說,this 取決於第一個引數,如果第一個引數為空,那麼就是 window。

那麼說到 bind,不知道大家是否考慮過,如果對一個函式進行多次 bind,那麼上下文會是什麼呢?

let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)()
// => ?複製程式碼

如果你認為輸出結果是 a,那麼你就錯了,其實我們可以把上述程式碼轉換成另一種形式

//fn.bind().bind(a) 等於
let fn2 = function fn1() {
    return function() {
        return fn.apply()
    }.apply(a)
}
fn2()複製程式碼

可以從上述程式碼中發現,不管我們給函式 bind 幾次,fn 中的 this 永遠由第一次 bind 決定,所以結果永遠是 window。

let a = { 
    name: 'yck' 
}
function foo() {
    console.log(this.name)
}
foo.bind(a)() // => 'yck'複製程式碼

以上就是 this 的規則了,但是可能會發生多個規則同時出現的情況,這時候不同的規則之間會根據優先順序最高的來決定 this 最終指向哪裡。

首先,new 的方式優先順序最高,接下來是 bind 這些函式,然後是 obj.foo() 這種呼叫方式,最後是 foo 這種呼叫方式,同時,箭頭函式的 this 一旦被繫結,就不會再被任何方式所改變。

小結

以上就是我們 JS 基礎知識點的第一部分內容了。這一小節中涉及到的知識點在我們日常的開發中經常可以看到,並且很多容易出現的坑 也出自於這些知識點,相信認真讀完的你一定會在日後的開發中少踩很多坑。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。


JS 基礎知識點及常考面試題(二)

在這一章節中我們繼續來了解 JS 的一些常考和容易混亂的基礎知識點。

== vs ===

涉及面試題:== 和 === 有什麼區別?

對於 == 來說,如果對比雙方的型別不一樣的話,就會進行型別轉換,這也就用到了我們上一章節講的內容。

假如我們需要對比 x 和 y 是否相同,就會進行如下判斷流程:

1. 首先會判斷兩者型別是否相同。相同的話就是比大小了

2. 型別不相同的話,那麼就會進行型別轉換

3. 會先判斷是否在對比 null 和 undefined,是的話就會返回 true

4. 判斷兩者型別是否為 string 和 number,是的話就會將字串轉換為 number

5. 1 == '1'

6. ↓

7. 1 == 1

8. 判斷其中一方是否為 boolean,是的話就會把 boolean 轉為 number 再進行判斷

9. '1' == true

10. ↓

11. '1' == 1

12. ↓

13. 1 == 1

14. 判斷其中一方是否為 object 且另一方為 string、number 或者 symbol,是的話就會把 object 轉為原始型別再進行判斷

15. '1' == { name: 'yck' }

16. ↓

17. '1' == '[object Object]'

思考題:看完了上面的步驟,對於 [] == ![] 你是否能正確寫出答案呢?

當然了,這個流程圖並沒有將所有的情況都列舉出來,我這裡只將常用到的情況列舉了,如果你想了解更多的內容可以參考 標準文件

對於 === 來說就簡單多了,就是判斷兩者型別和值是否相同。

閉包

涉及面試題:什麼是閉包?

閉包的定義其實很簡單:函式 A 內部有一個函式 B,函式 B 可以訪問到函式 A 中的變數,那麼函式 B 就是閉包。

function A() {
    let a = 1
    window.B = function() {
        console.log(a)
    }
}
A()
B() // 1複製程式碼

很多人對於閉包的解釋可能是函式巢狀了函式,然後返回一個函式。其實這個解釋是不完整的,就比如我上面這個例子就可以反駁這個觀點。

在 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)
}複製程式碼

在上述程式碼中,我們首先使用了立即執行函式將 i 傳入函式內部,這個時候值就被固定在了引數 j上面不會改變,當下次執行 timer 這個閉包的時候,就可以使用外部函式的變數 j,從而達到目的。

第二種就是使用 setTimeout 的第三個引數,這個引數會被當成 timer 函式的引數傳入。

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 a = {
    age: 1
}
let b = a
a.age = 2
console.log(b.age) // 2複製程式碼

淺拷貝

首先可以通過 Object.assign 來解決這個問題,很多人認為這個函式是用來深拷貝的。其實並不是,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 。

但是在通常情況下,複雜資料都是可以序列化的,所以這個函式可以解決大部分問題。

如果你所需拷貝的物件含有內建型別並且不包含函式,可以使用 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: 2
    }
}
obj.b.d = obj.b
// 注意該方法是非同步的
// 可以處理 undefined 和迴圈引用物件
const test = async () => {
    const clone = await
    structuralClone(obj)
    console.log(clone)
}
test()複製程式碼

當然你可能想自己來實現一個深拷貝,但是其實實現一個深拷貝是很困難的,需要我們考慮好多種邊界情況,比如原型鏈如何處理、DOM 如何處理等等,所以這裡我們實現的深拷貝只是簡易版,並且我其實更推薦使用 lodash 的深拷貝函式

function deepClone(obj) {
    function isObject(o) {
        return (typeof o === 'object' || typeof o === 'function') && o !== null
    }
    if (!isObject(obj)) {
        throw new Error('非物件')
    }
    let isArray = Array.isArray(obj)
    let newObj = isArray ? [...obj] : {...obj }
    Reflect.ownKeys(newObj).forEach(key=> {
        newObj[key] = isObject(obj[key]) ?deepClone(obj[key]) : obj[key]
    })
    return newObj
}
let obj = {
    a: [1, 2, 3],
    b: {
        c: 2,
        d: 3
    }
}
let newObj = deepClone(obj)
newObj.b.c = 1
console.log(obj.b.c) // 2複製程式碼

原型

涉及面試題:如何理解原型?如何理解原型鏈?

當我們建立一個物件時 let obj = { age: 25 },我們可以發現能使用很多種函式,但是我們明明沒有定義過它們,對於這種情況你是否有過疑惑?

當我們在瀏覽器中列印 obj 時你會發現,在 obj 上居然還有一個 __proto__ 屬性,那麼看來之前的疑問就和這個屬性有關係了。

其實每個 JS 物件都有 __proto__ 屬性,這個屬性指向了原型。這個屬性在現在來說已經不推薦直接去使用它了,這只是瀏覽器在早期為了讓我們訪問到內部屬性 [[prototype]] 來實現的一個東西。

講到這裡好像還是沒有弄明白什麼是原型,接下來讓我們再看看 __proto__ 裡面有什麼吧。

看到這裡你應該明白了,原型也是一個物件,並且這個物件中包含了很多函式,所以我們可以得出一個結論:對於 obj 來說,可以通過 __proto__ 找到一個原型物件,在該物件中定義了很多函式讓我們來使用。

在上面的圖中我們還可以發現一個 constructor 屬性,也就是建構函式

2019 JavaScript面試題詳解(基礎+進階)

開啟 constructor 屬性我們又可以發現其中還有一個 prototype 屬性,並且這個屬性對應的值和先前我們在 __proto__ 中看到的一模一樣。所以我們又可以得出一個結論:原型的 constructor 屬性指向建構函式,建構函式又通過 prototype 屬性指回原型,但是並不是所有函式都具有這個屬性,Function.prototype.bind() 就沒有這個屬性。

其實原型就是那麼簡單,接下來我們再來看一張圖,相信這張圖能讓你徹底明白原型和原型鏈

2019 JavaScript面試題詳解(基礎+進階)

看完這張圖,我再來解釋下什麼是原型鏈吧。其實原型鏈就是多個物件通過 __proto__ 的方式連線了起來。為什麼 obj 可以訪問到 valueOf 函式,就是因為 obj 通過原型鏈找到了 valueOf 函式。

對於這一小節的知識點,總結起來就是以下幾點:

  • Object 是所有物件的爸爸,所有物件都可以通過 __proto__ 找到它
  • Function 是所有函式的爸爸,所有函式都可以通過 __proto__ 找到它
  • 函式的 prototype 是一個物件
  • 物件的 __proto__ 屬性指向原型, __proto__ 將物件和原型連線起來組成了原型鏈

如果你還想深入學習原型這部分的內容,可以閱讀我之前寫的文章

小結

以上就是全部的常考和容易混亂的基礎知識點了,下一章節我們將會學習 ES6 部分的知識。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。

ES6 知識點及常考面試題

本章節我們將來學習 ES6 部分的內容。

var、let 及 const 區別

涉及面試題:什麼是提升?什麼是暫時性死區?var、let 及 const 區別?

對於這個問題,我們應該先來了解提升(hoisting)這個概念。

console.log(a) // undefined
var a = 1複製程式碼

從上述程式碼中我們可以發現,雖然變數還沒有被宣告,但是我們卻可以使用這個未被宣告的變數,這種情況就叫做提升,並且提升的是宣告。

對於這種情況,我們可以把程式碼這樣來看

var a
console.log(a) // undefined
a = 1複製程式碼

接下來我們再來看一個例子

var a = 10
var a
console.log(a)複製程式碼

對於這個例子,如果你認為列印的值為 undefined 那麼就錯了,答案應該是 10,對於這種情況,我們這樣來看程式碼

var a
var a
a = 10
console.log(a)複製程式碼

到這裡為止,我們已經瞭解了 var 宣告的變數會發生提升的情況,其實不僅變數會提升函式也會被提升。

console.log(a) // ƒ a() {}
function a() {}
var a = 1複製程式碼

對於上述程式碼,列印結果會是 ƒ a() {},即使變數宣告在函式之後,這也說明了函式會被提升,並且優先於變數提升。

說完了這些,想必大家也知道 var 存在的問題了,使用 var 宣告的變數會被提升到作用域的頂部,接下來我們再來看 let 和 const 。

我們先來看一個例子:

var a = 1
let b = 1
const c = 1
console.log(window.b) // undefined
console.log(window. c) // undefined
function test(){
    console.log(a)
    let a
}
test()複製程式碼

首先在全域性作用域下使用 let 和 const 宣告變數,變數並不會被掛載到 window 上,這一點就和 var 宣告有了區別。

再者當我們在宣告 a 之前如果使用了 a,就會出現報錯的情況

2019 JavaScript面試題詳解(基礎+進階)你可能會認為這裡也出現了提升的情況,但是因為某些原因導致不能訪問。

首先報錯的原因是因為存在暫時性死區,我們不能在宣告前就使用變數,這也是 let 和 const 優於 var 的一點。然後這裡你認為的提升和 var 的提升是有區別的,雖然變數在編譯的環節中被告知在這塊作用域中可以訪問,但是訪問是受限制的。

那麼到這裡,想必大家也都明白 var、let 及 const 區別了,不知道你是否會有這麼一個疑問,為什麼要存在提升這個事情呢,其實提升存在的根本原因就是為了解決函式間互相呼叫的情況

function test1() {
    test2()
}
function test2() {
    test1()
}
test1()複製程式碼

假如不存在提升這個情況,那麼就實現不了上述的程式碼,因為不可能存在 test1 在 test2 前面然後 test2 又在 test1 前面。

那麼最後我們總結下這小節的內容:

· 函式提升優先於變數提升,函式提升會把整個函式挪到作用域頂部,變數提升只會把宣告挪到作用域頂部

  • var 存在提升,我們能在宣告之前使用。let、const 因為暫時性死區的原因,不能在宣告前使用
  • var 在全域性作用域下宣告變數會導致變數掛載在 window 上,其他兩者不會
  • let 和 const 作用基本一致,但是後者宣告的變數不能再次賦值

原型繼承和 Class 繼承

涉及面試題:原型如何實現繼承?Class 如何實現繼承?Class 本質是什麼?

首先先來講下 class,其實在 JS 中並不存在類,class 只是語法糖,本質還是函式。

class Person {}
Person instanceof
Function // true複製程式碼

在上一章節中我們講解了原型的知識點,在這一小節中我們將會分別使用原型和 class 的方式來實現繼承。

組合繼承

組合繼承是最常用的繼承方式,

function Parent(value) {
    this.val = value
}
Parent.prototype.getValue= function() {
    console.log(this.val)
}
function Child(value) {
    Parent.call(this, value)
}
Child.prototype = new Parent()
const child = new Child(1)
child.getValue() // 1
child instanceof
Parent // true複製程式碼

以上繼承的方式核心是在子類的建構函式中通過 Parent.call(this) 繼承父類的屬性,然後改變子類的原型為 new Parent() 來繼承父類的函式。

這種繼承方式優點在於建構函式可以傳參,不會與父類引用屬性共享,可以複用父類的函式,但是也存在一個缺點就是在繼承父類函式的時候呼叫了父類建構函式,導致子類的原型上多了不需要的父類屬性,存在記憶體上的浪費。

寄生組合繼承

這種繼承方式對組合繼承進行了優化,組合繼承缺點在於繼承父類函式時呼叫了建構函式,我們只需要優化掉這點就行了。

function Parent(value) {
    this.val = value
}
Parent.prototype.getValue= function() {
    console.log(this.val)
}
function Child(value) {
    Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
    constructor: {
        value: Child,
        enumerable: false,
        writable: true,
        configurable: true
    }
})
const child = new Child(1)
child.getValue() // 1
child instanceof
Parent // true複製程式碼

以上繼承實現的核心就是將父類的原型賦值給了子類,並且將建構函式設定為子類,這樣既解決了無用的父類屬性問題,還能正確的找到子類的建構函式。

Class 繼承

以上兩種繼承方式都是通過原型去解決的,在 ES6 中,我們可以使用 class 去實現繼承,並且實現起來很簡單

class Parent {
    constructor(value) {
        this.val = value
    }
    getValue() {
        console.log(this.val)
    }
}
class Child extends Parent {
    constructor(value) {
        super(value)
        this.val = value
    }
}
let child = new Child(1)
child.getValue() // 1
child instanceof
Parent // true複製程式碼

class 實現繼承的核心在於使用 extends 表明繼承自哪個父類,並且在子類建構函式中必須呼叫 super,因為這段程式碼可以看成 Parent.call(this, value)。

當然了,之前也說了在 JS 中並不存在類,class 的本質就是函式。

模組化

涉及面試題:為什麼要使用模組化?都有哪幾種方式可以實現模組化,各有什麼特點?

使用一個技術肯定是有原因的,那麼使用模組化可以給我們帶來以下好處

  • 解決命名衝突
  • 提供複用性
  • 提高程式碼可維護性

立即執行函式

在早期,使用立即執行函式實現模組化是常見的手段,通過函式作用域解決了命名衝突、汙染全域性作用域的問題

(function(globalVariable){
    globalVariable.test = function() {}
    // ... 宣告各種變數、函式都不會汙染全域性作用域
})(globalVariable)複製程式碼

AMD CMD

鑑於目前這兩種實現方式已經很少見到,所以不再對具體特性細聊,只需要瞭解這兩者是如何使用的。

// AMD
define(['./a', './b'], function(a,b) {
    // 載入模組完畢可以使用
    a.do()
    b.do()
})
// CMD
define(function(require,exports, module) {
    // 載入模組
    // 可以把 require 寫在函式體的任意地方實現延遲載入
    var a = require('./a')
    a.doSomething()
})複製程式碼

CommonJS

CommonJS 最早是 Node 在使用,目前也仍然廣泛使用,比如在 Webpack 中你就能見到它,當然目前在 Node 中的模組管理已經和 CommonJS 有一些區別了。

// a.js
module.exports = {
    a: 1
}
// or 
exports.a = 1
// b.js
var module = require('./a.js')
module.a // -> log 1
因為 CommonJS 還是會使用到的,所以這裡會對一些疑難點進行解析
先說 require 吧
var module = require('./a.js')
module.a 
// 這裡其實就是包裝了一層立即執行函式,這樣就不會汙染全域性變數了,
// 重要的是 module 這裡,module 是 Node 獨有的一個變數
module.exports = {
    a: 1
}
// module 基本實現
var module = {
    id: 'xxxx', // 我總得知道怎麼去找到他吧
    exports: {} // exports 就是個空物件
}
// 這個是為什麼 exports 和 module.exports 用法相似的原因
var exports = module.exports 
var load = function (module) {
    // 匯出的東西
    var a = 1
    module.exports = a
    return module.exports
};
// 然後當我 require 的時候去找到獨特的
// id,然後將要使用的東西用立即執行函式包裝下,over複製程式碼

另外雖然 exports 和 module.exports 用法相似,但是不能對 exports 直接賦值。因為 var exports = module.exports 這句程式碼表明瞭 exports 和 module.exports 享有相同地址,通過改變物件的屬性值會對兩者都起效,但是如果直接對 exports 賦值就會導致兩者不再指向同一個記憶體地址,修改並不會對 module.exports 起效。

ES Module

ES Module 是原生實現的模組化方案,與 CommonJS 有以下幾個區別

  • CommonJS 支援動態匯入,也就是 require(${path}/xx.js),後者目前不支援,但是已有提案
  • CommonJS 是同步匯入,因為用於服務端,檔案都在本地,同步匯入即使卡住主執行緒影響也不大。而後者是非同步匯入,因為用於瀏覽器,需要下載檔案,如果也採用同步匯入會對渲染有很大影響
  • CommonJS 在匯出時都是值拷貝,就算匯出的值變了,匯入的值也不會改變,所以如果想更新值,必須重新匯入一次。但是 ES Module 採用實時繫結的方式,匯入匯出的值都指向同一個記憶體地址,所以匯入值會跟隨匯出值變化
  • ES Module 會編譯成 require/exports 來執行的

// 引入模組 API
import XXX from './a.js'
import { XXX } from './a.js'
// 匯出模組 API
export function a() {}
export default function() {}複製程式碼

Proxy

涉及面試題:Proxy 可以實現什麼功能?

如果你平時有關注 Vue 的進展的話,可能已經知道了在 Vue3.0 中將會通過 Proxy 來替換原本的 Object.defineProperty 來實現資料響應式。 Proxy 是 ES6 中新增的功能,它可以用來自定義物件中的操作。

let p = new Proxy(target, handler)複製程式碼

target 代表需要新增代理的物件,handler 用來自定義物件中的操作,比如可以用來自定義 set 或者 get 函式。

接下來我們通過 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, property)
            return Reflect.set(target, property, value)
        }
    }
    return new Proxy(obj, handler)
}
let obj = { a: 1 }
let p = onWatch(obj,(v, property) => {
    console.log(`監聽到屬性${property}改變為${v}`)
},(target, property) => {
        console.log(`'${property}' = ${target[property]}`)
})
p.a = 2 // 監聽到屬性a改變
p.a // 'a' = 2複製程式碼

在上述程式碼中,我們通過自定義 set 和 get 函式的方式,在原本的邏輯中插入了我們的函式邏輯,實現了在對物件任何屬性進行讀寫時發出通知。

當然這是簡單版的響應式實現,如果需要實現一個 Vue 中的響應式,需要我們在 get 中收集依賴,在 set 派發更新,之所以 Vue3.0 要使用 Proxy 替換原本的 API 原因在於 Proxy 無需一層層遞迴為每個屬性新增代理,一次即可完成以上操作,效能上更好,並且原本的實現有一些資料更新不能監聽到,但是 Proxy 可以完美監聽到任何方式的資料改變,唯一缺陷可能就是瀏覽器的相容性不好了。

map, filter, reduce

涉及面試題:map, filter, reduce 各自有什麼作用?

map 作用是生成一個新陣列,遍歷原陣列,將每個元素拿出來做一些變換然後放入到新的陣列中。

[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複製程式碼

filter 的作用也是生成一個新陣列,在遍歷陣列的時候將返回值為 true 的元素放入新陣列,我們可以利用這個函式刪除一些不需要的元素

let array = [1, 2, 4, 6]
let newArray = array.filter(item => item
!== 6)
console.log(newArray) // [1, 2, 4]複製程式碼

和 map 一樣,filter 的回撥函式也接受三個引數,用處也相同。

最後我們來講解 reduce 這塊的內容,同時也是最難理解的一塊內容。reduce 可以將陣列中的元素通過回撥函式最終轉換為一個值。

如果我們想實現一個功能將函式裡的元素全部相加得到一個值,可能會這樣寫程式碼

const arr = [1, 2, 3]
let total = 0
for (let i = 0; i < arr.length; i++) {
    total += arr[i]
}
console.log(total) //6複製程式碼

但是如果我們使用 reduce 的話就可以將遍歷部分的程式碼優化為一行程式碼

const arr = [1, 2, 3]
const sum = arr.reduce((acc, current) =>
acc + current, 0)
console.log(sum)複製程式碼

對於 reduce 來說,它接受兩個引數,分別是回撥函式和初始值,接下來我們來分解上述程式碼中 reduce 的過程

  • 首先初始值為 0,該值會在執行第一次回撥函式時作為第一個引數傳入
  • 回撥函式接受四個引數,分別為累計值、當前元素、當前索引、原陣列,後三者想必大家都可以明白作用,這裡著重分析第一個引數
  • 在一次執行回撥函式時,當前值和初始值相加得出結果 1,該結果會在第二次執行回撥函式時當做第一個引數傳入
  • 所以在第二次執行回撥函式時,相加的值就分別是 1 和 2,以此類推,迴圈結束後得到結果 6

想必通過以上的解析大家應該明白 reduce 是如何通過回撥函式將所有元素最終轉換為一個值的,當然 reduce 還可以實現很多功能,接下來我們就通過 reduce 來實現 map 函式

const arr = [1, 2, 3]
const mapArray = arr.map(value => value * 2)
const reduceArray = arr.reduce((acc, current)=> {
    acc.push(current * 2)
    return acc
}, [])
console.log(mapArray, reduceArray) // [2, 4, 6]複製程式碼

如果你對這個實現還有困惑的話,可以根據上一步的解析步驟來分析過程。

小結

這一章節我們瞭解了部分 ES6 常考的知識點,其他的一些非同步內容我們會放在下一章節去講。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。

JS 非同步程式設計及常考面試題

在上一章節中我們瞭解了常見 ES6 語法的一些知識點。這一章節我們將會學習非同步程式設計這一塊的內容,鑑於非同步程式設計是 JS 中至關重要的內容,所以我們將會用三個章節來學習非同步程式設計涉及到的重點和難點,同時這一塊內容也是面試常考範圍,希望大家認真學習。

併發(concurrency)和並行(parallelism)區別

涉及面試題:併發與並行的區別?

非同步和這小節的知識點其實並不是一個概念,但是這兩個名詞確實是很多人都常會混淆的知識點。其實混淆的原因可能只是兩個名詞在中文上的相似,在英文上來說完全是不同的單詞。

併發是巨集觀概念,我分別有任務 A 和任務 B,在一段時間內通過任務間的切換完成了這兩個任務,這種情況就可以稱之為併發。

並行是微觀概念,假設 CPU 中存在兩個核心,那麼我就可以同時完成任務 A、B。同時完成多個任務的情況就可以稱之為並行。

回撥函式(Callback)

涉及面試題:什麼是回撥函式?回撥函式有什麼缺點?如何解決回撥地獄問題?

回撥函式應該是大家經常使用到的,以下程式碼就是一個回撥函式的例子:

ajax(url, () => {
    // 處理邏輯
})複製程式碼

但是回撥函式有一個致命的弱點,就是容易寫出回撥地獄(Callback hell)。假設多個請求存在依賴性,你可能就會寫出如下程式碼:

ajax(url, () => {
    // 處理邏輯
    ajax(url1, () => {
        // 處理邏輯
        ajax(url2,() => {
            // 處理邏輯
        })
    })
})複製程式碼

以上程式碼看起來不利於閱讀和維護,當然,你可能會想說解決這個問題還不簡單,把函式分開來寫不就得了

function firstAjax() {
    ajax(url1,() => {
        // 處理邏輯
        secondAjax()
    })
}
function secondAjax() {
    ajax(url2,() => {
        // 處理邏輯
    })
}
ajax(url, () => {
    // 處理邏輯
    firstAjax()
})複製程式碼

以上的程式碼雖然看上去利於閱讀了,但是還是沒有解決根本問題。

回撥地獄的根本問題就是:

1. 巢狀函式存在耦合性,一旦有所改動,就會牽一髮而動全身

2. 巢狀函式一多,就很難處理錯誤

當然,回撥函式還存在著別的幾個缺點,比如不能使用 try catch 捕獲錯誤,不能直接 return。在接下來的幾小節中,我們將來學習通過別的技術解決這些問題。

Generator

涉及面試題:你理解的 Generator 是什麼?

Generator 算是 ES6 中難理解的概念之一了,Generator 最大的特點就是可以控制函式的執行。在這一小節中我們不會去講什麼是 Generator,而是把重點放在 Generator 的一些容易困惑的地方。

function *foo(x) {
    let y = 2 * (yield (x + 1))
    let z = yield (y / 3)
    return (x + y + z)
}
let it = foo(5)
console.log(it.next())   // => {value: 6, done: false}
console.log(it.next(12)) // => {value: 8,
done: false}
console.log(it.next(13)) // => {value:
42, done: true}複製程式碼

你也許會疑惑為什麼會產生與你預想不同的值,接下來就讓我為你逐行程式碼分析原因

  • 首先 Generator 函式呼叫和普通函式不同,它會返回一個迭代器
  • 當執行第一次 next 時,傳參會被忽略,並且函式暫停在 yield (x + 1) 處,所以返回 5 + 1 = 6
  • 當執行第二次 next 時,傳入的引數等於上一個 yield 的返回值,如果你不傳參,yield 永遠返回 undefined。此時 let y = 2 * 12,所以第二個 yield 等於 2 * 12 / 3 = 8
  • 當執行第三次 next 時,傳入的引數會傳遞給 z,所以 z = 13, x = 5, y = 24,相加等於 42

Generator 函式一般見到的不多,其實也於他有點繞有關係,並且一般會配合 co 庫去使用。當然,我們可以通過 Generator 函式解決回撥地獄的問題,可以把之前的回撥地獄例子改寫為如下程式碼:

function *fetch() {
    yield ajax(url, () => {})
    yield ajax(url1, () => {})
    yield ajax(url2, () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()複製程式碼

Promise

涉及面試題:Promise 的特點是什麼,分別有什麼優缺點?什麼是 Promise 鏈?Promise 建構函式執行和 then 函式執行有什麼區別?

Promise 翻譯過來就是承諾的意思,這個承諾會在未來有一個確切的答覆,並且該承諾有三種狀態,分別是:

1. 等待中(pending)

2. 完成了 (resolved)

3. 拒絕了(rejected)

這個承諾一旦從等待狀態變成為其他狀態就永遠不能更改狀態了,也就是說一旦狀態變為 resolved 後,就不能再次改變

new Promise((resolve,reject) => {
    resolve('success')
    // 無效
    reject('reject')
})複製程式碼

當我們在構造 Promise 的時候,建構函式內部的程式碼是立即執行的

new Promise((resolve, reject) => {
    console.log('new Promise')
    resolve('success')
})
console.log('finifsh')
// new Promise
-> finifsh複製程式碼

Promise 實現了鏈式呼叫,也就是說每次呼叫 then 之後返回的都是一個 Promise,並且是一個全新的 Promise,原因也是因為狀態不可變。如果你在 then 中 使用了 return,那麼 return 的值會被 Promise.resolve() 包裝

Promise.resolve(1)
.then(res => {
    console.log(res) // => 1
    return 2 // 包裝成 Promise.resolve(2)
})
.then(res => {
    console.log(res) // => 2
})複製程式碼

當然了,Promise 也很好地解決了回撥地獄的問題,可以把之前的回撥地獄例子改寫為如下程式碼:

ajax(url)
.then(res => {
    console.log(res)
    return ajax(url1)
}).then(res => {
    console.log(res)
    return ajax(url2)
}).then(res => console.log(res))複製程式碼

前面都是在講述 Promise 的一些優點和特點,其實它也是存在一些缺點的,比如無法取消 Promise,錯誤需要通過回撥函式捕獲。

async 及 await

涉及面試題:async 及 await 的特點,它們的優點和缺點分別是什麼?await 原理是什麼?

一個函式如果加上 async ,那麼該函式就會返回一個 Promise

async function test() {
    return "1"
}
console.log(test()) // -> Promise {<resolved>:"1"}複製程式碼

async 就是將函式返回值使用 Promise.resolve() 包裹了下,和 then 中處理返回值一樣,並且 await 只能配套 async 使用

async function test() {
    let value = await sleep()
}複製程式碼

async 和 await 可以說是非同步終極解決方案了,相比直接使用 Promise 來說,優勢在於處理 then的呼叫鏈,能夠更清晰準確的寫出程式碼,畢竟寫一大堆 then 也很噁心,並且也能優雅地解決回撥地獄問題。當然也存在一些缺點,因為 await 將非同步程式碼改造成了同步程式碼,如果多個非同步程式碼沒有依賴性卻使用了 await 會導致效能上的降低。

async function test() {
    // 以下程式碼沒有依賴性的話,完全可以使用 Promise.all 的方式
    // 如果有依賴性的話,其實就是解決回撥地獄的例子了
    await fetch(url)
    await fetch(url1)
    await fetch(url2)
}複製程式碼

下面來看一個使用 await 的例子:

let a = 0
let b = async () => {
    a = a + await 10
    console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1複製程式碼

對於以上程式碼你可能會有疑惑,讓我來解釋下原因

  • 首先函式 b 先執行,在執行到 await 10 之前變數 a 還是 0,因為 await 內部實現了 generator ,generator 會保留堆疊中東西,所以這時候 a = 0 被儲存了下來
  • 因為 await 是非同步操作,後來的表示式不返回 Promise 的話,就會包裝成 Promise.reslove(返回值),然後會去執行函式外的同步程式碼
  • 同步程式碼執行完畢後開始執行非同步程式碼,將儲存下來的值拿出來使用,這時候 a = 0 + 10

上述解釋中提到了 await 內部實現了 generator,其實 await 就是 generator 加上 Promise 的語法糖,且內部實現了自動執行 generator。如果你熟悉 co 的話,其實自己就可以實現這樣的語法糖。

常用定時器函式

涉及面試題:setTimeout、setInterval、requestAnimationFrame 各有什麼特點?

非同步程式設計當然少不了定時器了,常見的定時器函式有 setTimeout、setInterval、requestAnimationFrame。我們先來講講最常用的setTimeout,很多人認為 setTimeout 是延時多久,那就應該是多久後執行。

其實這個觀點是錯誤的,因為 JS 是單執行緒執行的,如果前面的程式碼影響了效能,就會導致 setTimeout 不會按期執行。當然了,我們可以通過程式碼去修正 setTimeout,從而使定時器相對準確

let period = 60 * 1000 * 60 * 2
let startTime = new Date().getTime()
let count = 0
let end = new Date().getTime() + period
let interval = 1000
let currentInterval = interval
function loop() {
    count++
    // 程式碼執行所消耗的時間
    let offset = new Date().getTime() - (startTime + count *interval);
    let diff = end - new Date().getTime()
    let h = Math.floor(diff / (60 * 1000 * 60))
    let hdiff = diff % (60 * 1000 * 60)
    let m = Math.floor(hdiff / (60 * 1000))
    let mdiff = hdiff % (60 * 1000)
    let s = mdiff / (1000)
    let sCeil = Math.ceil(s)
    let sFloor = Math.floor(s)
    // 得到下一次迴圈所消耗的時間
    currentInterval = interval - offset 
    console.log('時:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '程式碼執行時間:'+offset, '下次迴圈間隔'+currentInterval) 
    setTimeout(loop, currentInterval)
}
setTimeout(loop,currentInterval)複製程式碼

接下來我們來看 setInterval,其實這個函式作用和 setTimeout 基本一致,只是該函式是每隔一段時間執行一次回撥函式。

通常來說不建議使用 setInterval。第一,它和 setTimeout 一樣,不能保證在預期的時間執行任務。第二,它存在執行累積的問題,請看以下虛擬碼

function demo() {
    setInterval(function(){
        console.log(2)
    },1000)
    sleep(2000)
}
demo()複製程式碼

以上程式碼在瀏覽器環境中,如果定時器執行過程中出現了耗時操作,多個回撥函式會在耗時操作結束以後同時執行,這樣可能就會帶來效能上的問題。

如果你有迴圈定時器的需求,其實完全可以通過 requestAnimationFrame 來實現

function setInterval(callback, interval) {
    let timer
    const now = Date.now
    let startTime = now()
    let endTime = startTime
    const loop = () => {
        timer = window.requestAnimationFrame(loop)
        endTime = now()
        if (endTime - startTime >=interval) {
            startTime = endTime = now()
            callback(timer)
        }
    }
timer = window.requestAnimationFrame(loop)
    return timer
}
let a = 0
setInterval(timer=> {
    console.log(1)
    a++
    if (a === 3) cancelAnimationFrame(timer)
}, 1000)複製程式碼

首先 requestAnimationFrame 自帶函式節流功能,基本可以保證在 16.6 毫秒內只執行一次(不掉幀的情況下),並且該函式的延時效果是精確的,沒有其他定時器時間不準的問題,當然你也可以通過該函式來實現 setTimeout。

小結

非同步程式設計是 JS 中較難掌握的內容,同時也是很重要的知識點。以上提到的每個知識點其實都可以作為一道面試題,希望大家可以好好掌握以上內容如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。

JS 進階知識點及常考面試題

在這一章節中,我們將會學習到一些原理相關的知識,不會解釋涉及到的知識點的作用及用法,如果大家對於這些內容還不怎麼熟悉,推薦先去學習相關的知識點內容再來學習原理知識。

手寫 call、apply 及 bind 函式

涉及面試題:call、apply 及 bind 函式內部實現是怎麼樣的?

首先從以下幾點來考慮如何實現這幾個函式

  • 不傳入第一個引數,那麼上下文預設為 window
  • 改變了 this 指向,讓新的物件可以執行該函式,並能接受引數

那麼我們先來實現 call

Function.prototype.myCall = function(context){
    if (typeof this !== 'function') {
        throw new TypeError('Error')
    }
    context = context || window
    context.fn = this
    const args = [...arguments].slice(1)
    const result = context.fn(...args)
    delete context.fn
    return result
}複製程式碼

以下是對實現的分析:

  • 首先 context 為可選引數,如果不傳的話預設上下文為 window
  • 接下來給 context 建立一個 fn 屬性,並將值設定為需要呼叫的函式
  • 因為 call 可以傳入多個引數作為呼叫函式的引數,所以需要將引數剝離出來
  • 然後呼叫函式並將物件上的函式刪除

以上就是實現 call 的思路,apply 的實現也類似,區別在於對引數的處理,所以就不一一分析思路了

Function.prototype.myApply = function(context){
    if (typeof this !== 'function') {
        throw new TypeError('Error')
    }
    context = context || window
    context.fn = this
    let result
    // 處理引數和 call 有區別
    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')
    }
    const _this = this
    const 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))
    }
}複製程式碼

以下是對實現的分析:

  • 前幾步和之前的實現差不多,就不贅述了
  • bind 返回了一個函式,對於函式來說有兩種方式呼叫,一種是直接呼叫,一種是通過 new 的方式,我們先來說直接呼叫的方式
  • 對於直接呼叫來說,這裡選擇了 apply 的方式實現,但是對於引數需要注意以下情況:因為 bind 可以實現類似這樣的程式碼 f.bind(obj, 1)(2),所以我們需要將兩邊的引數拼接起來,於是就有了這樣的實現 args.concat(...arguments)
  • 最後來說通過 new 的方式,在之前的章節中我們學習過如何判斷 this,對於 new 的情況來說,不會被任何方式改變 this,所以對於這種情況我們需要忽略傳入的 this

new

涉及面試題:new 的原理是什麼?通過 new 的方式建立物件和通過字面量建立有什麼區別?

在呼叫 new 的過程中會發生以上四件事情:

1. 新生成了一個物件

2. 連結到原型

3. 繫結 this

4. 返回新物件

根據以上幾個過程,我們也可以試著來自己實現一個 new

function create() {
    let obj = {}
    let Con = [].shift.call(arguments)
    obj.__proto__ = Con.prototype
    let result = Con.apply(obj, arguments)
    return result instanceof Object ? result : obj
}複製程式碼

以下是對實現的分析:

  • 建立一個空物件
  • 獲取建構函式
  • 設定空物件的原型
  • 繫結 this 並執行建構函式
  • 確保返回值為物件

對於物件來說,其實都是通過 new 產生的,無論是 function Foo() 還是 let a = { b : 1 } 。

對於建立一個物件來說,更推薦使用字面量的方式建立物件(無論效能上還是可讀性)。因為你使用 new Object() 的方式建立物件需要通過作用域鏈一層層找到 Object,但是你使用字面量的方式就沒這個問題。

function Foo() {}
// function 就是個語法糖
// 內部等同於 new Function()
let a = { b: 1 }
// 這個字面量內部也是使用了 new Object()
更多關於 new 的內容可以閱讀我寫的文章 聊聊 new 操作符。複製程式碼

instanceof 的原理

涉及面試題:instanceof 的原理是什麼?

instanceof 可以正確的判斷物件的型別,因為內部機制是通過判斷物件的原型鏈中是不是能找到型別的 prototype。

我們也可以試著實現一下 instanceof

function myInstanceof(left, right) {
    let prototype = right.prototype
    left = left.__proto__
    while (true) {
        if (left === null || left === undefined)
        return false
        if (prototype === left)
        return true
        left = left.__proto__
    }
}複製程式碼

以下是對實現的分析:

  • 首先獲取型別的原型
  • 然後獲得物件的原型
  • 然後一直迴圈判斷物件的原型是否等於型別的原型,直到物件原型為 null,因為原型鏈最終為 null

為什麼 0.1 + 0.2 != 0.3

涉及面試題:為什麼 0.1 + 0.2 != 0.3?如何解決這個問題?

先說原因,因為 JS 採用 IEEE 754 雙精度版本(64位),並且只要採用 IEEE 754 的語言都有該問題。

我們都知道計算機是通過二進位制來儲存東西的,那麼 0.1 在二進位制中會表示為

// (0011) 表示迴圈
0.1 = 2^-4 * 1.10011(0011)複製程式碼

我們可以發現,0.1 在二進位制中是無限迴圈的一些數字,其實不只是 0.1,其實很多十進位制小數用二進位制表示都是無限迴圈的。這樣其實沒什麼問題,但是 JS 採用的浮點數標準卻會裁剪掉我們的數字。

IEEE 754 雙精度版本(64位)將 64 位分為了三段

  • 第一位用來表示符號
  • 接下去的 11 位用來表示指數
  • 其他的位數用來表示有效位,也就是用二進位制表示 0.1 中的 10011(0011)

那麼這些迴圈的數字被裁剪了,就會出現精度丟失的問題,也就造成了 0.1 不再是 0.1 了,而是變成了 0.100000000000000002

0.100000000000000002 === 0.1 // true複製程式碼

那麼同樣的,0.2 在二進位制也是無限迴圈的,被裁剪後也失去了精度變成了 0.200000000000000002

0.200000000000000002 === 0.2 // true複製程式碼

所以這兩者相加不等於 0.3 而是 0.300000000000000004

0.1 + 0.2 === 0.30000000000000004 // true複製程式碼

那麼可能你又會有一個疑問,既然 0.1 不是 0.1,那為什麼 console.log(0.1) 卻是正確的呢?

因為在輸入內容的時候,二進位制被轉換為了十進位制,十進位制又被轉換為了字串,在這個轉換的過程中發生了取近似值的過程,所以列印出來的其實是一個近似值,你也可以通過以下程式碼來驗證

console.log(0.100000000000000002) // 0.1複製程式碼

那麼說完了為什麼,最後來說說怎麼解決這個問題吧。其實解決的辦法有很多,這裡我們選用原生提供的方式來最簡單的解決問題

parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true複製程式碼

垃圾回收機制

涉及面試題: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 執行,你可以點選 該部落格 詳細閱讀。

清除物件後會造成堆記憶體出現碎片的情況,當碎片超過一定限制後會啟動壓縮演算法。在壓縮過程中,將活的物件像一端移動,直到所有物件都移動完成然後清理掉不需要的記憶體。

小結

以上就是 JS 進階知識點的內容了,這部分的知識相比於之前的內容更加深入也更加的理論,也是在面試中能夠於別的候選者拉開差距的一塊內容。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。

JS 思考題

之前我們通過了七個章節來學習關於 JS 這部分的內容,那麼接下來,會以幾道思考題的方式來確保大家理解這部分的內容。

這種方式不僅能加深你對知識點的理解,同時也能幫助你串聯起多個碎片知識點。一旦你擁有將多個碎片知識點串聯起來的能力,在面試中就不會經常出現一問一答的情況。如果面試官的每個問題你都能引申出一些相關聯的知識點,那麼面試官一定會提高對你的評價。

思考題一:JS 分為哪兩大型別?都有什麼各自的特點?你該如何判斷正確的型別?

首先這幾道題目想必很多人都能夠很好的答出來,接下來就給大家一點思路講出與眾不同的東西。

思路引導:

1. 對於原始型別來說,你可以指出 null 和 number 存在的一些問題。對於物件型別來說,你可以從垃圾回收的角度去切入,也可以說一下物件型別存在深淺拷貝的問題。

2. 對於判斷型別來說,你可以去對比一下 typeof 和 instanceof 之間的區別,也可以指出 instanceof 判斷型別也不是完全準確的。

以上就是這道題目的回答思路,當然不是說讓大家完全按照這個思路去答題,而是存在一個意識,當回答面試題的時候,儘量去引申出這個知識點的某些坑或者與這個知識點相關聯的東西。

思考題二:你理解的原型是什麼?

思路引導:

起碼說出原型小節中的總結內容,然後還可以指出一些小點,比如並不是所有函式都有 prototype 屬性,然後引申出原型鏈的概念,提出如何使用原型實現繼承,繼而可以引申出 ES6 中的 class 實現繼承。

思考題三:bind、call 和 apply 各自有什麼區別?

思路引導:

首先肯定是說出三者的不同,如果自己實現過其中的函式,可以嘗試說出自己的思路。然後可以聊一聊 this 的內容,有幾種規則判斷 this 到底是什麼,this 規則會涉及到 new,那麼最後可以說下自己對於 new 的理解。

思考題四:ES6 中有使用過什麼?

思路引導:

這邊可說的實在太多,你可以列舉 1 - 2 個點。比如說說 class,那麼 class 又可以拉回到原型的問題;可以說說 promise,那麼線就被拉到了非同步的內容;可以說說 proxy,那麼如果你使用過 Vue 這個框架,就可以談談響應式原理的內容;同樣也可以說說 let 這些宣告變數的語法,那麼就可以談及與 var 的不同,說到提升這塊的內容。

思考題五:JS 是如何執行的?

思路引導:

這其實是很大的一塊內容。你可以先說 JS 是單執行緒執行的,這裡就可以說說你理解的執行緒和程式的區別。然後講到執行棧,接下來的內容就是涉及 Eventloop 了,微任務和巨集任務的區別,哪些是微任務,哪些又是巨集任務,還可以談及瀏覽器和 Node 中的 Eventloop 的不同,最後還可以聊一聊 JS 中的垃圾回收。

小結

雖然思考題不多,但是其實每一道思考題背後都可以引申出很多內容,大家接下去在學習的過程中也應該始終有一個意識,你學習的這塊內容到底和你現在腦海裡的哪一個知識點有關聯。同時也歡迎大家總結這些思考題,並且把總結的內容連結放在評論中,我會挑選出不錯的文章單獨放入一章節給大家參考。

DOM 斷點

給 JS 打斷點想必各位都聽過,但是 DOM 斷點知道的人應該就少了。如果你想檢視一個 DOM 元素是如何通過 JS 更改的,你就可以使用這個功能。

當我們給 ul 新增該斷點以後,一旦 ul 子元素髮生了改動,比如說增加了子元素的個數,那麼就會自動跳轉到對應的 JS 程式碼

其實不光可以給 DOM 打斷點,我們還可以給 Ajax 或者 Event Listener 打斷點。

2019 JavaScript面試題詳解(基礎+進階)檢視事件

我們還可以通過 DevTools 來檢視頁面中新增了多少的事件。假如當你發現頁面滾動起來有效能上的問題時,就可以檢視一下有多少 scroll 事件被新增了

2019 JavaScript面試題詳解(基礎+進階)找到之前檢視過的 DOM 元素

不知道你是否遇到過這樣的問題,找不到之前檢視過的 DOM 元素在哪裡了,需要一個個去找這就有點麻煩了,這時候你就可以使用這個功能。

2019 JavaScript面試題詳解(基礎+進階)我們可以通過 $0 來找到上一次檢視過的 DOM 元素,$1 就是上上次的元素,之後以此類推。這時候你可能會說,列印出來元素有啥用,在具體什麼位置還要去找啊,不用急,馬上我就可以解決這個問題

2019 JavaScript面試題詳解(基礎+進階)當你點選這個選項時,頁面立馬會跳轉至元素所在位置,並且 DevTools 也會變到 Elements 標籤。

Debugging

給 JS 打斷點想必大家都會,但是打斷點也是有一個不為人知的 Tips 的。

for (let index = 0; index < 10; index++) {
    // 各種邏輯
    console.log(index)
}複製程式碼

對於這段程式碼來說,如果我只想看到 index 為 5 時相應的斷點資訊,但是一旦打了斷點,就會每次迴圈都會停下來,很浪費時間,那麼通過這個小技巧我們就可以圓滿解決這個問題

2019 JavaScript面試題詳解(基礎+進階)

首先我們先右鍵斷點,然後選擇 Edit breakpoint... 選項

在彈框內輸入 index === 5,這樣斷點就會變為橙色,並且只有當符合表示式的情況時斷點才會被執行

小結

雖然這一章的內容並不多,但是涉及到的幾個場景都是日常經常會碰到的,希望這一章節的內容會對大家有幫助。



相關文章