【乾貨滿滿】1.5w字初中級前端面試複習總結

前言

金九銀十,又是一波跑路。趁著有空把前端基礎和面試相關的知識點都系統的學習一遍,參考一些權威的書籍和優秀的文章,最後加上自己的一些理解,總結出來這篇文章。適合複習和準備面試的同學,其中的知識點包括:

  • JavsScript
  • 設計模式
  • Vue
  • 模組化
  • 瀏覽器
  • HTTP
  • 前端安全

JavaScript

數據類型

String、Number、Boolean、Null、Undefined、Symbol、BigInt、Object

堆、棧

兩者都是存放數據的地方。

棧(stack)是自動分配的記憶體空間,它存放基本類型的值和引用類型的記憶體地址。

堆(heap)是動態分配的記憶體空間,它存放引用類型的值。

JavaScript 不允許直接操作堆空間的對象,在操作對象時,實際操作是對象的引用,而存放在棧空間中的記憶體地址就起到指向的作用,通過記憶體地址找到堆空間中的對應引用類型的值。

隱式類型轉換

JavaScript 作為一個弱類型語言,因使用靈活的原因,在一些場景中會對類型進行自動轉換。

常見隱式類型轉換場景有3種:運算取反比較

運算

運算的隱式類型轉換會將運算的成員轉換為 number 類型。

基本類型轉換:

true + false   // 1
null + 10      // 10
false + 20     // 20
undefined + 30 // NaN
1 + '2'        // "12"
NaN + ''       // "NaN"
undefined + '' // "undefined"
null + ''      // "null"
'' - 3         // -3
  • nullfalse'' 轉換 number 類型都是 0
  • undefined 轉換 number 類型是 NaN,所以 undefined 和其他基本類型運算都會輸出 NaN
  • 字元串在加法運算(其實是字元串拼接)中很強勢,和任何類型相加都會輸出字元串(symbol除外),即使是 NaNundefined。其他運算則正常轉為 number 進行運算。

引用類型轉換:

[1] + 10    // "110"
[] + 20     // "20"
[1,2] + 20  // "1,220"
[20] - 10   // 10
[1,2] - 10  // NaN
({}) + 10   // "[object Object]10"
({}) - 10   // NaN
  • 引用類型運算時,會默認調用 toString 先轉換為 string
  • 同上結論,除了加法都會輸出字元串外,其他情況都是先轉 string 再轉 number

解析引用類型轉換過程:

[1,2] + 20
// 過程:
[1,2].toString() // '1,2'
'1,2' + 20       // '1,220'

[20] - 10
// 過程
[20].toString()  // '20'
Number('20')     // 20
20 - 10          // 10

取反

取反的隱式類型轉換會將運算的成員轉換為 boolean 類型。

這個隱式類型轉換比較簡單,就是將值轉為布爾值再取反:

![]     // false
!{}     // false
!false  // true

通常為了快速獲得一個值的布爾值類型,可以取反兩次:

!![]  // true
!!0   // false

比較

比較分為 嚴格比較=== 和 非嚴格比較==,由於 === 會比較類型,不會進行類型轉換。這裡只討論 ==

比較的隱式類型轉換基本會將運算的成員轉換為 number 類型。

undefined == null  // true
'' == 0            // true
true == 1          // true
'1' == true        // true
[1] == '1'         // true
[1,2] == '1,2'     // true
({}) == '[object Object]' // true
  • undefined 等於 null
  • 字元串、布爾值、null比較時,都會轉 number
  • 引用類型在隱式轉換時會先轉成 string 比較,如果不相等然再轉成 number 比較

預編譯

預編譯發生在 JavaScript 程式碼執行前,對程式碼進行語法分析和程式碼生成,初始化的創建並存儲變數,為執行程式碼做好準備。

預編譯過程:

  1. 創建GO/AO對象(GO是全局對象,AO是活動對象)
  2. 將形參和變數聲明賦值為 undefined
  3. 實參形參相統一
  4. 函數聲明提升(將變數賦值為函數體)

例子:

function foo(x, y) {
    console.log(x)
    var x = 10
    console.log(x)
    function x(){}
    console.log(x)
}
foo(20, 30)
// 1. 創建AO對象
AO {}
// 2. 尋找形參和變數聲明賦值為 undefined
AO {
    x: undefined
    y: undefined
}
// 3. 實參形參相統一
AO {
    x: 20
    y: 30
}
// 4. 函數聲明提升
AO {
    x: function x(){}
    y: 30
}

編譯結束後程式碼開始執行,第一個 x 從 AO 中取值,輸出是函數xx 被賦值為 10,第二個 x 輸出 10;函數x 已被聲明提升,此處不會再賦值 x,第三個 x 輸出 10。

作用域

作用域能保證對有權訪問的所有變數和函數的有序訪問,是程式碼在運行期間查找變數的一種規則。

函數作用域

函數在運行時會創建屬於自己的作用域,將內部的變數和函數定義「隱藏」起來,外部作用域無法訪問包裝函數內部的任何內容。

塊級作用域

在ES6之前創建塊級作用域,可以使用 withtry/catch。而在ES6引入 let 關鍵字後,讓塊級作用域聲明變得更簡單。let 關鍵字可以將變數綁定到所在的任意作用域中(通常是{…}內部)。

{
    let num = 10
}
console.log(num) // ReferenceError: num is not defined

參數作用域

一旦設置了參數的默認值,函數進行聲明初始化時,參數會形成一個單獨的作用域。等到初始化結束,這個作用域就會消失。這種語法行為,在不設置參數默認值時,是不會出現的。

let x = 1;

function f(x, y = x) {
  console.log(y);
}

f(2) // 2

參數y的默認值等於變數x。調用函數f時,參數形成一個單獨的作用域。在這個作用域裡面,默認值變數x指向第一個參數x,而不是全局變數x,所以輸出是2。

let x = 1;
function foo(x, y = function() { x = 2; }) {
  x = 3;
  y();
  console.log(x);
}

foo() // 2
x // 1

y 的默認是一個匿名函數,匿名函數內的x指向同一個作用域的第一個參數x。函數foo的內部變數x就指向第一個參數x,與匿名函數內部的x是一致的。y函數執行對參數x重新賦值,最後輸出的就是2,而外層的全局變數x依然不受影響。

閉包

閉包的本質就是作用域問題。當函數可以記住並訪問所在作用域,且該函數在所處作用域之外被調用時,就會產生閉包。

簡單點說,一個函數內引用著所在作用域的變數,並且它被保存到其他作用域執行,引用變數的作用域並沒有消失,而是跟著這個函數。當這個函數執行時,就可以通過作用域鏈查找到變數。

let bar
function foo() {
    let a = 10
    // 函數被保存到了外部
    bar = function () {
        // 引用著不是當前作用域的變數a
        console.log(a)
    }
}
foo()
// bar函數不是在本身所處的作用域執行
bar() // 10

優點:私有變數或方法、快取

缺點:閉包讓作用域鏈得不到釋放,會導致記憶體泄漏

原型鏈

JavaScript 中的對象有一個特殊的內置屬性 prototype(原型),它是對於其他對象的引用。當查找一個變數時,會優先在本身的對象上查找,如果找不到就會去該對象的 prototype 上查找,以此類推,最終以 Object.prototype 為終點。多個 prototype 連接在一起被稱為原型鏈。

原型繼承

原型繼承的方法有很多種,這裡不會全部提及,只記錄兩種常用的方法。

聖杯模式

function inherit(Target, Origin){
  function F() {};
  F.prototype = Origin.prototype;
  Target.prototype = new F();
  // 還原 constuctor
  Target.prototype.constuctor = Target;
  // 記錄繼承自誰
  Target.prototype.uber = Origin.prototype; 
}

