前端工程師的自我修養:React Fiber 是如何實現更新過程可控的

2021-02-14 政採雲前端團隊
前言

從 React 16 開始,React 採用了 Fiber 機制替代了原先基於原生執行棧遞歸遍歷 VDOM 的方案,提高了頁面渲染性能和用戶體驗。乍一聽 Fiber 好像挺神秘,在原生執行棧都還沒搞懂的情況下,又整出個 Fiber,還能不能愉快的寫代碼了。別慌,老鐵!下面就來嘮嘮關於 Fiber 那點事兒。

什麼是 Fiber

Fiber 的英文含義是「纖維」,它是比線程(Thread)更細的線,比線程(Thread)控制得更精密的執行模型。在廣義計算機科學概念中,Fiber 又是一種協作的(Cooperative)編程模型,幫助開發者用一種【既模塊化又協作化】的方式來編排代碼。

簡單點說,Fiber 就是 React 16 實現的一套新的更新機制,讓 React 的更新過程變得可控,避免了之前一竿子遞歸到底影響性能的做法。

關於 Fiber 你需要知道的基礎知識1. 瀏覽器刷新率(幀)

頁面的內容都是一幀一幀繪製出來的,瀏覽器刷新率代表瀏覽器一秒繪製多少幀。目前瀏覽器大多是 60Hz(60幀/s),每一幀耗時也就是在 16ms 左右。原則上說 1s 內繪製的幀數也多,畫面表現就也細膩。那麼在這一幀的(16ms) 過程中瀏覽器又幹了啥呢?

通過上面這張圖可以清楚的知道,瀏覽器一幀會經過下面這幾個過程:

執行 RAF (RequestAnimationFrame)執行 RIC  (RequestIdelCallback)

第七步的 RIC 事件不是每一幀結束都會執行,只有在一幀的 16ms 中做完了前面 6 件事兒且還有剩餘時間,才會執行。這裡提一下,如果一幀執行結束後還有時間執行 RIC 事件,那麼下一幀需要在事件執行結束才能繼續渲染,所以 RIC 執行不要超過 30ms,如果長時間不將控制權交還給瀏覽器,會影響下一幀的渲染,導致頁面出現卡頓和事件響應不及時。

2. JS 原生執行棧

React Fiber 出現之前,React 通過原生執行棧遞歸遍歷 VDOM。當瀏覽器引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文並將其壓入執行棧,接下來每遇到一個函數調用,又會往棧中壓入一個新的上下文。比如:

function A(){
  B();
  C();
}
function B(){}
function C(){}
A();

瀏覽器引擎會從執行棧的頂端開始執行,執行完畢就彈出當前執行上下文,開始執行下一個函數,直到執行棧被清空才會停止。然後將執行權交還給瀏覽器。由於 React 將頁面視圖視作一個個函數執行的結果。每一個頁面往往由多個視圖組成,這就意味著多個函數的調用。

如果一個頁面足夠複雜,形成的函數調用棧就會很深。每一次更新,執行棧需要一次性執行完成,中途不能幹其他的事兒,只能"一心一意"。結合前面提到的瀏覽器刷新率,JS 一直執行,瀏覽器得不到控制權,就不能及時開始下一幀的繪製。如果這個時間超過 16ms,當頁面有動畫效果需求時,動畫因為瀏覽器不能及時繪製下一幀,這時動畫就會出現卡頓。不僅如此,因為事件響應代碼是在每一幀開始的時候執行,如果不能及時繪製下一幀,事件響應也會延遲。

3. 時間分片(Time Slicing)

時間分片指的是一種將多個粒度小的任務放入一個時間切片(一幀)中執行的一種方案,在 React Fiber 中就是將多個任務放在了一個時間片中去執行。

4. 鍊表

在 React Fiber 中用鍊表遍歷的方式替代了 React 16 之前的棧遞歸方案。在 React 16 中使用了大量的鍊表。例如:

例如下面這個組件:

<div id="id">
  A1
  <div id="B1">
    B1
     <div id="C1"></div>
  </div>
  <div id="B2">
   B2
  </div>
</div>

會使用下面這樣的鍊表表示:

