前言
金九銀十,又是一波跑路。趁著有空把前端基礎和麵試相關的知識點都系統的學習一遍,參考一些權威的書籍和優秀的文章,最後加上自己的一些理解,總結出來這篇文章。適合複習和準備面試的同學,其中的知識點包括:
- 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
null
、false
、''
轉換number
型別都是 0undefined
轉換number
型別是NaN
,所以undefined
和其他基本型別運算都會輸出NaN
- 字串在加法運算(其實是字串拼接)中很強勢,和任何型別相加都會輸出字串(
symbol
除外),即使是NaN
、undefined
。其他運算則正常轉為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 程式碼執行前,對程式碼進行語法分析和程式碼生成,初始化的建立並儲存變數,為執行程式碼做好準備。
預編譯過程:
- 建立GO/AO物件(GO是全域性物件,AO是活動物件)
- 將形參和變數宣告賦值為
undefined
- 實參形參相統一
- 函式宣告提升(將變數賦值為函式體)
例子:
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 中取值,輸出是函式x
;x
被賦值為 10,第二個 x
輸出 10;函式x
已被宣告提升,此處不會再賦值 x
,第三個 x
輸出 10。
作用域
作用域能保證對有權訪問的所有變數和函式的有序訪問,是程式碼在執行期間查詢變數的一種規則。
函式作用域
函式在執行時會建立屬於自己的作用域,將內部的變數和函式定義“隱藏”起來,外部作用域無法訪問包裝函式內部的任何內容。
塊級作用域
在ES6之前建立塊級作用域,可以使用 with
或 try/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
方法。
為了方便操作基本型別值,每當讀取一個基本型別值的時候,後臺會建立一個對應的基本包裝型別的物件,從而讓我們能夠呼叫方法來操作這些資料。大概過程如下:
- 建立
String
型別的例項 - 在例項上呼叫指定的方法
- 銷燬這個例項
let str = new String('hello')
str.split('')
str = null
this
this
是函式被呼叫時發生的繫結,它指向什麼完全取決於函式在哪裡被呼叫。我理解的this
是函式的呼叫者物件,當在函式內使用this
,可以訪問到呼叫者物件上的屬性和方法。
this
繫結的四種情況:
- new 繫結。
new
例項化 - 顯示繫結。
call
、apply
、bind
手動更改指向 - 隱式繫結。由上下文物件呼叫,如
obj.fn()
,this
指向obj
- 預設繫結。預設繫結全域性物件,在嚴格模式下會繫結到
undefined
優先順序new繫結最高,最後到預設繫結。
new的過程
- 建立一個空物件
- 設定原型,將物件的
__proto__
指向建構函式的prototype
- 建構函式中的
this
執行物件,並執行建構函式,為空物件新增屬性和方法 - 返回例項物件
注意點:建構函式內出現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
指向的。
call
和 apply
改變 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:
- 只接受引用型別作為鍵名
- 鍵名是弱引用,鍵值可以是任意值,可以被垃圾回收。鍵名所指向的外部引用被回收後,對應鍵名也可以被回收
- 不能遍歷
箭頭函式和普通函式的區別
- 箭頭函式的
this
指向在編寫程式碼時就已經確定,即箭頭函式本身所在的作用域;普通函式在呼叫時確定this
。 - 箭頭函式沒有
arguments
- 箭頭函式沒有
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)
優點:
- 引數複用,由於引數可以分開傳入,我們可以複用傳入引數後的函式
- 延遲執行,就跟
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的渲染流程
流程主要分為三個部分:
- 模板編譯,
parse
解析模板生成抽象語法樹(AST);optimize
標記靜態節點,在後續頁面更新時會跳過靜態節點;generate
將AST轉成render
函式,render
函式用於構建VNode
。 - 構建VNode(虛擬dom),構建過程使用
createElement
構建VNode
,createElement
也是自定義render
函式時接受到的第一個引數。 - VNode轉真實dom,
patch
函式負責將VNode
轉換成真實dom,核心方法是createElm
,遞迴建立真實dom樹,最終渲染到頁面上。
data為什麼要求是函式
當一個元件被定義,data 必須宣告為返回一個初始資料物件的函式,因為元件可能被用來建立多個例項。如果 data 仍然是一個純粹的物件,則所有的例項將共享引用同一個資料物件!通過提供 data 函式,每次建立一個新例項後,我們能夠呼叫 data 函式,從而返回初始資料的一個全新副本資料物件。
JavaScript 中的物件作為引用型別,如果是建立多個例項,直接使用物件會導致例項的共享引用。而這裡建立多個例項,指的是元件複用的情況。因為在編寫元件時,是通過 export
暴露出去的一個物件,如果元件複用的話,多個例項都是引用這個物件,就會造成共享引用。使用函式返回一個物件,由於是不同引用,自然可以避免這個問題發生。
Vue生命週期
- beforeCreate: 在例項建立之前呼叫,由於例項還未建立,所以無法訪問例項上的
data
、computed
、method
等。 - created: 在例項建立完成後呼叫,這時已完成資料的觀測,可以獲取資料和更改資料,但還無法與dom進行互動,如果想要訪問dom,可以使用
vm.$nextTick
。此時可以對資料進行更改,不會觸發updated
。 - beforeMount: 在掛載之前呼叫,這時的模板已編譯完成並生成
render
函式,準備開始渲染。在此時也可以對資料進行更改,不會觸發updated
。 - mounted: 在掛載完成後呼叫,真實的dom掛載完畢,可以訪問到dom節點,使用
$refs
屬性對dom進行操作。 - beforeUpdate: 在更新之前呼叫,也就是響應式資料發生更新,虛擬dom重新渲染之前被觸發,在當前階段進行更改資料,不會造成重渲染。
- updated: 在更新完成之後呼叫,元件dom已完成更新。要注意的是避免在此期間更改資料,這可能會導致死迴圈。
- beforeDestroy: 在例項銷燬之前呼叫,這時例項還可以被使用,一般這個週期內可以做清除計時器和取消事件監聽的工作。
- destroyed: 在例項銷燬之後呼叫,這時已無法訪問例項。當前例項從父例項中被移除,觀測被解除安裝,所有事件監聽器唄移除,子例項也統統被銷燬。
請說出 Vue 的5種指令
v-if
v-for
v-show
v-html
v-model
computed 和 watch 的區別
computed
依賴data
的改變而改變,computed
會返回值;watch
觀察data
,執行對應的函式。computed
有快取功能,重複取值不會執行求值函式。computed
依賴收集在頁面渲染時觸發,watch
收集依賴在頁面渲染前觸發。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
}
})
元件通訊方式
- props/emit
- $children/$parent
- ref
- $attrs/$listeners
- provide/inject
- eventBus
- vuex
雙向繫結原理
雙向繫結是檢視變化會反映到資料,資料變化會反映到檢視,v-model
就是個很好理解的例子。其實主要考查的還是響應式原理,響應式原理共包括3個主要成員,Observer
負責監聽資料變化,Dep
負責依賴收集,Watcher
負責資料或檢視更新,我們常說的收集依賴就是收集 Watcher
。
響應式原理主要工作流程如下:
Observer
內使用Object.defineProperty
劫持資料,為其設定set
和get
。- 每個資料都會有自己的
dep
。資料取值觸發get
函式,呼叫dep.depend
收集依賴;資料更新觸發set
函式,呼叫dep.notify
通知Watcher
更新。 Watcher
接收到更新的通知,將這些通知加入到一個非同步佇列中,並且進行去重處理,等到所有同步操作完成後,再一次性更新檢視。
Vue如何檢測陣列變化
Vue
內部重寫陣列原型鏈,當陣列發生變化時,除了執行原生的陣列方法外,還會呼叫 dep.notify
通知 Watcher
更新。觸發陣列更新的方法共7種:
push
pop
shift
unshift
splice
sort
reverse
keep-alive
keep-alive
是 Vue
的內建元件,同時也是一個抽象元件,它主要用於元件快取。當元件切換時會將元件的VNode
快取起來,等待下次重新啟用時,再將快取的元件VNode
渲染出來,從而實現快取。
常用的兩個屬性 include
和 exclude
,支援字串、正則和陣列的形式,允許元件有條件的進行快取。還有 max
屬性,用於設定最大快取數。
兩個生命週期 activated
和 deactivated
,在元件啟用和失活時觸發。
keep-alive
的快取機制運用LRU(Least Recently Used)演算法,
nextTick
在下次 dom 更新結束之後執行延遲迴調。nextTick
主要使用了巨集任務和微任務。根據執行環境分別嘗試採用:
- Promise
- MutationObserver
- setImmediate
- setTimeout
nextTick
主要用於內部 Watcher
的非同步更新,對外我們可以使用 Vue.nextTick
和 vm.$nextTick
。在 nextTick
中可以獲取更新完成的 dom。
如何理解單向資料流
所有的 prop 都使得其父子 prop 之間形成了一個單向下行繫結:父級 prop 的更新會向下流動到子元件中,但是反過來則不行。這樣會防止從子元件意外變更父級元件的狀態,從而導致你的應用的資料流向難以理解。
單向資料流只允許資料由父元件傳遞給子元件,資料只能由父元件更新。當資料傳遞到多個子元件,而子元件能夠在其內部更新資料時,在主觀上很難知道是哪個子元件更新了資料,導致資料流向不明確,從而增加應用除錯的難度。
但子元件更新父元件資料的場景確實存在,有3種方法可以使用:
- 子元件emit,父元件接受自定義事件。這種方法最終還是由父元件進行修改,子元件只是起到一個通知的作用。
- 子元件自定義雙向繫結,設定元件的
model
選項為元件新增自定義雙向繫結。 .sync
屬性修飾符,它是第一種方法的語法糖,在傳遞屬性新增上該修飾符,子元件內可呼叫this.$emit('update:屬性名', value)
更新屬性。
Vue3 和 Vue2.x 的差異
- 使用
Proxy
代替Object.defineProperty
- 新增
Composition API
- 模板允許多個根節點
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
路由進入前呼叫,beforeEnter
在 beforeEach
之後執行
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
監聽位址列的變化。使用 pushState
和 replaceState
修改url,而無需載入頁面。但是在重新整理頁面時還是會向後端發起請求,需要後端配合將資源定向回前端,交由前端路由處理。
abstract
不涉及和瀏覽器地址的相關記錄。通過陣列維護模擬瀏覽器的歷史記錄棧。
vuex 怎麼跨模組呼叫
跨模組呼叫是指當前名稱空間模組呼叫全域性模組或者另一個名稱空間模組。在呼叫 dispatch
和 commit
時設定第三個引數為 {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儲存的狀態在頁面重新整理後會丟失,使用持久化技術能保證頁面重新整理後狀態依然存在。
- 使用本地儲存配合,設定 state 同時設定 storage,在重新整理後再初始化 vuex
- vuex-persistedstate 外掛
模組化
這裡只記錄常用的兩種模組:CommonJS模組、ES6模組。
CommonJS模組
Node.js 採用 CommonJS 模組規範,在服務端執行時是同步載入,在客戶端使用需要編譯後才可以執行。
特點
- 模組可以多次載入。但在第一次載入時,結果會被快取起來,再次載入模組,直接獲取快取的結果
- 模組載入的順序,按照其在程式碼中出現的順序
語法
- 暴露模組:
module.exports = value
或exports.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模組載入只能在程式碼頂層使用
- 模組不能多次載入同一個變數
語法
- 暴露模組:
export
或export 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 模組的差異
- CommonJS 模組輸出的是一個值的拷貝,ES6 模組輸出的是值的引用。
- CommonJS 模組是執行時載入,ES6 模組是編譯時輸出介面。
瀏覽器
頁面渲染流程
- 位元組流解碼。瀏覽器獲得位元組資料,根據位元組編碼將位元組流解碼,轉換為程式碼。
- 輸入流預處理。字元資料進行統一格式化。
- 令牌化。從輸入流中提取可識別的子串和標記符號。可以理解為對HTML解析,進行詞法分析,匹配標籤生成令牌結構。
- 構建DOM樹、構建CSSOM樹。DOM樹和CSSOM樹的構建過程是同時進行的,在 HTML 解析過程中如果遇到 script 標籤,解析會暫停並將執行許可權交給 JavaScript 引擎,等到 JavaScript 指令碼執行完畢後再交給渲染引擎繼續解析。(補充:如果指令碼中呼叫了改變 DOM 結構的 document.write() 函式,此時渲染引擎會回到第二步,將這些程式碼加入字元流,重新進行解析。)
- 構建渲染樹。DOM樹負責結構內容,CSSOM樹負責樣式規則,為了渲染,需要將它們合成渲染樹。
- 佈局。佈局階段根據渲染樹的節點和節點的CSS定義以及節點從屬關係,計算元素的大小和位置,將所有相對值轉換為螢幕上的絕對畫素。
- 繪製。繪製就是將渲染樹中的每個節點轉換成螢幕上的實際畫素的過程。在繪製階段,瀏覽器會遍歷渲染樹,呼叫渲染器的paint方法在螢幕上顯示其內容。實際上,繪製過程是在多個層上完成的,這些層稱為渲染層(RenderLayer)。
- 渲染層合成。多個繪製後的渲染層按照恰當的重疊順序進行合併,而後生成點陣圖,最終通過顯示卡展示到螢幕上。
資料變化過程:位元組 → 字元 → 令牌 → 樹 → 頁面
迴流、重繪
迴流(Reflow)
在佈局完成後,對DOM佈局進行修改(比如大小或位置),會引起頁面重新計算佈局,這個過程稱為“迴流”。
重繪(Repaint)
對DOM進行不影響佈局的修改引起的螢幕區域性繪製(比如背景顏色、字型顏色),這個過程稱為“重繪”。
小結
迴流一定會引起重繪,而重繪不一定會引起迴流。由於迴流需要重新計算節點佈局,迴流的渲染耗時會高於重繪。
對於迴流重繪,瀏覽器本身也有優化策略,瀏覽器會維護一個佇列,將回流重繪操作放入佇列中,等佇列到達一定時間,再按順序去一次性執行佇列的操作。
但是也有例外,有時我們需要獲取某些樣式資訊,例如:
offsetTop
,offsetLeft
,offsetWidth
,offsetHeight
,scrollTop/Left/Width/Height
,clientTop/Left/Width/Height
,getComputedStyle()
,或者 IE 的 currentStyle
。
這時,瀏覽器為了反饋準確的資訊,需要立即迴流重繪一次,所以可能導致佇列提前執行。
事件迴圈(Event Loop)
在瀏覽器的實現上,諸如渲染任務、JavaScript 指令碼執行、User Interaction(使用者互動)、網路處理都跑在同一個執行緒上,當執行其中一個型別的任務的時候意味著其他任務的阻塞,為了有序的對各個任務按照優先順序進行執行瀏覽器實現了我們稱為 Event Loop 排程流程。
簡單來說,Event Loop 就是執行程式碼、收集和處理事件以及執行佇列中子任務的一個過程。
巨集任務
在一次新的事件迴圈的過程中,遇到巨集任務時,巨集任務將被加入任務佇列,但需要等到下一次事件迴圈才會執行。
常見巨集任務:setTimeout
、setInterval
、requestAnimationFrame
微任務
當前事件迴圈的任務佇列為空時,微任務佇列中的任務就會被依次執行。在執行過程中,如果遇到微任務,微任務被加入到當前事件迴圈的微任務佇列中。簡單來說,只要有微任務就會繼續執行,而不是放到下一個事件迴圈才執行。
微任務佇列屬於任務執行環境內的一員,並非處於全域性的位置。也就是說,每個任務都會有一個微任務佇列。
常見微任務:Promise.then
、Promise.catch
、MutationObserver
流程
- 取出一個巨集任務執行,如果碰到巨集任務,將其放入任務佇列,如果碰到微任務,將其放入微任務佇列
- 檢查微任務佇列是否有可執行的微任務,如果有則執行微任務。微任務執行過程中,如果碰到巨集任務,將其放入任務佇列。如果碰到微任務,繼續將其放入當前的微任務佇列,直到微任務全部執行。
- 更新渲染階段,判斷是否需要渲染,也就是說不一定每一輪
Event Loop
都會對應一次瀏覽器渲染。 - 對於需要渲染的文件,執行
requestAnimationFrame
幀動畫回撥。 - 對於需要渲染的文件,重新渲染繪製使用者介面。
- 判斷任務佇列和微任務佇列是否為空,如果是,則進行
Idle
空閒週期的演算法,判斷是否要執行requestIdleCallback
的回撥函式。
小結
在當前任務執行環境內,微任務總是先於巨集任務執行;
requestAnimationFrame
回撥在頁面渲染之前呼叫,適合做動畫;
requestIdleCallback
在渲染螢幕之後呼叫,可以使用它來執行一些不太重要的任務。
同源策略(Same origin policy)
源是由 URL 中協議、主機名(域名)以及埠共同組成的部分。
同源策略是瀏覽器的行為,為了保護本地資料不被JavaScript程式碼獲取回來的資料汙染,它是存在於瀏覽器最核心也最基本的安全功能。
所謂同源指的是:協議、域名、埠號必須一致,只要有一個不相同,那麼就是“跨源”。
最常見的同源策略是因為域名不同,也就是常說的“跨域”。一般分為請求跨域和頁面跨域。
請求跨域解決方案
- 跨域資源共享(CORS)。服務端設定HTTP響應頭(Access-Control-Allow-Origin)
- 代理轉發。同源策略只存在於瀏覽器,使用服務端設定代理轉發沒有同源策略的限制。
- JSONP。依賴的是 script 標籤跨域引用 js 檔案不會受到瀏覽器同源策略的限制。
- Websocket。HTML5 規範提出的一個應用層的全雙工協議,適用於瀏覽器與伺服器進行實時通訊場景。
常用方法是CORS和代理轉發。
頁面跨域解決方案
- postMessage。HTML5 的 postMessage 方法可用於兩個頁面之間通訊,而且不論這兩個頁面是否同源。
- document.domain。對於主域名相同,子域名不同的情況,可以通過修改 document.domain 的值來進行跨域。
- window.location.hash,通過 url 帶 hash ,通過一個非跨域的中間頁面來傳遞資料。
- window. name,當 window 的 location 變化,然後重新載入,它的 name 屬性可以依然保持不變。通過 iframe 的 src 屬性由外域轉向本地域,跨域資料即由 iframe 的 window. name 從外域傳遞到本地域。
CORS請求
對於CORS請求,瀏覽器將其分成兩個型別:簡單請求和非簡單請求。
簡單請求
簡單請求符合下面 2 個特徵:
-
請求方法為 GET、POST、HEAD。
-
請求頭只能使用以下規定的安全欄位:
- 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在傳輸過程中,對內容使用對稱加密,而金鑰使用非對稱加密。
過程
- 客戶端向服務端發起HTTPS請求
- 服務端返回HTTPS證照
- 客戶端驗證證照是否合法,不合法會提示告警
- 證照驗證合法後,在本地生成隨機數
- 用公鑰加密隨機數併傳送到服務端
- 服務端使用私鑰對隨機數解密
- 服務端使用隨機數構造對稱加密演算法,對內容加密後傳輸
- 客戶端收到加密內容,使用本地儲存的隨機數構建對稱加密演算法進行解密
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 對比資源最後修改時間來實現快取。
- 瀏覽器第一次請求資源,服務端在返回資源的響應頭上新增 Last-Modified 欄位,值是資源在服務端的最後修改時間;
- 瀏覽器再次請求資源,在請求頭上新增 If-Modified-Since,值是上次服務端返回的最後修改時間;
- 服務端收到請求,根據 If-Modified-Since 的值進行判斷。若資源未修改過,則返回 304 狀態碼,並且不返回內容,瀏覽器使用快取;否則返回資源內容,並更新 Last-Modified 的值;
ETag 和 If-None-Match
ETag 和 If-None-Match 對比資源雜湊值,雜湊值由資源內容計算得出,即依賴資源內容實現快取。
- 瀏覽器第一次請求資源,服務端在返回資源的響應頭上新增 ETag 欄位,值是資源的雜湊值
- 瀏覽器再次請求資源,在請求頭上新增 If-None-Match,值是上次服務端返回的資源雜湊值;
- 服務端收到請求,根據 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>";
惡意程式碼連結:
http://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>
惡意程式碼連結:
http://www.a.com/test.index?name=<srcipt src="xss.js"></script>
防禦手段
- 引數驗證,不符合要求的資料不要存入資料庫
- 對特殊字元轉義,如"<"、">"、"/"、"&"等
- 避免使用
eval
、new Function
動態執行字串的方法 - 避免使用
innerHTML
、document.write
直接將字串輸出到HTML - 把一些敏感的
cookie
設定為http only
,避免前端訪問cookie
跨站請求偽造(CSRF)
CSRF 攻擊就是在受害者毫不知情的情況下以受害者名義偽造請求傳送給受攻擊站點,從而在並未授權的情況下執行在許可權保護之下的操作。CSRF 並不需要直接獲取使用者資訊,只需要“借用”使用者的登入資訊相關操作即可,隱蔽性更強。
案例:
假設現在有一個部落格網站,得知刪除博文的 URL 為:
http://blog.com?m=delete&id=123
攻擊者構造一個頁面,內容為:
<img src="http://blog.com?m=delete&id=123"></img>
攻擊者偽裝該網站連結並誘導使用者進行點選,使用者恰好訪問過 blog.com
,與該網站的 cookie
身份驗證資訊還未過期。這時進入攻擊者的網站,img 發起請求,請求裡攜帶上cookie
,成功刪除博文。但是對於使用者是無感知的,當使用者返回到部落格時會發現博文不見了,而這個請求是屬於合法請求,因為攻擊者借用受害者的身份資訊進行操作。
防禦手段
- 設定 Cookie 的 SameSite
- 服務端驗證 Refer 欄位,Refer 是請求源網址,對於不合法的 Refer 拒絕請求
- 新增
token
,讓連結變得不可預測,攻擊者無法構造一個完整的 URL 實施 CSRF 攻擊 - 新增驗證碼,強制使用者必須與應用互動,但會降低使用者體驗,只能作為輔助手段
點選劫持(ClickJacking)
攻擊者建立一個網頁利用 iframe 包含目標網站,然後通過設定透明度等方式隱藏目標網站,使使用者無法察覺目標網站的存在,並且把它遮罩在網頁上。在網頁中誘導使用者點選特定的按鈕,而這個按鈕的位置和目標網站的某個按鈕重合,當使用者點選網頁上的按鈕時,實際上是點選目標網站的按鈕。
防禦手段
- frame busting,通常可以寫一段JavaScript,以禁止 iframe 的巢狀。
if (top.location != location) {
top.location = self.location
}
- 新增 HTTP 頭 X-Frame-Options
參考資料
- 《JavaScript高階程式設計(第3版)》
- 《你不知道的JavaScript(上卷)》
- 《JavaScript設計模式與開發實踐》
- 《白帽子講Web安全》
- ES6 入門教程
- Vue.js 技術揭祕
- 前端模組化詳解(完整版)
- HTML規範 - 解析HTML文件
- 瀏覽器層合成與頁面渲染優化
- 10種跨域解決方案(附終極大招)
- MDN - HTTP訪問控制(CORS)
- HTML規範 - 事件迴圈
- MDN - 深入:微任務與Javascript執行時環境
- 深入解析你不知道的 EventLoop 和瀏覽器渲染、幀動畫、空閒回撥(動圖演示)
- Tasks, microtasks, queues and schedules
- 臥槽!牛皮了,頭一次見有大佬把TCP三次握手四次揮手解釋的這麼明白
- 你連 HTTPS 原理都不懂,還講“中間人攻擊”?
- 進階 · 那些你必須搞懂的網路基礎