聖杯模式的好處在於,使用中間對象隔離,子級添加屬性時,都會加在這個對象裡面,不會對父級產生影響。而查找屬性是沿著 __proto__ 查找,可以順利查找到父級的屬性,實現繼承。

使用:

function Person() {
    this.name = 'people'
}
Person.prototype.sayName = function () { console.log(this.name) }
function Child() {
    this.name = 'child'
}
inherit(Child, Person)
Child.prototype.age = 18
let child = new Child()

ES6 Class

class Person {
    constructor() {
        this.name = 'people'
    }
    sayName() {
        console.log(this.name)
    }
}
class Child extends Person {
    constructor() {
        super()
        this.name = 'child'
    }
}
Child.prototype.age = 18
let child = new Child()

Class 可以通過 extends 關鍵字實現繼承,這比 ES5 的通過修改原型鏈實現繼承,要清晰和方便很多。

基本包裝類型

let str = 'hello'
str.split('')

基本類型按道理說是沒有屬性和方法,但是在實際操作時,我們卻能從基本類型調用方法,就像一個字元串能調用 split 方法。

為了方便操作基本類型值,每當讀取一個基本類型值的時候,後台會創建一個對應的基本包裝類型的對象,從而讓我們能夠調用方法來操作這些數據。大概過程如下:

  1. 創建String類型的實例
  2. 在實例上調用指定的方法
  3. 銷毀這個實例
let str = new String('hello')
str.split('')
str = null

this

this是函數被調用時發生的綁定,它指向什麼完全取決於函數在哪裡被調用。我理解的this是函數的調用者對象,當在函數內使用this,可以訪問到調用者對象上的屬性和方法。

this綁定的四種情況:

  1. new 綁定。new實例化
  2. 顯示綁定。callapplybind手動更改指向
  3. 隱式綁定。由上下文對象調用,如 obj.fn()this 指向 obj
  4. 默認綁定。默認綁定全局對象,在嚴格模式下會綁定到undefined

優先順序new綁定最高,最後到默認綁定。

new的過程

  1. 創建一個空對象
  2. 設置原型,將對象的 __proto__ 指向構造函數的 prototype
  3. 構造函數中的 this 執行對象,並執行構造函數,為空對象添加屬性和方法
  4. 返回實例對象

注意點:構造函數內出現return,如果返回基本類型,則提前結束構造過程,返回實例對象;如果返回引用類型,則返回該引用類型。

// 返回基本類型
function Foo(){
    this.name = 'Joe'
    return 123
    this.age = 20
}
new Foo() // Foo {name: "Joe"}

// 返回引用類型
function Foo(){
    this.name = 'Joe'
    return [123]
    this.age = 20
}
new Foo() // [123]

call、apply、bind

三者作用都是改變this指向的。

callapply 改變 this 指向並調用函數,它們兩者區別就是傳參形式不同,前者的參數是逐個傳入,後者傳入數組類型的參數列表。

bind 改變 this 並返回一個函數引用,bind 多次調用是無效的,它改變的 this 指向只會以第一次調用為準。

手寫call

Function.prototype.mycall = function () {
  if(typeof this !== 'function'){
    throw 'caller must be a function'
  }
  let othis = arguments[0] || window
  othis._fn = this
  let arg = [...arguments].slice(1)
  let res = othis._fn(...arg)
  Reflect.deleteProperty(othis, '_fn') //刪除_fn屬性
  return res
}

apply 實現同理,修改傳參形式即可

手寫bind

Function.prototype.mybind = function (oThis) {
  if(typeof this != 'function'){
    throw 'caller must be a function'
  }
  let fThis = this
  //Array.prototype.slice.call 將類數組轉為數組
  let arg = Array.prototype.slice.call(arguments,1)
  let NOP = function(){}
  let fBound = function(){
    let arg_ = Array.prototype.slice.call(arguments)
    // new 綁定等級高於顯式綁定
    // 作為構造函數調用時,保留指向不做修改
    // 使用 instanceof 判斷是否為構造函數調用
    return fThis.apply(this instanceof fBound ? this : oThis, arg.concat(arg_))
  }
  // 維護原型
  if(this.prototype){
    NOP.prototype = this.prototype
    fBound.prototype = new NOP()
  }
  return fBound
}

對ES6語法的了解

常用:let、const、擴展運算符、模板字元串、對象解構、箭頭函數、默認參數、Promise

數據結構:Set、Map、Symbol

其他:Proxy、Reflect

Set、Map、WeakSet、WeakMap

Set

  • 成員的值都是唯一的,沒有重複的值,類似於數組
  • 可以遍歷

WeakSet:

  • 成員必須為引用類型
  • 成員都是弱引用,可以被垃圾回收。成員所指向的外部引用被回收後,該成員也可以被回收
  • 不能遍歷

Map:

  • 鍵值對的集合,鍵值可以是任意類型
  • 可以遍歷

WeakMap

  • 只接受引用類型作為鍵名
  • 鍵名是弱引用,鍵值可以是任意值,可以被垃圾回收。鍵名所指向的外部引用被回收後,對應鍵名也可以被回收
  • 不能遍歷

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

  1. 箭頭函數的this指向在編寫程式碼時就已經確定,即箭頭函數本身所在的作用域;普通函數在調用時確定this
  2. 箭頭函數沒有arguments
  3. 箭頭函數沒有prototype屬性

Promise

Promise 是ES6中新增的非同步編程解決方案,避免回調地獄問題。Promise 對象是通過狀態的改變來實現通過同步的流程來表示非同步的操作, 只要狀態發生改變就會自動觸發對應的函數。

Promise對象有三種狀態,分別是:

  • pending: 默認狀態,只要沒有告訴 promise 任務是成功還是失敗就是 pending 狀態
  • fulfilled: 只要調用 resolve 函數, 狀態就會變為fulfilled, 表示操作成功
  • rejected: 只要調用 rejected 函數, 狀態就會變為 rejected, 表示操作失敗

狀態一旦改變既不可逆,可以通過函數來監聽 Promise 狀態的變化,成功執行 then 函數的回調,失敗執行 catch 函數的回調

淺拷貝

淺拷貝是值的複製,對於對象是記憶體地址的複製,目標對象的引用和源對象的引用指向的是同一塊記憶體空間。如果其中一個對象改變,就會影響到另一個對象。

常用淺拷貝的方法:

  • Array.prototype.slice
let arr = [{a:1}, {b:2}]
let newArr = arr1.slice()
  • 擴展運算符
let newArr = [...arr1]

深拷貝

深拷貝是將一個對象從記憶體中完整的拷貝一份出來,對象與對象間不會共享記憶體,而是在堆記憶體中新開闢一個空間去存儲,所以修改新對象不會影響原對象。

常用的深拷貝方法:

  • JSON.parse(JSON.stringify())
JSON.parse(JSON.stringify(obj))
  • 手寫深拷貝
function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== "object") return obj; 
  const type = Object.prototype.toString.call(obj).slice(8, -1) 
  let strategy = {
    Date: (obj) => new Date(obj),
    RegExp: (obj) => new RegExp(obj),
    Array: clone,
    Object: clone
  }
  function clone(obj){
    // 防止循環引用,導致棧溢出,相同引用的對象直接返回
    if (map.get(obj)) return map.get(obj);
    let target = new obj.constructor();
    map.set(obj, target);
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        target[key] = deepClone(obj[key], map);
      }
    }
    return target;
  }
  return strategy[type] && strategy[type](obj)
}

事件委託