鍊表是一種簡單高效的數據結構,它在當前節點中保存著指向下一個節點的指針,就好像火車一樣一節連著一節

遍歷的時候,通過操作指針找到下一個元素。但是操作指針時(調整順序和指向)一定要小心。

鍊表相比順序結構數據格式的好處就是:

操作更高效,比如順序調整、刪除,只需要改變節點的指針指向就好了。不僅可以根據當前節點找到下一個節點,在多向鍊表中,還可以找到他的父節點或者兄弟節點。

但鍊表也不是完美的,缺點就是:

比順序結構數據更佔用空間,因為每個節點對象還保存有指向下一個對象的指針。

React 用空間換時間,更高效的操作可以方便根據優先級進行操作。同時可以根據當前節點找到其他節點,在下面提到的掛起和恢復過程中起到了關鍵作用。

React Fiber 是如何實現更新過程可控?

前面講完基本知識,現在正式開始介紹今天的主角 Fiber,看看 React Fiber 是如何實現對更新過程的管控。

更新過程的可控主要體現在下面幾個方面:

1. 任務拆分

前面提到,React Fiber 之前是基於原生執行棧,每一次更新操作會一直佔用主線程,直到更新完成。這可能會導致事件響應延遲,動畫卡頓等現象。

在 React Fiber 機制中,它採用"化整為零"的戰術,將調和階段(Reconciler)遞歸遍歷 VDOM 這個大任務分成若干小任務,每個任務只負責一個節點的處理。例如:

import React from "react";
import ReactDom from "react-dom"
const jsx = (
    <div id="A1">
    A1
    <div id="B1">
      B1
      <div id="C1">C1</div>
      <div id="C2">C2</div>
    </div>
    <div id="B2">B2</div>
  </div>
)
ReactDom.render(jsx,document.getElementById("root"))

這個組件在渲染的時候會被分成八個小任務,每個任務用來分別處理 A1(div)、A1(text)、B1(div)、B1(text)、C1(div)、C1(text)、C2(div)、C2(text)、B2(div)、B2(text)。再通過時間分片,在一個時間片中執行一個或者多個任務。這裡提一下,所有的小任務並不是一次性被切分完成,而是處理當前任務的時候生成下一個任務,如果沒有下一個任務生成了,就代表本次渲染的 Diff 操作完成。

2. 掛起、恢復、終止

再說掛起、恢復、終止之前,不得不提兩棵 Fiber 樹,workInProgress tree 和 currentFiber tree。

workInProgress 代表當前正在執行更新的 Fiber 樹。在 render 或者 setState 後,會構建一顆 Fiber 樹,也就是 workInProgress tree,這棵樹在構建每一個節點的時候會收集當前節點的副作用,整棵樹構建完成後,會形成一條完整的副作用鏈。

currentFiber 表示上次渲染構建的 Filber 樹。在每一次更新完成後 workInProgress 會賦值給  currentFiber。在新一輪更新時 workInProgress tree 再重新構建,新 workInProgress 的節點通過 alternate 屬性和 currentFiber 的節點建立聯繫。

在新 workInProgress tree 的創建過程中,會同 currentFiber 的對應節點進行 Diff 比較,收集副作用。同時也會復用和 currentFiber 對應的節點對象,減少新創建對象帶來的開銷。也就是說無論是創建還是更新,掛起、恢復以及終止操作都是發生在 workInProgress tree 創建過程中。workInProgress tree  構建過程其實就是循環的執行任務和創建下一個任務,大致過程如下:

當沒有下一個任務需要執行的時候,workInProgress tree 構建完成,開始進入提交階段,完成真實 DOM 更新。

在構建 workInProgressFiber tree 過程中可以通過掛起、恢復和終止任務,實現對更新過程的管控。下面簡化了一下源碼,大致實現如下:

