(中篇)中高階前端大廠面試祕籍,寒冬中為您保駕護航,直通大廠

郭東東發表於2019-03-21

感恩!~~沒想到上篇文章能這麼受大家的喜歡,激動不已。?。但是卻也是誠惶誠恐,這也意味著責任。下篇許多知識點都需要比較深入的研究和理解,博主也是水平有限,擔心自己無法承擔大家的期待。不過終究還是需要擺正心態,放下情緒,一字一字用心專注,不負自己,也不負社群。與各位小夥伴相互學習,共同成長,以此共勉!

最近業務繁忙,精力有限,雖然我儘量嚴謹和反覆修訂,但文章也定有疏漏。上篇文章中,許多小夥伴們指出了不少的問題,為此我也是深表抱歉,我也會虛心接受和糾正錯誤。也非常感激那麼多通過微信或公眾號與我探討的小夥伴,感謝大家的支援和鼓勵。

引言

大家知道,React 現在已經在前端開發中佔據了主導的地位。優異的效能,強大的生態,讓其無法阻擋。博主面的 5 家公司,全部是 React 技術棧。據我所知,大廠也大部分以 React 作為主技術棧。React 也成為了面試中並不可少的一環。

中篇主要從以下幾個方面對 React 展開闡述:

本來是計劃只有上下兩篇,可是寫著寫著越寫越多,受限於篇幅,也為了有更好的閱讀體驗,只好拆分出中篇,希望各位童鞋別介意。?,另外,下篇還有 Hybrid App / Webpack / 效能優化 / Nginx 等方面的知識,敬請期待。

建議還是先從上篇基礎開始哈~有個循序漸進的過程: 面試上篇。?

進階知識

框架: React

React 也是現如今最流行的前端框架,也是很多大廠面試必備。React 與 Vue 雖有不同,但同樣作為一款 UI 框架,雖然實現可能不一樣,但在一些理念上還是有相似的,例如資料驅動、元件化、虛擬 dom 等。這裡就主要列舉一些 React 中獨有的概念。

1. Fiber

React 的核心流程可以分為兩個部分:

  • reconciliation (排程演算法,也可稱為 render):
    • 更新 state 與 props;
    • 呼叫生命週期鉤子;
    • 生成 virtual dom;
      • 這裡應該稱為 Fiber Tree 更為符合;
    • 通過新舊 vdom 進行 diff 演算法,獲取 vdom change;
    • 確定是否需要重新渲染
  • commit:
    • 如需要,則操作 dom 節點更新;

要了解 Fiber,我們首先來看為什麼需要它?

  • 問題: 隨著應用變得越來越龐大,整個更新渲染的過程開始變得吃力,大量的元件渲染會導致主程式長時間被佔用,導致一些動畫或高頻操作出現卡頓和掉幀的情況。而關鍵點,便是 同步阻塞。在之前的排程演算法中,React 需要例項化每個類元件,生成一顆元件樹,使用 同步遞迴 的方式進行遍歷渲染,而這個過程最大的問題就是無法 暫停和恢復

  • 解決方案: 解決同步阻塞的方法,通常有兩種: 非同步任務分割。而 React Fiber 便是為了實現任務分割而誕生的。

  • 簡述:

    • 在 React V16 將排程演算法進行了重構, 將之前的 stack reconciler 重構成新版的 fiber reconciler,變成了具有連結串列和指標的 單連結串列樹遍歷演算法。通過指標對映,每個單元都記錄著遍歷當下的上一步與下一步,從而使遍歷變得可以被暫停和重啟。
    • 這裡我理解為是一種 任務分割排程演算法,主要是 將原先同步更新渲染的任務分割成一個個獨立的 小任務單位,根據不同的優先順序,將小任務分散到瀏覽器的空閒時間執行,充分利用主程式的事件迴圈機制。
  • 核心:

    • Fiber 這裡可以具象為一個 資料結構:
    class Fiber {
    	constructor(instance) {
    		this.instance = instance
    		// 指向第一個 child 節點
    		this.child = child
    		// 指向父節點
    		this.return = parent
    		// 指向第一個兄弟節點
    		this.sibling = previous
    	}	
    }
    複製程式碼
    • 連結串列樹遍歷演算法: 通過 節點儲存與對映,便能夠隨時地進行 停止和重啟,這樣便能達到實現任務分割的基本前提;

      • 1、首先通過不斷遍歷子節點,到樹末尾;
      • 2、開始通過 sibling 遍歷兄弟節點;
      • 3、return 返回父節點,繼續執行2;
      • 4、直到 root 節點後,跳出遍歷;
    • 任務分割,React 中的渲染更新可以分成兩個階段:

      • reconciliation 階段: vdom 的資料對比,是個適合拆分的階段,比如對比一部分樹後,先暫停執行個動畫呼叫,待完成後再回來繼續比對。
      • Commit 階段: 將 change list 更新到 dom 上,並不適合拆分,才能保持資料與 UI 的同步。否則可能由於阻塞 UI 更新,而導致資料更新和 UI 不一致的情況。
    • 分散執行: 任務分割後,就可以把小任務單元分散到瀏覽器的空閒期間去排隊執行,而實現的關鍵是兩個新API: requestIdleCallbackrequestAnimationFrame

      • 低優先順序的任務交給requestIdleCallback處理,這是個瀏覽器提供的事件迴圈空閒期的回撥函式,需要 pollyfill,而且擁有 deadline 引數,限制執行事件,以繼續切分任務;
      • 高優先順序的任務交給requestAnimationFrame處理;
    // 類似於這樣的方式
    requestIdleCallback((deadline) => {
        // 當有空閒時間時,我們執行一個元件渲染;
        // 把任務塞到一個個碎片時間中去;
        while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
            nextComponent = performWork(nextComponent);
        }
    });
    複製程式碼
    • 優先順序策略: 文字框輸入 > 本次排程結束需完成的任務 > 動畫過渡 > 互動反饋 > 資料更新 > 不會顯示但以防將來會顯示的任務