事件委託也叫做事件代理,是一種dom事件優化的手段。事件委託利用事件冒泡的機制,只指定一個事件處理程式,就可以管理某一類型的所有事件。

假設有個列表,其中每個子元素都會有個點擊事件。當子元素變多時,事件綁定佔用的記憶體將會成線性增加,這時候就可以使用事件委託來優化這種場景。代理的事件通常會綁定到父元素上,而不必為每個子元素都添加事件。

<ul @click="clickHandler">
    <li class="item">1</li>
    <li class="item">2</li>
    <li class="item">3</li>
</ul>
clickHandler(e) {
    // 點擊獲取的子元素
    let target = e.target
    // 輸出子元素內容
    consoel.log(target.textContent)
}

防抖

防抖用於減少函數調用次數,對於頻繁的調用,只執行這些調用的最後一次。

/**
 * @param {function} func - 執行函數
 * @param {number} wait - 等待時間
 * @param {boolean} immediate - 是否立即執行
 * @return {function}
 */
function debounce(func, wait = 300, immediate = false){
  let timer, ctx;
  let later = (arg) => setTimeout(()=>{
    func.apply(ctx, arg)
    timer = ctx = null
  }, wait)
  return function(...arg){
    if(!timer){
      timer = later(arg)
      ctx = this
      if(immediate){
        func.apply(ctx, arg)
      }
    }else{
      clearTimeout(timer)
      timer = later(arg)
    }
  }
}

節流

節流用於減少函數請求次數,與防抖不同,節流是在一段時間執行一次。

/**
 * @param {function} func - 執行函數
 * @param {number} delay - 延遲時間
 * @return {function}
 */
function throttle(func, delay){
  let timer = null
  return function(...arg){
    if(!timer){
      timer = setTimeout(()=>{
        func.apply(this, arg)
        timer = null
      }, delay)
    }
  }
}

柯里化

Currying(柯里化)是把接受多個參數的函數變換成接受一個單一參數的函數,並且返回接受餘下的參數而且返回結果的新函數的技術。

通用柯里化函數:

function currying(fn, arr = []) {
  let len = fn.length
  return (...args) => {
    let concatArgs = [...arr, ...args]
    if (concatArgs.length < len) {
      return currying(fn, concatArgs)
    } else {
      return fn.call(this, ...concatArgs)
    }
  }
}

使用:

let sum = (a,b,c,d) => {
  console.log(a,b,c,d)
}

let newSum = currying(sum)

newSum(1)(2)(3)(4)

優點:

  1. 參數復用,由於參數可以分開傳入,我們可以復用傳入參數後的函數
  2. 延遲執行,就跟 bind 一樣可以接收參數並返回函數的引用,而沒有調用

垃圾回收

堆分為新生代和老生代,分別由副垃圾回收器和主垃圾回收器來負責垃圾回收。

新生代

一般剛使用的對象都會放在新生代,它的空間比較小,只有幾十MB,新生代里還會劃分出兩個空間:form空間和to空間。

對象會先被分配到form空間中,等到垃圾回收階段,將form空間的存活對象複製到to空間中,對未存活對象進行回收,之後調換兩個空間,這種演算法稱之為 「Scanvage」。

新生代的記憶體回收頻率很高、速度也很快,但空間利用率較低,因為讓一半的記憶體空間處於「閑置」狀態。

老生代

老生代的空間較大,新生代經過多次回收後還存活的對象會被送到老生代。

老生代使用「標記清除」的方式,從根元素開始遍歷,將存活對象進行標記。標記完成後,對未標記的對象進行回收。

經過標記清除之後的記憶體空間會產生很多不連續的碎片空間,導致一些大對象無法存放進來。所以在回收完成後,會對這些不連續的碎片空間進行整理。

JavaScript設計模式

單例模式

定義:保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。

JavaScript 作為一門無類的語言,傳統的單例模式概念在 JavaScript 中並不適用。稍微轉換下思想:單例模式確保只有一個對象,並提供全局訪問。

常見的應用場景就是彈窗組件,使用單例模式封裝全局彈窗組件方法:

import Vue from 'vue'
import Index from './index.vue'

let alertInstance = null
let alertConstructor = Vue.extend(Index)

let init = (options)=>{
  alertInstance = new alertConstructor()
  Object.assign(alertInstance, options)
  alertInstance.$mount()
  document.body.appendChild(alertInstance.$el)
}

let caller = (options)=>{
  // 單例判斷
  if(!alertInstance){
    init(options)
  }
  return alertInstance.show(()=>alertInstance = null)
}

export default {
  install(vue){
    vue.prototype.$alert = caller
  }
}

無論調用幾次,組件也只實例化一次,最終獲取的都是同一個實例。

策略模式

定義:定義一系列的演算法,把它們一個個封裝起來,並且使它們可以相互替換。

策略模式是開發中最常用的設計模式,在一些場景下如果存在大量的 if/else,且每個分支點的功能獨立,這時候就可以考慮使用策略模式來優化。

就像就上面手寫深拷貝就用到策略模式來實現:

function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== "object") return obj; 
  const type = Object.prototype.toString.call(obj).slice(8, -1) 
  // 策略對象
  let strategy = {
    Date: (obj) => new Date(obj),
    RegExp: (obj) => new RegExp(obj),
    Array: clone,
    Object: clone
  }
  function clone(obj){
    // 防止循環引用,導致棧溢出,相同引用的對象直接返回
    if (map.get(obj)) return map.get(obj);
    let target = new obj.constructor();
    map.set(obj, target);
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        target[key] = deepClone(obj[key], map);
      }
    }
    return target;
  }
  return strategy[type] && strategy[type](obj)
}

這樣的程式碼看起來會更簡潔,只需要維護一個策略對象,需要新功能就添加一個策略。由於策略項是單獨封裝的方法,也更易於復用。

代理模式

定義:為一個對象提供一個代用品,以便控制對它的訪問。

當不方便直接訪問一個對象或者不滿足需要的時候,提供一個代理對象來控制對這個對象的訪問,實際訪問的是代理對象,代理對象對請求做出處理後,再轉交給本體對象。

使用快取代理請求數據:

function getList(page) {
    return this.$api.getList({
        page
    }).then(res => {
        this.list = res.data
        return res
    })
}

// 代理getList
let proxyGetList = (function() {
    let cache = {}
    return async function(page) {
        if (cache[page]) {
            return cache[page]
        }
        let res = await getList.call(this, page)
        return cache[page] = res.data
    }
})()

上面的場景是常見的分頁需求,同一頁的數據只需要去後台獲取一次,並將獲取到的數據快取起來,下次再請求同一頁時,便可以直接使用之前的數據。

發布訂閱模式

定義:它定義對象間的一種一對多的依賴關係,當一個對象的狀態發送改變時,所有依賴於它的對象都將得到通知。

發布訂閱模式主要優點是解決對象間的解耦,它的應用非常廣泛,既可以用在非同步編程中,也可以幫助我們完成松耦合的程式碼編寫。像 eventBus 的通訊方式就是發布訂閱模式。

let event = {
    events: [],
    on(key, fn){
        if(!this.events[key]) {
            this.events[key] = []
        }
        this.events[key].push(fn)
    },
    emit(key, ...arg){
        let fns = this.events[key]
        if(!fns || fns.length == 0){
            return false
        }
        fns.forEach(fn => fn.apply(this, arg))
    }
}

上面只是發布訂閱模式的簡單實現,還可以為其添加 off 方法來取消監聽事件。在 Vue 中,通常是實例化一個新的 Vue 實例來做發布訂閱中心,解決組件通訊。而在小程式中可以手動實現發布訂閱模式,用於解決頁面通訊的問題。

