1. 程式人生 > >前端面試題(一)JS篇

前端面試題(一)JS篇

內建型別

JS 中分為七種內建型別,七種內建型別又分為兩大型別:基本型別和物件(Object)。

基本型別有六種: nullundefinedbooleannumberstringsymbol

其中 JS 的數字型別是浮點型別的,沒有整型。並且浮點型別基於 IEEE 754標準實現,在使用中會遇到某些 BugNaN 也屬於 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

在條件判斷時,除了 undefinednullfalseNaN''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

== 操作符

image

上圖中的 toPrimitive 就是物件轉基本型別。

這裡來解析一道題目 [] == ![] // -> true ,下面是這個表示式為何為 true 的步驟

// [] 轉成 true,然後取反變成 false
[] == false
// 根據第 8 條得出
[] == ToNumber(false)
[] == 0
// 根據第 10 條得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根據第 6 條得出
0 == 0 // -> true

比較運算子

  1. 如果是物件,就通過 toPrimitive 轉換物件
  2. 如果是字串,就通過 unicode 字元索引來比較

原型

prototype

每個函式都有 prototype 屬性,除了 Function.prototype.bind(),該屬性指向原型。

每個物件都有 __proto__ 屬性,指向了建立該物件的建構函式的原型。其實這個屬性指向了 [[prototype]],但是 [[prototype]] 是內部屬性,我們並不能訪問到,所以使用 _proto_ 來訪問。

物件可以通過 __proto__ 來尋找不屬於該物件的屬性,__proto__ 將物件連線起來組成了原型鏈。

如果你想更進一步的瞭解原型,可以仔細閱讀 深度解析原型中的各個難點

new

  1. 新生成了一個物件
  2. 連結到原型
  3. 繫結 this
  4. 返回新物件

在呼叫 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       

image

從上圖可以看出,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 符合前面程式碼中的第一個情況,所以 thiswindow。並且 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中引入了 letlet 不能在宣告前使用,但是這並不是常說的 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)

如果你有這麼一個迴圈引用物件,你會發現你不能通過該方法深拷貝

image

在遇到函式、 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.exportsexports 很容易混淆,讓我們來看看大致內部實現

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.exportsexports,用法其實是相似的,但是不能對 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() 你會驚奇地發現出現了報錯

image

因為在 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 區別

首先說下前兩者的區別。

callapply 都是為了解決改變 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 狀態,可以通過函式 resolvereject ,將狀態轉變為 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 的完整測試

image

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

FlatMapmap 的作用幾乎是相同的,但是對於多維陣列來說,會將原陣列降維。可以將 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 內部實現了 generatorsgenerators 會保留堆疊中東西,所以這時候 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)

那麼如何得到這個二進位制的呢,我們可以來演算下

image

小數算二進位制和整數不同。乘法計算時,只計算小數位,整數位用作每一位的二進位制,並且得到的第一位為最高位。所以我們得出 0.1 = 2^-4 * 1.10011(0011),那麼 0.2 的演算也基本如上所示,只需要去掉第一步乘法,所以得出 0.2 = 2^-3 * 1.10011(0011)

回來繼續說 IEEE 754 雙精度。六十四位中符號位佔一位,整數位佔十一位,其餘五十二位都為小數位。因為 0.10.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 執行,你可以點選 該部落格 詳細閱讀。

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