Tips:

Fiber 其實可以算是一種程式設計思想,在其它語言中也有許多應用(Ruby Fiber)。核心思想是 任務拆分和協同,主動把執行權交給主執行緒,使主執行緒有時間空擋處理其他高優先順序任務。

當遇到程式阻塞的問題時,任務分割非同步呼叫快取策略 是三個顯著的解決思路。

感謝 @Pengyuan 童鞋,在評論中指出了幾個 Fiber 中最核心的理念,感恩!!

2. 生命週期

在新版本中,React 官方對生命週期有了新的 變動建議:

  • 使用getDerivedStateFromProps 替換componentWillMount
  • 使用getSnapshotBeforeUpdate替換componentWillUpdate
  • 避免使用componentWillReceiveProps

其實該變動的原因,正是由於上述提到的 Fiber。首先,從上面我們知道 React 可以分成 reconciliation 與 commit 兩個階段,對應的生命週期如下:

  • reconciliation:

    • componentWillMount
    • componentWillReceiveProps
    • shouldComponentUpdate
    • componentWillUpdate
  • commit:

    • componentDidMount
    • componentDidUpdate
    • componentWillUnmount

在 Fiber 中,reconciliation 階段進行了任務分割,涉及到 暫停 和 重啟,因此可能會導致 reconciliation 中的生命週期函式在一次更新渲染迴圈中被 多次呼叫 的情況,產生一些意外錯誤。

新版的建議生命週期如下:

class Component extends React.Component {
  // 替換 `componentWillReceiveProps` ,
  // 初始化和 update 時被呼叫
  // 靜態函式,無法使用 this
  static getDerivedStateFromProps(nextProps, prevState) {}
  
  // 判斷是否需要更新元件
  // 可以用於元件效能優化
  shouldComponentUpdate(nextProps, nextState) {}
  
  // 元件被掛載後觸發
  componentDidMount() {}
  
  // 替換 componentWillUpdate
  // 可以在更新之前獲取最新 dom 資料
  getSnapshotBeforeUpdate() {}
  
  // 元件更新後呼叫
  componentDidUpdate() {}
  
  // 元件即將銷燬
  componentWillUnmount() {}
  
  // 元件已銷燬
  componentDidUnMount() {}
}
複製程式碼
  • 使用建議:

    • constructor初始化 state;
    • componentDidMount中進行事件監聽,並在componentWillUnmount中解綁事件;
    • componentDidMount中進行資料的請求,而不是在componentWillMount
    • 需要根據 props 更新 state 時,使用getDerivedStateFromProps(nextProps, prevState)
      • 舊 props 需要自己儲存,以便比較;
    public static getDerivedStateFromProps(nextProps, prevState) {
    	// 當新 props 中的 data 發生變化時,同步更新到 state 上
    	if (nextProps.data !== prevState.data) {
    		return {
    			data: nextProps.data
    		}
    	} else {
    		return null1
    	}
    }
    複製程式碼
    • 可以在componentDidUpdate監聽 props 或者 state 的變化,例如:
    componentDidUpdate(prevProps) {
    	// 當 id 發生變化時,重新獲取資料
    	if (this.props.id !== prevProps.id) {
    		this.fetchData(this.props.id);
    	}
    }
    複製程式碼
    • componentDidUpdate使用setState時,必須加條件,否則將進入死迴圈;
    • getSnapshotBeforeUpdate(prevProps, prevState)可以在更新之前獲取最新的渲染資料,它的呼叫是在 render 之後, update 之前;
    • shouldComponentUpdate: 預設每次呼叫setState,一定會最終走到 diff 階段,但可以通過shouldComponentUpdate的生命鉤子返回false來直接阻止後面的邏輯執行,通常是用於做條件渲染,優化渲染的效能。

3. setState

在瞭解setState之前,我們先來簡單瞭解下 React 一個包裝結構: Transaction:

  • 事務 (Transaction):
    • 是 React 中的一個呼叫結構,用於包裝一個方法,結構為: initialize - perform(method) - close。通過事務,可以統一管理一個方法的開始與結束;處於事務流中,表示程式正在執行一些操作;