裝飾器模式

定義:動態地為某個對象添加一些額外的職責,而不會影響對象本身。

裝飾器模式在開發中也是很常用的設計模式,它能夠在不影響源程式碼的情況下,很方便的擴展屬性和方法。比如以下應用場景是提交表單。

methods: {
    submit(){
        this.$api.submit({
            data: this.form
        })
    },
    // 為提交表單添加驗證功能
    validateForm(){
        if(this.form.name == ''){
            return
        }
        this.submit()
    }
}

想像一下,如果你剛接手一個項目,而 submit 的邏輯很複雜,可能還會牽扯到很多地方。冒然的侵入源程式碼去擴展功能會有風險,這時候裝飾器模式就幫上大忙了。

Vue

對MVVM模式的理解

MVVM 對應 3個組成部分,Model(模型)、View(視圖) 和 ViewModel(視圖模型)。

  • View 是用戶在螢幕上看到的結構、布局和外觀,也稱UI。
  • ViewModel 是一個綁定器,能和 View 層和 Model 層進行通訊。
  • Model 是數據和邏輯。

View 不能和 Model 直接通訊,它們只能通過 ViewModel 通訊。Model 和 ViewModel 之間的交互是雙向的,ViewModel 通過雙向數據綁定把 View 層和 Model 層連接起來,因此 View 數據的變化會同步到 Model 中,而 Model 數據的變化也會立即反應到 View 上。

題外話,你可能不知道 Vue 不完全是 MVVM 模式:

嚴格的 MVVM 要求 View 不能和 Model 直接通訊,而 Vue 在組件提供了 $refs 這個屬性,讓 Model 可以直接操作 View,違反了這一規定。

Vue的渲染流程

流程主要分為三個部分:

  1. 模板編譯parse 解析模板生成抽象語法樹(AST);optimize 標記靜態節點,在後續頁面更新時會跳過靜態節點;generate 將AST轉成 render 函數,render 函數用於構建 VNode
  2. 構建VNode(虛擬dom),構建過程使用 createElement 構建 VNodecreateElement 也是自定義 render 函數時接受到的第一個參數。
  3. VNode轉真實dompatch 函數負責將 VNode 轉換成真實dom,核心方法是createElm,遞歸創建真實dom樹,最終渲染到頁面上。

data為什麼要求是函數

當一個組件被定義,data 必須聲明為返回一個初始數據對象的函數,因為組件可能被用來創建多個實例。如果 data 仍然是一個純粹的對象,則所有的實例將共享引用同一個數據對象!通過提供 data 函數,每次創建一個新實例後,我們能夠調用 data 函數,從而返回初始數據的一個全新副本數據對象。

JavaScript 中的對象作為引用類型,如果是創建多個實例,直接使用對象會導致實例的共享引用。而這裡創建多個實例,指的是組件復用的情況。因為在編寫組件時,是通過 export 暴露出去的一個對象,如果組件復用的話,多個實例都是引用這個對象,就會造成共享引用。使用函數返回一個對象,由於是不同引用,自然可以避免這個問題發生。

Vue生命周期

  1. beforeCreate: 在實例創建之前調用,由於實例還未創建,所以無法訪問實例上的 datacomputedmethod等。
  2. created: 在實例創建完成後調用,這時已完成數據的觀測,可以獲取數據和更改數據,但還無法與dom進行交互,如果想要訪問dom,可以使用 vm.$nextTick。此時可以對數據進行更改,不會觸發 updated
  3. beforeMount: 在掛載之前調用,這時的模板已編譯完成並生成render函數,準備開始渲染。在此時也可以對數據進行更改,不會觸發 updated
  4. mounted: 在掛載完成後調用,真實的dom掛載完畢,可以訪問到dom節點,使用 $refs 屬性對dom進行操作。
  5. beforeUpdate: 在更新之前調用,也就是響應式數據發生更新,虛擬dom重新渲染之前被觸發,在當前階段進行更改數據,不會造成重渲染。
  6. updated: 在更新完成之後調用,組件dom已完成更新。要注意的是避免在此期間更改數據,這可能會導致死循環。
  7. beforeDestroy: 在實例銷毀之前調用,這時實例還可以被使用,一般這個周期內可以做清除計時器和取消事件監聽的工作。
  8. destroyed: 在實例銷毀之後調用,這時已無法訪問實例。當前實例從父實例中被移除,觀測被卸載,所有事件監聽器唄移除,子實例也統統被銷毀。

請說出 Vue 的5種指令

  1. v-if
  2. v-for
  3. v-show
  4. v-html
  5. v-model

computed 和 watch 的區別

  1. computed 依賴 data 的改變而改變,computed 會返回值;watch 觀察 data,執行對應的函數。
  2. computed 有快取功能,重複取值不會執行求值函數。
  3. computed 依賴收集在頁面渲染時觸發,watch 收集依賴在頁面渲染前觸發。
  4. computed 更新需要「渲染Watcher」的配合,computed 更新只是設置 dirty,需要頁面渲染觸發 get 重新求值

Vue 中的 computed 是如何實現快取的

「計算屬性Watcher」會帶有一個 dirty 的屬性,在初始化取值完成後,會將 dirty 設置為 false。只要依賴屬性不更新,dirty 永遠為 false,重複取值也不會再去執行求值函數,而是直接返回結果,從而實現快取。相反,依賴屬性更新會將「計算屬性 Watcher」的 dirty 設置為 true,在頁面渲染對計算屬性取值時,再次觸發求值函數更新計算屬性。

Object.defineProperty(target, key, {
    get() {
        const watcher = this._computedWatchers && this._computedWatchers[key]
        // 計算屬性快取
        if (watcher.dirty) {  
            // 計算屬性求值
            watcher.evaluate()  
        }
        return watcher.value
    }
})

組件通訊方式

  1. props/emit
  2. $children/$parent
  3. ref
  4. $attrs/$listeners
  5. provide/inject
  6. eventBus
  7. vuex

雙向綁定原理

雙向綁定是視圖變化會反映到數據,數據變化會反映到視圖,v-model 就是個很好理解的例子。其實主要考查的還是響應式原理,響應式原理共包括3個主要成員,Observer 負責監聽數據變化,Dep 負責依賴收集,Watcher 負責數據或視圖更新,我們常說的收集依賴就是收集 Watcher

響應式原理主要工作流程如下:

  1. Observer 內使用 Object.defineProperty 劫持數據,為其設置 setget
  2. 每個數據都會有自己的 dep。數據取值觸發 get 函數,調用 dep.depend 收集依賴;數據更新觸發 set 函數,調用 dep.notify 通知 Watcher 更新。
  3. Watcher 接收到更新的通知,將這些通知加入到一個非同步隊列中,並且進行去重處理,等到所有同步操作完成後,再一次性更新視圖。

Vue如何檢測數組變化

Vue 內部重寫數組原型鏈,當數組發生變化時,除了執行原生的數組方法外,還會調用 dep.notify 通知 Watcher 更新。觸發數組更新的方法共7種:

  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse

keep-alive

keep-aliveVue 的內置組件,同時也是一個抽象組件,它主要用於組件快取。當組件切換時會將組件的VNode快取起來,等待下次重新激活時,再將快取的組件VNode渲染出來,從而實現快取。

常用的兩個屬性 includeexclude,支援字元串、正則和數組的形式,允許組件有條件的進行快取。還有 max 屬性,用於設置最大快取數。

兩個生命周期 activateddeactivated,在組件激活和失活時觸發。

keep-alive 的快取機制運用LRU(Least Recently Used)演算法,

nextTick

