动态

详情 返回 返回

【面試系列】萬字長文,讓面試沒有難撕的JS基礎題 - 动态 详情

背景介紹:
從研一剛開始找實習到現在秋招,這一路經歷了不少八股拷打,經常被要求手撕一些js基礎題,每次面試完後不語,只是默默打開筆記,把被問到的八股/手撕自己整理,方便日後複習。因此,記錄了很多手撕題,在此做個分享,有誤之處歡迎討論指正。
下面的幾乎每道題都是筆者被大廠問到過的,都是些基礎的題目,基礎不牢地動山搖,書到用時方恨少啊~。切忌走馬觀花,務必深刻理解爛熟於心。建議以本文為大綱,自行拓展廣度和深度。

面試感悟:
手撕題應該理解原理,練習/默寫3遍及以上,確保能立即寫出來。
基礎八股在回答時,一個是要説話條理清晰,第二個是回答全面。

數據類型

7種原始類型:String, Number, Boolean, Null, Undefined, Symbol, BigInt
引用類型:Object

拓展 - TS的數據類型
基本類型​​(如 string, number, boolean,undefined,symbol,bigint
​複合類型​​(如 array, tuple, object,enum
​特殊類型​​(如 any,unknown, void,never

類型判斷的幾種方式?

typeof
typeof 判斷基礎類型和函數,但不能區分對象類型(不能區分Object和Array)。

注意typeof的兩個槽點:typeof null === 'object'typeof NaN === 'number'

instanceof
a instanceof A用來判斷某個實例是否是某個構造函數創造出來的。原理是:這個實例的原型鏈上,有沒有這個構造函數的原型。

Object.prototype.toString.call()
Object 原型上的toString方法被xx調用,用來判斷xx的類型。

//判斷基礎類型
Object.prototype.toString.call(123); // "[object Number]"
//判斷數組
Object.prototype.toString.call([]); //"[object Array]"
//判斷其他內置類型
Object.prototype.toString.call(new Map()); //"[object Map]"
Object.prototype.toString.call(/abc/); // "[object RegExp]"

Array.isArray()
用來判斷是否為數組。
判斷數組的3中方式:

  • Array.isArray(arr)
  • arr instanceof Array
  • Object.prototype.toString.call(arg) === '[object Array]'

你用過Symbol和BigInt嗎?

Symbol
Symbol是一種ES6引入的、表示獨一無二值的第七種基本數據類型,主要用於避免命名衝突,作為對象屬性的標識符,以實現唯一標識和支持可定製的屬性

特性:使用Symbol用同樣字符串構造產生的變量,是不全等的。

let k1 = Symbol("KK");
console.log(k1);   // Symbol(KK)
typeof(k1);        // "symbol"
 
// 相同參數 Symbol() 返回的值不相等
let k2 = Symbol("KK"); 
k1 === k2;       // false

使用場景舉例:
1.vue中創建provide/inject使用的key

//創建不同key
export const ThemeKey = Symbol('theme') 
export const UserKey = Symbol('user')

//父組件
<script setup>
import { provide, ref } from 'vue'
import { ThemeKey, UserKey, UpdateUserKey } from './keys.js'

const theme = ref('dark')
const user = ref({ name: 'Alice', age: 25 })

provide(ThemeKey, theme)
provide(UserKey, user)
</script>

//子組件
<script setup>
import { inject } from 'vue'
import { ThemeKey, UserKey } from './keys.js'

const theme = inject(ThemeKey)
const user = inject(UserKey)
</script>

2.apply/call函數的實現中,為了防止污染屬性

Bigint
能創建任意大的整數,避免了Number的最大整數限制(2^53-1)
1.創建BigInt::在數字後面添加 n 後綴即可創建BigInt。也可以使用 BigInt() 函數。
2.支持兩個BigInt之間的加減乘除、取模% 和 指數**運算

const a = 10n;
const b = 5n;
console.log(a + b); // 15n
console.log(a * b); // 50n

Map和WeakMap的區別?

Map的key可以是任意值,當key為對象時,如果作為key的對象沒有被引用,Map的key不會被回收。
WeakMap的key只能是對象(Object/Array/Function)和Symbol,當key為對象時,如果作為key的對象沒有被引用,WeakMap的key被回收——垃圾回收機制會回收該鍵值對。

Map和普通對象區別?

1.key的順序
Map保留了key-value插入的順序。
普通字面對象遍歷順序沒有保證。
2.key的類型
Map的key可以是任意類型(String, Boolean, Number, Symbol等)
普通字面對象的key只能是 String和Symbol(注意:當使用數字做key,實際上是給轉成了字符串處理);
3.迭代方式
Map本身是可迭代對象,可以被for of迭代。
普通字面對象本身不可迭代,需要藉助Object.keys()來迭代(不包含原型上的key)。(注意:使用for in 可以遍歷普通字面對象的所有key,這個key包含了來自原型上的key)
4.序列化
普通字面對象可以被JSON.stringify()序列化
Map無法被JSON.stringify()序列化
5.性能考慮
在涉及​​頻繁增刪鍵值對​​的場景下,​Map 的性能通常優於普通對象​

拓展 - for of可以迭代可迭代對象的原理,這個就寫在後面了~

浮點數精度 0.1+0.2!=0.3 問題

0.1+0.2 = 0.30000000000000004 。原因解釋:這是所有采用IEEE 754標準的浮點數都存在的問題,0.1和0.2在進行計算的時候要轉為二進制進行計算,但是0.1和0.2的二進制是無限循環的,那麼當計算(加)完成後的數也是無限循環小數,有效位存不下就會“截斷”。所以計算結果是一個近似值(會存在一點點誤差)。

解決方法1
toFixed(參數表示小數位數)toPrecision(參數表示有效數位數)

console.log((0.1 + 0.2).toFixed(1)); // 0.3
console.log((0.1 + 0.2).toPrecision(1)); // 0.3

解決方法2
使用mathjs等第三方庫,

解決方法3
不使用浮點數,轉換成整數計算(用整數表示浮點數)。比如1塊4分,比起1.04,可直接使用104。

手撕

深拷貝

1.JSON.stringify深拷貝存在的問題

  • 序列化會丟失:undefined,symbol,function這些會被忽略
  • 序列化過程中Date會變成字符串,RegExp會變成空{}
  • 無法處理原型鏈 (原型鏈丟失)
  • 無法處理循環引用(報錯)

循環引用問題舉例:

const a = {
    next: null
}
const b = {
    next = a;
}
a.next = b;

Ps.後面會有個JSON.stringify的實現(手撕題)哦~

2.深拷貝基礎版 - 考慮基礎類型、數組、對象和對象的原型鏈

/*
    按基礎類型、數組、內置對象、對象 4塊分類處理
    基礎類型 - 直接返回
    數組 - 遍歷deepCopy
    內置對象 - 可迭代的就迭代並deepCopy,Date和RegExp就重新new一個
    對象 - 創建一個空的新對象(創建時帶原型),遍歷對象的所有屬性並deepCopy,依次掛到新對象上
*/
function deepCopy(obj){
  const type = Object.prototype.toString.call(obj) // '[object String]','[object Object]'...
  const isPrimitive = /String|Number|Boolean|Null|Undefined|Symbol|BigInt|Function/.test(type)

  // 基礎類型
  if(isPrimitive){
    return obj
  }else if(type === '[object Array]'){ //數組
    return obj.map(item=>deepCopy(item))
  }else if(type === '[object Map]'){
    const map = new Map()
    for(const [k, v] of obj.entries()){
      map.set(k, deepCopy(v))
    }
    return map
  }else if(type === '[object WeakMap]'){
    const map = new WeakMap()
    for(const [k, v] of obj.entries()){
      map.set(k, deepCopy(v))
    }
    return map
  }else if(type === '[object Set]' ){
    const set = new Set()
    for(const item of obj){
      set.add(deepCopy(item))
    }
    return set
  }else if(type === '[object WeakSet]'){
    const set = new WeakSet()
    for(const item of obj){
      set.add(deepCopy(item))
    }
    return set
  }else if(type === '[object Date]'){
    return new Date(obj)
  }else if(type === '[object RegExp]'){
    return new RegExp(obj)
  }else{ //對象
    const ans = Object.create(obj.__proto__) // 考慮原型,以obj.__proto__創建一個新對象
    for(const key of Object.keys(obj)){ // for in會把原型上的屬性直接拷貝過來,所以用keys()
      ans[key] = deepCopy(obj[key])
    }
    return ans;
  } 
  
}

測試:

const obj = {
  name: '瘋狂踩坑人',
  children: [
    {
      name: 'God',
      children: [{
        name: 'Jessie'
      }]
    },
  ],
  say : function(){
    console.log('my name is '+this.name);
  },
  skills: ["CET-6", "Coding"],
  relationship:{
    parent: Symbol('parent'),
    brothers: Symbol('brothers')
  },
}
console.log(deepCopy(obj))

3.深拷貝高級版 - 考慮循環引用

/*
    例. a = {next: a} 
    用WeakMap記錄上層出現過的對象,當某個屬性引用到前面出現的對象,説明出現了循環引用,直接從WeakMap返回該對象即可。
*/
function advanceDeepCopy(target){
  const wkMap = new WeakMap()
  function _deepCopy(obj){
    const type = Object.prototype.toString.call(obj) 
    const isPrimitive = /String|Number|Boolean|Null|Undefined|Symbol|BigInt|Function/.test(type)

    // 基礎類型
    if(isPrimitive){
      return obj
    }else if(type === '[object Array]'){ //數組
      obj.map(item=>_deepCopy(item))
    }else if(type === '[object Map]'){
      const map = new Map()
      for(const [k, v] of obj.entries()){
        map.set(k, _deepCopy(v))
      }
      return map
    }else if(type === '[object WeakMap]'){
      const map = new WeakMap()
      for(const [k, v] of obj.entries()){
        map.set(k, _deepCopy(v))
      }
      return map
    }else if(type === '[object Set]' ){
      const set = new Set()
      for(const item of obj){
        set.add(_deepCopy(item))
      }
      return set
    }else if(type === '[object WeakSet]'){
      const set = new WeakSet()
      for(const item of obj){
        set.add(_deepCopy(item))
      }
      return set
    }else if(type === '[object Date]'){
      return new Date(obj)
    }else if(type === '[object RegExp]'){
      return new RegExp(obj)
    }else{ //對象。用weakMap記錄對象,如果後續要拷貝同一對象,則不要深拷貝,而是直接反返回記錄的這個對象
      if(wkMap.has(obj)){ 
        return wkMap.get(obj)
      }
      const ans = Object.create(obj.__proto__) // 考慮原型
      wkMap.set(obj, ans)
      
      for(const key of Object.keys(obj)){ 
        ans[key] = _deepCopy(obj[key])
      }
      
      return ans;
    } 
  }

  return _deepCopy(target)
}
判斷是否為空對象

可以使用lodash中的isEmpty方法判斷,那麼如何實現isEmpty呢?
方式一:stringify. 存在的小問題是無法處理函數和循環引用

return !(JSON.stringify(obj) === '{}')

方式二:使用for ... in

for(let key in obj){
    return true;
}
return false;

方式三:使用Object.keys (Date/RegExp/空Map/空Set/空數組 這些內置對象通過Object.keys獲取到都是一個空數組)

return !Object.keys(obj).length

判斷是否為空對象,除了判斷普通對象外,還需要判斷null、內置對象這些情況

function isEmpty(obj){
    if(typeof obj === 'object' && obj !== null){
        return !Object.keys(obj).length
    }
    return false;
}

//測試
console.log(isEmpty({})) // true
console.log(isEmpty({ name:'瘋狂踩坑人' })) // false
console.log(isEmpty([])) // true
console.log(isEmpty([1,2])) // false
console.log(isEmpty(new Date())) //true

原型和原型鏈

原型鏈

我覺得這篇文章 一文徹底搞懂原型鏈很清晰的解釋了原型鏈的,可以仔細看看。
這裏借用了一下這篇文章的圖,如下:
image.png

原型鏈關係對於剛接觸的同學會有點繞,所以一定要先建立先驗知識,記住下面兩點:

  • 記住第1點:原型是一個普通對象(是普通對象,就有__protop__指針)
  • 記住第2點:函數和普通對象上(默認/自動)都掛載了一個原型(函數通過prototype指針指向,對象通過私有屬性__proto__指向)

如果普通對象是通過某個(構造)函數創建來的,那麼它們的原型是同一個。普通對象通過__proto__指向原型,剛才説了原型本身也是一個普通對象,有__proto__,這樣就構成了原型鏈。原型鏈就是通過__proto__一級級往上找原型形成的鏈。

下面舉一個例子來理解:

function A(name){
    this.name = name
}
const a = new A('瘋狂踩坑人');

默認/自動/沒有手動修改的情況下,A.prototype(A的原型)是一個Object構建出來的對象,所以此時a的原型鏈如下:
a.__proto__ 指向 {..., __proto__指向{..., __proto__:null} }

打印結果如圖:
image.png

繼承

下面將重點介紹4種方式:借用構造函數繼承原型式繼承組合式繼承寄生式組合式繼承
還有另外兩種(原型式繼承寄生式繼承)就不貼代碼了,自行了解下即可。

1.借用構造函數繼承:在子構造方法中調用父構造方法,完成this的屬性方法綁定。

/*1.構造函數繼承*/
function test1(){
  function Foo(label){
    this.labels = [label]
    this.say = function(){
      console.log('my name is '+this.name+', a '+this.label+'.');
    }
  }
    
  function Bar(name, label){  
    Foo.call(this, label)
    this.name = name
  }
  
  const b1 = new Bar('瘋狂踩坑人', '程序員')
  b1.say() // my name is 瘋狂踩坑人, labels: 程序員.
}
  • 優點:創建子類對象可以傳參,每個子類實例保留自己獨立的屬性
  • 缺點:方法不能共享(每個實例都有一個獨立的say方法)

2.原型鏈繼承:通過原型鏈,沿用祖先的屬性和方法。

/*2.原型鏈繼承*/
function test2(){

  function Foo(label){
    this.labels = [label]
  }

  Foo.prototype.say = function(){
    console.log('my name is '+this.name+', labels: '+String(this.labels)+'.');
  }

  function Bar(name){  
    this.name = name;
  }
  Bar.prototype = new Foo('自由職業');

  
  const b1 = new Bar('瘋狂踩坑人'); 
  b1.labels.push('程序員') 
  const b2 = new Bar('瘋狂踩坑人2');
  b2.labels.push('程序員2') //
  b1.say()  // my name is 瘋狂踩坑人, labels: 自由職業,程序員,程序員2.
  b2.say()  // my name is 瘋狂踩坑人2, labels: 自由職業,程序員,程序員2.
}
  • 優點:方法共享,不用每個對象都創建一個方法
  • 缺點:屬性共享,不能在創建子類實例時給父類傳參。(後續操作繼承的屬性時,會影響到所有實例)

3.組合式繼承:結合了前面兩項的優點
把實例方法都放在原型對象上,通過Child.prototype = new Father(),以實現函數複用。
通過Foo.call(this)繼承父類的屬性,並保留能傳參的優點.

/* 組合式繼承 */
function test3(){
  function Foo(label){
    this.labels = [label]
  }

  Foo.prototype.say = function(){
    console.log('my name is '+this.name+', labels: '+String(this.labels)+'.');
  }

  function Bar(name, label){  
    Foo.call(this, label)
    this.name = name;
  }
  Bar.prototype = new Foo();

  const b1 = new Bar('瘋狂踩坑人', '自由職業'); 
  b1.labels.push('程序員') 
  const b2 = new Bar('瘋狂踩坑人2', '自由職業2');
  b2.labels.push('程序員2') //
  b1.say()  // my name is 瘋狂踩坑人, labels: 自由職業,程序員.
  b2.say()  // my name is 瘋狂踩坑人2, labels: 自由職業2,程序員2.
}
  • 優點:屬性不共享,相互獨立,可以通過傳參來初始化。函數複用/共享。
  • 缺點:直接用父類構造對象作為原型,這個原型是有一些不必要的屬性(浪費內存)。上述代碼中就是,子類實例本身有this.labels,子類的原型上也有labels

4.寄生式組合式繼承:主要在組合式的基礎上做一個小改動——原型不使用new Foo(),而是使用Object.create(Foo.prototype)

function test4(){
  // 屬性放構造方法裏,方法放原型上
  function Foo(label){
    this.labels = [label]
  }
  Foo.prototype.say = function(){
    console.log('my name is '+this.name+', labels: '+String(this.labels)+'.');
  }

  function Bar(name, label){  
    Foo.call(this, label)
    this.name = name;
  }
  Bar.prototype = Object.create(Foo.prototype, {
    constructor:{
      value: Bar
    }
  })
  // 這樣一來,Bar.prototype = {__proto__: Foo.prototype}

  const b1 = new Bar('瘋狂踩坑人', '自由職業'); 
  b1.labels.push('程序員') 
  const b2 = new Bar('瘋狂踩坑人2', '自由職業2');
  b2.labels.push('程序員2') //
  b1.say()  // my name is 瘋狂踩坑人, labels: 自由職業,程序員.
  b2.say()  // my name is 瘋狂踩坑人2, labels: 自由職業2,程序員2.
}

這是最完美的繼承方案。具備了組合式繼承的屬性不共享、方法共享的優點,同時解決了它的缺點,令子類的原型上沒有多餘的屬性。

手撕

instanceof

a instanceof A用來判斷某個實例是否是某個構造函數創造出來的。原理是:這個實例的原型鏈上,有沒有這個構造函數的原型。

function instanceOf(obj, constructor){
  //obj的原型鏈上,是否存在constructor.prototype
  let proto = obj.__proto__
  while(proto){
    if(proto === constructor.prototype){
      return true;
    }
    proto = proto.__proto__
  }

  return false
}

測試:

function A(a){
  this.a = a
}
function B(b){
  this.b = b
}
// B繼承A
B.prototype = Object.create(B.prototype, {
  constructor:{
    value: B
  }
})
const x = new B('hello')

console.log(x instanceof A); //true
console.log(instanceOf(x, A)); //true

作用域和作用域鏈

作用域鏈是指==在JavaScript中,當訪問一個變量時,解釋器會按照從內到外的順序查找變量的機制,這個查找的路徑就構成了一個鏈條==。這個鏈條由當前的作用域開始,然後逐級向上查找,直到全局作用域。
在var變量中,只有functon作用域和全局作用域。下面通過例子來説明作用域鏈:

var x = "global scope";
function checkscope(){
    var x = "local scope";
    console.log(x); // "local scope"
}
// 在checkscope函數中,先從checkscope函數域中找x變量,發現存在x的聲明,就使用函數域的x,不會找到外面的全局x.
var x = "global scope";
function checkscope(){
    console.log(x); // "global scope"
}
// 在checkscope函數中,先從checkscope函數域中找x變量,發現沒有x聲明,然後從外面第一層(這裏是全局作用域)找到了x的聲明,就使用了外面的全局x

var, let, const 區別

var和function存在變量提升,以var舉例:

function funcTest() { 
    console.log(arg); 
    var arg = 2; 
} 
funcTest();

等價於

function funcTest() { 
    var arg; // 變量聲明被提升到函數作用域頂部,初始值為 undefined   
    console.log(arg); // 因此這裏輸出 undefined 
    arg = 2; // 賦值操作保留在原位執行 
}

這樣一來var的變量可以先使用,再聲明。對於function關鍵字定義的函數也是如此。

var和let區別一

  • var只有funciton作用域和全局作用域
  • let則除了funciton作用域和全局作用域,還有塊級({})作用域

var和let區別二

  • var有變量提升,可以先使用後聲明。
  • let/const也有變量提升,但是由於暫時性死區,先使用後聲明會導致報錯。

作用域考題一:

// 題1
for(var i=0; i<5; i++){
  setTimeout(()=>{
    console.log(i);
  }, 1000)
}
// 5 5 5 5 5

// 每次迭代,都是一個新的塊作用域,每個塊作用域都有一個獨立的i變量
for(let i=0; i<5; i++){
  setTimeout(()=>{
    console.log(i);
  }, 1000)
}
// 0 1 2 3 4

作用域考題二:

// 題2
var a = 1;
(() => {
    console.log(a);
    a = 2;
})();
// 輸出 1,查找到全局 a   

var a = 1;
(() => {
    console.log(a);
    var a = 2;
})();
// 輸出 undefined,變量聲明提升  

var a = 1;
(() => {
    console.log(a);
    let a = 2;
})();
// 輸出 報錯

閉包

閉包是什麼?

一句話説明:==閉包是一種編程形式,通過內部函數訪問外部函數的變量,從而做到變量複用和避免污染全局變量等作用。== (也可以簡單的把這個內部函數稱為閉包,一種共識的表示而已)

閉包需要滿足的條件:

  • 函數嵌套,外面函數內部定義了一個內部函數
  • 將內部函數返回

閉包舉例:

function outer() {
    const x = 10;
    function inner() { 
        console.log('inner: ', x); 
    } 
    return innner;
} 

const fn = outer();
fn(); // inner: 10

下面舉一個反面例子,不少人會誤以為這也是一種閉包:

function outer(callback) {
    const x = 10;
    callback(x);
} 

function inner() { 
    console.log('callback: ', x); 
} 

outer(inner); // callback: 10

這種通過參數將函數傳入的方式,閉包的兩個條件其實都不滿足。其實可以從本質上分析為什麼上面這種不是閉包。

首先,關於這個問題還可以進一步深入解釋,從詞法環境(執行上下文)和垃圾回收的角度去分析。時間充足且有興趣的同學可以看:
理解 JavaScript 中的執行上下文和執行棧
變量作用域,閉包

然後,這裏簡單分析下。

第一個例子 第二個例子
當執行outer函數時會創建一個詞法環境,執行棧將x、inner裝入。由於inner函數(閉包)被引用,導致執行棧中的x、inner變量都不會被清理,內存並沒有釋放。 當執行outer函數時會創建一個詞法環境,執行棧將x、inner裝入。當outer函數結束,outer的詞法環境(執行上下文)會被銷燬,執行棧中的變量會被清理。

手撕柯里化

一句話説明:把一個接收多個參數的函數,轉換為一系列接收單個參數的函數,直到參數全部收集才執行計算

記住兩個關鍵詞:收集參數、延遲計算。

下面通過一個例子來認識下柯里化:
編寫一個像 sum(a)(b) = a+b 這樣工作的 sum 函數。

function sum(a, b){
  return a+b;
}

function currySum(sum){
  return function(a){
    return function(b){
      return a+b
    }
  }
}

這個currySum函數就被稱為"柯里化函數",它返回的函數稱為"被柯里化的函數"。看完這個例子,相信聰明的同學看出來了,一個通用型的柯里化函數是應該通過遞歸來實現的。下面就來手撕一個通用型的柯里化函數吧

實現一個通用的curry函數:

function curry(func, initArgs){
  const arity = func.length; // func函數的形參數量
  initArgs = initArgs || []
  return function(...args){
    const accArgs = initArgs.concat(args)
    if(accArgs.length < arity){
      return curry(func, accArgs)
    }else {
      return func.apply(this, accArgs) //誰調用了包裝器,這個func就被誰調用
    }
  }
}

測試:

function log(date, importance, info){
  console.log(`${date} : ${importance} : ${info}`)
}
//  測試
const curriedLog = curry(log)

const logTool = curriedLog(new Date())
logTool('error', '出錯了!')
logTool('warn', '這是警告')
logTool('info', '正常輸出')

this指向

全局的this(瀏覽器環境): 1.undefined(嚴格模式); 2.window(非嚴格模式)

全局的this(node環境):1.嚴格模式,undefined; 2.非嚴格模式, 指向模塊自身的導出對象 module.exports

函數的this:

  1. 普通函數作為對象的方法調用,this指向該對象
  2. 構造函數調用(new),this指向實例對象
  3. 全局定義的函數會自動成為window的方法,直接調用相當於window調用,this是window
  4. 非全局定義的普通函數,通過賦值給全局變量,再調用,this是window
  5. 箭頭函數沒有自己的this,指向定義時所在this域

手撕

有三種方法可以改變函數this的指向,需要對這三種方法隨時能手撕出來。

方法 調用時機 參數形式 返回值
call 立即執行 參數列表 (arg1, arg2, ...) 原函數的執行結果
apply 立即執行 參數數組 ([arg1, arg2, ...]) 原函數的執行結果
bind 不立即執行,返回新函數 參數列表 一個永久綁定 this 的新函數
apply
Function.prototype.apply = function(context, args){
  const fn = Symbol('fn')
  context[fn] = this
  const res = context[fn](...args)
  delete context[fn]
  return res;
}
call
Function.prototype.call = function(context, ...args){
  const fn = Symbol('fn')
  context[fn] = this
  const res = context[fn](...args)
  delete context[fn]
  return res;
}
bind
// 顯式綁定,不管誰調用,函數最終的this綁定的是context
Function.prototype.bind = function(context){
  context = context || window
  const fn = Symbol('fn')
  context[fn] = this
  return function (){
    // 不是直接指向this()
    // 而是改變this的指向,再執行
    return context[fn](...arguments)
  }
}

根據實現代碼,提一個小問題:為什麼需要用Symbol ?

因為要把函數this掛載到上下文context對象上,但不能污染上下文對象原來的屬性,所以用Symbol。

函數

箭頭函數和普通函數的區別

箭頭函數和普通函數的區別:

  1. 箭頭函數沒有this,不能改變this指向 (即無法通過call, apply, bind去顯示的改變this)
  2. 箭頭函數沒有arguments
  3. 箭頭函數不能作為構造函數 (不能new)
  4. 箭頭函數沒有原型

動態函數

API new Function ([arg1[, arg2[, ...argN]],] functionBody)
例子:

// 創建一個加法函數。最後一個參數字符串,可以被當做js執行
const add = new Function('a', 'b', 'return a + b');
console.log(add(2, 3)); // 輸出: 5

new實現

new的過程(5步):

  1. 創建一個空對象,作為將要返回的對象實例
  2. 將這個空對象的原型,指向了構造函數的prototype屬性
  3. 將這個空對象賦值給函數內部的this關鍵字
  4. 開始執行構造函數內部的代碼
  5. 如果構造函數返回一個對象,那麼就直接返回該對象,否則返回創建的對象

下面是代碼實現:

function myNew(Constructor, ...args) {
    obj = Object.create(Constructor.prototype) //1,2
    const result = Constructor.apply(obj, arguments) //3,4
    return (result !== null && (typeof result === 'object' || typeof result === 'function')) ? result : obj;  //5
}

數組

數組有哪些方法?

方法分類 方法名稱 描述 返回值 是否改變原數組 ES版本
​添加/刪除元素​ push() 向數組末尾添加一個或多個元素 新數組的長度 ES5
pop() 刪除並返回數組的最後一個元素 被刪除的元素 ES5
unshift() 向數組開頭添加一個或多個元素 新數組的長度 ES5
shift() 刪除並返回數組的第一個元素 被刪除的元素 ES5
​數組截取/拼接​ slice() 返回數組的指定部分(淺拷貝) 新數組 ES5
splice() 在指定位置刪除/添加元素 被刪除元素組成的數組 ES5
concat() 合併兩個或多個數組 合併後的新數組 ES5
​數組轉換​ join() 將數組元素連接成字符串 字符串 ES5
toString() 將數組轉換為字符串 字符串 ES5
Array.from() 將類數組對象轉換為數組 新數組 ES6
​排序/反轉​ sort() 對數組元素進行排序 排序後的數組 ES5
reverse() 反轉數組元素的順序 反轉後的數組 ES5
​查找/判斷​ indexOf() 查找元素第一次出現的索引 索引值(未找到返回-1) ES5
lastIndexOf() 查找元素最後一次出現的索引 索引值(未找到返回-1) ES5
includes() 判斷數組是否包含某個元素 布爾值 ES6
find() 查找第一個符合條件的元素 元素值(未找到返回undefined) ES6
findIndex() 查找第一個符合條件的元素索引 索引值(未找到返回-1) ES6
findLast() 查找最後一個符合條件的元素 元素值(未找到返回undefined) ES2023
findLastIndex() 查找最後一個符合條件的元素索引 索引值(未找到返回-1) ES2023
​遍歷/迭代​ forEach() 遍歷數組執行回調函數 undefined ES5
map() 對每個元素執行函數並返回新數組 新數組 ES5
filter() 過濾符合條件的元素組成新數組 新數組 ES5
reduce() 從左到右累加數組元素 累加結果 ES5
reduceRight() 從右到左累加數組元素 累加結果 ES5
​其他操作​ every() 檢測所有元素是否都滿足條件 布爾值 ES5
some() 檢測是否有元素滿足條件 布爾值 ES5
flat() 將嵌套數組扁平化 新數組 ES6
flatMap() 先map後扁平化(深度為1) 新數組 ES6
fill() 用固定值填充數組元素 填充後的數組 ES6

數組去重的方法?

方式一、使用Set

function unique(arr){
  Array.from(Set(arr))
}

方式二、for雙重循環

function unique(arr){
  const uniqueArr = [];
  for (let i = 0; i < arr.length; i++) {
      let isUnique = true;
      // 檢查當前元素是否已在結果數組中
      for (let j = 0; j < uniqueArr.length; j++) {
          if (arr[i] === uniqueArr[j]) {
              isUnique = false;
              break;
          }
      }
      if (isUnique) {
          uniqueArr.push(arr[i]);
      }
  }
  return uniqueArr;
}

方式三、使用filter嵌套循環

function unique(arr){
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

方式1的時間複雜度O(n),另外兩個時間複雜度是O(n^2). (Set的插入和查找時間複雜度是O(1),所以能做到第一種方式時間複雜度O(n).)

迭代方式 for in 和 for of的區別?

for of 能迭代可迭代對象的原理
JavaScript 中許多內置類型默認就是可迭代的,因為它們都實現了 [Symbol.iterator]方法,例如數組、字符串、Map 和 Set。這也是你能直接用 for...of循環它們的原因。

看定義:

type IteratorFn = () => {  
    next(): { value: any; done: boolean };  
};  
  
type Iterator = {  
    [Symbol.iterator]: IteratorFn;  
};

就是可迭代對象有一個屬性[Symbol.iterator],它表示一個函數,這個函數返回一個帶有next方法的對象。
不知道你見沒見過Map.next(),這説明了可迭代對象上可以通過不斷調用next方法來遍歷。
基於這一點,你可以創建一個對象來實現這種協議,從而變成可迭代的,比如:

const myIterable: Iterator = {
    [Symbol.iterator]: () => {
        let count = 0;
        return {
            next: () => {
                if (count < 3) {
                    return { value: count++, done: false };
                }
                return { value: undefined, done: true };
            }
        };
    }
};

for in 工作原理
for in是用於遍歷對象屬性的一種循環機制。它可以遍歷對象上除了Symbol外的所有可枚舉屬性,包括繼承的可枚舉屬性。

// 創建一個對象並設置其原型
const proto = { inheritedProp: '來自原型' };
const obj = Object.create(proto);
obj.ownProp = '自身屬性';

// 使用 for...in 遍歷(會遍歷自身和原型鏈上的可枚舉屬性)
for (let key in obj) {
  console.log(key); // 輸出: ownProp, inheritedProp
}

由於會找原型鏈上的屬性,所以性能不高。通常使用for of 遍歷Object.keys()來替代。

數組亂序的方法

核心思想:遍歷數組的每個位置,從該位置後面的元素中隨機選擇一個和該位置元素交換。


for (var i = 0; i < arr.length; i++) {
  const randomIndex = Math.floor(Math.random() * (arr.length - i)) + i;
  [arr[i], arr[randomIndex]] = [arr[randomIndex], arr[i]];
}

//測試
var arr = [1,2,3,4,5,6,7,8,9,10];
console.log(arr);

隨機獲取數組的一個元素索引:
const randomIndex = Math.floor(Math.random() * arr.length)

隨機獲取數組某區間[i,j]的索引:
const randomIndex = Math.floor(Math.random() * (j-i+1)) + i

手撕

reduce
Array.prototype.myReduce = function(fn, initialValue) {
  var arr = Array.prototype.slice.call(this);
  var res, startIndex;

  res = initialValue ? initialValue : arr[0]; // 不傳默認取數組第一項
  startIndex = initialValue ? 0 : 1;

  for(var i = startIndex; i < arr.length; i++) {
    // 把初始值、當前值、索引、當前數組 傳遞給調用函數
    res = fn.call(null, res, arr[i], i, this); 
  }
  return res;
}

// 測試
const res = [1,2,3].myReduce((pre, cur)=>{
  return pre + cur;
})
console.log(res) // 6
flat

遍歷數組,如果元素是數組,那麼遞歸繼續「打平」,返回一個新數組。

function flat(arr, n=1){
    if(n<1){
        return arr; //n<1後不打平了
    }
    let res = []
    for(const item of arr){
        if(Array.isArray(item)){
            res = res.concat(flat(item, n-1));
        }else{
            res.push(item)
        }
    }
    return res;
}

// 測試
const arr = [1,2,['ss','hh', ['peace', 'love', null, {name: '瘋狂踩坑人'}]], 3]
console.log(flat(arr, 1)); // [ 1, 2, 'ss', 'hh', [ 'peace', 'love', null, { name: '瘋狂踩坑人' } ], 3 ]

異步Promise

宏任務&微任務以及事件循環

這篇文章由淺入深瞭解瀏覽器的事件循環Event Loop可謂是比較詳細介紹了事件循環。這裏總結下就是:

  • 瀏覽器/JS引擎中有兩個隊列:宏任務隊列、微任務隊列,分別用來存放瀏覽器的兩類異步任務——宏任務和微任務。
  • 所謂的宏任務和微任務就是一段延後執行的腳本/回調函數,比如setTimeout(fn, 5)這裏的fn將是一個宏任務。
  • 瀏覽器先從微任務隊列中取出所有任務執行完,然後從宏任務隊列中取出一個宏任務執行。之後總是這樣循環:先取出所有微任務,再取一個宏任務執行。構成了事件循環。

下面列舉了宏任務和微任務:
宏任務

# 任務類型 瀏覽器 Node
1 I/O (請求,讀寫文件)
2 setTimeout
3 setInterval
4 setImmediate

微任務

# 任務類型 瀏覽器 Node
1 process.nextTick
2 MutationObserver
3 IntersectionObserver
4 Promise.then/catch/finally

Promise規範和原理

請你簡單介紹下Promise?

1.介紹創建:Promise初始化接受一個函數參數,該函數會立即執行。這個參數函數接受兩個參數:resolve函數、reject函數。Promise對象有3個狀態——pending,fulfilledrejected,resolve能將狀態變成fulfilled,reject能將狀態變成rejected。一旦狀態從pending變成結果狀態,狀態就不再改變。

2.介紹then/catch方法:當Promise對象狀態變成結果狀態,就會調用then的回調方法,then函數接受兩個函數參數:成功回調&失敗回調;並且返回一個新的Promise,這使得Promise可以鏈式調用。then返回的這個新的Promise的結果狀態取決於上一個Promise的狀態和then的兩個回調函數的處理。catch方法可以作為錯誤的兜底處理。

3.介紹靜態方法Promise.resolve, Promise.reject, Promsie.all, Promise.race, Promise.allSettled

建議時間充足的情況下,都嘗試自己實現一個簡單的Promise,理解其運行原理,可以參考「硬核JS」圖解Promise迷惑行為|運行機制補充或其他資料。

異步輸出練習

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
});
console.log('script end');


/*
過程分析:
宏任務隊列:setTimeout
微任務隊列:async1 end, promise2

輸出 =======================
script start
async1 start
async2
promise1
script end  (到這裏,整個腳本結束。下面就是先取所有微任務,再取一個宏任務)
async1 end   
promise2  
setTimeout
*/

手撕

實現delay

實現一個delay函數,等待ms時間後繼續執行後面代碼。

function delay(ms){
  return new Promise((resolve)=>{
    setTimeout(()=>{
      resolve()
    }, ms)
  })
}

async function test(){
    await delay(2000)
    console.log('print after ms')
}
實現promise的all/race/allSettled

實現all
all接受一個promise數組/可迭代對象,返回一個promise。當數組的所有promise都成功,則結果fulfilled;任一個失敗則結果是rejected

Promise.all([pa, pb]).then([resA, resB]=>{
  // 全部成功
}).catch(e=>{
  // 任一個失敗
})

具體實現:

Promise.all = (iterable) => {
  return new Promise((resolve, reject) => {
    // 處理空迭代對象的情況
    if (!iterable || iterable.length === 0 || iterable.size === 0) {
      return resolve([]);
    }
    
    const len = Array.isArray(iterable) ? iterable.length : iterable.size; // 數組是length, Map是size
    const results = Array(len).fill(null);
    let successCnt = 0;

    // 處理每個promise
    iterable.forEach((item, i) => {
      Promise.resolve(item).then((res) => {
        results[i] = res;
        successCnt++;
        if (successCnt === len) {
          resolve(results);
        }
      }).catch(e => {
        reject(e);
      });
    });
  });
}

這裏注意,需要用Promise.resolve包裹item,因為item可能不是Promise。(Promise.resolve(item)在item是Promise的時候直接返回item,否則會返回一個內部創建的成功Promise,value是item)

實現race
race接受一個promise數組/可迭代對象,返回一個promise。當數組中任一個promise先成功,則結果fulfilled;任一個先失敗則結果是rejected

Promise.race([pa, pb]).then(res=>{
  // 任一個先成功
}).catch(e=>{
  // 任一個先失敗
})

具體實現:

Promise.race = (iterable)=>{
  if(!iterable || iterable.length === 0 || iterable.size === 0){
    return Promise.reject(new TypeError('Promise.race requires an iterable argument'))
  }
  // 重點
  return new Promise((resolve, reject)=>{
    iterable.forEach(item=>{
      Promise.resolve(item).then((res)=>{
        resolve(res)
      }).catch(e=>{
        reject(e)
      })
    })
  })
}

實現allSettled
MDN allSettled規範
allSettled規範:

  • 當參數數組中所有Promise都達到結束狀態時才結束promise,並且一定是返回一個成功的Promise
  • 失敗的promise以{status:'rejected', reason: r}形式保存到數組,成功的promise以{status:'fulfilled', value: v}的形式保存到數組。

具體實現

Promise.allSettled = (iterable)=>{
  const len = Array.isArray(iterable) ? iterable.length : (iterable?.size || 0)
  const results = Array(len)
  let cnt = 0
  return new Promise((resolve, reject)=>{
    if (!iterable || len === 0) return resolve([])
    iterable.forEach((p,i)=>{
      Promise.resolve(p).then((val)=>{
        results[i] = {status: 'fulfilled', value:val}
        cnt++
        if(cnt === len)
          resolve(results)
      }).catch(e=>{
        results[i] = {status:'rejected', reason:e}
        cnt++
        if(cnt === len)
          resolve(results)
      })
    })
  })
}

//測試
const p1 = new Promise((resolve, reject)=>{
  setTimeout(_=>resolve(1), 2000)
})
const p2 = new Promise((resolve, reject)=>{
  setTimeout(_=>reject('bad'), 1000)
})
Promise.allSettled([p1, p2]).then(results=>{
  console.log(results);
  /*
    [
      { status: 'fulfilled', value: 1 },
      { status: 'rejected', reason: 'bad' }
    ]
  */
})
限制異步併發數

方式一: 先運行limit個promise,然後當其中一個promise結束,喚醒下一個promise執行。

Promise.limitConcurrency = (urls, request, limit)=>{

  if (!urls || !urls.length) return Promise.resolve([]);
  
  return new Promise((resolve)=>{
    let idx = 0; //資源序號
    const result = Array(urls.length).fill(null)
    let finishCnt = 0;
    function next(){
      if(idx === urls.length){ //調完
        return 
      }
      const curIdx = idx; //記錄索引快照,用於then後的回調使用
      idx++;
      request(urls[curIdx]).then((val)=>{
        result[curIdx] = {
          status:'fulfilled',
          value: val
        }
        console.log({
          status:'fulfilled',
          value: val
        });
      }).catch(err=>{
        result[curIdx] = {
          status:'rejected',
          reason: err
        }
        console.log({
          status:'rejected',
          reason: err
        });
      }).finally(()=>{
        next()
        finishCnt++;
        if(finishCnt === urls.length){
          resolve(result)
        }
      })
    }

    // 先執行limit個,在next內部,當一個promise結束時,再啓動下一個。
    for(let i=0; i<limit && i<urls.length; i++){
      next();
    }
  })
}

測試代碼:

// Mock請求
const request = item=> new Promise((resolve, reject)=>{
  setTimeout(()=>{
    if(item===2){
      reject('error')
    }else{
      resolve(item)
    }
  }, 1000)
})

const urls = [1,2,3,4,5] // 請求資源

Promise.limitConcurrency(urls, request, 2).then(results=>{
  console.log(results);
})

你可以按我下面的思路來記住/默寫這個方法
1.先搭建框架,傳入多個資源、一個執行資源的異步方法和限制併發數limit. 結果返回一個Promise.

Promise.limitConcurrency = (urls, request, limit)=>{
  if (!urls || !urls.length) return Promise.resolve([]);
  return new Promise((resolve)=>{
      const result = Array(urls.length).fill(null); //存放結果
  })
}

2.先假設有一個next方法,用來執行一個promise產生結果。一開始要執行limit次next方法。

Promise.limitConcurrency = (urls, request, limit)=>{
  if (!urls || !urls.length) return Promise.resolve([]);
  return new Promise((resolve)=>{
    const result = Array(urls.length).fill(null); //存放結果
    //next執行promise和處理結果
    function next(){}
    
    //先執行 limit次
    for(let i=0; i<limit && i<urls.length; i++){
      next();
    }
  })
}

3.完善next方法,當next中的promise結束了,應該喚起下一個next(promise)的執行。並且考慮當所有promise結束,resolve結果.

Promise.limitConcurrency = (urls, request, limit)=>{
  if (!urls || !urls.length) return Promise.resolve([]);
  return new Promise((resolve)=>{
    const result = Array(urls.length).fill(null); //存放結果
    let idx = 0;
    let finishCnt = 0;
    
    //next執行promise和處理結果
    function next(){
      //記錄索引快照,用於then後的回調使用。這一步需要稍微理解下為什麼要curIdx。因為request調用後要保存結果到result,不確定當前的異步什麼時候結束,而idx在這期間可能是變化了的,所以要保留一個快照。
      const curIdx = idx; 
      idx++;
      request(urls[curIdx]).then(()=>{
          //...
      })
      .catch(e=>{})
      .finally(()=>{
        next(); //喚醒下一次
        finishCnt++;
        if(finishCnt === urls.length){
          resolve(result)
        }      
      })
    }
    
    //先執行 limit次
    for(let i=0; i<limit && i<urls.length; i++){
      next();
    }
  })
}

4.考慮每次promise執行完成結果怎麼保存。就是最終版本辣~

//自行默寫一遍哦~

方式二: 使用race的特性,只要有一個成功就結束,這樣可以做到有一個完成後添加下一個promise執行。

Promise.limitConcurrency = async (urls, request, limit) => {
  const executing = [];         
  const result = Array(urls.length).fill(null)

  for (let i=0; i<urls.length; i++) {   
    const p = request(urls[i]).then((val)=>{
      result[i] = {
        status:'fulfilled',
        value: val
      }
      console.log({
        status:'fulfilled',
        value: val
      })
    }).catch(err=>{
      result[i] = {
        status:'rejected',
        reason: err
      }
      console.log({
        status:'fulfilled',
        reason: err
      })
    }).finally(()=>{
      executing.splice(executing.indexOf(p), 1); //完成後刪除,讓位給下一個promise
      
    }); 
    executing.push(p);

    if (executing.length >= limit) { //執行隊列達到限制,就race。當其中一個promise完成了就會出隊讓出位置來。
      await Promise.race(executing);     
    }
  }
  
  if(executing.length)
    await Promise.all(executing);

  return result
}

解釋:
1.executing 數組存放執行中的promise。
2.遍歷url,構建promise,將promise放入executing。同時每個promise在結束後需要被從executing中移除。
3.當executing達到限制,就race併發,待其中一個完成後繼續循環(繼續往executing加promise)
4.executing中剩下的用all併發,全部完成後就返回結果。

實現串行請求

有一個資源數組(每個資源可以用來創建Promise),實現一個函數,接受這個數組做到串行執行每個資源promise。

具體來説,有[1,2,3]。像下面這樣的就是串行執行了:

// 寫法一
createPromise(1).then(()=>{
    return createPromise(2).then(()=>{})
}).then(()=>{
    return createPromise(3).then(()=>{})
})
// 寫法二
createPromise(1).then(()=>{
    return createPromise(2).then(()=>{
        return createPromise(3).then(()=>{
        
        })
    })
})

輔助代碼:

const createPromise = (id) => new Promise((solve, reject) =>
  setTimeout(() => {
    console.log("promise", id);
    if(id === 2){
      reject('2 error')
    }
    solve(id);
  }, 1000)
);
  
// 實現queueExecPromise
// 測試
queueExecPromise([1,2,3]);

實現方式很多。
按寫法一的形式來實現: 前一個promise處理了,能在本次迭代拿到前一個promise。前一個promise的then方法中創建本次的promise.

function queueExecPromise(arr){
  arr.reduce((prePromise, cur)=>{
    return prePromise.then(val=>{
      return createPromise(cur)
    }).catch(e=>{
      return undefined
    })
  }, Promise.resolve())
}

按寫法二的形式來實現: 一種遞歸的實現方式。(其實和併發限制很像,不過limit=1)

function queueExecPromise(arr){
  function next(){
    if(arr.length === 0){
      return Promise.resolve()
    }
    const cur = arr.shift()
    return createPromise(cur).then(val=>{
      return next()
    }).catch(e=>{
      return undefined
    })
  }

  return next()
}

還可以使用async/await 來實現

async function queueExecPromise(arr){
  for(const item of arr){
    try {
      const res = await createPromise(item)
      console.log(res)
    } catch (e) {
      console.log(e)
    }
  }
}
實現一個異步調度器

異步調度器 可以添加任務,然後調用flushQueue方法來刷新所有任務(即執行完成所有任務),並且執行任務有併發限制。

class Scheduler {
  constructor(limit){
    this.limit = limit;
    this.queue = [];
  }
  add(taskFn){
    this.queue.push(taskFn)
  }

  // 執行
  flushQueue(){
    for(let i=0; i<this.limit; i++){
      this.next()
    }
  }

  next(){
    if(this.queue.length<=0){
      return;
    }
    const task = this.queue.shift()
    if(task){
      task().then(res=>{
        // console.log(res);
        this.next()
      }).catch(e=>{
        // console.log(e);
        this.next();
      })
    }
  }
}

測試:

//測試
let scheduler = new Scheduler(2);

const addTask = (time, order) => {
  const task = () => {
    return new Promise((reslove, reject) => {
      setTimeout(() => {
        console.log(order);
        reslove();
      }, time);
    });
  };
  scheduler.add(task);
};

addTask(1000, "1");
addTask(500, "2");
addTask(300, "3");
addTask(400, "4");

scheduler.flushQueue();
實現一個具有重試功能的request

實現一個request,可以在失敗的時候重試,額外有兩個參數:

  • interval 重試時間間隔(s)
  • retry 重試次數(次)
async function request(url, options, interval=3, retry=5){
  let leftCount = retry
  // 閉包+遞歸實現
  const _requestData = (_url, _options)=>{
    
    return fetch(_url, _options).catch((err)=>{
      // 失敗 => 重試(遞歸)
      if(leftCount > 0){
        return new Promise((resolve, reject)=>{ //=========catch返回的promise將取決於這個promise結果==================
          setTimeout(()=>{
            leftCount--;
            console.log('重試...'+(retry-leftCount));
            _requestData(_url, _options).then(resolve, reject)
          }, interval*1000)
        })
      }else {
        throw err
      }
    })
  
  }
  return _requestData(url, options)
}

測試:

request('https://www.baidusdf.com',{Method:"POST"}).then(res=>{
  console.log(res);
}).catch(err=>{
  console.log('出錯了!');
  console.log(err);
})
/*
重試...1
重試...2
重試...3
重試...4
重試...5
出錯了!
TypeError: fetch failed
*/

節流防抖

手撕

節流

簡單實現:

// 節流:每單位時間只執行一次。
// 場景:滾動;收藏/點贊按鈕
// 實現:基於時間戳,判斷時間差是否大於等於delay,來決定是否執行
function throttle(fn, delay){
  let lastTime = 0
  return function(){
    if(Date.now() - lastTime > delay){
      fn.apply(this, arguments)
      lastTime = Date.now()
    }
  }
}

Ps.也可以使用setTimeout定時器來實現。

function throttle2(fn, delay){
  let timer = null
  return function(){
    if(timer){
      return;
    }
    fn.apply(this, arguments)
    timer = setTimeout(()=>{
      timer = null;
    }, delay)
  }
}

測試:

let cnt = 0
const throttledFn = throttle((x)=>{
  console.log(x);
}, 1000)
setInterval(()=>{
  throttledFn(cnt)
  cnt++
}, 400)

進階要求:保證節流的最後一次調用一定執行

function throttle2(fn, delay){
  let timer = null
  let lastTimer = null;
  return function(){
    if(timer){  // 凍結期
      if(lastTimer){
        clearTimeout(lastTimer)
      }
      lastTimer = setTimeout(()=>{
        lastTimer = null
        fn.apply(this, arguments)
      }, delay)
      return;
    }
    fn.apply(this, arguments)
    timer = setTimeout(()=>{
      timer = null;
      clearTimeout(lastTimer)
    }, delay)
  }
}

核心思想
在凍結期間,設置一個setTimeout定時器(對應lastTimer)觸發fn調用,這個定時任務和凍結前的setTimeout(對應timer)的定時任務是互相“競賽”的。
若timer定時先觸發則最後一次就被取消,若lastTimer定時先觸發就是最後一次執行。

防抖

簡單實現:

// 防抖:延遲執行函數,等觸發停止了單位時間再執行 (每次觸發重新計算延遲時間)
// 場景:搜索輸入;調整窗口大小
// 實現: 函數觸發後, 延遲單位時間後執行,如果單位時間內觸發,則重新計時 
function debounce(fn, delay){
  let timer = null
  return function(){
    if(timer){
      clearTimeout(timer)
    }
    timer = setTimeout(()=>{
      fn.apply(this, arguments)
    }, delay)
  }
}

測試:

let cnt = 0
const debouncedFn = debounce((x)=>{
  console.log(x);
}, 1000, true)
setTimeout(()=>{
  debouncedFn(cnt)
  cnt++
}, 500)
setTimeout(()=>{
  debouncedFn(cnt)
  cnt++
}, 500)
setTimeout(()=>{
  debouncedFn(cnt)
  cnt++
}, 1000)

訂閲發佈模式

手撕

常見的問法:"寫一個EventBus"、"寫一個EventMitter"和"寫一個訂閲發佈/觀察者模式"。

/* 
  功能要求:實現on, off, emit, once
*/

// 調度中心/中介: 負責訂閲事件、通知事件
function Event(){
  this.listeners = {
  }
}

// 1.註冊/訂閲
Event.prototype.on = function(eventName, listener){
  if(this.listeners.hasOwnProperty(eventName)){
    this.listeners[eventName].push(listener)
  }else{
    this.listeners[eventName] = [listener]
  }
}

// 2.註銷
Event.prototype.off = function(eventName, listener){
  if(this.listeners.hasOwnProperty(eventName)){
    this.listeners[eventName] = this.listeners[eventName].filter(e=>e!==listener)
  }
}


// 3.once 一次註冊,用完即註銷
Event.prototype.once = function(eventName, listener){
  const context = this
  const fn = function(...args){
    context.off(eventName, fn)
    listener(...args)
  }
  this.on(eventName, fn)
}

// 4.通知
Event.prototype.emit = function(eventName, ...args){
  if(this.listeners.hasOwnProperty(eventName)){
    for(const listener of this.listeners[eventName]){
      listener(...args)
    }
  }
}

寫訂閲發佈模式有兩個注意點:

  • 需要判斷是否有該事件,使用hasOwnProperty;
  • once註冊,不是將listener直接註冊,而是註冊一個包裝函數,用完即註銷(這種方式比較優雅)。

另外,有些面試官會提到,可不可以訂閲後產生一個ID,後續通過ID取消某個訂閲?
解決這個問題,只需要Event維護一個全局的id,然後原來的listeners[eventName]從數組結構,改為對象結構({[id]:callback} , id做key,回調函數做值)。

正則相關

正則基礎知識

字符串的方法
1.match方法
string.match(regexp) 匹配到項則返回一個數組,沒有則返回null。數組的內容則根據正則是否是g模式有區分。

  • 不是g模式:[0]匹配的完整文本,後續元素表示捕獲組(括號()匹配的文本),最後兩項分別是index(匹配的字符串的起始位置)、input(原字符串)
  • 是g模式:返回所有匹配的字符串構成的數組(此時沒有捕獲組、index和input)

2.replace方法
string.replace(regexp, fn) ,當regexp不是g模式,則只調用一次fn來替換。當regexp是g模式則全局匹配了多少次就會調用多少次fn來替換。舉個例子如下:

//第二個回調函數fn的參數分別是 匹配的字符串、捕獲組、匹配字符串的起始索引和原始字符串引用(和match方法的非g模式是一樣的)
export function test(){
  const res = "1abc".replace(/(ab)(c)/g, (match, g1, g2, index, intput)=>{
    console.log(match) //abc
    console.log(g1) //ab
    console.log(g2) //c
    console.log(index) //1
    console.log(intput) //1abc
    return '0'
  })
  console.log(res); //10
}

正則對象的方法
1.test方法
regexp.test(string)。測試字符串是否匹配正則,返回Boolean。

2.exec方法
regexp.exec(string)。g模式下,返回結果和match的非g模式幾乎一樣。不同的是正則對象可以多次調用exec方法,從而不斷的匹配下一項(符合全局模式行為),舉個例子:

export function test(){
  const reg = /(ab)(c)/g
  const str = "1abc2abc"
  const res = reg.exec(str)
  const res2 = reg.exec(str)
  console.log(res); // [ 'abc', 'ab', 'c', index: 1, input: '1abc2abc', groups: undefined ]
  console.log(res2); //[ 'abc', 'ab', 'c', index: 5, input: '1abc2abc', groups: undefined ]
}

手撕

千分位
function formatPrice(price) {
  return String(price).replace(/\B(?=(\d{3})+$)/g, ',');
}

//測試
console.log(formatPrice('888999')); //888,999
console.log(formatPrice('7888999')); //7,888,999
console.log(formatPrice('77888999')); //77,888,999

解釋:

  • 首先(\d{3})+$匹配3個數字3個數字一組的,並以3個數字結尾。
  • ?=表示後面的正則內容僅僅是斷言(不是實際匹配),換句話説就是僅斷言是否能匹配,但不作為最終匹配結果,這樣就不會被replace掉。
  • \B表示非單詞邊界(即前面應該有字符)
插值語法 {{}}匹配

如題:

/*
將插值{{}}的內容替換
*/
let str = "我是{{name }},年齡{ {age }},愛好{{ hobby}}";
const obj = {
    name:'瘋狂踩坑人',
    age: 24,
    hobby: 'buy'
}

代碼:

function replaceVar(str){
    return str.replace(/\{\s*?\{(.*?)\}\s*?\}/g, (matchStr, g1)=>{
        return obj[g1.trim()]
    })
}
console.log(replaceVar(str)); //我是瘋狂踩坑人,年齡24,愛好buy
url的params獲取
function getUrlQuery(url, key) {
  const queryObj = {}
  const matches = /.+?\?(.+)/.exec(url)
  if(matches){
    //matches是數組,從第二個參數開始是捕獲分組
    if(!matches[1])
      return undefined
    const hashStartIndex = matches[1].indexOf('#')
    const query = hashStartIndex===-1?matches[1]: matches[1].slice(0,hashStartIndex) //去hash
    if(query){
      const queries = query.split('&')  
      
      queries.reduce((acc, item)=> {
        const kw = item.split('=')
        const key = kw[0].trim()
        if(acc[key] === undefined){
          acc[key] = kw[1]
        }else{
          acc[key] = Array.isArray(acc[key]) ? [...acc[key], kw[1]] : [acc[key], kw[1]]
        }
        return acc
      }, queryObj)
    }

  }
  return key ? queryObj[key] : queryObj
}

const query = getUrlQuery('http://www.google.com/search?q=javascript&aqs=chrome.0.0l6j69i60j69i61j69i60.3518j1j7&sourceid=chrome&test=1&test=2#title');
console.log(query);

一些API實現

JSON.stringify (wxg經典的一道手撕)

只考慮普通字面對象,如何實現JSON.stringify呢?先了解下一些規則

  • 對於對象、數組會遞歸序列化,使用{}[]表示對象和數組邊界
  • 會丟失:Undefined/Function/Symbol
  • 字符串需要""邊界,其他基礎數據類型Number/Boolean/Null 等轉字符串
    舉例:
const obj = {
  name: '瘋狂踩坑人',
  children: ['good', 'bad', {rank: 1, has: false}],
  say: ()=>{
    console.log('hhh')
  },
  x:undefined,
  y:null,
  z:Symbol(1),
  1:1,
}

// 轉換後
// {"1":1,"name":"瘋狂踩坑人","children":["good","bad",{"rank":1,"has":false}],"y":null}

實現:

JSON.mystringify = (obj)=>{
  const type = typeof obj
  if(type === 'object' && obj!==null){ //對象
    if(Array.isArray(obj)){
      let ans = ''
      for(const item of obj){
        const value = JSON.mystringify(item)
        if(value){
          if(ans!==''){
            ans += ','
          }
          ans += value
        }
      }
      return `[${ans}]`
      
    }else{ //簡單處理, 其實還要考慮Map, Set 
      let ans = ''
      for(const key of Object.keys(obj)){
        const value = JSON.mystringify(obj[key])
        if(value){
          if(ans!==''){
            ans += ','
          }
          ans +=  `"${key}":${value}` 
        }
      }
      return `{${ans}}`
    }
  }else if( /undefined|function|symbol/.test(type)){
    // 忽略function 、undefined 、symbol
    return 
  }else{
    // 基礎類型
    return  type === 'string' ?  `"${obj}"` : String(obj)
  }
}

面試官可能會深挖的點:
1.考慮Map,Set,情況是怎麼樣的?
Map和Set對象會被處理為空對象{}

2.循環依賴了,怎麼處理?
使用一個WeakSet來記錄已訪問過的對象,如果遇到了訪問過的,説明循環依賴了,拋出錯誤

parseInt

leetcode有一道類似的題 字符串轉換整數 (atoi)

這裏實現一個簡單版本(一個正整數字符串 轉 數字類型,忽略第二個參數,就按10進制來).
比如:"42" -> 42

代碼:

function parseInt(str){
  const baseCode = "0".charCodeAt(0)
  let ans = 0;
  for(let i=0; i<str.length; i++){
    const curCode = str.charCodeAt(i)
    const num = curCode - baseCode
    ans *= 10;
    ans += num;
  }

  return ans;
}


// 測試
console.log(parseInt("123")); //123
console.log(parseInt("042")); //42

這裏是有一點技巧的:

  • 利用ASCII碼做減法,算出字符的數值
  • 從左到右遍歷,每次對ans先乘10,一方面完成權重增加,另一方面有效處理了前導0。
trim

這是字符串的trim方法,作用:刪除兩端的空格。

function trim(str){
  let i=0, j=str.length-1;
  while(i<=j && str[i]===' '){
    i++;
  }
  while(i<=j && str[j]===' '){
    j--;
  }
  return str.substring(i, j+1)
}

// 測試
console.log(trim("x  ab  c d 1  ") + '_end');
console.log(trim("   x  ab  c d 1") + '_end');
console.log(trim("  x  ab  c d 1  ") + '_end');
lodash.get
function get(obj, str, defaultVal=undefined){
  const strType = typeof str
  let path = []
  if(strType === 'string'){
    path = str.split('.')
  }else if(Array.isArray(str)){
    path = str
  }else{
    return defaultVal
  }
  let i=0; 
  let parent = obj
  while(i<path.length ){
    const type = typeof parent[path[i]]
    if(type === 'object' && type !== null){ //繼續搜
      parent = parent[path[i]]
      i++;
    }else{
      return parent[path[i]]===undefined ? defaultVal: parent[path[i]]
    }
  }
  return parent;
}

測試:

const _ = {
  get
}

const object = { 
  a: { 
    b: { 
      c: 3 
    } 
  } 
};

console.log(_.get(object, 'a.b.c')); // 輸出: 3
console.log(_.get(object, 'a.b.x', 'default')); // 輸出: 'default'

console.log(_.get(object, ['a', 'b'])); // { c: 3 }
console.log(_.get(object, ['a', 'b', 'c'])); // 輸出: 3

遞歸/迭代 思想

1.路徑命名轉樹(字節面)

// 將arr轉為output的形式
const arr = ['A', 'A.B', 'A.B.C', 'A.C', 'A.C.D', 'E', 'E.F', 'E.G', 'E.G.H']
const output = [
    {
        name: 'A',
        children: [
            {
                name: 'B',
                children: [
                    {
                        name: 'C',
                        children: []
                    }
                ]
            },
            {
                name: 'C',
                children: [
                    {
                        name: 'D',
                        children: []
                    }
                ]
            }
        ]
    },
    {
        name: 'E',
        children: [
            {
                name: 'F',
                children: []
            },
            {
                name: 'G',
                children: [
                    {
                        name: 'H',
                        children: []
                    }
                ]
            }
        ]
    }
]

實現思路:路徑字符串轉數組,然後沿數組一級級找(沒找到就創建節點掛載到主體上,找到節點則作為新主體)。

function transform(arr){
    const root = {children:[]}; //頂級節點

    // 根據path一級級往下找,插入到root中
    const _insert = (path)=>{
        let cur = root; 
        for(const item of path){
            const newCur = cur.children.find(child=>child.name === item);
            if(newCur){ //找到節點,直接更新主體
                cur = newCur
            }else{ //沒找到節點,創建節點,掛載到主體上
                const s = {name: item, children:[]}
                cur.children.push(s)
                cur = s;
            }
        }
    }

    for(const item of arr){
        //1.轉數組
        const names = item.split('.')
        //2.沿數組找,插入
        _insert(names)
    }
    return root.children;
}

2.解析DOM屬性(美團面)

<div>
  <span>
    <a>網址1</a>
  </span>
  <span>
    <a>網址2</a>
    <a>網址3</a>
  </span>
</div>

已知一個DOM結構如上,轉換為下面的JSON格式

{
  tag: 'DIV',
  children: [
    {
      tag: 'SPAN',
      children: [
        { tag: 'A', children: [] }
      ]
    },
    {
      tag: 'SPAN',
      children: [
        { tag: 'A', children: [] },
        { tag: 'A', children: [] }
      ]
    }
  ]
}

實現代碼:

function dom2json(domTree){
  const json = {}
  if(typeof domTree === 'object' && domTree !== null){
    json.tag = domTree.tagName
    json.children = domTree.childNodes.map(child => dom2json(child))
  }
  return json;
}

3.對象的key駝峯化命名(騰訊面)

// 輸入
const input = {
  err_msg:'hhh',
  my_real_data: {
    list: ['item1', {list_children: []}]
  },
  count: 13,
  errors: [{field:'name', error_msg:'xx'}]
}


// 輸出
{
  errMsg: 'hhh',
  myRealData: { list: [ 'item1', listChildren:[] ] },
  count: 13,
  errors: [ { field: 'name', errorMsg: 'xx' } ]
}

實現代碼:

function transformKey(key){
  return key.replace(/_([a-z])/g, (matchStr, g1)=>{
    return g1.toUpperCase();
  })
}

function transformObj(obj){
  const type = typeof obj;
  
  // 對象
  if(type === 'object' && type!==null){
    if(Array.isArray(obj)){
      return obj.map(item=>transformObj(item))
    }else{
      let newObj = {}
      for(const key of Object.keys(obj)){
        const newKey = transformKey(key)
        // console.log(newKey);
        newObj[newKey] = transformObj(obj[key])
      }
      return newObj
    }
  }else{// 基礎數據類型
    return obj;
  }
  
}

4.扁平數組轉嵌套數組/Tree(猿輔導面)

//輸入
const data = [
  {id:1, name:'a', pid: 0},
  {id:2, name:'bb', pid: 6},
  {id:3, name:'cc', pid: 5},
  {id:4, name:'dd', pid: 3},
  {id:5, name:'ee', pid: 6},
  {id:6, name:'ff', pid: 0},
]
//轉換結果
{
    "id": 0,
    "children": [
        {
            "id": 1,
            "name": "a",
            "pid": 0
        },
        {
            "id": 6,
            "name": "ff",
            "pid": 0,
            "children": [
                {
                    "id": 2,
                    "name": "bb",
                    "pid": 6
                },
                {
                    "id": 5,
                    "name": "ee",
                    "pid": 6,
                    "children": [
                        {
                            "id": 3,
                            "name": "cc",
                            "pid": 5,
                            "children": [
                                {
                                    "id": 4,
                                    "name": "dd",
                                    "pid": 3
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    ]
}

實現代碼:

function buildTree(arr){
  // [pid]:nodes , pid為key記錄節點
  const map = new Map();
  arr.forEach(item=>{
    if(map.has(item.pid)){
      map.set(item.pid, map.get(item.pid).concat(item))
    }else{
      map.set(item.pid, [item])
    }
  })
  // root = {id:0, children:[]}
  // 含義:找到每個節點的children
  function _dfs(nodes){ 
    for(const node of nodes){
      if(map.has(node.id)){
        node.children = map.get(node.id);
        _dfs(node.children)
      }
    }
  }
  const root = {id:0}
  _dfs([root])
  return root
}

排序算法

手撕

快速排序

可以去試試這道leetcode 排序數組

function partition(nums: number[], i:number, j:number){
    const idx = Math.floor(Math.random()*(j+1-i)) + i;
    [nums[i], nums[idx]] = [nums[idx], nums[i]];
    const x = nums[i];
    
    while(i<j){
        while(i<j && nums[j]>=x){
            j--;
        }
        nums[i] = nums[j]
        while(i<j && nums[i]<=x){
            i++
        }
        nums[j] = nums[i]
    }
    nums[i] = x;
    return i;
}

function quickSort(nums: number[], i:number, j:number) {
    if(i<j){
        const mid = partition(nums, i, j)
        quickSort(nums, i, mid-1)
        quickSort(nums, mid+1, j)
    }
}

function sortArray(nums: number[]): number[] {
    quickSort(nums, 0, nums.length-1)
    return nums;
};

快排應該是比較經常被問到的了,建議10min內能寫完代碼。並且能分析時間複雜度。

user avatar cyzf 头像 savokiss 头像 Dream-new 头像 zero_dev 头像 febobo 头像 zhulongxu 头像 ccVue 头像 yixiyidong 头像 DingyLand 头像 youyoufei 头像 lin494910940 头像 abc-x 头像
点赞 71 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.