2萬字 | 前端基礎拾遺90問

寫程式碼像蔡徐抻發表於2020-04-06

大家好,我是練習時長一年半的前端練習生,喜歡唱、跳、rap、敲程式碼。轉眼又到了金三銀四的時節,相信很多小夥伴已經在為自己心儀的公司做準備,本文是筆者一年多來對前端基礎知識的總結和思考,這些題目對自己是總結,對大家也是一點微薄的資料,希望能給大家帶來一些幫助和啟發。成文過程中得到了許多大佬的幫助,在此感謝愷哥的小冊、神三元同學的前端每日一問以及許多素未謀面的朋友們,讓我等萌新也有機會在前人的財富中拾人牙慧,班門弄斧Thanks♪(・ω・)ノ

本文將從以下十一個維度為讀者總結前端基礎知識

2萬字 | 前端基礎拾遺90問
行文不易,卑微求個贊(`∀´)Ψ

JS基礎

1. 如何在ES5環境下實現let

對於這個問題,我們可以直接檢視babel轉換前後的結果,看一下在迴圈中通過let定義的變數是如何解決變數提升的問題

2萬字 | 前端基礎拾遺90問
babel在let定義的變數前加了道下劃線,避免在塊級作用域外訪問到該變數,除了對變數名的轉換,我們也可以通過自執行函式來模擬塊級作用域

(function(){
  for(var i = 0; i < 5; i ++){
    console.log(i)  // 0 1 2 3 4
  }
})();

console.log(i)      // Uncaught ReferenceError: i is not defined
複製程式碼

2. 如何在ES5環境下實現const

實現const的關鍵在於Object.defindProperty()這個API,這個API用於在一個物件上增加或修改屬性。通過配置屬性描述符,可以精確地控制屬性行為。Object.defindProperty() 接收三個引數:

Object.defineProperty(obj, prop, desc)

引數 說明
obj 要在其上定義屬性的物件
prop 要定義或修改的屬性的名稱
descriptor 將被定義或修改的屬性描述符

屬性描述符 說明 預設值
value 該屬性對應的值。可以是任何有效的 JavaScript 值(數值,物件,函式等)。預設為 undefined undefined
get 一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined undefined
set 一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法 undefined
writable 當且僅當該屬性的writable為true時,value才能被賦值運算子改變。預設為 false false
enumerable enumerable定義了物件的屬性是否可以在 for...in 迴圈和 Object.keys() 中被列舉 false
Configurable configurable特性表示物件的屬性是否可以被刪除,以及除value和writable特性外的其他特性是否可以被修改 false

對於const不可修改的特性,我們通過設定writable屬性來實現

function _const(key, value) {    
    const desc = {        
        value,        
        writable: false    
    }    
    Object.defineProperty(window, key, desc)
}
    
_const('obj', {a: 1})   //定義obj
obj.b = 2               //可以正常給obj的屬性賦值
obj = {}                //丟擲錯誤,提示物件read-only
複製程式碼

參考資料:如何在 ES5 環境下實現一個const ?

3. 手寫call()

call() 方法使用一個指定的 this 值和單獨給出的一個或多個引數來呼叫一個函式
語法:function.call(thisArg, arg1, arg2, ...)

call()的原理比較簡單,由於函式的this指向它的直接呼叫者,我們變更呼叫者即完成this指向的變更:

//變更函式呼叫者示例
function foo() {
    console.log(this.name)
}

// 測試
const obj = {
    name: '寫程式碼像蔡徐抻'
}
obj.foo = foo   // 變更foo的呼叫者
obj.foo()       // '寫程式碼像蔡徐抻'
複製程式碼

基於以上原理, 我們兩句程式碼就能實現call()

Function.prototype.myCall = function(thisArg, ...args) {
    thisArg.fn = this              // this指向呼叫call的物件,即我們要改變this指向的函式
    return thisArg.fn(...args)     // 執行函式並return其執行結果
}
複製程式碼

但是我們有一些細節需要處理:

Function.prototype.myCall = function(thisArg, ...args) {
    if(typeof this !== 'function') {
        throw new TypeError('error')
    }
    const fn = Symbol('fn')        // 宣告一個獨有的Symbol屬性, 防止fn覆蓋已有屬性
    thisArg = thisArg || window    // 若沒有傳入this, 預設繫結window物件
    thisArg.fn = this              // this指向呼叫call的物件,即我們要改變this指向的函式
    const result = thisArg.fn(...args)  // 執行當前函式
    delete thisArg.fn              // 刪除我們宣告的fn屬性
    return result                  // 返回函式執行結果
}

//測試
foo.myCall(obj)     // 輸出'寫程式碼像蔡徐抻'
複製程式碼

4. 手寫apply()

apply() 方法呼叫一個具有給定this值的函式,以及作為一個陣列(或類似陣列物件)提供的引數。
語法:func.apply(thisArg, [argsArray])

apply()call()類似,區別在於call()接收引數列表,而apply()接收一個引數陣列,所以我們在call()的實現上簡單改一下入參形式即可

Function.prototype.myApply = function(thisArg, args) {
    if(typeof this !== 'function') {
        throw new TypeError('error')
    }
    const fn = Symbol('fn')        // 宣告一個獨有的Symbol屬性, 防止fn覆蓋已有屬性
    thisArg = thisArg || window    // 若沒有傳入this, 預設繫結window物件
    thisArg.fn = this              // this指向呼叫call的物件,即我們要改變this指向的函式
    const result = thisArg.fn(...args)  // 執行當前函式
    delete thisArg.fn              // 刪除我們宣告的fn屬性
    return result                  // 返回函式執行結果
}

//測試
foo.myCall(obj)     // 輸出obj物件
複製程式碼

5. 手寫bind()

bind() 方法建立一個新的函式,在 bind() 被呼叫時,這個新函式的 this 被指定為 bind() 的第一個引數,而其餘引數將作為新函式的引數,供呼叫時使用。
語法: function.bind(thisArg, arg1, arg2, ...)

從用法上看,似乎給call/apply包一層function就實現了bind():

Function.prototype.myBind = function(thisArg, ...args) {
    return () => {
        this.apply(thisArg, args)
    }
}
複製程式碼

但我們忽略了三點:

  1. bind()除了this還接收其他引數,bind()返回的函式也接收引數,這兩部分的引數都要傳給返回的函式
  2. new的優先順序:如果bind繫結後的函式被new了,那麼此時this指向就發生改變。此時的this就是當前函式的例項
  3. 沒有保留原函式在原型鏈上的屬性和方法
Function.prototype.myBind = function (thisArg, ...args) {
    if (typeof this !== "function") {
      throw TypeError("Bind must be called on a function")
    }

    var self = this
    // new優先順序
    var fbound = function () {
        self.apply(this instanceof self ? this : thisArg, args.concat(Array.prototype.slice.call(arguments)))
    }
    // 繼承原型上的屬性和方法
    fbound.prototype = Object.create(self.prototype);

    return fbound;
}

//測試
const obj = { name: '寫程式碼像蔡徐抻' }
function foo() {
    console.log(this.name)
    console.log(arguments)
}

foo.myBind(obj, 'a', 'b', 'c')()    //輸出寫程式碼像蔡徐抻 ['a', 'b', 'c']
複製程式碼

6. 手寫一個防抖函式

防抖和節流的概念都比較簡單,所以我們就不在“防抖節流是什麼”這個問題上浪費過多篇幅了,簡單點一下:

防抖,即短時間內大量觸發同一事件,只會執行一次函式,實現原理為設定一個定時器,約定在xx毫秒後再觸發事件處理,每次觸發事件都會重新設定計時器,直到xx毫秒內無第二次操作,防抖常用於搜尋框/滾動條的監聽事件處理,如果不做防抖,每輸入一個字/滾動螢幕,都會觸發事件處理,造成效能浪費。

function debounce(func, wait) {
    let timeout = null
    return function() {
        let context = this
        let args = arguments
        if (timeout) clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, wait)
    }
}
複製程式碼

7. 手寫一個節流函式

防抖是延遲執行,而節流是間隔執行,函式節流即每隔一段時間就執行一次,實現原理為設定一個定時器,約定xx毫秒後執行事件,如果時間到了,那麼執行函式並重置定時器,和防抖的區別在於,防抖每次觸發事件都重置定時器,而節流在定時器到時間後再清空定時器

function throttle(func, wait) {
    let timeout = null
    return function() {
        let context = this
        let args = arguments
        if (!timeout) {
            timeout = setTimeout(() => {
                timeout = null
                func.apply(context, args)
            }, wait)
        }

    }
}
複製程式碼

實現方式2:使用兩個時間戳prev舊時間戳now新時間戳,每次觸發事件都判斷二者的時間差,如果到達規定時間,執行函式並重置舊時間戳

function throttle(func, wait) {
    var prev = 0;
    return function() {
        let now = Date.now();
        let context = this;
        let args = arguments;
        if (now - prev > wait) {
            func.apply(context, args);
            prev = now;
        }
    }
}
複製程式碼

8. 陣列扁平化

對於[1, [1,2], [1,2,3]]這樣多層巢狀的陣列,我們如何將其扁平化為[1, 1, 2, 1, 2, 3]這樣的一維陣列呢:

1.ES6的flat()

const arr = [1, [1,2], [1,2,3]]
arr.flat(Infinity)  // [1, 1, 2, 1, 2, 3]
複製程式碼

2.序列化後正則

const arr = [1, [1,2], [1,2,3]]
const str = `[${JSON.stringify(arr).replace(/(\[|\])/g, '')}]`
JSON.parse(str)   // [1, 1, 2, 1, 2, 3]
複製程式碼

3.遞迴
對於樹狀結構的資料,最直接的處理方式就是遞迴

const arr = [1, [1,2], [1,2,3]]
function flat(arr) {
  let result = []
  for (const item of arr) {
    item instanceof Array ? result = result.concat(flat(item)) : result.push(item)
  }
  return result
}

flat(arr) // [1, 1, 2, 1, 2, 3]
複製程式碼

4.reduce()遞迴

const arr = [1, [1,2], [1,2,3]]
function flat(arr) {
  return arr.reduce((prev, cur) => {
    return prev.concat(cur instanceof Array ? flat(cur) : cur)
  }, [])
}

flat(arr)  // [1, 1, 2, 1, 2, 3]
複製程式碼

5.迭代+展開運算子

let arr = [1, [1,2], [1,2,3]]
while (arr.some(Array.isArray)) {
  arr = [].concat(...arr);
}

console.log(arr)  // [1, 1, 2, 1, 2, 3]
複製程式碼

9. 手寫一個Promise

實現一個符合規範的Promise篇幅比較長,建議閱讀筆者上一篇文章:非同步程式設計二三事 | Promise/async/Generator實現原理解析 | 9k字


JS物件導向

在JS中一切皆物件,但JS並不是一種真正的物件導向(OOP)的語言,因為它缺少類(class)的概念。雖然ES6引入了classextends,使我們能夠輕易地實現類和繼承。但JS並不存在真實的類,JS的類是通過函式以及原型鏈機制模擬的,本小節的就來探究如何在ES5環境下利用函式和原型鏈實現JS物件導向的特性

在開始之前,我們先回顧一下原型鏈的知識,後續new繼承等實現都是基於原型鏈機制。很多介紹原型鏈的資料都能寫上洋洋灑灑幾千字,但我覺得讀者們不需要把原型鏈想太複雜,容易把自己繞進去,其實在我看來,原型鏈的核心只需要記住三點:

  1. 每個物件都有__proto__屬性,該屬性指向其原型物件,在呼叫例項的方法和屬性時,如果在例項物件上找不到,就會往原型物件上找
  2. 建構函式的prototype屬性也指向例項的原型物件
  3. 原型物件的constructor屬性指向建構函式

2萬字 | 前端基礎拾遺90問

1. 模擬實現new

首先我們要知道new做了什麼

  1. 建立一個新物件,並繼承其建構函式的prototype,這一步是為了繼承建構函式原型上的屬性和方法
  2. 執行建構函式,方法內的this被指定為該新例項,這一步是為了執行建構函式內的賦值操作
  3. 返回新例項(規範規定,如果構造方法返回了一個物件,那麼返回該物件,否則返回第一步建立的新物件)
// new是關鍵字,這裡我們用函式來模擬,new Foo(args) <=> myNew(Foo, args)
function myNew(foo, ...args) {
  // 建立新物件,並繼承構造方法的prototype屬性, 這一步是為了把obj掛原型鏈上, 相當於obj.__proto__ = Foo.prototype
  let obj = Object.create(foo.prototype)  
  
  // 執行構造方法, 併為其繫結新this, 這一步是為了讓構造方法能進行this.name = name之類的操作, args是構造方法的入參, 因為這裡用myNew模擬, 所以入參從myNew傳入
  let result = foo.apply(obj, args)

  // 如果構造方法已經return了一個物件, 那麼就返回該物件, 一般情況下,構造方法不會返回新例項,但使用者可以選擇返回新例項來覆蓋new建立的物件 否則返回myNew建立的新物件
  return typeof result === 'object' && result !== null ? result : obj
}

function Foo(name) {
  this.name = name
}
const newObj = myNew(Foo, 'zhangsan')
console.log(newObj)                 // Foo {name: "zhangsan"}
console.log(newObj instanceof Foo)  // true
複製程式碼

2. ES5如何實現繼承

說到繼承,最容易想到的是ES6的extends,當然如果只回答這個肯定不合格,我們要從函式和原型鏈的角度上實現繼承,下面我們一步步地、遞進地實現一個合格的繼承

一. 原型鏈繼承

原型鏈繼承的原理很簡單,直接讓子類的原型物件指向父類例項,當子類例項找不到對應的屬性和方法時,就會往它的原型物件,也就是父類例項上找,從而實現對父類的屬性和方法的繼承

// 父類
function Parent() {
    this.name = '寫程式碼像蔡徐抻'
}
// 父類的原型方法
Parent.prototype.getName = function() {
    return this.name
}
// 子類
function Child() {}

// 讓子類的原型物件指向父類例項, 這樣一來在Child例項中找不到的屬性和方法就會到原型物件(父類例項)上尋找
Child.prototype = new Parent()
Child.prototype.constructor = Child // 根據原型鏈的規則,順便繫結一下constructor, 這一步不影響繼承, 只是在用到constructor時會需要

// 然後Child例項就能訪問到父類及其原型上的name屬性和getName()方法
const child = new Child()
child.name          // '寫程式碼像蔡徐抻'
child.getName()     // '寫程式碼像蔡徐抻'
複製程式碼

原型繼承的缺點:

  1. 由於所有Child例項原型都指向同一個Parent例項, 因此對某個Child例項的父類引用型別變數修改會影響所有的Child例項
  2. 在建立子類例項時無法向父類構造傳參, 即沒有實現super()的功能
// 示例:
function Parent() {
    this.name = ['寫程式碼像蔡徐抻'] 
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {}

Child.prototype = new Parent()
Child.prototype.constructor = Child 

// 測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['foo'] (預期是['寫程式碼像蔡徐抻'], 對child1.name的修改引起了所有child例項的變化)
複製程式碼

二. 建構函式繼承

建構函式繼承,即在子類的建構函式中執行父類的建構函式,併為其繫結子類的this,讓父類的建構函式把成員屬性和方法都掛到子類的this上去,這樣既能避免例項之間共享一個原型例項,又能向父類構造方法傳參

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    Parent.call(this, 'zhangsan')   // 執行父類構造方法並繫結子類的this, 使得父類中的屬效能夠賦到子類的this上
}

//測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // 報錯,找不到getName(), 建構函式繼承的方式繼承不到父類原型上的屬性和方法
複製程式碼

建構函式繼承的缺點:

  1. 繼承不到父類原型上的屬性和方法

三. 組合式繼承

既然原型鏈繼承和建構函式繼承各有互補的優缺點, 那麼我們為什麼不組合起來使用呢, 所以就有了綜合二者的組合式繼承

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 建構函式繼承
    Parent.call(this, 'zhangsan') 
}
//原型鏈繼承
Child.prototype = new Parent()
Child.prototype.constructor = Child

//測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // ['zhangsan']
複製程式碼

組合式繼承的缺點:

  1. 每次建立子類例項都執行了兩次建構函式(Parent.call()new Parent()),雖然這並不影響對父類的繼承,但子類建立例項時,原型中會存在兩份相同的屬性和方法,這並不優雅

四. 寄生式組合繼承

為了解決建構函式被執行兩次的問題, 我們將指向父類例項改為指向父類原型, 減去一次建構函式的執行

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 建構函式繼承
    Parent.call(this, 'zhangsan') 
}
//原型鏈繼承
// Child.prototype = new Parent()
Child.prototype = Parent.prototype  //將`指向父類例項`改為`指向父類原型`
Child.prototype.constructor = Child

//測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // ['zhangsan']
複製程式碼

但這種方式存在一個問題,由於子類原型和父類原型指向同一個物件,我們對子類原型的操作會影響到父類原型,例如給Child.prototype增加一個getName()方法,那麼會導致Parent.prototype也增加或被覆蓋一個getName()方法,為了解決這個問題,我們給Parent.prototype做一個淺拷貝

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 建構函式繼承
    Parent.call(this, 'zhangsan') 
}
//原型鏈繼承
// Child.prototype = new Parent()
Child.prototype = Object.create(Parent.prototype)  //將`指向父類例項`改為`指向父類原型`
Child.prototype.constructor = Child

//測試
const child = new Child()
const parent = new Parent()
child.getName()                  // ['zhangsan']
parent.getName()                 // 報錯, 找不到getName()
複製程式碼

到這裡我們就完成了ES5環境下的繼承的實現,這種繼承方式稱為寄生組合式繼承,是目前最成熟的繼承方式,babel對ES6繼承的轉化也是使用了寄生組合式繼承

我們回顧一下實現過程:
一開始最容易想到的是原型鏈繼承,通過把子類例項的原型指向父類例項來繼承父類的屬性和方法,但原型鏈繼承的缺陷在於對子類例項繼承的引用型別的修改會影響到所有的例項物件以及無法向父類的構造方法傳參
因此我們引入了建構函式繼承, 通過在子類建構函式中呼叫父類建構函式並傳入子類this來獲取父類的屬性和方法,但建構函式繼承也存在缺陷,建構函式繼承不能繼承到父類原型鏈上的屬性和方法
所以我們綜合了兩種繼承的優點,提出了組合式繼承,但組合式繼承也引入了新的問題,它每次建立子類例項都執行了兩次父類構造方法,我們通過將子類原型指向父類例項改為子類原型指向父類原型的淺拷貝來解決這一問題,也就是最終實現 —— 寄生組合式繼承

2萬字 | 前端基礎拾遺90問

V8引擎機制

1. V8如何執行一段JS程式碼

2萬字 | 前端基礎拾遺90問

  1. 預解析:檢查語法錯誤但不生成AST
  2. 生成AST:經過詞法/語法分析,生成抽象語法樹
  3. 生成位元組碼:基線編譯器(Ignition)將AST轉換成位元組碼
  4. 生成機器碼:優化編譯器(Turbofan)將位元組碼轉換成優化過的機器碼,此外在逐行執行位元組碼的過程中,如果一段程式碼經常被執行,那麼V8會將這段程式碼直接轉換成機器碼儲存起來,下一次執行就不必經過位元組碼,優化了執行速度

上面幾點只是V8執行機制的極簡總結,建議閱讀參考資料:

1.V8 是怎麼跑起來的 —— V8 的 JavaScript 執行管道
2.JavaScript 引擎 V8 執行流程概述

2. 介紹一下引用計數和標記清除

  • 引用計數:給一個變數賦值引用型別,則該物件的引用次數+1,如果這個變數變成了其他值,那麼該物件的引用次數-1,垃圾回收器會回收引用次數為0的物件。但是當物件迴圈引用時,會導致引用次數永遠無法歸零,造成記憶體無法釋放。
  • 標記清除:垃圾收集器先給記憶體中所有物件加上標記,然後從根節點開始遍歷,去掉被引用的物件和執行環境中物件的標記,剩下的被標記的物件就是無法訪問的等待回收的物件。

3. V8如何進行垃圾回收

JS引擎中對變數的儲存主要有兩種位置,棧記憶體和堆記憶體,棧記憶體儲存基本型別資料以及引用型別資料的記憶體地址,堆記憶體儲存引用型別的資料

2萬字 | 前端基礎拾遺90問

棧記憶體的回收:

棧記憶體呼叫棧上下文切換後就被回收,比較簡單

堆記憶體的回收:

V8的堆記憶體分為新生代記憶體和老生代記憶體,新生代記憶體是臨時分配的記憶體,存在時間短,老生代記憶體存在時間長

2萬字 | 前端基礎拾遺90問

  • 新生代記憶體回收機制:
    • 新生代記憶體容量小,64位系統下僅有32M。新生代記憶體分為From、To兩部分,進行垃圾回收時,先掃描From,將非存活物件回收,將存活物件順序複製到To中,之後調換From/To,等待下一次回收
  • 老生代記憶體回收機制
    • 晉升:如果新生代的變數經過多次回收依然存在,那麼就會被放入老生代記憶體中
    • 標記清除:老生代記憶體會先遍歷所有物件並打上標記,然後對正在使用或被強引用的物件取消標記,回收被標記的物件
    • 整理記憶體碎片:把物件挪到記憶體的一端

參考資料:聊聊V8引擎的垃圾回收

4. JS相較於C++等語言為什麼慢,V8做了哪些優化

  1. JS的問題:
    • 動態型別:導致每次存取屬性/尋求方法時候,都需要先檢查型別;此外動態型別也很難在編譯階段進行優化
    • 屬性存取:C++/Java等語言中方法、屬性是儲存在陣列中的,僅需陣列位移就可以獲取,而JS儲存在物件中,每次獲取都要進行雜湊查詢
  2. V8的優化:
    • 優化JIT(即時編譯):相較於C++/Java這類編譯型語言,JS一邊解釋一邊執行,效率低。V8對這個過程進行了優化:如果一段程式碼被執行多次,那麼V8會把這段程式碼轉化為機器碼快取下來,下次執行時直接使用機器碼。
    • 隱藏類:對於C++這類語言來說,僅需幾個指令就能通過偏移量獲取變數資訊,而JS需要進行字串匹配,效率低,V8借用了類和偏移位置的思想,將物件劃分成不同的組,即隱藏類
    • 內嵌快取:即快取物件查詢的結果。常規查詢過程是:獲取隱藏類地址 -> 根據屬性名查詢偏移值 -> 計算該屬性地址,內嵌快取就是對這一過程結果的快取
    • 垃圾回收管理:上文已介紹

2萬字 | 前端基礎拾遺90問

參考資料:為什麼V8引擎這麼快?


瀏覽器渲染機制

1. 瀏覽器的渲染過程是怎樣的

2萬字 | 前端基礎拾遺90問
大體流程如下:

  1. HTML和CSS經過各自解析,生成DOM樹和CSSOM樹
  2. 合併成為渲染樹
  3. 根據渲染樹進行佈局
  4. 最後呼叫GPU進行繪製,顯示在螢幕上

2. 如何根據瀏覽器渲染機制加快首屏速度

  1. 優化檔案大小:HTML和CSS的載入和解析都會阻塞渲染樹的生成,從而影響首屏展示速度,因此我們可以通過優化檔案大小、減少CSS檔案層級的方法來加快首屏速度
  2. 避免資源下載阻塞文件解析:瀏覽器解析到<script>標籤時,會阻塞文件解析,直到指令碼執行完成,因此我們通常把<script>標籤放在底部,或者加上defer、async來進行非同步下載

3. 什麼是迴流(重排),什麼情況下會觸發迴流

  • 當元素的尺寸或者位置發生了變化,就需要重新計算渲染樹,這就是迴流
  • DOM元素的幾何屬性(width/height/padding/margin/border)發生變化時會觸發迴流
  • DOM元素移動或增加會觸發迴流
  • 讀寫offset/scroll/client等屬性時會觸發迴流
  • 呼叫window.getComputedStyle會觸發迴流

4. 什麼是重繪,什麼情況下會觸發重繪

  • DOM樣式發生了變化,但沒有影響DOM的幾何屬性時,會觸發重繪,而不會觸發迴流。重繪由於DOM位置資訊不需要更新,省去了佈局過程,因而效能上優於迴流

5. 什麼是GPU加速,如何使用GPU加速,GPU加速的缺點

  • 優點:使用transform、opacity、filters等屬性時,會直接在GPU中完成處理,這些屬性的變化不會引起迴流重繪
  • 缺點:GPU渲染字型會導致字型模糊,過多的GPU處理會導致記憶體問題

6. 如何減少迴流

  • 使用class替代style,減少style的使用
  • 使用resize、scroll時進行防抖和節流處理,這兩者會直接導致迴流
  • 使用visibility替換display: none,因為前者只會引起重繪,後者會引發迴流
  • 批量修改元素時,可以先讓元素脫離文件流,等修改完畢後,再放入文件流
  • 避免觸發同步佈局事件,我們在獲取offsetWidth這類屬性的值時,可以使用變數將查詢結果存起來,避免多次查詢,每次對offset/scroll/client等屬性進行查詢時都會觸發迴流
  • 對於複雜動畫效果,使用絕對定位讓其脫離文件流,複雜的動畫效果會頻繁地觸發迴流重繪,我們可以將動畫元素設定絕對定位從而脫離文件流避免反覆迴流重繪。

2萬字 | 前端基礎拾遺90問

參考資料:必須明白的瀏覽器渲染機制


瀏覽器快取策略

1. 介紹一下瀏覽器快取位置和優先順序

  1. Service Worker
  2. Memory Cache(記憶體快取)
  3. Disk Cache(硬碟快取)
  4. Push Cache(推送快取)
  5. 以上快取都沒命中就會進行網路請求

2. 說說不同快取間的差別

  1. Service Worker

和Web Worker類似,是獨立的執行緒,我們可以在這個執行緒中快取檔案,在主執行緒需要的時候讀取這裡的檔案,Service Worker使我們可以自由選擇快取哪些檔案以及檔案的匹配、讀取規則,並且快取是持續性的

  1. Memory Cache

即記憶體快取,記憶體快取不是持續性的,快取會隨著程式釋放而釋放

  1. Disk Cache

即硬碟快取,相較於記憶體快取,硬碟快取的持續性和容量更優,它會根據HTTP header的欄位判斷哪些資源需要快取

  1. Push Cache

即推送快取,是HTTP/2的內容,目前應用較少

3. 介紹一下瀏覽器快取策略

強快取(不要向伺服器詢問的快取)

設定Expires

  • 即過期時間,例如「Expires: Thu, 26 Dec 2019 10:30:42 GMT」表示快取會在這個時間後失效,這個過期日期是絕對日期,如果修改了本地日期,或者本地日期與伺服器日期不一致,那麼將導致快取過期時間錯誤。

設定Cache-Control

  • HTTP/1.1新增欄位,Cache-Control可以通過max-age欄位來設定過期時間,例如「Cache-Control:max-age=3600」除此之外Cache-Control還能設定private/no-cache等多種欄位

協商快取(需要向伺服器詢問快取是否已經過期)

Last-Modified

  • 即最後修改時間,瀏覽器第一次請求資源時,伺服器會在響應頭上加上Last-Modified ,當瀏覽器再次請求該資源時,瀏覽器會在請求頭中帶上If-Modified-Since 欄位,欄位的值就是之前伺服器返回的最後修改時間,伺服器對比這兩個時間,若相同則返回304,否則返回新資源,並更新Last-Modified

ETag

  • HTTP/1.1新增欄位,表示檔案唯一標識,只要檔案內容改動,ETag就會重新計算。快取流程和 Last-Modified 一樣:伺服器傳送 ETag 欄位 -> 瀏覽器再次請求時傳送 If-None-Match -> 如果ETag值不匹配,說明檔案已經改變,返回新資源並更新ETag,若匹配則返回304

兩者對比

  • ETag 比 Last-Modified 更準確:如果我們開啟檔案但並沒有修改,Last-Modified 也會改變,並且 Last-Modified 的單位時間為一秒,如果一秒內修改完了檔案,那麼還是會命中快取
  • 如果什麼快取策略都沒有設定,那麼瀏覽器會取響應頭中的 Date 減去 Last-Modified 值的 10% 作為快取時間

2萬字 | 前端基礎拾遺90問

參考資料:瀏覽器快取機制剖析


網路相關

1. 講講網路OSI七層模型,TCP/IP和HTTP分別位於哪一層

alt

模型 概述 單位
物理層 網路連線介質,如網線、光纜,資料在其中以位元為單位傳輸 bit
資料鏈路層 資料鏈路層將位元封裝成資料幀並傳遞
網路層 定義IP地址,定義路由功能,建立主機到主機的通訊 資料包
傳輸層 負責將資料進行可靠或者不可靠傳遞,建立埠到埠的通訊 資料段
會話層 控制應用程式之間會話能力,區分不同的程式
表示層 資料格式標識,基本壓縮加密功能
應用層 各種應用軟體

2. 常見HTTP狀態碼有哪些

2xx 開頭(請求成功)

200 OK:客戶端傳送給伺服器的請求被正常處理並返回


3xx 開頭(重定向)

301 Moved Permanently:永久重定向,請求的網頁已永久移動到新位置。 伺服器返回此響應時,會自動將請求者轉到新位置

302 Moved Permanently:臨時重定向,請求的網頁已臨時移動到新位置。伺服器目前從不同位置的網頁響應請求,但請求者應繼續使用原有位置來進行以後的請求

304 Not Modified:未修改,自從上次請求後,請求的網頁未修改過。伺服器返回此響應時,不會返回網頁內容


4xx 開頭(客戶端錯誤)

400 Bad Request:錯誤請求,伺服器不理解請求的語法,常見於客戶端傳參錯誤

401 Unauthorized:未授權,表示傳送的請求需要有通過 HTTP 認證的認證資訊,常見於客戶端未登入

403 Forbidden:禁止,伺服器拒絕請求,常見於客戶端許可權不足

404 Not Found:未找到,伺服器找不到對應資源


5xx 開頭(服務端錯誤)

500 Inter Server Error:伺服器內部錯誤,伺服器遇到錯誤,無法完成請求

501 Not Implemented:尚未實施,伺服器不具備完成請求的功能

502 Bad Gateway:作為閘道器或者代理工作的伺服器嘗試執行請求時,從上游伺服器接收到無效的響應。

503 service unavailable:服務不可用,伺服器目前無法使用(處於超載或停機維護狀態)。通常是暫時狀態。


3. GET請求和POST請求有何區別

標準答案:

  • GET請求引數放在URL上,POST請求引數放在請求體裡
  • GET請求引數長度有限制,POST請求引數長度可以非常大
  • POST請求相較於GET請求安全一點點,因為GET請求的引數在URL上,且有歷史記錄
  • GET請求能快取,POST不能

更進一步:

其實HTTP協議並沒有要求GET/POST請求引數必須放在URL上或請求體裡,也沒有規定GET請求的長度,目前對URL的長度限制,是各家瀏覽器設定的限制。GET和POST的根本區別在於:GET請求是冪等性的,而POST請求不是

冪等性,指的是對某一資源進行一次或多次請求都具有相同的副作用。例如搜尋就是一個冪等的操作,而刪除、新增則不是一個冪等操作。

由於GET請求是冪等的,在網路不好的環境中,GET請求可能會重複嘗試,造成重複運算元據的風險,因此,GET請求用於無副作用的操作(如搜尋),新增/刪除等操作適合用POST

參考資料:HTTP|GET 和 POST 區別?網上多數答案都是錯的


4. HTTP的請求報文由哪幾部分組成

一個HTTP請求報文由請求行(request line)、請求頭(header)、空行和請求資料4個部分組成

2萬字 | 前端基礎拾遺90問
響應報文和請求報文結構類似,不再贅述

5. HTTP常見請求/響應頭及其含義

通用頭(請求頭和響應頭都有的首部)

欄位 作用
Cache-Control 控制快取 public:表示響應可以被任何物件快取(包括客戶端/代理伺服器)
private(預設值):響應只能被單個客戶快取,不能被代理伺服器快取
no-cache:快取要經過伺服器驗證,在瀏覽器使用快取前,會對比ETag,若沒變則返回304,使用快取
no-store:禁止任何快取
Connection 是否需要持久連線(HTTP 1.1預設持久連線) keep-alive / close
Transfer-Encoding 報文主體的傳輸編碼格式 chunked(分塊) / identity(未壓縮和修改) / gzip(LZ77壓縮) / compress(LZW壓縮,棄用) / deflate(zlib結構壓縮)

請求頭

欄位 作用 語法
Accept 告知(伺服器)客戶端可以處理的內容型別 text/html、image/*、*/*
If-Modified-Since Last-Modified的值傳送給伺服器,詢問資源是否已經過期(被修改),過期則返回新資源,否則返回304 示例:If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT
If-Unmodified-Since Last-Modified的值傳送給伺服器,詢問檔案是否被修改,若沒有則返回200,否則返回412預處理錯誤,可用於斷點續傳。通俗點說If-Unmodified-Since是檔案沒有修改時下載,If-Modified-Since是檔案修改時下載 示例:If-Unmodified-Since: Wed, 21 Oct 2015 07:28:00 GMT
If-None-Match ETag的值傳送給伺服器,詢問資源是否已經過期(被修改),過期則返回新資源,否則返回304 示例:If-None-Match: "bfc13a6472992d82d"
If-Match ETag的值傳送給伺服器,詢問檔案是否被修改,若沒有則返回200,否則返回412預處理錯誤,可用於斷點續傳 示例:If-Match: "bfc129c88ca92d82d"
Range 告知伺服器返回檔案的哪一部分, 用於斷點續傳 示例:Range: bytes=200-1000, 2000-6576, 19000-
Host 指明瞭伺服器的域名(對於虛擬主機來說),以及(可選的)伺服器監聽的TCP埠號 示例:Host:www.baidu.com
User-Agent 告訴HTTP伺服器, 客戶端使用的作業系統和瀏覽器的名稱和版本 User-Agent: Mozilla/<version> (<system-information>) <platform> (<platform-details>) <extensions>

響應頭

欄位 作用 語法
Location 需要將頁面重新定向至的地址。一般在響應碼為3xx的響應中才會有意義 Location: <url>
ETag 資源的特定版本的識別符號,如果內容沒有改變,Web伺服器不需要傳送完整的響應 ETag: "<etag_value>"
Server 處理請求的源頭伺服器所用到的軟體相關資訊 Server: <product>

實體頭(針對請求報文和響應報文的實體部分使用首部)

欄位 作用 語法
Allow 資源可支援http請求的方法 Allow: <http-methods>,示例:Allow: GET, POST, HEAD
Last-Modified 資源最後的修改時間,用作一個驗證器來判斷接收到的或者儲存的資源是否彼此一致,精度不如ETag 示例:Last-Modified: Wed, 21 Oct 2020 07:28:00 GMT
Expires 響應過期時間 Expires: <http-date>,示例:Expires: Wed, 21 Oct 2020 07:28:00 GMT

HTTP首部當然不止這麼幾個,但為了避免寫太多大家記不住(主要是別的我也沒去看),這裡只介紹了一些常用的,詳細的可以看MDN的文件


6. HTTP/1.0和HTTP/1.1有什麼區別

  • 長連線: HTTP/1.1支援長連線和請求的流水線,在一個TCP連線上可以傳送多個HTTP請求,避免了因為多次建立TCP連線的時間消耗和延時
  • 快取處理: HTTP/1.1引入Entity tag,If-Unmodified-Since, If-Match, If-None-Match等新的請求頭來控制快取,詳見瀏覽器快取小節
  • 頻寬優化及網路連線的使用: HTTP1.1則在請求頭引入了range頭域,支援斷點續傳功能
  • Host頭處理: 在HTTP/1.0中認為每臺伺服器都有唯一的IP地址,但隨著虛擬主機技術的發展,多個主機共享一個IP地址愈發普遍,HTTP1.1的請求訊息和響應訊息都應支援Host頭域,且請求訊息中如果沒有Host頭域會400錯誤

7. 介紹一下HTTP/2.0新特性

  • 多路複用: 即多個請求都通過一個TCP連線併發地完成
  • 服務端推送: 服務端能夠主動把資源推送給客戶端
  • 新的二進位制格式: HTTP/2採用二進位制格式傳輸資料,相比於HTTP/1.1的文字格式,二進位制格式具有更好的解析性和擴充性
  • header壓縮: HTTP/2壓縮訊息頭,減少了傳輸資料的大小

8. 說說HTTP/2.0多路複用基本原理以及解決的問題

HTTP/2解決的問題,就是HTTP/1.1存在的問題:

  • TCP慢啟動: TCP連線建立後,會經歷一個先慢後快的傳送過程,就像汽車啟動一般,如果我們的網頁檔案(HTML/JS/CSS/icon)都經過一次慢啟動,對效能是不小的損耗。另外慢啟動是TCP為了減少網路擁塞的一種策略,我們是沒有辦法改變的。
  • 多條TCP連線競爭頻寬: 如果同時建立多條TCP連線,當頻寬不足時就會競爭頻寬,影響關鍵資源的下載。
  • HTTP/1.1隊頭阻塞: 儘管HTTP/1.1長連結可以通過一個TCP連線傳輸多個請求,但同一時刻只能處理一個請求,當前請求未結束前,其他請求只能處於阻塞狀態。

為了解決以上幾個問題,HTTP/2一個域名只使用一個TCP⻓連線來傳輸資料,而且請求直接是並行的、非阻塞的,這就是多路複用

實現原理: HTTP/2引入了一個二進位制分幀層,客戶端和服務端進行傳輸時,資料會先經過二進位制分幀層處理,轉化為一個個帶有請求ID的幀,這些幀在傳輸完成後根據ID組合成對應的資料。


9. 說說HTTP/3.0

儘管HTTP/2解決了很多1.1的問題,但HTTP/2仍然存在一些缺陷,這些缺陷並不是來自於HTTP/2協議本身,而是來源於底層的TCP協議,我們知道TCP連結是可靠的連線,如果出現了丟包,那麼整個連線都要等待重傳,HTTP/1.1可以同時使用6個TCP連線,一個阻塞另外五個還能工作,但HTTP/2只有一個TCP連線,阻塞的問題便被放大了。

由於TCP協議已經被廣泛使用,我們很難直接修改TCP協議,基於此,HTTP/3選擇了一個折衷的方法——UDP協議,HTTP/2在UDP的基礎上實現多路複用、0-RTT、TLS加密、流量控制、丟包重傳等功能。


參考資料:http發展史(http0.9、http1.0、http1.1、http2、http3)梳理筆記 (推薦閱讀)


10. HTTP和HTTPS有何區別

  • HTTPS使用443埠,而HTTP使用80
  • HTTPS需要申請證照
  • HTTP是超文字傳輸協議,是明文傳輸;HTTPS是經過SSL加密的協議,傳輸更安全
  • HTTPS比HTTP慢,因為HTTPS除了TCP握手的三個包,還要加上SSL握手的九個包

11. HTTPS是如何進行加密的

我們通過分析幾種加密方式,層層遞進,理解HTTPS的加密方式以及為什麼使用這種加密方式:

對稱加密

客戶端和伺服器公用一個密匙用來對訊息加解密,這種方式稱為對稱加密。客戶端和伺服器約定好一個加密的密匙。客戶端在發訊息前用該密匙對訊息加密,傳送給伺服器後,伺服器再用該密匙進行解密拿到訊息。

2萬字 | 前端基礎拾遺90問
這種方式一定程度上保證了資料的安全性,但金鑰一旦洩露(金鑰在傳輸過程中被截獲),傳輸內容就會暴露,因此我們要尋找一種安全傳遞金鑰的方法。

非對稱加密

採用非對稱加密時,客戶端和服務端均擁有一個公鑰和私鑰,公鑰加密的內容只有對應的私鑰能解密。私鑰自己留著,公鑰發給對方。這樣在傳送訊息前,先用對方的公鑰對訊息進行加密,收到後再用自己的私鑰進行解密。這樣攻擊者只拿到傳輸過程中的公鑰也無法破解傳輸的內容

2萬字 | 前端基礎拾遺90問
儘管非對稱加密解決了由於金鑰被獲取而導致傳輸內容洩露的問題,但中間人仍然可以用篡改公鑰的方式來獲取或篡改傳輸內容,而且非對稱加密的效能比對稱加密的效能差了不少
2萬字 | 前端基礎拾遺90問

第三方認證

上面這種方法的弱點在於,客戶端不知道公鑰是由服務端返回,還是中間人返回的,因此我們再引入一個第三方認證的環節:即第三方使用私鑰加密我們自己的公鑰,瀏覽器已經內建一些權威第三方認證機構的公鑰,瀏覽器會使用第三方的公鑰來解開第三方私鑰加密過的我們自己的公鑰,從而獲取公鑰,如果能成功解密,就說明獲取到的自己的公鑰是正確的

但第三方認證也未能完全解決問題,第三方認證是面向所有人的,中間人也能申請證照,如果中間人使用自己的證照掉包原證照,客戶端還是無法確認公鑰的真偽

2萬字 | 前端基礎拾遺90問

數字簽名

為了讓客戶端能夠驗證公鑰的來源,我們給公鑰加上一個數字簽名,這個數字簽名是由企業、網站等各種資訊和公鑰經過單向hash而來,一旦構成數字簽名的資訊發生變化,hash值就會改變,這就構成了公鑰來源的唯一標識。

具體來說,服務端本地生成一對金鑰,然後拿著公鑰以及企業、網站等各種資訊到CA(第三方認證中心)去申請數字證照,CA會通過一種單向hash演算法(比如MD5),生成一串摘要,這串摘要就是這堆資訊的唯一標識,然後CA還會使用自己的私鑰對摘要進行加密,連同我們自己伺服器的公鑰一同傳送給我我們。

瀏覽器拿到數字簽名後,會使用瀏覽器本地內建的CA公鑰解開數字證照並驗證,從而拿到正確的公鑰。由於非對稱加密效能低下,拿到公鑰以後,客戶端會隨機生成一個對稱金鑰,使用這個公鑰加密併傳送給服務端,服務端用自己的私鑰解開對稱金鑰,此後的加密連線就通過這個對稱金鑰進行對稱加密。

綜上所述,HTTPS在驗證階段使用非對稱加密+第三方認證+數字簽名獲取正確的公鑰,獲取到正確的公鑰後以對稱加密的方式通訊

2萬字 | 前端基礎拾遺90問

參考資料:看圖學HTTPS


前端安全

什麼是CSRF攻擊

CSRF即Cross-site request forgery(跨站請求偽造),是一種挾制使用者在當前已登入的Web應用程式上執行非本意的操作的攻擊方法。

假如黑客在自己的站點上放置了其他網站的外鏈,例如"www.weibo.com/api,預設情況下,瀏覽器會帶著weibo.com的cookie訪問這個網址,如果使用者已登入過該網站且網站沒有對CSRF攻擊進行防禦,那麼伺服器就會認為是使用者本人在呼叫此介面並執行相關操作,致使賬號被劫持。

如何防禦CSRF攻擊

  • 驗證Token:瀏覽器請求伺服器時,伺服器返回一個token,每個請求都需要同時帶上token和cookie才會被認為是合法請求
  • 驗證Referer:通過驗證請求頭的Referer來驗證來源站點,但請求頭很容易偽造
  • 設定SameSite:設定cookie的SameSite,可以讓cookie不隨跨域請求發出,但瀏覽器相容不一

什麼是XSS攻擊

XSS即Cross Site Scripting(跨站指令碼),指的是通過利用網頁開發時留下的漏洞,注入惡意指令程式碼到網頁,使使用者載入並執行攻擊者惡意製造的網頁程式。常見的例如在評論區植入JS程式碼,使用者進入評論頁時程式碼被執行,造成頁面被植入廣告、賬號資訊被竊取

XSS攻擊有哪些型別

  • 儲存型:即攻擊被儲存在服務端,常見的是在評論區插入攻擊指令碼,如果指令碼被儲存到服務端,那麼所有看見對應評論的使用者都會受到攻擊。
  • 反射型:攻擊者將指令碼混在URL裡,服務端接收到URL將惡意程式碼當做引數取出並拼接在HTML裡返回,瀏覽器解析此HTML後即執行惡意程式碼
  • DOM型:將攻擊指令碼寫在URL中,誘導使用者點選該URL,如果URL被解析,那麼攻擊指令碼就會被執行。和前兩者的差別主要在於DOM型攻擊不經過服務端

如何防禦XSS攻擊

  • 輸入檢查:對輸入內容中的<script><iframe>等標籤進行轉義或者過濾
  • 設定httpOnly:很多XSS攻擊目標都是竊取使用者cookie偽造身份認證,設定此屬性可防止JS獲取cookie
  • 開啟CSP,即開啟白名單,可阻止白名單以外的資源載入和執行

2萬字 | 前端基礎拾遺90問


排序演算法

1. 手寫氣泡排序

氣泡排序應該是很多人第一個接觸的排序,比較簡單,不展開講解了

function bubbleSort(arr){
  for(let i = 0; i < arr.length; i++) {
    for(let j = 0; j < arr.length - i - 1; j++) {
      if(arr[j] > arr[j+1]) {
        let temp = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = temp
      }
    }
  }
  return arr
}
複製程式碼

2. 如何優化一個氣泡排序

氣泡排序總會執行(N-1)+(N-2)+(N-3)+..+2+1趟,但如果執行到當中某一趟時排序已經完成,或者輸入的是一個有序陣列,那麼後邊的比較就都是多餘的,為了避免這種情況,我們增加一個flag,判斷排序是否在中途就已經完成(也就是判斷有無發生元素交換)

function bubbleSort(arr){
  let flag = true
  for(let i = 0; i < arr.length; i++) {
    for(let j = 0; j < arr.length - i - 1; j++) {
      if(arr[j] > arr[j+1]) {
        flag = false
        let temp = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = temp
      }
    }
    if(flag)break;
  }
  return arr
}
複製程式碼

3. 手寫快速排序

快排基本步驟:

  1. 選取基準元素
  2. 比基準元素小的元素放到左邊,大的放右邊
  3. 在左右子陣列中重複步驟一二,直到陣列只剩下一個元素
  4. 向上逐級合併陣列
function quickSort(arr) {
    if(arr.length <= 1) return arr          //遞迴終止條件
    const pivot = arr.length / 2 | 0        //基準點
    const pivotValue = arr.splice(pivot, 1)
    const leftArr = []
    const rightArr = []
    arr.forEach(val => {
        val > pivotValue ? rightArr.push(val) : leftArr.push(val)
    })
    return [ ...quickSort(leftArr), pivotValue, ...quickSort(rightArr)]
}
複製程式碼

4. 如何優化一個快速排序

原地排序

上邊這個快排只是讓讀者找找感覺,我們不能這樣寫快排,如果每次都開兩個陣列,會消耗很多記憶體空間,資料量大時可能造成記憶體溢位,我們要避免開新的記憶體空間,即原地完成排序

我們可以用元素交換來取代開新陣列,在每一次分割槽的時候直接在原陣列上交換元素,將小於基準數的元素挪到陣列開頭,以[5,1,4,2,3]為例:

2萬字 | 前端基礎拾遺90問
我們定義一個pos指標, 標識等待置換的元素的位置, 然後逐一遍歷陣列元素, 遇到比基準數小的就和arr[pos]交換位置, 然後pos++

程式碼實現:

function quickSort(arr, left, right) {          //這個left和right代表分割槽後“新陣列”的區間下標,因為這裡沒有新開陣列,所以需要left/right來確認新陣列的位置
    if (left < right) {
        let pos = left - 1                      //pos即“被置換的位置”,第一趟為-1
        for(let i = left; i <= right; i++) {    //迴圈遍歷陣列,置換元素
            let pivot = arr[right]              //選取陣列最後一位作為基準數,
            if(arr[i] <= pivot) {               //若小於等於基準數,pos++,並置換元素, 這裡使用小於等於而不是小於, 其實是為了避免因為重複資料而進入死迴圈
                pos++
                let temp = arr[pos]
                arr[pos] = arr[i]
                arr[i] = temp
            }
        }
        //一趟排序完成後,pos位置即基準數的位置,以pos的位置分割陣列
        quickSort(arr, left, pos - 1)        
        quickSort(arr, pos + 1, right)
    }
    return arr      //陣列只包含1或0個元素時(即left>=right),遞迴終止
}

//使用
var arr = [5,1,4,2,3]
var start = 0;
var end = arr.length - 1;
quickSort(arr, start, end)
複製程式碼

這個交換的過程還是需要一些時間理解消化的,詳細分析可以看這篇:js演算法-快速排序(Quicksort)

三路快排

上邊這個快排還談不上優化,應當說是快排的糾正寫法,其實有兩個問題我們還能優化一下:

  1. 有序陣列的情況:如果輸入的陣列是有序的,而取基準點時也順序取,就可能導致基準點一側的子陣列一直為空, 使時間複雜度退化到O(n2)
  2. 大量重複資料的情況:例如輸入的資料是[1,2,2,2,2,3], 無論基準點取1、2還是3, 都會導致基準點兩側陣列大小不平衡, 影響快排效率

對於第一個問題, 我們可以通過在取基準點的時候隨機化來解決,對於第二個問題,我們可以使用三路快排的方式來優化,比方說對於上面的[1,2,2,2,2,3],我們基準點取2,在分割槽的時候,將陣列元素分為小於2|等於2|大於2三個區域,其中等於基準點的部分不再進入下一次排序, 這樣就大大提高了快排效率

2萬字 | 前端基礎拾遺90問

5. 手寫歸併排序

歸併排序和快排的思路類似,都是遞迴分治,區別在於快排邊分割槽邊排序,而歸併在分割槽完成後才會排序

2萬字 | 前端基礎拾遺90問

function mergeSort(arr) {
    if(arr.length <= 1) return arr		//陣列元素被劃分到剩1個時,遞迴終止
    const midIndex = arr.length/2 | 0
    const leftArr = arr.slice(0, midIndex)
    const rightArr = arr.slice(midIndex, arr.length)
    return merge(mergeSort(leftArr), mergeSort(rightArr))	//先劃分,後合併
}

//合併
function merge(leftArr, rightArr) {
    const result = []
    while(leftArr.length && rightArr.length) {
    	leftArr[0] <= rightArr[0] ? result.push(leftArr.shift()) : result.push(rightArr.shift())
    }
    while(leftArr.length) result.push(leftArr.shift())
    while(rightArr.length) result.push(rightArr.shift())
    return result
}
複製程式碼

6. 手寫堆排序

堆是一棵特殊的樹, 只要滿足這棵樹是完全二叉樹堆中每一個節點的值都大於或小於其左右孩子節點這兩個條件, 那麼就是一個堆, 根據堆中每一個節點的值都大於或小於其左右孩子節點, 又分為大根堆和小根堆

堆排序的流程:

  1. 初始化大(小)根堆,此時根節點為最大(小)值,將根節點與最後一個節點(陣列最後一個元素)交換
  2. 除開最後一個節點,重新調整大(小)根堆,使根節點為最大(小)值
  3. 重複步驟二,直到堆中元素剩一個,排序完成

[1,5,4,2,3]為例構築大根堆:

2萬字 | 前端基礎拾遺90問
程式碼實現:

// 堆排序
const heapSort = array => {
        // 我們用陣列來儲存這個大根堆,陣列就是堆本身
	// 初始化大頂堆,從第一個非葉子結點開始
	for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
		heapify(array, i, array.length);
	}
	// 排序,每一次 for 迴圈找出一個當前最大值,陣列長度減一
	for (let i = Math.floor(array.length - 1); i > 0; i--) {
		// 根節點與最後一個節點交換
		swap(array, 0, i);
		// 從根節點開始調整,並且最後一個結點已經為當前最大值,不需要再參與比較,所以第三個引數為 i,即比較到最後一個結點前一個即可
		heapify(array, 0, i);
	}
	return array;
};

// 交換兩個節點
const swap = (array, i, j) => {
	let temp = array[i];
	array[i] = array[j];
	array[j] = temp;
};

// 將 i 結點以下的堆整理為大頂堆,注意這一步實現的基礎實際上是:
// 假設結點 i 以下的子堆已經是一個大頂堆,heapify 函式實現的
// 功能是實際上是:找到 結點 i 在包括結點 i 的堆中的正確位置。
// 後面將寫一個 for 迴圈,從第一個非葉子結點開始,對每一個非葉子結點
// 都執行 heapify 操作,所以就滿足了結點 i 以下的子堆已經是一大頂堆
const heapify = (array, i, length) => {
	let temp = array[i]; // 當前父節點
	// j < length 的目的是對結點 i 以下的結點全部做順序調整
	for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
		temp = array[i]; // 將 array[i] 取出,整個過程相當於找到 array[i] 應處於的位置
		if (j + 1 < length && array[j] < array[j + 1]) {
			j++; // 找到兩個孩子中較大的一個,再與父節點比較
		}
		if (temp < array[j]) {
			swap(array, i, j); // 如果父節點小於子節點:交換;否則跳出
			i = j; // 交換後,temp 的下標變為 j
		} else {
			break;
		}
	}
}
複製程式碼

參考資料: JS實現堆排序

7. 歸併、快排、堆排有何區別

排序 時間複雜度(最好情況) 時間複雜度(最壞情況) 空間複雜度 穩定性
快速排序 O(nlogn) O(n^2) O(logn)~O(n) 不穩定
歸併排序 O(nlogn) O(nlogn) O(n) 穩定
堆排序 O(nlogn) O(nlogn) O(1) 不穩定

其實從表格中我們可以看到,就時間複雜度而言,快排並沒有很大優勢,然而為什麼快排會成為最常用的排序手段,這是因為時間複雜度只能說明隨著資料量的增加,演算法時間代價增長的趨勢,並不直接代表實際執行時間,實際執行時間還包括了很多常數引數的差別,此外在面對不同型別資料(比如有序資料、大量重複資料)時,表現也不同,綜合來說,快排的時間效率是最高的

在實際運用中, 並不只使用一種排序手段, 例如V8的Array.sort()就採取了當 n<=10 時, 採用插入排序, 當 n>10 時,採用三路快排的排序策略

2萬字 | 前端基礎拾遺90問


設計模式

設計模式有許多種,這裡挑出幾個常用的:

設計模式 描述 例子
單例模式 一個類只能構造出唯一例項 Redux/Vuex的store
工廠模式 對建立物件邏輯的封裝 jQuery的$(selector)
觀察者模式 當一個物件被修改時,會自動通知它的依賴物件 Redux的subscribe、Vue的雙向繫結
裝飾器模式 對類的包裝,動態地擴充類的功能 React高階元件、ES7 裝飾器
介面卡模式 相容新舊介面,對類的包裝 封裝舊API
代理模式 控制物件的訪問 事件代理、ES6的Proxy

1. 介紹一下單一職責原則和開放封閉原則

  • 單一職責原則:一個類只負責一個功能領域中的相應職責,或者可以定義為:就一個類而言,應該只有一個引起它變化的原因。

  • 開放封閉原則:核心的思想是軟體實體(類、模組、函式等)是可擴充套件的、但不可修改的。也就是說,對擴充套件是開放的,而對修改是封閉的。


2. 單例模式

單例模式即一個類只能構造出唯一例項,單例模式的意義在於共享、唯一Redux/Vuex中的store、JQ的$或者業務場景中的購物車、登入框都是單例模式的應用

class SingletonLogin {
  constructor(name,password){
    this.name = name
    this.password = password
  }
  static getInstance(name,password){
    //判斷物件是否已經被建立,若建立則返回舊物件
    if(!this.instance)this.instance = new SingletonLogin(name,password)
    return this.instance
  }
}
 
let obj1 = SingletonLogin.getInstance('CXK','123')
let obj2 = SingletonLogin.getInstance('CXK','321')
 
console.log(obj1===obj2)    // true
console.log(obj1)           // {name:CXK,password:123}
console.log(obj2)           // 輸出的依然是{name:CXK,password:123}
複製程式碼

3. 工廠模式

工廠模式即對建立物件邏輯的封裝,或者可以簡單理解為對new的封裝,這種封裝就像建立物件的工廠,故名工廠模式。工廠模式常見於大型專案,比如JQ的$物件,我們建立選擇器物件時之所以沒有new selector就是因為$()已經是一個工廠方法,其他例子例如React.createElement()Vue.component()都是工廠模式的實現。工廠模式有多種:簡單工廠模式工廠方法模式抽象工廠模式,這裡只以簡單工廠模式為例:

class User {
  constructor(name, auth) {
    this.name = name
    this.auth = auth
  }
}

class UserFactory {
  static createUser(name, auth) {
    //工廠內部封裝了建立物件的邏輯:許可權為admin時,auth傳1,而使用者在外部建立物件時,不需要知道admin對應哪個欄位
    if(auth === 'admin')  new User(name, 1)
    if(auth === user)  new User(name, 2)
  }
}

const admin = UserFactory.createUser('admin');
const user = UserFactory.createUser('user');
複製程式碼

4. 觀察者模式

觀察者模式算是前端最常用的設計模式了,觀察者模式概念很簡單:觀察者監聽被觀察者的變化,被觀察者發生改變時,通知所有的觀察者。觀察者模式被廣泛用於監聽事件的實現,有關觀察者模式的詳細應用,可以看我另一篇講解Redux實現的文章

//觀察者
class Observer {    
  constructor (fn) {      
    this.update = fn    
  }
}
//被觀察者
class Subject {    
    constructor() {        
        this.observers = []          //觀察者佇列    
    }    
    addObserver(observer) {          
        this.observers.push(observer)//往觀察者佇列新增觀察者    
    }    
    notify() {                       //通知所有觀察者,實際上是把觀察者的update()都執行了一遍       
        this.observers.forEach(observer => {        
            observer.update()            //依次取出觀察者,並執行觀察者的update方法        
        })    
    }
}

var subject = new Subject()       //被觀察者
const update = () => {console.log('被觀察者發出通知')}  //收到廣播時要執行的方法
var ob1 = new Observer(update)    //觀察者1
var ob2 = new Observer(update)    //觀察者2
subject.addObserver(ob1)          //觀察者1訂閱subject的通知
subject.addObserver(ob2)          //觀察者2訂閱subject的通知
subject.notify()                  //發出廣播,執行所有觀察者的update方法
複製程式碼

有些文章也把觀察者模式稱為釋出訂閱模式,其實二者是有所區別的,釋出訂閱相較於觀察者模式多一個排程中心。


5. 裝飾器模式

裝飾器模式,可以理解為對類的一個包裝,動態地擴充類的功能,ES7的裝飾器語法以及React中的高階元件(HoC)都是這一模式的實現。react-redux的connect()也運用了裝飾器模式,這裡以ES7的裝飾器為例:

function info(target) {
  target.prototype.name = '張三'
  target.prototype.age = 10
}

@info
class Man {}

let man = new Man()
man.name // 張三
複製程式碼

6. 介面卡模式

介面卡模式,將一個介面轉換成客戶希望的另一個介面,使介面不相容的那些類可以一起工作。我們在生活中就常常有使用介面卡的場景,例如出境旅遊插頭插座不匹配,這時我們就需要使用轉換插頭,也就是介面卡來幫我們解決問題。

class Adaptee {
  test() {
      return '舊介面'
  }
}
 
class Target {
  constructor() {
      this.adaptee = new Adaptee()
  }
  test() {
      let info = this.adaptee.test()
      return `適配${info}`
  }
}
 
let target = new Target()
console.log(target.test())
複製程式碼

7. 代理模式

代理模式,為一個物件找一個替代物件,以便對原物件進行訪問。即在訪問者與目標物件之間加一層代理,通過代理做授權和控制。最常見的例子是經紀人代理明星業務,假設你作為一個投資者,想聯絡明星打廣告,那麼你就需要先經過代理經紀人,經紀人對你的資質進行考察,並通知你明星排期,替明星本人過濾不必要的資訊。事件代理、JQuery的$.proxy、ES6的proxy都是這一模式的實現,下面以ES6的proxy為例:

const idol = {
  name: '蔡x抻',
  phone: 10086,
  price: 1000000  //報價
}

const agent = new Proxy(idol, {
  get: function(target) {
    //攔截明星電話的請求,只提供經紀人電話
    return '經紀人電話:10010'
  },
  set: function(target, key, value) {
    if(key === 'price' ) {
      //經紀人過濾資質
      if(value < target.price) throw new Error('報價過低')
      target.price = value
    }
  }
})


agent.phone        //經紀人電話:10010
agent.price = 100  //Uncaught Error: 報價過低
複製程式碼

2萬字 | 前端基礎拾遺90問


HTML相關

1. 說說HTML5在標籤、屬性、儲存、API上的新特性

  • 標籤:新增語義化標籤(aside / figure / section / header / footer / nav等),增加多媒體標籤videoaudio,使得樣式和結構更加分離
  • 屬性:增強表單,主要是增強了input的type屬性;meta增加charset以設定字符集;script增加async以非同步載入指令碼
  • 儲存:增加localStoragesessionStorageindexedDB,引入了application cache對web和應用進行快取
  • API:增加拖放API地理定位SVG繪圖canvas繪圖Web WorkerWebSocket

2. doctype的作用是什麼?

宣告文件型別,告知瀏覽器用什麼文件標準解析這個文件:

  • 怪異模式:瀏覽器使用自己的模式解析文件,不加doctype時預設為怪異模式
  • 標準模式:瀏覽器以W3C的標準解析文件

3. 幾種前端儲存以及它們之間的區別

  • cookies: HTML5之前本地儲存的主要方式,大小隻有4k,HTTP請求頭會自動帶上cookie,相容性好
  • localStorage:HTML5新特性,永續性儲存,即使頁面關閉也不會被清除,以鍵值對的方式儲存,大小為5M
  • sessionStorage:HTML5新特性,操作及大小同localStorage,和localStorage的區別在於sessionStorage在選項卡(頁面)被關閉時即清除,且不同選項卡之間的sessionStorage不互通
  • IndexedDB: NoSQL型資料庫,類比MongoDB,使用鍵值對進行儲存,非同步運算元據庫,支援事務,儲存空間可以在250MB以上,但是IndexedDB受同源策略限制
  • Web SQL:是在瀏覽器上模擬的關係型資料庫,開發者可以通過SQL語句來操作Web SQL,是HTML5以外一套獨立的規範,相容性差

4. href和src有什麼區別

href(hyperReference)即超文字引用:當瀏覽器遇到href時,會並行的地下載資源,不會阻塞頁面解析,例如我們使用<link>引入CSS,瀏覽器會並行地下載CSS而不阻塞頁面解析. 因此我們在引入CSS時建議使用<link>而不是@import

<link href="style.css" rel="stylesheet" />
複製程式碼

src(resource)即資源,當瀏覽器遇到src時,會暫停頁面解析,直到該資源下載或執行完畢,這也是script標籤之所以放底部的原因

<script src="script.js"></script>
複製程式碼

5. meta有哪些屬性,作用是什麼

meta標籤用於描述網頁的元資訊,如網站作者、描述、關鍵詞,meta通過name=xxxcontent=xxx的形式來定義資訊,常用設定如下:

  • charset:定義HTML文件的字符集
 <meta charset="UTF-8" >
複製程式碼
  • http-equiv:可用於模擬http請求頭,可設定過期時間、快取、重新整理
<meta http-equiv="expires" content="Wed, 20 Jun 2019 22:33:00 GMT"複製程式碼
  • viewport:視口,用於控制頁面寬高及縮放比例
<meta 
    name="viewport" 
    content="width=device-width, initial-scale=1, maximum-scale=1"
>
複製程式碼

6. viewport有哪些引數,作用是什麼

  • width/height,寬高,預設寬度980px
  • initial-scale,初始縮放比例,1~10
  • maximum-scale/minimum-scale,允許使用者縮放的最大/小比例
  • user-scalable,使用者是否可以縮放 (yes/no)

7. http-equive屬性的作用和引數

  • expires,指定過期時間
  • progma,設定no-cache可以禁止快取
  • refresh,定時重新整理
  • set-cookie,可以設定cookie
  • X-UA-Compatible,使用瀏覽器版本
  • apple-mobile-web-app-status-bar-style,針對WebApp全屏模式,隱藏狀態列/設定狀態列顏色

2萬字 | 前端基礎拾遺90問


CSS相關

清除浮動的方法

為什麼要清除浮動:清除浮動是為了解決子元素浮動而導致父元素高度塌陷的問題

2萬字 | 前端基礎拾遺90問
1.新增新元素

<div class="parent">
  <div class="child"></div>
  <!-- 新增一個空元素,利用css提供的clear:both清除浮動 -->
  <div style="clear: both"></div>
</div>  
複製程式碼

2.使用偽元素

/* 對父元素新增偽元素 */
.parent::after{
  content: "";
  display: block;
  height: 0;
  clear:both;
}
複製程式碼

3.觸發父元素BFC

/* 觸發父元素BFC */
.parent {
  overflow: hidden;
  /* float: left; */
  /* position: absolute; */
  /* display: inline-block */
  /* 以上屬性均可觸發BFC */
}
複製程式碼

常見佈局

編輯中,請稍等-_-||


什麼是BFC

BFC全稱 Block Formatting Context 即塊級格式上下文,簡單的說,BFC是頁面上的一個隔離的獨立容器,不受外界干擾或干擾外界

如何觸發BFC

  • float不為 none
  • overflow的值不為 visible
  • position 為 absolute 或 fixed
  • display的值為 inline-block 或 table-cell 或 table-caption 或 grid

BFC的渲染規則是什麼

  • BFC是頁面上的一個隔離的獨立容器,不受外界干擾或干擾外界
  • 計算BFC的高度時,浮動子元素也參與計算(即內部有浮動元素時也不會發生高度塌陷)
  • BFC的區域不會與float的元素區域重疊
  • BFC內部的元素會在垂直方向上放置
  • BFC內部兩個相鄰元素的margin會發生重疊

BFC的應用場景

  • 清除浮動:BFC內部的浮動元素會參與高度計算,因此可用於清除浮動,防止高度塌陷
  • 避免某元素被浮動元素覆蓋:BFC的區域不會與浮動元素的區域重疊
  • 阻止外邊距重疊:屬於同一個BFC的兩個相鄰Box的margin會發生摺疊,不同BFC不會發生摺疊

2萬字 | 前端基礎拾遺90問


總結

對於前端基礎知識的講解,到這裡就告一小段落。前端的世界紛繁複雜,遠非筆者寥寥幾筆所能勾畫,筆者就像在沙灘上拾取貝殼的孩童,有時僥倖拾取收集小筐,就為之歡欣鼓舞,迫不及待與夥伴們分享。

最後還想可恥地抒發一下(•‾̑⌣‾̑•)✧˖°:
不知不覺,我在掘金已經水了半年有餘,這半年來我寫下了近6萬字,但其實一共只有5篇文章,這是因為我並不想寫水文,不想把基礎的東西水上幾千字幾十篇來混贊升級。寫下的文章,首先要能說服自己。要對自己寫下的東西負責任,即使是一張圖、一個標點。比如第一張圖我調整了不下十次,第一次我直接擷取babel的轉化結果,覺得不好看,換成了程式碼塊,還是不好看,又換成了carbon的程式碼圖,第一次下載,發現兩張圖寬度不一樣,填充寬度重新下載,又發現自己的程式碼少了一個空格,重新下載,為了實現兩張圖並排效果,寫了一個HTML來調整兩張圖的樣式,為了保證每張圖的內容和邊距一致,我一邊截圖,一邊記錄下每次截圖的尺寸和邊距,每次截圖都根據上一次的資料調整邊距。

其實我並非提倡把時間花在這些細枝末節上,只是單純覺得文章沒寫好,就不能發出來,就像小野二郎先生說的那樣:“菜做的不好,就不能拿給客人吃”,世間的大道理,往往都這樣通俗簡單。

2萬字 | 前端基礎拾遺90問

相關文章