在下次 dom 更新結束之後執行延遲回調。nextTick 主要使用了宏任務和微任務。根據執行環境分別嘗試採用:

  • Promise
  • MutationObserver
  • setImmediate
  • setTimeout

nextTick 主要用於內部 Watcher 的非同步更新,對外我們可以使用 Vue.nextTickvm.$nextTick。在 nextTick 中可以獲取更新完成的 dom。

如何理解單向數據流

所有的 prop 都使得其父子 prop 之間形成了一個單向下行綁定:父級 prop 的更新會向下流動到子組件中,但是反過來則不行。這樣會防止從子組件意外變更父級組件的狀態,從而導致你的應用的數據流向難以理解。

單向數據流只允許數據由父組件傳遞給子組件,數據只能由父組件更新。當數據傳遞到多個子組件,而子組件能夠在其內部更新數據時,在主觀上很難知道是哪個子組件更新了數據,導致數據流向不明確,從而增加應用調試的難度。

但子組件更新父組件數據的場景確實存在,有3種方法可以使用:

  1. 子組件emit,父組件接受自定義事件。這種方法最終還是由父組件進行修改,子組件只是起到一個通知的作用。
  2. 子組件自定義雙向綁定,設置組件的 model 選項為組件添加自定義雙向綁定。
  3. .sync 屬性修飾符,它是第一種方法的語法糖,在傳遞屬性添加上該修飾符,子組件內可調用 this.$emit('update:屬性名', value) 更新屬性。

Vue3 和 Vue2.x 的差異

  1. 使用 Proxy 代替 Object.defineProperty
  2. 新增 Composition API
  3. 模板允許多個根節點

Vue3 為什麼使用 Proxy 代替 Object.definedProperty

Object.definedProperty 只能檢測到屬性的獲取和設置,對於新增和刪除是沒辦法檢測的。在數據初始化時,由於不知道哪些數據會被用到,Vue 是直接遞歸觀測全部數據,這會導致性能多餘的消耗。

Proxy 劫持整個對象,對象屬性的增加和刪除都能檢測到。Proxy 並不能監聽到內部深層的對象變化,因此 Vue 3.0 的處理方式是在 getter 中去遞歸響應式,只有真正訪問到的內部對象才會變成響應式,而不是無腦遞歸,在很大程度上提升了性能。

路由懶載入是如何實現的

路由懶載入是性能優化的一種手段,在編寫程式碼時可以使用 import() 引入路由組件,使用懶載入的路由會在打包時單獨出來成一個 js 文件,可以使用 webpackChunkName 自定義包名。在項目上線後,懶載入的 js 文件不會在第一時間載入,而是在訪問到對應的路由時,才會動態創建 script 標籤去載入這個 js 文件。

{
  path:'users',
  name:'users',
  component:()=> import(/*webpackChunkName: "users"*/ '@/views/users'),
}

Vue路由鉤子函數

全局鉤子

  • beforeEach

路由進入前調用

const router = new VueRouter({ ... })

router.beforeEach((to, from, next) => {
  // ...
})
  • beforeResolve (2.5.0 新增)

在所有組件內守衛和非同步組件被解析之後調用

router.beforeResolve((to, from, next) => {
 // ...
})
  • afterEach

路由在確認後調用

router.afterEach((to, from) => {
  // ...
})

路由獨享鉤子

  • beforeEnter

路由進入前調用,beforeEnterbeforeEach 之後執行

const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      beforeEnter: (to, from, next) => {
        // ...
      }
    }
  ]
})

組件鉤子

  • beforeRouteEnter

路由確認前調用,組件實例還沒被創建,不能獲取組件實例 this

beforeRouteEnter (to, from, next) {
    // ...
    // 可以通過回調訪問實例
    next(vm => {
        // vm 為組件實例
    })
},
  • beforeRouteUpdate (2.2 新增)

路由改變時調用,可以訪問組件實例

beforeRouteUpdate (to, from, next) {
    // ...
},
  • beforeRouteLeave

離開該組件的對應路由時調用,可以訪問組件實例 this

beforeRouteLeave (to, from, next) {
    // ...
}

vue-router的原理

vue-router原理是更新視圖而不重新請求頁面。vue-router共有3種模式:hash模式history模式abstract模式

hash模式

hash模式使用 hashchange 監聽地址欄的hash值的變化,載入對應的頁面。每次的hash值變化後依然會在瀏覽器留下歷史記錄,可以通過瀏覽器的前進後退按鈕回到上一個頁面。

history模式

history模式基於History Api實現,使用 popstate 監聽地址欄的變化。使用 pushStatereplaceState 修改url,而無需載入頁面。但是在刷新頁面時還是會向後端發起請求,需要後端配合將資源定向回前端,交由前端路由處理。

abstract

不涉及和瀏覽器地址的相關記錄。通過數組維護模擬瀏覽器的歷史記錄棧。

vuex 怎麼跨模組調用

跨模組調用是指當前命名空間模組調用全局模組或者另一個命名空間模組。在調用 dispatchcommit 時設置第三個參數為 {root:true}

modules: {
  foo: {
    namespaced: true,
    actions: {
      someAction ({ dispatch, commit, getters, rootGetters }) {
        // 調用自己的action
        dispatch('someOtherAction') // -> 'foo/someOtherAction'
        // 調用全局的action
        dispatch('someOtherAction', null, { root: true }) // -> 'someOtherAction'
        // 調用其他模組的action
        dispatch('user/someOtherAction', null, { root: true }) // -> 'user/someOtherAction'
      },
      someOtherAction (ctx, payload) { ... }
    }
  }
}

vuex 如何實現持久化

vuex存儲的狀態在頁面刷新後會丟失,使用持久化技術能保證頁面刷新後狀態依然存在。

  1. 使用本地存儲配合,設置 state 同時設置 storage,在刷新後再初始化 vuex
  2. vuex-persistedstate 插件

模組化

這裡只記錄常用的兩種模組:CommonJS模組、ES6模組。

CommonJS模組

Node.js 採用 CommonJS 模組規範,在服務端運行時是同步載入,在客戶端使用需要編譯後才可以運行。

特點

  • 模組可以多次載入。但在第一次載入時,結果會被快取起來,再次載入模組,直接獲取快取的結果
  • 模組載入的順序,按照其在程式碼中出現的順序

語法

  • 暴露模組:module.exports = valueexports.xxx = value
  • 引入模組:require('xxx'),如果是第三方模組,xxx為模組名;如果是自定義模組,xxx為模組文件路徑
  • 清楚模組快取:delete require.cache[moduleName];,快取保存在 require.cache 中,可操作該屬性進行刪除

模組載入機制

  • 載入某個模組,其實是載入該模組的 module.exports 屬性
  • exports 是指向 module.exports 的引用
  • module.exports 的初始值為一個空對象,exports 也為空對象,module.exports 對象不為空的時候 exports 對象就被忽略
  • 模組載入的是值的拷貝,一旦輸出值,模組內的變化不會影響到值,引用類型除外

module.exports 不為空:

// nums.js
exports.a = 1
module.exports = {
    b: 2
}
exports.c = 3
let nums = require('./nums.js') // { b: 2 }

module.exports 為空:

// nums.js
exports.a = 1
exports.c = 3
let nums = require('./nums.js') // { a: 1, c: 3 }

值拷貝的體現:

// nums.js
let obj = {
    count: 10
}
let count = 20
function addCount() {
    count++
}
function getCount() {
    return count
}
function addObjCount() {
    obj.count++
}
module.exports = { count, obj, addCount, getCount, addObjCount }
let { count, obj, addCount, getCount, addObjCount } = require('./nums.js')