let nextUnitWork = null;//下一個執行單元
//開始調度
function shceduler(task){
     nextUnitWork = task; 
}
//循環執行工作
function workLoop(deadline){
  let shouldYield = false;//是否要讓出時間片交出控制權
  while(nextUnitWork && !shouldYield){
    nextUnitWork = performUnitWork(nextUnitWork)
    shouldYield = deadline.timeRemaining()<1 // 沒有時間了,檢出控制權給瀏覽器
  }
  if(!nextUnitWork) {
    conosle.log("所有任務完成")
    //commitRoot() //提交更新視圖
  }
  // 如果還有任務,但是交出控制權後,請求下次調度
  requestIdleCallback(workLoop,{timeout:5000}) 
}
/*
 * 處理一個小任務,其實就是一個 Fiber 節點,如果還有任務就返回下一個需要處理的任務,沒有就代表整個
 */
function performUnitWork(currentFiber){
  ....
  return FiberNode
}

掛起

當第一個小任務完成後,先判斷這一幀是否還有空閒時間,沒有就掛起下一個任務的執行,記住當前掛起的節點,讓出控制權給瀏覽器執行更高優先級的任務。

恢復

在瀏覽器渲染完一幀後,判斷當前幀是否有剩餘時間,如果有就恢復執行之前掛起的任務。如果沒有任務需要處理,代表調和階段完成,可以開始進入渲染階段。這樣完美的解決了調和過程一直佔用主線程的問題。

那麼問題來了他是如何判斷一幀是否有空閒時間的呢?答案就是我們前面提到的 RIC (RequestIdleCallback) 瀏覽器原生 API,React 源碼中為了兼容低版本的瀏覽器,對該方法進行了 Polyfill。

當恢復執行的時候又是如何知道下一個任務是什麼呢?答案在前面提到的鍊表。在 React Fiber 中每個任務其實就是在處理一個 FiberNode 對象,然後又生成下一個任務需要處理的 FiberNode。順便提一嘴,這裡提到的FiberNode 是一種數據格式,下面是它沒有開美顏的樣子:

class FiberNode {
  constructor(tag, pendingProps, key, mode) {
    // 實例屬性
    this.tag = tag; // 標記不同組件類型,如函數組件、類組件、文本、原生組件...
    this.key = key; // react 元素上的 key 就是 jsx 上寫的那個 key ,也就是最終 ReactElement 上的
    this.elementType = null; // createElement的第一個參數,ReactElement 上的 type
    this.type = null; // 表示fiber的真實類型 ,elementType 基本一樣,在使用了懶加載之類的功能時可能會不一樣
    this.stateNode = null; // 實例對象,比如 class 組件 new 完後就掛載在這個屬性上面,如果是RootFiber,那麼它上面掛的是 FiberRoot,如果是原生節點就是 dom 對象
    // fiber
    this.return = null; // 父節點,指向上一個 fiber
    this.child = null; // 子節點,指向自身下面的第一個 fiber
    this.sibling = null; // 兄弟組件, 指向一個兄弟節點
    this.index = 0; //  一般如果沒有兄弟節點的話是0 當某個父節點下的子節點是數組類型的時候會給每個子節點一個 index,index 和 key 要一起做 diff
    this.ref = null; // reactElement 上的 ref 屬性
    this.pendingProps = pendingProps; // 新的 props
    this.memoizedProps = null; // 舊的 props
    this.updateQueue = null; // fiber 上的更新隊列執行一次 setState 就會往這個屬性上掛一個新的更新, 每條更新最終會形成一個鍊表結構,最後做批量更新
    this.memoizedState = null; // 對應  memoizedProps,上次渲染的 state,相當於當前的 state,理解成 prev 和 next 的關係
    this.mode = mode; // 表示當前組件下的子組件的渲染方式
    // effects
    this.effectTag = NoEffect; // 表示當前 fiber 要進行何種更新
    this.nextEffect = null; // 指向下個需要更新的fiber
    this.firstEffect = null; // 指向所有子節點裡,需要更新的 fiber 裡的第一個
    this.lastEffect = null; // 指向所有子節點中需要更新的 fiber 的最後一個
    this.expirationTime = NoWork; // 過期時間,代表任務在未來的哪個時間點應該被完成
    this.childExpirationTime = NoWork; // child 過期時間
    this.alternate = null; // current 樹和 workInprogress 樹之間的相互引用
  }
}

額…看著好像有點上頭,這是開了美顏的樣子:

是不是好看多了?在每次循環的時候,找到下一個執行需要處理的節點。