(中篇)中高階前端大廠面試祕籍,寒冬中為您保駕護航,直通大廠

  • setState: React 中用於修改狀態,更新檢視。它具有以下特點:

  • 非同步與同步: setState並不是單純的非同步或同步,這其實與呼叫時的環境相關:

    • 合成事件生命週期鉤子(除 componentDidUpdate) 中,setState是"非同步"的;
      • 原因: 因為在setState的實現中,有一個判斷: 當更新策略正在事務流的執行中時,該元件更新會被推入dirtyComponents佇列中等待執行;否則,開始執行batchedUpdates佇列更新;
        • 在生命週期鉤子呼叫中,更新策略都處於更新之前,元件仍處於事務流中,而componentDidUpdate是在更新之後,此時元件已經不在事務流中了,因此則會同步執行;
        • 在合成事件中,React 是基於 事務流完成的事件委託機制 實現,也是處於事務流中;
      • 問題: 無法在setState後馬上從this.state上獲取更新後的值。
      • 解決: 如果需要馬上同步去獲取新值,setState其實是可以傳入第二個引數的。setState(updater, callback),在回撥中即可獲取最新值;
    • 原生事件setTimeout 中,setState是同步的,可以馬上獲取更新後的值;
      • 原因: 原生事件是瀏覽器本身的實現,與事務流無關,自然是同步;而setTimeout是放置於定時器執行緒中延後執行,此時事務流已結束,因此也是同步;
  • 批量更新: 在 合成事件生命週期鉤子 中,setState更新佇列時,儲存的是 合併狀態(Object.assign)。因此前面設定的 key 值會被後面所覆蓋,最終只會執行一次更新;

  • 函式式: 由於 Fiber 及 合併 的問題,官方推薦可以傳入 函式 的形式。setState(fn),在fn中返回新的state物件即可,例如this.setState((state, props) => newState);

    • 使用函式式,可以用於避免setState的批量更新的邏輯,傳入的函式將會被 順序呼叫
  • 注意事項:

    • setState 合併,在 合成事件 和 生命週期鉤子 中多次連續呼叫會被優化為一次;
    • 當元件已被銷燬,如果再次呼叫setState,React 會報錯警告,通常有兩種解決辦法:
      • 將資料掛載到外部,通過 props 傳入,如放到 Redux 或 父級中;
      • 在元件內部維護一個狀態量 (isUnmounted),componentWillUnmount中標記為 true,在setState前進行判斷;

4. HOC(高階元件)

HOC(Higher Order Componennt) 是在 React 機制下社群形成的一種元件模式,在很多第三方開源庫中表現強大。

  • 簡述:

    • 高階元件不是元件,是 增強函式,可以輸入一個元元件,返回出一個新的增強元件;
    • 高階元件的主要作用是 程式碼複用操作 狀態和引數;
  • 用法:

    • 屬性代理 (Props Proxy): 返回出一個元件,它基於被包裹元件進行 功能增強

      • 預設引數: 可以為元件包裹一層預設引數;
      function proxyHoc(Comp) {
      	return class extends React.Component {
      		render() {
      			const newProps = {
      				name: 'tayde',
      				age: 1,
      			}
      			return <Comp {...this.props} {...newProps} />
      		}
      	}
      }
      複製程式碼
      • 提取狀態: 可以通過 props 將被包裹元件中的 state 依賴外層,例如用於轉換受控元件:
      function withOnChange(Comp) {
      	return class extends React.Component {
      		constructor(props) {
      			super(props)
      			this.state = {
      				name: '',
      			}
      		}
      		onChangeName = () => {
      			this.setState({
      				name: 'dongdong',
      			})
      		}
      		render() {
      			const newProps = {
      				value: this.state.name,
      				onChange: this.onChangeName,
      			}
      			return <Comp {...this.props} {...newProps} />
      		}
      	}
      }
      複製程式碼

      使用姿勢如下,這樣就能非常快速的將一個 Input 元件轉化成受控元件。

      const NameInput = props => (<input name="name" {...props} />)
      export default withOnChange(NameInput)
      複製程式碼
      • 包裹元件: 可以為被包裹元素進行一層包裝,
      function withMask(Comp) {
        return class extends React.Component {
            render() {
      		  return (
      		      <div>
      				  <Comp {...this.props} />
      					<div style={{
      					  width: '100%',
      					  height: '100%',
      					  backgroundColor: 'rgba(0, 0, 0, .6)',
      				  }} 
      			  </div>
      		  )
      	  }
        }
      }
      複製程式碼
    • 反向繼承 (Inheritance Inversion): 返回出一個元件,繼承於被包裹元件,常用於以下操作:

      function IIHoc(Comp) {
          return class extends Comp {
              render() {
                  return super.render();
              }
          };
      }
      複製程式碼
      • 渲染劫持 (Render Highjacking)

        • 條件渲染: 根據條件,渲染不同的元件
        function withLoading(Comp) {
            return class extends Comp {
                render() {
                    if(this.props.isLoading) {
                        return <Loading />
                    } else {
                        return super.render()
                    }
                }
            };
        }
        複製程式碼
        • 可以直接修改被包裹元件渲染出的 React 元素樹
      • 操作狀態 (Operate State): 可以直接通過 this.state 獲取到被包裹元件的狀態,並進行操作。但這樣的操作容易使 state 變得難以追蹤,不易維護,謹慎使用。

  • 應用場景:

    • 許可權控制,通過抽象邏輯,統一對頁面進行許可權判斷,按不同的條件進行頁面渲染:
    function withAdminAuth(WrappedComponent) {
        return class extends React.Component {
    		constructor(props){
    			super(props)
    			this.state = {
    		    	isAdmin: false,
    			}
    		} 
    		async componentWillMount() {
    		    const currentRole = await getCurrentUserRole();
    		    this.setState({
    		        isAdmin: currentRole === 'Admin',
    		    });
    		}
    		render() {
    		    if (this.state.isAdmin) {
    		        return <Comp {...this.props} />;
    		    } else {
    		        return (<div>您沒有許可權檢視該頁面,請聯絡管理員!</div>);
    		    }
    		}
        };
    }
    複製程式碼
    • 效能監控,包裹元件的生命週期,進行統一埋點:
    function withTiming(Comp) {
        return class extends Comp {
            constructor(props) {
                super(props);
                this.start = Date.now();
                this.end = 0;
            }
            componentDidMount() {
                super.componentDidMount && super.componentDidMount();
                this.end = Date.now();
                console.log(`${WrappedComponent.name} 元件渲染時間為 ${this.end - this.start} ms`);
            }
            render() {
                return super.render();
            }
        };
    }
    複製程式碼
    • 程式碼複用,可以將重複的邏輯進行抽象。
  • 使用注意:

      1. 純函式: 增強函式應為純函式,避免侵入修改元元件;
      1. 避免用法汙染: 理想狀態下,應透傳元元件的無關引數與事件,儘量保證用法不變;
      1. 名稱空間: 為 HOC 增加特異性的元件名稱,這樣能便於開發除錯和查詢問題;
      1. 引用傳遞: 如果需要傳遞元元件的 refs 引用,可以使用React.forwardRef
      1. 靜態方法: 元元件上的靜態方法並無法被自動傳出,會導致業務層無法呼叫;解決:
      • 函式匯出
      • 靜態方法賦值
      1. 重新渲染: 由於增強函式每次呼叫是返回一個新元件,因此如果在 Render 中使用增強函式,就會導致每次都重新渲染整個HOC,而且之前的狀態會丟失;