// 原始類型不受影響
console.log(count) // 20
addCount()
console.log(count) // 20
// 如果想獲取到變化的值,可以使用函數返回
console.log(getCount()) // 21

// 引用類型會被改變
console.log(obj) // { count: 10 }
addObjCount()
console.log(obj) // { count: 11 }

ES6模組

ES6 模組的設計思想是盡量的靜態化,使得編譯時就能確定模組的依賴關係,以及輸入和輸出的變數。

特點

  • 由於靜態分析的原因,ES6模組載入只能在程式碼頂層使用
  • 模組不能多次載入同一個變數

語法

  • 暴露模組:exportexport default
  • 引入模組:import

模組載入機制

  • 模組載入的是引用的拷貝,模組內的變化會影響到值
// nums.js
export let count = 20
export function addCount() {
  count++
}
export default {
  other: 30
}
// 同時引入 export default 和 export 的變數
import other, { count, addCount } from './async.js'

console.log(other) // { other: 30 }
console.log(count) // 20
addCount()
console.log(count) // 21

ES6 模組與 CommonJS 模組的差異

  1. CommonJS 模組輸出的是一個值的拷貝,ES6 模組輸出的是值的引用。
  2. CommonJS 模組是運行時載入,ES6 模組是編譯時輸出介面。

瀏覽器

頁面渲染流程

  1. 位元組流解碼。瀏覽器獲得位元組數據,根據位元組編碼將位元組流解碼,轉換為程式碼。
  2. 輸入流預處理。字元數據進行統一格式化。
  3. 令牌化。從輸入流中提取可識別的子串和標記符號。可以理解為對HTML解析,進行詞法分析,匹配標籤生成令牌結構。
  4. 構建DOM樹、構建CSSOM樹。DOM樹和CSSOM樹的構建過程是同時進行的,在 HTML 解析過程中如果遇到 script 標籤,解析會暫停並將執行許可權交給 JavaScript 引擎,等到 JavaScript 腳本執行完畢後再交給渲染引擎繼續解析。(補充:如果腳本中調用了改變 DOM 結構的 document.write() 函數,此時渲染引擎會回到第二步,將這些程式碼加入字元流,重新進行解析。)
  5. 構建渲染樹。DOM樹負責結構內容,CSSOM樹負責樣式規則,為了渲染,需要將它們合成渲染樹。
  6. 布局。布局階段根據渲染樹的節點和節點的CSS定義以及節點從屬關係,計算元素的大小和位置,將所有相對值轉換為螢幕上的絕對像素。
  7. 繪製。繪製就是將渲染樹中的每個節點轉換成螢幕上的實際像素的過程。在繪製階段,瀏覽器會遍歷渲染樹,調用渲染器的paint方法在螢幕上顯示其內容。實際上,繪製過程是在多個層上完成的,這些層稱為渲染層(RenderLayer)。
  8. 渲染層合成。多個繪製後的渲染層按照恰當的重疊順序進行合併,而後生成點陣圖,最終通過顯示卡展示到螢幕上。

數據變化過程:位元組 → 字元 → 令牌 → 樹 → 頁面

迴流、重繪

迴流(Reflow)

在布局完成後,對DOM布局進行修改(比如大小或位置),會引起頁面重新計算布局,這個過程稱為「迴流」。

重繪(Repaint)

對DOM進行不影響布局的修改引起的螢幕局部繪製(比如背景顏色、字體顏色),這個過程稱為「重繪」。

小結

迴流一定會引起重繪,而重繪不一定會引起迴流。由於迴流需要重新計算節點布局,迴流的渲染耗時會高於重繪。

對於迴流重繪,瀏覽器本身也有優化策略,瀏覽器會維護一個隊列,將迴流重繪操作放入隊列中,等隊列到達一定時間,再按順序去一次性執行隊列的操作。

但是也有例外,有時我們需要獲取某些樣式資訊,例如:
offsetTopoffsetLeftoffsetWidthoffsetHeightscrollTop/Left/Width/HeightclientTop/Left/Width/HeightgetComputedStyle(),或者 IE 的 currentStyle

這時,瀏覽器為了回饋準確的資訊,需要立即迴流重繪一次,所以可能導致隊列提前執行。

事件循環(Event Loop)

在瀏覽器的實現上,諸如渲染任務、JavaScript 腳本執行、User Interaction(用戶交互)、網路處理都跑在同一個執行緒上,當執行其中一個類型的任務的時候意味著其他任務的阻塞,為了有序的對各個任務按照優先順序進行執行瀏覽器實現了我們稱為 Event Loop 調度流程。

簡單來說,Event Loop 就是執行程式碼、收集和處理事件以及執行隊列中子任務的一個過程。

宏任務

在一次新的事件循環的過程中,遇到宏任務時,宏任務將被加入任務隊列,但需要等到下一次事件循環才會執行。

常見宏任務:setTimeoutsetIntervalrequestAnimationFrame

微任務

當前事件循環的任務隊列為空時,微任務隊列中的任務就會被依次執行。在執行過程中,如果遇到微任務,微任務被加入到當前事件循環的微任務隊列中。簡單來說,只要有微任務就會繼續執行,而不是放到下一個事件循環才執行。

微任務隊列屬於任務運行環境內的一員,並非處於全局的位置。也就是說,每個任務都會有一個微任務隊列。

常見微任務:Promise.thenPromise.catchMutationObserver

流程

  1. 取出一個宏任務執行,如果碰到宏任務,將其放入任務隊列,如果碰到微任務,將其放入微任務隊列
  2. 檢查微任務隊列是否有可執行的微任務,如果有則執行微任務。微任務執行過程中,如果碰到宏任務,將其放入任務隊列。如果碰到微任務,繼續將其放入當前的微任務隊列,直到微任務全部執行。
  3. 更新渲染階段,判斷是否需要渲染,也就是說不一定每一輪 Event Loop 都會對應一次瀏覽器渲染。
  4. 對於需要渲染的文檔,執行requestAnimationFrame幀動畫回調。
  5. 對於需要渲染的文檔,重新渲染繪製用戶介面。
  6. 判斷任務隊列和微任務隊列是否為空,如果是,則進行 Idle 空閑周期的演算法,判斷是否要執行 requestIdleCallback 的回調函數。

小結

在當前任務運行環境內,微任務總是先於宏任務執行;

requestAnimationFrame 回調在頁面渲染之前調用,適合做動畫;

requestIdleCallback 在渲染螢幕之後調用,可以使用它來執行一些不太重要的任務。

同源策略(Same origin policy)

源是由 URL 中協議、主機名(域名)以及埠共同組成的部分。

同源策略是瀏覽器的行為,為了保護本地數據不被JavaScript程式碼獲取回來的數據污染,它是存在於瀏覽器最核心也最基本的安全功能。

所謂同源指的是:協議、域名、埠號必須一致,只要有一個不相同,那麼就是「跨源」。

最常見的同源策略是因為域名不同,也就是常說的「跨域」。一般分為請求跨域和頁面跨域。

請求跨域解決方案

  1. 跨域資源共享(CORS)。服務端設置HTTP響應頭(Access-Control-Allow-Origin)
  2. 代理轉發。同源策略只存在於瀏覽器,使用服務端設置代理轉發沒有同源策略的限制。
  3. JSONP。依賴的是 script 標籤跨域引用 js 文件不會受到瀏覽器同源策略的限制。
  4. Websocket。HTML5 規範提出的一個應用層的全雙工協議,適用於瀏覽器與伺服器進行實時通訊場景。

常用方法是CORS和代理轉發。