function performUnitWork(currentFiber){
    //beginWork(currentFiber) //找到兒子,並通過鍊表的方式掛到currentFiber上,每一偶兒子就找後面那個兄弟
  //有兒子就返回兒子
  if(currentFiber.child){
    return currentFiber.child;
  } 
  //如果沒有兒子,則找弟弟
  while(currentFiber){//一直往上找
    //completeUnitWork(currentFiber);//將自己的副作用掛到父節點去
    if(currentFiber.sibling){
      return currentFiber.sibling
    }
    currentFiber = currentFiber.return;
  }
}

在一次任務結束後返回該處理節點的子節點或兄弟節點或父節點。只要有節點返回,說明還有下一個任務,下一個任務的處理對象就是返回的節點。通過一個全局變量記住當前任務節點,當瀏覽器再次空閒的時候,通過這個全局變量,找到它的下一個任務需要處理的節點恢復執行。就這樣一直循環下去,直到沒有需要處理的節點返回,代表所有任務執行完成。最後大家手拉手,就形成了一顆 Fiber 樹。

終止

其實並不是每次更新都會走到提交階段。當在調和過程中觸發了新的更新,在執行下一個任務的時候,判斷是否有優先級更高的執行任務,如果有就終止原來將要執行的任務,開始新的 workInProgressFiber 樹構建過程,開始新的更新流程。這樣可以避免重複更新操作。這也是在 React 16 以後生命周期函數 componentWillMount 有可能會執行多次的原因。

3. 任務具備優先級

React Fiber 除了通過掛起,恢復和終止來控制更新外,還給每個任務分配了優先級。具體點就是在創建或者更新 FiberNode 的時候,通過算法給每個任務分配一個到期時間(expirationTime)。在每個任務執行的時候除了判斷剩餘時間,如果當前處理節點已經過期,那麼無論現在是否有空閒時間都必須執行改任務。

同時過期時間的大小還代表著任務的優先級。