5. Redux

Redux 是一個 資料管理中心,可以把它理解為一個全域性的 data store 例項。它通過一定的使用規則和限制,保證著資料的健壯性、可追溯和可預測性。它與 React 無關,可以獨立執行於任何 JavaScript 環境中,從而也為同構應用提供了更好的資料同步通道。

  • 核心理念:

    • 單一資料來源: 整個應用只有唯一的狀態樹,也就是所有 state 最終維護在一個根級 Store 中;
    • 狀態只讀: 為了保證狀態的可控性,最好的方式就是監控狀態的變化。那這裡就兩個必要條件:
      • Redux Store 中的資料無法被直接修改;
      • 嚴格控制修改的執行;
    • 純函式: 規定只能通過一個純函式 (Reducer) 來描述修改;
  • 大致的資料結構如下所示:

(中篇)中高階前端大廠面試祕籍,寒冬中為您保駕護航,直通大廠

  • 理念實現:

    • Store: 全域性 Store 單例, 每個 Redux 應用下只有一個 store, 它具有以下方法供使用:
      • getState: 獲取 state;
      • dispatch: 觸發 action, 更新 state;
      • subscribe: 訂閱資料變更,註冊監聽器;
    // 建立
    const store = createStore(Reducer, initStore)
    複製程式碼
    • Action: 它作為一個行為載體,用於對映相應的 Reducer,並且它可以成為資料的載體,將資料從應用傳遞至 store 中,是 store 唯一的資料來源
    // 一個普通的 Action
    const action = {
    	type: 'ADD_LIST',
    	item: 'list-item-1',
    }
    
    // 使用:
    store.dispatch(action)
    
    // 通常為了便於呼叫,會有一個 Action 建立函式 (action creater)
    funtion addList(item) {
    	return const action = {
    		type: 'ADD_LIST',
    		item,
    	}
    }
    
    // 呼叫就會變成:
    dispatch(addList('list-item-1'))
    複製程式碼
    • Reducer: 用於描述如何修改資料的純函式,Action 屬於行為名稱,而 Reducer 便是修改行為的實質;
    // 一個常規的 Reducer
    // @param {state}: 舊資料
    // @param {action}: Action 物件
    // @returns {any}: 新資料
    const initList = []
    function ListReducer(state = initList, action) {
    	switch (action.type) {
    		case 'ADD_LIST':
    			return state.concat([action.item])
    			break
    		defalut:
    			return state
    	}
    }
    複製程式碼

    注意:

    1. 遵守資料不可變,不要去直接修改 state,而是返回出一個 新物件,可以使用 assign / copy / extend / 解構 等方式建立新物件;
    2. 預設情況下需要 返回原資料,避免資料被清空;
    3. 最好設定 初始值,便於應用的初始化及資料穩定;
  • 進階:

    • React-Redux: 結合 React 使用;
      • <Provider>: 將 store 通過 context 傳入元件中;
      • connect: 一個高階元件,可以方便在 React 元件中使用 Redux;
          1. store通過mapStateToProps進行篩選後使用props注入元件
          1. 根據mapDispatchToProps建立方法,當元件呼叫時使用dispatch觸發對應的action
    • Reducer 的拆分與重構:
      • 隨著專案越大,如果將所有狀態的 reducer 全部寫在一個函式中,將會 難以維護
      • 可以將 reducer 進行拆分,也就是 函式分解,最終再使用combineReducers()進行重構合併;
    • 非同步 Action: 由於 Reducer 是一個嚴格的純函式,因此無法在 Reducer 中進行資料的請求,需要先獲取資料,再dispatch(Action)即可,下面是三種不同的非同步實現:

6. React Hooks

React 中通常使用 類定義 或者 函式定義 建立元件:

在類定義中,我們可以使用到許多 React 特性,例如 state、 各種元件生命週期鉤子等,但是在函式定義中,我們卻無能為力,因此 React 16.8 版本推出了一個新功能 (React Hooks),通過它,可以更好的在函式定義元件中使用 React 特性。

  • 好處:

    • 1、跨元件複用: 其實 render props / HOC 也是為了複用,相比於它們,Hooks 作為官方的底層 API,最為輕量,而且改造成本小,不會影響原來的元件層次結構和傳說中的巢狀地獄;
    • 2、類定義更為複雜:
      • 不同的生命週期會使邏輯變得分散且混亂,不易維護和管理;
      • 時刻需要關注this的指向問題;
      • 程式碼複用代價高,高階元件的使用經常會使整個元件樹變得臃腫;
    • 3、狀態與UI隔離: 正是由於 Hooks 的特性,狀態邏輯會變成更小的粒度,並且極容易被抽象成一個自定義 Hooks,元件中的狀態和 UI 變得更為清晰和隔離。
  • 注意:

    • 避免在 迴圈/條件判斷/巢狀函式 中呼叫 hooks,保證呼叫順序的穩定;
    • 只有 函式定義元件 和 hooks 可以呼叫 hooks,避免在 類元件 或者 普通函式 中呼叫;
    • 不能在useEffect中使用useState,React 會報錯提示;
    • 類元件不會被替換或廢棄,不需要強制改造類元件,兩種方式能並存;
  • 重要鉤子*:

    • 狀態鉤子 (useState): 用於定義元件的 State,其到類定義中this.state的功能;
    // useState 只接受一個引數: 初始狀態
    // 返回的是元件名和更改該元件對應的函式
    const [flag, setFlag] = useState(true);
    // 修改狀態
    setFlag(false)
    	
    // 上面的程式碼對映到類定義中:
    this.state = {
    	flag: true	
    }
    const flag = this.state.flag
    const setFlag = (bool) => {
        this.setState({
            flag: bool,
        })
    }
    複製程式碼
    • 生命週期鉤子 (useEffect):

    類定義中有許多生命週期函式,而在 React Hooks 中也提供了一個相應的函式 (useEffect),這裡可以看做componentDidMountcomponentDidUpdatecomponentWillUnmount的結合。

    • useEffect(callback, [source])接受兩個引數
      • callback: 鉤子回撥函式;
      • source: 設定觸發條件,僅當 source 發生改變時才會觸發;
      • useEffect鉤子在沒有傳入[source]引數時,預設在每次 render 時都會優先呼叫上次儲存的回撥中返回的函式,後再重新呼叫回撥;
    useEffect(() => {
    	// 元件掛載後執行事件繫結
    	console.log('on')
    	addEventListener()
    	
    	// 元件 update 時會執行事件解綁
    	return () => {
    		console.log('off')
    		removeEventListener()
    	}
    }, [source]);
    
    
    // 每次 source 發生改變時,執行結果(以類定義的生命週期,便於大家理解):
    // --- DidMount ---
    // 'on'
    // --- DidUpdate ---
    // 'off'
    // 'on'
    // --- DidUpdate ---
    // 'off'
    // 'on'
    // --- WillUnmount --- 
    // 'off'
    複製程式碼
    • 通過第二個引數,我們便可模擬出幾個常用的生命週期:

      • componentDidMount: 傳入[]時,就只會在初始化時呼叫一次;
      const useMount = (fn) => useEffect(fn, [])
      複製程式碼
      • componentWillUnmount: 傳入[],回撥中的返回的函式也只會被最終執行一次;
      const useUnmount = (fn) => useEffect(() => fn, [])
      複製程式碼
      • mounted: 可以使用 useState 封裝成一個高度可複用的 mounted 狀態;
      const useMounted = () => {
          const [mounted, setMounted] = useState(false);
          useEffect(() => {
              !mounted && setMounted(true);
              return () => setMounted(false);
          }, []);
          return mounted;
      }
      複製程式碼
      • componentDidUpdate: useEffect每次均會執行,其實就是排除了 DidMount 後即可;
      const mounted = useMounted() 
      useEffect(() => {
          mounted && fn()
      })
      複製程式碼
  • 其它內建鉤子:

    • useContext: 獲取 context 物件

    • useReducer: 類似於 Redux 思想的實現,但其並不足以替代 Redux,可以理解成一個元件內部的 redux:

      • 並不是持久化儲存,會隨著元件被銷燬而銷燬;
      • 屬於元件內部,各個元件是相互隔離的,單純用它並無法共享資料;
      • 配合useContext的全域性性,可以完成一個輕量級的 Redux;(easy-peasy)
    • useCallback: 快取回撥函式,避免傳入的回撥每次都是新的函式例項而導致依賴元件重新渲染,具有效能優化的效果;

    • useMemo: 用於快取傳入的 props,避免依賴的元件每次都重新渲染;

    • useRef: 獲取元件的真實節點;

    • useLayoutEffect:

      • DOM更新同步鉤子。用法與useEffect類似,只是區別於執行時間點的不同。
      • useEffect屬於非同步執行,並不會等待 DOM 真正渲染後執行,而useLayoutEffect則會真正渲染後才觸發;
      • 可以獲取更新後的 state;
  • 自定義鉤子(useXxxxx): 基於 Hooks 可以引用其它 Hooks 這個特性,我們可以編寫自定義鉤子,如上面的useMounted。又例如,我們需要每個頁面自定義標題:

function useTitle(title) {
  useEffect(
    () => {
      document.title = title;
    });
}

// 使用:
function Home() {
	const title = '我是首頁'
	useTitle(title)
	
	return (
		<div>{title}</div>
	)
}
複製程式碼

7. SSR

SSR,俗稱 服務端渲染 (Server Side Render),講人話就是: 直接在服務端層獲取資料,渲染出完成的 HTML 檔案,直接返回給使用者瀏覽器訪問。

  • 前後端分離: 前端與服務端隔離,前端動態獲取資料,渲染頁面。

  • 痛點:

    • 首屏渲染效能瓶頸:

      • 空白延遲: HTML下載時間 + JS下載/執行時間 + 請求時間 + 渲染時間。在這段時間內,頁面處於空白的狀態。
    • SEO 問題: 由於頁面初始狀態為空,因此爬蟲無法獲取頁面中任何有效資料,因此對搜尋引擎不友好。

      • 雖然一直有在提動態渲染爬蟲的技術,不過據我瞭解,大部分國內搜尋引擎仍然是沒有實現。

最初的服務端渲染,便沒有這些問題。但我們不能返璞歸真,既要保證現有的前端獨立的開發模式,又要由服務端渲染,因此我們使用 React SSR。

  • 原理:

    • Node 服務: 讓前後端執行同一套程式碼成為可能。
    • Virtual Dom: 讓前端程式碼脫離瀏覽器執行。
  • 條件: Node 中間層、 React / Vue 等框架。 結構大概如下:

(中篇)中高階前端大廠面試祕籍,寒冬中為您保駕護航,直通大廠

  • 開發流程: (此處以 React + Router + Redux + Koa 為例)

    • 1、在同個專案中,搭建 前後端部分,常規結構:

      • build
      • public
      • src
        • client
        • server
    • 2、server 中使用 Koa 路由監聽 頁面訪問:

    import * as Router from 'koa-router'
    
    const router = new Router()
    // 如果中間也提供 Api 層
    router.use('/api/home', async () => {
    	// 返回資料
    })
    
    router.get('*', async (ctx) => {
    	// 返回 HTML
    })
    複製程式碼
    • 3、通過訪問 url 匹配 前端頁面路由:
    // 前端頁面路由
    import { pages } from '../../client/app'
    import { matchPath } from 'react-router-dom'
    
    // 使用 react-router 庫提供的一個匹配方法
    const matchPage = matchPath(ctx.req.url, page)
    複製程式碼
    • 4、通過頁面路由的配置進行 資料獲取。通常可以在頁面路由中增加 SSR 相關的靜態配置,用於抽象邏輯,可以保證服務端邏輯的通用性,如:

      class HomePage extends React.Component{
      	public static ssrConfig = {
      		  cache: true,
               fetch() {
              	  // 請求獲取資料
               }
          }
      }
      複製程式碼

      獲取資料通常有兩種情況:

      • 中間層也使用 http 獲取資料,則此時 fetch 方法可前後端共享;
      const data = await matchPage.ssrConfig.fetch()
      複製程式碼
      • 中間層並不使用 http,是通過一些 內部呼叫,例如 Rpc 或 直接讀資料庫 等,此時也可以直接由服務端呼叫對應的方法獲取資料。通常,這裡需要在 ssrConfig 中配置特異性的資訊,用於匹配對應的資料獲取方法。
      // 頁面路由
      class HomePage extends React.Component{
      	public static ssrConfig = {
              fetch: {
              	 url: '/api/home',
              }
          }
      }
      
      // 根據規則匹配出對應的資料獲取方法
      // 這裡的規則可以自由,只要能匹配出正確的方法即可
      const controller = matchController(ssrConfig.fetch.url)
      
      // 獲取資料
      const data = await controller(ctx)
      複製程式碼
    • 5、建立 Redux store,並將資料dispatch到裡面:

    import { createStore } from 'redux'
    // 獲取 Clinet層 reducer
    // 必須複用前端層的邏輯,才能保證一致性;
    import { reducers } from '../../client/store'
    
    // 建立 store
    const store = createStore(reducers)
     
    // 獲取配置好的 Action
    const action = ssrConfig.action
    
    // 儲存資料	
    store.dispatch(createAction(action)(data))
    複製程式碼
    • 6、注入 Store, 呼叫renderToString將 React Virtual Dom 渲染成 字串:
    import * as ReactDOMServer from 'react-dom/server'
    import { Provider } from 'react-redux'
    
    // 獲取 Clinet 層根元件
    import { App } from '../../client/app'
    
    const AppString = ReactDOMServer.renderToString(
    	<Provider store={store}>
    		<StaticRouter
    			location={ctx.req.url}
    			context={{}}>
    			<App />
    		</StaticRouter>
    	</Provider>
    )
    複製程式碼
    • 7、將 AppString 包裝成完整的 html 檔案格式;

    • 8、此時,已經能生成完整的 HTML 檔案。但只是個純靜態的頁面,沒有樣式沒有互動。接下來我們就是要插入 JS 與 CSS。我們可以通過訪問前端打包後生成的asset-manifest.json檔案來獲取相應的檔案路徑,並同樣注入到 Html 中引用。

    const html = `
    	<!DOCTYPE html>
    	<html lang="zh">
    		<head></head>
    		<link href="${cssPath}" rel="stylesheet" />
    		<body>
    			<div id="App">${AppString}</div>
    			<script src="${scriptPath}"></script>
    		</body>
    	</html>
    `
    複製程式碼
    • 9、進行 資料脫水: 為了把服務端獲取的資料同步到前端。主要是將資料序列化後,插入到 html 中,返回給前端。
    import serialize from 'serialize-javascript'
    // 獲取資料
    const initState = store.getState()
    const html = `
    	<!DOCTYPE html>
    	<html lang="zh">
    		<head></head>
    		<body>
    			<div id="App"></div>
    			<script type="application/json" id="SSR_HYDRATED_DATA">${serialize(initState)}</script>
    		</body>
    	</html>
    `
    
    ctx.status = 200
    ctx.body = html
    複製程式碼

    Tips:

    這裡比較特別的有兩點:

    1. 使用了serialize-javascript序列化 store, 替代了JSON.stringify,保證資料的安全性,避免程式碼注入和 XSS 攻擊;

    2. 使用 json 進行傳輸,可以獲得更快的載入速度;

    • 10、Client 層 資料吸水: 初始化 store 時,以脫水後的資料為初始化資料,同步建立 store。
    const hydratedEl = document.getElementById('SSR_HYDRATED_DATA')
    const hydrateData = JSON.parse(hydratedEl.textContent)
    
    // 使用初始 state 建立 Redux store
    const store = createStore(reducer, hydrateData)
    複製程式碼

8. 函數語言程式設計

函數語言程式設計是一種 程式設計正規化,你可以理解為一種軟體架構的思維模式。它有著獨立一套理論基礎與邊界法則,追求的是 更簡潔、可預測、高複用、易測試。其實在現有的眾多知名庫中,都蘊含著豐富的函數語言程式設計思想,如 React / Redux 等。

  • 常見的程式設計正規化:

    • 指令式程式設計(過程化程式設計): 更關心解決問題的步驟,一步步以語言的形式告訴計算機做什麼;
    • 事件驅動程式設計: 事件訂閱與觸發,被廣泛用於 GUI 的程式設計設計中;
    • 物件導向程式設計: 基於類、物件與方法的設計模式,擁有三個基礎概念: 封裝性、繼承性、多型性;
    • 函數語言程式設計
      • 換成一種更高階的說法,面向數學程式設計。怕不怕~?
  • 函數語言程式設計的理念:

    • 純函式(確定性函式): 是函數語言程式設計的基礎,可以使程式變得靈活,高度可擴充,可維護;

      • 優勢:

        • 完全獨立,與外部解耦;
        • 高度可複用,在任意上下文,任意時間線上,都可執行並且保證結果穩定;
        • 可測試性極強;
      • 條件:

        • 不修改引數;
        • 不依賴、不修改任何函式外部的資料;
        • 完全可控,引數一樣,返回值一定一樣: 例如函式不能包含new Date()或者Math.randon()等這種不可控因素;
        • 引用透明;
      • 我們常用到的許多 API 或者工具函式,它們都具有著純函式的特點, 如split / join / map

    • 函式複合: 將多個函式進行組合後呼叫,可以實現將一個個函式單元進行組合,達成最後的目標;

      • 扁平化巢狀: 首先,我們一定能想到組合函式最簡單的操作就是 包裹,因為在 JS 中,函式也可以當做引數:

        • f(g(k(x))): 巢狀地獄,可讀性低,當函式複雜後,容易讓人一臉懵逼;
        • 理想的做法: xxx(f, g, k)(x)
      • 結果傳遞: 如果想實現上面的方式,那也就是xxx函式要實現的便是: 執行結果在各個函式之間的執行傳遞;

        • 這時我們就能想到一個原生提供的陣列方法: reduce,它可以按陣列的順序依次執行,傳遞執行結果;
        • 所以我們就能夠實現一個方法pipe,用於函式組合:
        // ...fs: 將函式組合成陣列;
        // Array.prototype.reduce 進行組合;
        // p: 初始引數;
        const pipe = (...fs) => p => fs.reduce((v, f) => f(v), p)
        複製程式碼
      • 使用: 實現一個 駝峰命名 轉 中劃線命名 的功能:

      // 'Guo DongDong' --> 'guo-dongdong'
      // 函式組合式寫法
      const toLowerCase = str => str.toLowerCase()
      const join = curry((str, arr) => arr.join(str))
      const split = curry((splitOn, str) => str.split(splitOn));
      
      const toSlug = pipe(
      	toLowerCase,	
      	split(' '),
      	join('_'),
      	encodeURIComponent,
      );
      console.log(toSlug('Guo DongDong'))
      複製程式碼
      • 好處:

        • 隱藏中間引數,不需要臨時變數,避免了這個環節的出錯機率;
        • 只需關注每個純函式單元的穩定,不再需要關注命名,傳遞,呼叫等;
        • 可複用性強,任何一個函式單元都可被任意複用和組合;
        • 可擴充性強,成本低,例如現在加個需求,要檢視每個環節的輸出:
        const log = curry((label, x) => {
        	console.log(`${ label }: ${ x }`);
        	return x;
        });
        
        const toSlug = pipe(
        	toLowerCase,	
        	log('toLowerCase output'),
        	split(' '),
        	log('split output'),
        	join('_'),
        	log('join output'),
        	encodeURIComponent,
        );
        複製程式碼

      Tips:

      一些工具純函式可直接引用lodash/fp,例如curry/map/split等,並不需要像我們上面這樣自己實現;

    • 資料不可變性(immutable): 這是一種資料理念,也是函數語言程式設計中的核心理念之一:

      • 倡導: 一個物件再被建立後便不會再被修改。當需要改變值時,是返回一個全新的物件,而不是直接在原物件上修改;
      • 目的: 保證資料的穩定性。避免依賴的資料被未知地修改,導致了自身的執行異常,能有效提高可控性與穩定性;
      • 並不等同於const。使用const建立一個物件後,它的屬性仍然可以被修改;
      • 更類似於Object.freeze: 凍結物件,但freeze仍無法保證深層的屬性不被串改;
      • immutable.js: js 中的資料不可變庫,它保證了資料不可變,在 React 生態中被廣泛應用,大大提升了效能與穩定性;
        • trie資料結構:
          • 一種資料結構,能有效地深度凍結物件,保證其不可變;
          • 結構共享: 可以共用不可變物件的記憶體引用地址,減少記憶體佔用,提高資料操作效能;
    • 避免不同函式之間的 狀態共享,資料的傳遞使用複製或全新物件,遵守資料不可變原則;

    • 避免從函式內部 改變外部狀態,例如改變了全域性作用域或父級作用域上的變數值,可能會導致其它單位錯誤;

    • 避免在單元函式內部執行一些 副作用,應該將這些操作抽離成更獨立的工具單元;

      • 日誌輸出
      • 讀寫檔案
      • 網路請求
      • 呼叫外部程式
      • 呼叫有副作用的函式
  • 高階函式: 是指 以函式為引數,返回一個新的增強函式 的一類函式,它通常用於:

    • 將邏輯行為進行 隔離抽象,便於快速複用,如處理資料,相容性等;
    • 函式組合,將一系列單元函式列表組合成功能更強大的函式;
    • 函式增強,快速地擴充函式功能,
  • 函數語言程式設計的好處:

    • 函式副作用小,所有函式獨立存在,沒有任何耦合,複用性極高;
    • 不關注執行時間,執行順序,引數,命名等,能專注於資料的流動與處理,能有效提高穩定性與健壯性;
    • 追求單元化,粒度化,使其重構和改造成本降低,可維護、可擴充性較好;
    • 更易於做單元測試。
  • 總結:

    • 函數語言程式設計其實是一種程式設計思想,它追求更細的粒度,將應用拆分成一組組極小的單元函式,組合呼叫運算元據流;
    • 它提倡著 純函式 / 函式複合 / 資料不可變, 謹慎對待函式內的 狀態共享 / 依賴外部 / 副作用;

Tips:

其實我們很難也不需要在面試過程中去完美地闡述出整套思想,這裡也只是淺嘗輒止,一些個人理解而已。博主也是初級小菜鳥,停留在表面而已,只求對大家能有所幫助,輕噴?;

我個人覺得: 這些程式設計正規化之間,其實並不矛盾,各有各的 優劣勢

理解和學習它們的理念與優勢,合理地 設計融合,將優秀的軟體程式設計思想用於提升我們應用;

所有設計思想,最終的目標一定是使我們的應用更加 解耦顆粒化、易擴充、易測試、高複用,開發更為高效和安全

有一些庫能讓大家很快地接觸和運用函式思想: Underscore.js / Lodash/fp / Rxjs 等。

結語

到此,想必大家會發現已經開始深入一些理論和原理層面了,並不像上篇那麼的淺顯易懂了。但這也是個必經之路,不可能永遠停留在 5分鐘掌握的技術 上。不再停留在語言的表面,而是理解更深入的原理,模式,架構,因果,你就會突然發現你成為高階軟體工程師了。?。

希望各位小夥伴能沉下心來,一些理論、概念雖然枯燥,但反覆琢磨後再自己實踐嘗試下,就能有自己的理解。

當你開始面試高階工程師時,面試官便不再重點關注你會不會寫stopPropagation或者會不會水平居中了,而是更在乎你自己的思考和研究能力了。表現出自己深入理解研究的成果,定會讓面試官刮目相看。

Tips:

位元組跳動招中高階前端或實習,有興趣內推的同學可簡歷郵件至 guoxiaodong.tayde@bytedance.com (標題: 姓名-崗位-地點) 或關注下面公眾號加我微信詳聊哈。

博主真的寫得很辛苦,再不 star 下,真的要哭了。~ github。?

(中篇)中高階前端大廠面試祕籍,寒冬中為您保駕護航,直通大廠

相關文章