頁面跨域解決方案

  1. postMessage。HTML5 的 postMessage 方法可用於兩個頁面之間通訊,而且不論這兩個頁面是否同源。
  2. document.domain。對於主域名相同,子域名不同的情況,可以通過修改 document.domain 的值來進行跨域。
  3. window.location.hash,通過 url 帶 hash ,通過一個非跨域的中間頁面來傳遞數據。
  4. window. name,當 window 的 location 變化,然後重新載入,它的 name 屬性可以依然保持不變。通過 iframe 的 src 屬性由外域轉向本地域,跨域數據即由 iframe 的 window. name 從外域傳遞到本地域。

CORS請求

對於CORS請求,瀏覽器將其分成兩個類型:簡單請求和非簡單請求。

簡單請求

簡單請求符合下面 2 個特徵:

  1. 請求方法為 GET、POST、HEAD。

  2. 請求頭只能使用以下規定的安全欄位:

    • Accept(瀏覽器能夠接受的響應內容類型)
    • Accept-Language(瀏覽器能夠接受的自然語言列表)
    • Content-Type (請求對應的類型,只限於 text/plain、multipart/form-data、application/x-www-form-urlencode)
    • Content-Language(瀏覽器希望採用的自然語言)
    • Save-Data
    • DPR
    • DownLink
    • Viewport-Width
    • Width

非簡單請求

任意一條要求不符合的即為非簡單請求。常見是自定義 header,例如將token 設置到請求頭。

在處理非簡單請求時,瀏覽器會先發出「預檢請求」,預檢請求為OPTIONS方法,以獲知伺服器是否允許該實際請求,避免跨域請求對伺服器產生預期外的影響。如果預檢請求返回200允許通過,才會發真實的請求。

預檢請求並非每次都需要發送,可以使用 Access-Control-Max-Age 設置快取時間進行優化,減少請求發送。

HTTP

HTTP 1.0、HTTP 1.1、HTTP 2.0的區別

HTTP1.0

增加頭部設定,頭部內容以鍵值對的形式設置。請求頭部通過 Accept 欄位來告訴服務端可以接收的文件類型,響應頭部再通過 Content-Type 欄位來告訴瀏覽器返迴文件的類型。

HTTP1.1

HTTP1.0中每次通訊都需要經歷建立連接、傳輸數據和斷開連接三個階段,這會增加大量網路開銷。

HTTP1.1增加持久化連接,即連接傳輸完畢後,TCP連接不會馬上關閉,而是其他請求可以復用連接。這個連接保持到瀏覽器或者伺服器要求斷開連接為止。

HTTP2.0

HTTP1.1雖然減少連接帶來的性能消耗,但是請求最大並發受到限制,同一域下的HTTP連接數根據瀏覽器不同有所變化,一般是6 ~ 8個。而且一個TCP連接同一時刻只能處理一個請求,當前請求未結束之前,其他請求只能處於阻塞狀態。

HTTP2.0中增加「多路復用」的機制,不再受限於瀏覽器的連接數限制。基於二進位分幀,客戶端發送的數據會被分割成帶有編號的碎片(二進位幀),然後將這些碎片同時發送給服務端,服務端接收到數據後根據編號再合併成完整的數據。服務端返回數據也同樣遵循這個過程。

三次握手

過程

第一次握手:客戶端向服務端發起連接請求報文,報文中帶有一個連接標識(SYN);

第二次握手:服務端接收到客戶端的報文,發現報文中有連接標識,服務端知道是一個連接請求,於是給客戶端回復確認報文(帶有SYN標識);

第三次握手:客戶端收到服務端回復確認報文,得知服務端允許連接,於是客戶端回復確認報文給服務端,服務端收到客戶端的回復報文後,正式建立TCP連接;

為什麼需要三次握手,兩次可以嗎?

如果是兩次握手,在第二次握手出現確認報文丟失,客戶端不知道服務端是否準備好了,這種情況下客戶端不會給服務端發數據,也會忽略服務端發過來的數據。

如果是三次握手,在第三次握手出現確認報文丟失,服務端在一段時間沒有收到客戶端的回復報文就會重新第二次握手,客戶端收到重複的報文會再次給服務端發送確認報文。

三次握手主要考慮是丟包重連的問題。

四次揮手

過程

第一次揮手:客戶端向服務端發出連接釋放報文,報文中帶有一個連接釋放標識(FIN)。此時客戶端不能再發送數據,但是可以正常接收數據;

第二次揮手:服務端接收到客戶端的報文,知道是一個連接釋放請求。服務端給客戶端回復確認報文,但要注意這個回復報文未帶有FIN標識。此時服務端處於關閉等待狀態,這個狀態還要持續一段時間,因為服務端可能還有數據沒發完;

第三次揮手:服務端將最後的數據發送完畢後,給客戶端回復確認報文(帶有FIN標識),這個才是通知客戶端可以釋放連接的報文;

第四次揮手:客戶端收到服務端回復確認報文後,於是客戶端回復確認報文給服務端。而服務端一旦收到客戶端發出的確認報文就會立馬釋放TCP連接,所以服務端結束TCP連接的時間要比客戶端早一些。

為什麼握手需要三次,而揮手需要四次

服務端需要確保數據完整性,只能先回復客戶端確認報文告訴客戶端我收到了報文,進入關閉等待狀態。服務端在數據發送完畢後,才回復FIN報文告知客戶端數據發送完了,可以斷開了,由此多了一次揮手過程。

HTTPS

HTTPS之所以比HTTP安全,是因為對傳輸內容加密。HTTPS加密使用對稱加密和非對稱加密。

對稱加密:雙方共用一把鑰匙,可以對內容雙向加解密。但是只要有人和伺服器通訊就能獲得密鑰,也可以解密其他通訊數據。所以相比非對稱加密,安全性較低,但是它的效率比非對稱加密高。

非對稱加密:非對稱加密會生成公鑰和私鑰,一般是服務端持有私鑰,公鑰向外公開。非對稱加密對內容單向加解密,即公鑰加密只能私鑰解,私鑰加密只能公鑰解。非對稱加密安全性雖然高,但是它的加解密效率很低。

CA證書:由權威機構頒發,用於驗證服務端的合法性,其內容包括頒發機構資訊、公鑰、公司資訊、域名等。

對稱加密不安全主要是因為密鑰容易泄露,那隻要保證密鑰的安全,就可以得到兩全其美的方案,加解密效率高且安全性好。所以HTTPS在傳輸過程中,對內容使用對稱加密,而密鑰使用非對稱加密。

過程

  1. 客戶端向服務端發起HTTPS請求
  2. 服務端返回HTTPS證書
  3. 客戶端驗證證書是否合法,不合法會提示告警
  4. 證書驗證合法後,在本地生成隨機數
  5. 用公鑰加密隨機數並發送到服務端
  6. 服務端使用私鑰對隨機數解密
  7. 服務端使用隨機數構造對稱加密演算法,對內容加密後傳輸
  8. 客戶端收到加密內容,使用本地存儲的隨機數構建對稱加密演算法進行解密

HTTP 快取

HTTP 快取包括強快取和協商快取,強快取的優先順序高於協商快取。快取優點在於使用瀏覽器快取,對於某些資源服務端不必重複發送,減小服務端的壓力,使用快取的速度也會更快,從而提高用戶體驗。

強快取

強快取在瀏覽器載入資源時,先從快取中查找結果,如果不存在則向服務端發起請求。

Expirss

HTTP/1.0 中可以使用響應頭部欄位 Expires 來設置快取時間。

客戶端第一次請求時,服務端會在響應頭部添加 Expirss 欄位,瀏覽器在下一次發送請求時,會對比時間和Expirss的時間,沒有過期使用快取,過期則發送請求。