任務在執行過程中順便收集了每個 FiberNode 的副作用,將有副作用的節點通過 firstEffect、lastEffect、nextEffect 形成一條副作用單鍊表 AI(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A。

其實最終都是為了收集到這條副作用鍊表,有了它,在接下來的渲染階段就通過遍歷副作用鏈完成 DOM 更新。這裡需要注意,更新真實 DOM 的這個動作是一氣呵成的,不能中斷,不然會造成視覺上的不連貫。

關於 React Fiber 的思考1. 能否使用生成器(generater)替代鍊表

在 Fiber 機制中,最重要的一點就是需要實現掛起和恢復,從實現角度來說 generator 也可以實現。那麼為什麼官方沒有使用 generator 呢?猜測應該是是性能方面的原因。生成器不僅讓您在堆棧的中間讓步,還必須把每個函數包裝在一個生成器中。一方面增加了許多語法方面的開銷,另外還增加了任何現有實現的運行時開銷。性能上遠沒有鍊表的方式好,而且鍊表不需要考慮瀏覽器兼容性。

2. Vue 是否會採用 Fiber 機制來優化複雜頁面的更新

這個問題其實有點搞事情,如果 Vue 真這麼做了是不是就是變相承認 Vue 是在"集成" Angular 和 React 的優點呢?React 有 Fiber,Vue 就一定要有?

兩者雖然都依賴 DOM Diff,但是實現上卻有區別,DOM Diff 的目的都是收集副作用。Vue 通過 Watcher 實現了依賴收集,本身就是一種很好的優化。所以 Vue 沒有採用 Fiber 機制,也無傷大雅。

總結

React Fiber 的出現相當於是在更新過程中引進了一個中場指揮官,負責掌控更新過程,足球世界裡管這叫前腰。拋開帶來的性能和效率提升外,這種「化整為零」和任務編排的思想,可以應用到我們平時的架構設計中。

政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 40 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在日常的業務對接之外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推動並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

如果你想改變一直被事折騰,希望開始能折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個結果,卻不需要你;如果你想改變你想做成的事需要一個團隊去支撐,但沒你帶人的位置;如果你想改變既定的節奏,將會是「5 年工作時間 3 年工作經驗」;如果你想改變本來悟性不錯,但總是有那一層窗戶紙的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨著業務騰飛的過程,親手推動一個有著深入的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我覺得我們該聊聊。任何時間,等著你寫點什麼,發給 ZooTeam@cai-inc.com

相關焦點

  • 深入 React Fiber 內部
    React 會重複這個過程直到它知道頁面上所有組件的底層 DOM 標籤元素。這種為了知道 App 組件樹的最底層的 DOM 標籤元素的遞歸遍歷過程稱為 reconciliation, 在 reconciliation 的最後,React 就得到了 DOM 樹的結構。
  • 【前端技術】react渲染 - 流程概述
    作者:winkchen  騰訊IEG前端開發工程師|導語 web前端技術中
  • 深入介紹 React 中的 state 和 props 更新
    在之前我寫了一篇深入介紹 React Fiber的文章,在那篇文章中我介紹了 React 團隊為什麼要重新實現 reconciliation 算法、fiber 節點與 react element 的關係、fiber 節點的欄位以及 fiber 節點是如何被組織在一起的。
  • ReactFiber節點的更新入口:beginWork
    React的更新任務主要是調用一個叫做workLoop的工作循環去構建workInProgress樹,構建過程分為兩個階段:向下遍歷和向上回溯,向下和向上的過程中會對途徑的每個節點進行beginWork和completeWork。
  • React 入門
    一句話概括就是, setState 級別的小更新合併成一個狀態更新,組件中的多個狀態更新在組件的更新隊列中合併,就能夠計算出組件的新狀態 newState。對於初次渲染而言,只需要在第一個 fiber 上,掛載一個 update 標識這是一個初次渲染的 fiber 即可。
  • React-redux數據傳遞是如何實現的?
    主要內容React數據傳遞reduxReact-redux其他學習目標第一節 react數據傳遞react 中組件之間數據傳遞1. 父傳子2. 子傳父(狀態提升)3.2.使用純函數來執行修改如何改變 state tree ,你需要編寫 reducers。它接收先前的 state 和 action,並返回新的 state3.State 是只讀的唯一改變 state 的方法就是觸發 action,action 是一個用於描述已發生事件的普通對象。執行上面純函數。3.
  • 代碼無捷徑:一個前端開發的自我修養
    許多人因為web前端行業前景廣闊投身其中。但是,目前web前端開發工程師在求職時也不是一帆風順,在激烈競爭下,經受住考驗的永遠是那批更優秀的人。對於眾多的前端開發者來說,時常被這樣的問題所困擾,前端開發者要如何提升自我能力?
  • React16源碼解讀:揭秘ReactDOM.render
    在上一篇文章中我們通過create-react-app腳手架快速搭建了一個簡單的示例,並基於該示例講解了在類組件中React.Component和React.PureComponent背後的實現原理。同時我們也了解到,通過使用 Babel 預置工具包@babel/preset-react可以將類組件中render方法的返回值和函數定義組件中的返回值轉換成使用React.createElement方法包裝而成的多層嵌套結構,並基於源碼逐行分析了React.createElement方法背後的實現過程和ReactElement構造函數的成員結構,最後根據分析結果總結出了幾道面試中可能會碰到或者自己以前遇到過的面試考點
  • web前端開發工程師的三種級別
    隨著信息技術不斷發展,前端技術的發展也經歷了不同的階段。前端概念隨著移動智慧型手機的普及被正式提出,混合APP開始被廣泛開發。近年來,由於前端技術開始實現工程化,一些企業前端開發任務逐漸向後端拓展,邏輯思維能力也逐漸成為前端開發人員必備的能力。
  • 當後端一次性丟給你10萬條數據,作為前端工程師的你,要怎麼處理?
    本文轉載自【微信公眾號:趣談前端,ID:beautifulFront】經微信公眾號授權轉載,如需轉載與原文作者聯繫前段時間有朋友問我一個他們公司遇到的問題, 說是後端由於某種原因沒有實現分頁功能, 所以一次性返回了2萬條數據,讓前端用select組件展示到用戶界面裡.
  • 前端技術:React&Vue對比
    React生命周期組件的生命周期可分成三個狀態:在組件創建、到加載到頁面上運行、以及組件被銷毀的過程中,總是伴隨著各種各樣的事件,這些在組件特定時期,觸發的事件統稱為組件的生命周期render:react最重要的步驟,創建虛擬dom,進行diff算法,更新dom樹都在此進行。此時就不能更改state了。componentDidMount : 在第一次渲染後調用,只在客戶端。之後組件已經生成了對應的DOM結構,可以通過this.getDOMNode()來進行訪問。
  • Deep In React之淺談 React Fiber 架構(一)
    如何解決之前的不足之前的問題主要的問題是任務一旦執行,就無法中斷,js 線程一直佔用主線程,導致卡頓。可能有些接觸前端不久的不是特別理解上面為什麼 js 一直佔用主線程就會卡頓,我這裡還是簡單的普及一下。瀏覽器每一幀都需要完成哪些工作?頁面是一幀一幀繪製出來的,當每秒繪製的幀數(FPS)達到 60 時,頁面是流暢的,小於這個值時,用戶會感覺到卡頓。
  • 讀《程式設計師的自我修養》總結
    演員有演員的修養,同樣,作為一個個程式設計師也應該有程式設計師的修養,最近有在看《程式設計師的自我修養》,於是把新的體會記錄一下。《程式設計師的自我修養》有兩個版本,一個是陳逸鶴編寫的,一個是俞甲子,石凡,潘愛民所編寫的,這兩本書我都有在讀,這裡記錄的主要是陳逸鶴所編寫的。
  • 深入理解React Diff算法
    假設render一次之後得到了大量的ReactElement,而這些ReactElement之中若只有少量需要更新的節點,那麼顯然不能全部去更新它們,此時就需要有一個diff過程來決定哪些節點是真正需要更新的。
  • React源碼解析,實現一個React
    更新流程updateComponent()的實現實現完組件的初始化之後,接下來要實現組件的更新邏輯。React開放了 setState() 用於組件更新,回顧上面 React.Component 中 setState() 的定義, 實際調用的是 this._reactInternalInstance.updateComponent(null, newState) 這個函數。而 this.
  • 【React源碼筆記】setState原理解析
    大概意思就是說setState不能確保實時更新state,但也沒有明確setState就是異步的,只是告訴我們什麼時候會觸發同步操作,什麼時候是異步操作。首先要知道一點,setState本身的執行過程是同步的,只是因為在react的合成事件與鉤子函數中執行順序在更新之前,所以不能直接拿到更新後的值,形成了所謂的「 異步 」。
  • 寫給想成為前端工程師的同學們―前端工程師是做什麼的?
    從狹義上講,前端工程師使用 HTML、CSS、JavaScript 等專業技能和工具將產品UI設計稿實現成網站產品,涵蓋用戶PC端、移動端網頁,處理視覺和交互問題。從廣義上來講,所有用戶終端產品與視覺和交互有關的部分,都是前端工程師的專業領域。2005年的時候大多數網頁長這樣:
  • 精通react/vue組件設計之實現一個Tag(標籤)和Empty(空狀態)組件
    本文轉載自【微信公眾號:趣談前端,ID:beautifulFront】經微信公眾號授權轉載,如需轉載與原文作者聯繫前言本文是筆者寫組件設計的第五篇文章,之所以會寫組件設計相關的文章,是因為作為一名前端優秀的前端工程師,面對各種繁瑣而重複的工作,我們不應該按部就班的去
  • [前端工坊]如何學習React:一個五步計劃
    | 譯者: 京東金融-移動研發部-前端開發工程師  田騰我最近查看郵箱的時候注意到一封郵件。
  • 一名合格前端工程師的進階指南!
    我在 2011 年左右進入前端領域,當時工作的主要內容是,將設計稿切圖轉成靜態頁面,然後用 jQuery 插件實現一些頁面的輪播圖、跑馬燈等交互效果。最後使用後端的模板語言如 Smart、Velocity 等將靜態頁面添加頁面邏輯,代碼交給服務端同學完成上線。整個過程中,JS 框架以 jQuery 為主,CSS 頂多用一下 Less。