Cache-Control

HTTP/1.1 提出了 Cache-Control 響應頭部欄位。

一般會設置 max-age 的值,表示該資源需要快取多長時間。Cache-Control 的 max-age 優先順序高於 Expires。

協商快取

協商快取的更新策略是不再指定快取的有效時間,而是瀏覽器直接發送請求到服務端進行確認快取是否更新,如果請求響應返回的 HTTP 狀態為 304,則表示快取仍然有效。

Last-Modified 和 If-Modified-Since

Last-Modified 和 If-Modified-Since 對比資源最後修改時間來實現快取。

  1. 瀏覽器第一次請求資源,服務端在返回資源的響應頭上添加 Last-Modified 欄位,值是資源在服務端的最後修改時間;
  2. 瀏覽器再次請求資源,在請求頭上添加 If-Modified-Since,值是上次服務端返回的最後修改時間;
  3. 服務端收到請求,根據 If-Modified-Since 的值進行判斷。若資源未修改過,則返回 304 狀態碼,並且不返回內容,瀏覽器使用快取;否則返回資源內容,並更新 Last-Modified 的值;
ETag 和 If-None-Match

ETag 和 If-None-Match 對比資源哈希值,哈希值由資源內容計算得出,即依賴資源內容實現快取。

  1. 瀏覽器第一次請求資源,服務端在返回資源的響應頭上添加 ETag 欄位,值是資源的哈希值
  2. 瀏覽器再次請求資源,在請求頭上添加 If-None-Match,值是上次服務端返回的資源哈希值;
  3. 服務端收到請求,根據 If-None-Match 的值進行判斷。若資源內容沒有變化,則返回 304 狀態碼,並且不返回內容,瀏覽器使用快取;否則返回資源內容,並計算哈希值放到 ETag;

TCP 和 UDP 的區別

TCP

  • 面向連接
  • 一對一通訊
  • 面向位元組流
  • 可靠傳輸,使用流量控制和擁塞控制
  • 報頭最小20位元組,最大60位元組

UDP

  • 無連接
  • 支援一對一,一對多,多對一和多對多的通訊
  • 面向報文
  • 不可靠傳輸,不使用流量控制和擁塞控制
  • 報頭開銷小,僅8位元組

正向代理

  • 代理客戶;
  • 隱藏真實的客戶,為客戶端收發請求,使真實客戶端對伺服器不可見;
  • 一個區域網內的所有用戶可能被一台伺服器做了正向代理,由該台伺服器負責 HTTP 請求;
  • 意味著同伺服器做通訊的是正向代理伺服器;

反向代理

  • 代理伺服器;
  • 隱藏了真實的伺服器,為伺服器收發請求,使真實伺服器對客戶 端不可見;
  • 負載均衡伺服器,將用戶的請求分發到空閑的伺服器上;
  • 意味著用戶和負載均衡伺服器直接通訊,即用戶解析伺服器域名時得到的是負載均衡伺服器的 IP ;

前端安全

跨站腳本攻擊(XSS)

跨站腳本(Cross Site Scripting,XSS)指攻擊者在頁面插入惡意程式碼,當其他用戶訪問時,瀏覽會器解析並執行這些程式碼,達到竊取用戶身份、釣魚、傳播惡意程式碼等行為。一般我們把 XSS 分為反射型存儲型DOM 型 3 種類型。

反射型 XSS

反射型 XSS 也叫「非持久型 XSS」,是指攻擊者將惡意程式碼通過請求提交給服務端,服務端返回的內容,也帶上了這段 XSS 程式碼,最後導致瀏覽器執行了這段惡意程式碼。

反射型 XSS 攻擊方式需要誘導用戶點擊鏈接,攻擊者會偽裝該鏈接(例如短鏈接),當用戶點擊攻擊者的鏈接後,攻擊者便可以獲取用戶的 cookie 身份資訊。

案例:

服務端直接輸出參數內容:

<? php
$input = $_GET["param"];
echo "<div>".$input."</div>";

惡意程式碼鏈接:

//www.a.com/test.php?param=<srcipt src="xss.js"></script>

存儲型 XSS

存儲型 XSS 也叫「持久型XSS」,會把用戶輸入的數據存儲在服務端,這種XSS具有很強的穩定性。

案例:

比如攻擊者在一篇部落格下留言,留言包含惡意程式碼,提交到服務端後被存儲到資料庫。所有訪問該部落格的用戶,在載入出這條留言時,會在他們的瀏覽器中執行這段惡意的程式碼。

DOM 型 XSS

DOM 型 XSS 是一種特殊的反射型 XSS,它也是非持久型 XSS。相比於反射型 XSS,它不需要經過服務端,而是改變頁面 DOM 來達到攻擊。同樣,這種攻擊方式也需要誘導用戶點擊。

案例:

目標頁面:

<html>
    <body>hello</body>
</html>
<script>
  let search = new URLSearchParams(location.search)
  document.write("hello, " + search.get('name') + '!')
</script>

惡意程式碼鏈接:

//www.a.com/test.index?name=<srcipt src="xss.js"></script>

防禦手段

  1. 參數驗證,不符合要求的數據不要存入資料庫
  2. 對特殊字元轉義,如”<“、”>”、”/”、”&”等
  3. 避免使用evalnew Function動態執行字元串的方法
  4. 避免使用 innerHTMLdocument.write 直接將字元串輸出到HTML
  5. 把一些敏感的 cookie 設置為 http only,避免前端訪問 cookie

跨站請求偽造(CSRF)

CSRF 攻擊就是在受害者毫不知情的情況下以受害者名義偽造請求發送給受攻擊站點,從而在並未授權的情況下執行在許可權保護之下的操作。CSRF 並不需要直接獲取用戶資訊,只需要「借用」用戶的登錄資訊相關操作即可,隱蔽性更強。

案例:

假設現在有一個部落格網站,得知刪除博文的 URL 為:

//blog.com?m=delete&id=123

攻擊者構造一個頁面,內容為:

<img src="//blog.com?m=delete&id=123"></img>

攻擊者偽裝該網站鏈接並誘導用戶進行點擊,用戶恰好訪問過 blog.com,與該網站的 cookie 身份驗證資訊還未過期。這時進入攻擊者的網站,img 發起請求,請求里攜帶上cookie,成功刪除博文。但是對於用戶是無感知的,當用戶返回到部落格時會發現博文不見了,而這個請求是屬於合法請求,因為攻擊者借用受害者的身份資訊進行操作。

防禦手段

  1. 設置 Cookie 的 SameSite
  2. 服務端驗證 Refer 欄位,Refer 是請求源網址,對於不合法的 Refer 拒絕請求
  3. 添加 token,讓鏈接變得不可預測,攻擊者無法構造一個完整的 URL 實施 CSRF 攻擊
  4. 添加驗證碼,強制用戶必須與應用交互,但會降低用戶體驗,只能作為輔助手段

點擊劫持(ClickJacking)

攻擊者創建一個網頁利用 iframe 包含目標網站,然後通過設置透明度等方式隱藏目標網站,使用戶無法察覺目標網站的存在,並且把它遮罩在網頁上。在網頁中誘導用戶點擊特定的按鈕,而這個按鈕的位置和目標網站的某個按鈕重合,當用戶點擊網頁上的按鈕時,實際上是點擊目標網站的按鈕。

防禦手段

  1. frame busting,通常可以寫一段JavaScript,以禁止 iframe 的嵌套。
if (top.location != location) {
    top.location = self.location
}
  1. 添加 HTTP 頭 X-Frame-Options

參考資料

Tags: