本文主要和大家分享React的起源及發展,希望啊能幫助大家。
時間回到 2004 年,Mark Zuckerberg 當時還在宿舍搗鼓最初版的 Facebook 。
這一年,大家都在用 PHP 的字串拼接(String Concatenation)功能來開發網站。
$str = '<ul>'; foreach ($talks as $talk) { $str += '<li>' . $talk->name . '</li>'; } $str += '</ul>';
這種網站開發方式在當時看來是非常正確的,因為不管是後端開發還是前端開發,甚至根本沒有開發經驗,都可以用這種方式來建立一個大型網站。
唯一不足的是,這種開發方式容易造成 XSS 注入等安全性問題。如果 $talk->name
中包含惡意程式碼,而又沒有做任何防護措施的話,那麼攻擊者就可以注入任意 JS 程式碼。於是就催生了「永遠不要相信使用者的輸入」的安全守則。
最簡單的因應方法是對使用者的任何輸入進行轉義(Escape)。然而這也帶來了其他麻煩,如果對字串進行多次轉義,那麼反轉義的次數也必須是相同的,否則會無法得到原始內容。如果又不小心把 HTML 標籤(Markup)轉義了,那麼 HTML 標籤就會直接顯示給用戶,進而導致很差的使用者體驗。
到了 2010 年,為了更有效率的編碼,同時也避免轉義 HTML 標籤的錯誤,Facebook 開發了 XHP 。 XHP 是對 PHP 的語法拓展,它允許開發者直接在 PHP 中使用 HTML 標籤,而不再使用字串。
$content = <ul />; foreach ($talks as $talk) { $content->appendChild(<li>{$talk->name}</li>); }
這樣的話,所有的 HTML 標籤都使用不同於 PHP 的語法,我們可以輕易的分辨哪些需要轉義哪些不需要轉義。
不久的後來,Facebook 的工程師又發現他們還可以創建自訂標籤,而且透過組合自訂標籤有助於建立大型應用程式。
而這正是 Semantic Web 和 Web Components 概念的一種實作方式。
$content = <talk:list />; foreach ($talks as $talk) { $content->appendChild(<talk talk={$talk} />); }
之後,Facebook 在 JS 中嘗試了更多的新技術方式以減少客戶端和服務端之間的延時。例如跨瀏覽器 DOM 函式庫和資料綁定,但是都不是很理想。
等到 2013 年,突然有一天,前端工程師 Jordan Walke 向他的經理提出了一個大膽的想法:把 XHP 的拓展功能遷移到 JS 中。一開始大家都以為他瘋了,因為這與當時大家都看好的 JS 框架格格不入。不過他最終還是執著地說服了經理,允許他用 6 個月的時間來驗證這個想法。這裡不得不說 Facebook 良好的工程師管理哲學讓人敬佩,值得借鏡。
附:Lee Byron 談Facebook 工程師文化:Why Invest in Tools
要想把XHP 的拓展功能遷移到JS ,首要任務是需要一個拓展來讓JS 支援XML 語法,該拓展稱為JSX 。當時,隨著 Node.js 的興起,Facebook 內部對於轉換 JS 已經有相當多的工程實務了。所以實現 JSX 簡直輕而易舉,只花了大概一週的時間。
const content = ( <TalkList> { talks.map(talk => <Talk talk={talk} />)} </TalkList> );
自此,開始了 React 的萬裡長徵,更大的困難還在後頭。其中,最棘手的是如何重現 PHP 中的更新機制。
在 PHP 中,每當有資料改變時,只需要跳到由 PHP 全新渲染的新頁面即可。
從開發者的角度來看的話,這種方式開發應用程式是非常簡單的,因為它不需要擔心變更,且介面上使用者資料改變時所有內容都是同步的。
只要有資料變更,就重新渲染整個頁面。
雖然簡單粗暴,但這種方式的缺點也特別突出,那就是它非常慢。
“You need to be right before being good”,意思是說,為了驗證遷移方案的可行性,開發者必須快速實現一個可用版本,暫時不考慮效能問題。
取自PHP 的靈感,在JS 中實現重新渲染的最簡單方法是:當任何內容改變時,都重新建構整個DOM,然後用新DOM 取代舊DOM 。
這種方式是可以工作的,但在有些場景下不適用。
例如它會失去當前聚焦的元素和遊標,以及文字選擇和頁面滾動位置,這些都是頁面的當前狀態。
換句話說,DOM 節點是包含狀態的。
既然包含狀態,那麼記下舊 DOM 的狀態然後在新 DOM 上還原不就行了麼?
但是非常不幸,這種方式不僅實現起來複雜而且也無法涵蓋所有情況。
在 OSX 電腦上捲動頁面時,會伴隨著一定的滾動慣性。但是 JS 並沒有提供對應的 API 來讀取或寫入滾動慣性。
對於包含 iframe
的頁面來說,情況則更複雜。如果它來自其他網域,那麼瀏覽器安全性原則限制根本不會允許我們查看其內部的內容,更不用說還原了。
因此可以看出,DOM 不僅有狀態,它還包含隱藏的、無法觸及的狀態。
既然還原狀態行不通,那就換個方式繞過去。
對於沒有改變的 DOM 節點,讓它保持原樣不動,僅建立並取代變更過的 DOM 節點。
這種方式實作了 DOM 節點複用(Reuse)。
至此,只要能夠辨識出哪些節點改變了,那麼就可以實現 DOM 的更新。於是問題就轉化為如何比對兩個 DOM 的差異。
說到對比差異,相信大家馬上就能聯想到版本控制(Version Control)。它的原理很簡單,記錄多個程式碼快照,然後使用diff 演算法比對前後兩個快照,從而產生一系列諸如“刪除5 行”、“新增3 行”、“替換單字”等的改動;通過把這一系列的改動應用到先前的程式碼快照就可以得到之後的程式碼快照。
而這正是 React 所需要的,只不過它的處理物件是 DOM 而不是文字檔。
難怪有人說:「I tend to think of React as Version Control for the DOM」 。
DOM 是樹狀結構,所以 diff 演算法必須是針對樹狀結構的。目前已知的完整樹狀結構 diff 演算法複雜度為 O(n^3) 。
假如頁面中有 10,000 個 DOM 節點,這個數字看起來很龐大,但其實不是不可想像。為了計算該複雜度的數量級大小,我們也假設在一個 CPU 週期中我們可以完成單次對比操作(雖然不可能完成),且 CPU 主頻為 1 GHz 。在這種情況下,diff 要花費的時間如下:
整整有 17 分鐘之長,簡直無法想像!
雖然說驗證階段暫不考慮效能問題,但是我們還是可以簡單了解下該演算法是如何實現的。
附:完整的 Tree diff 實作演算法。
新樹上的每個節點與舊樹上的每個節點對比
#如果父節點相同,繼續循環對比子樹
在上圖的樹中,依據最小操作原則,可以找到三個嵌套的循環對比。
但如果認真思考下,其實在 Web 應用中,很少有移動一個元素到另一個地方的場景。一個例子可能的是拖曳(Drag)並放置(Drop)元素到另一個地方,但它並不常見。
唯一的常用場景是在子元素之間移動元素,例如在清單中新增、刪除和移動元素。既然如此,那可以僅僅對比同層級的節點。
如上圖所示,只對相同顏色的節點做 diff ,這樣能把時間複雜度降到了 O(n^2) 。
針對同級元素的比較,又引入了另一個問題。
同層級元素名稱不同時,可以直接辨識為不符;相同時,卻沒那麼簡單了。
假如在某個節點下,上一次渲染了三個 <input />
,然後下次渲染變成了兩個。此時 diff 的結果會是什麼呢?
最直覺的結果是前面兩個不變,刪除第三個。
當然,也可以刪除第一個同時保持最後兩個。
如果不嫌麻煩,還可以把舊的三個都刪除,然後再新增兩個新元素。
這說明,對於相同標籤名稱的節點,我們沒有足夠資訊來比較前後差異。
如果再加上元素的屬性呢?例如 value
,如果前後兩次標籤名稱和 value
屬性都相同,那麼就認為元素匹配中,無須改動。但現實是這行不通,因為使用者輸入時值總是在變,會導致元素一直被替換,導致失去焦點;;更糟的是,並不是所有 HTML 元素都有這個屬性。
那使用所有元素都有的 id
属性呢?这是可以的,如上图,我们可以容易的识别出前后 DOM 的差异。考虑表单情况,表单模型的输入通常跟 id
关联,但如果使用 AJAX 来提交表单的话,我们通常不会给 input
设置 id
属性。因此,更好的办法是引入一个新的属性名称,专门用来辅助 diff 算法。这个属性最终确定为 key
。这也是为什么在 React 中使用列表时会要求给子元素设置 key
属性的原因。
结合 key
,再加上哈希表,diff 算法最终实现了 O(n) 的最优复杂度。
至此,可以看到从 XHP 迁移到 JS 的方案可行的。接下来就可以针对各个环节进行逐步优化。
附:详细的 diff 理解:不可思议的 react diff 。
前面说到,React 其实实现了对 DOM 节点的版本控制。
做过 JS 应用优化的人可能都知道,DOM 是复杂的,对它的操作(尤其是查询和创建)是非常慢非常耗费资源的。看下面的例子,仅创建一个空白的 p
,其实例属性就达到 231 个。
// Chrome v63 const p = document.createElement('p'); let m = 0; for (let k in p) { m++; } console.log(m); // 231
之所以有这么多属性,是因为 DOM 节点被用于浏览器渲染管道的很多过程中。
浏览器首先根据 CSS 规则查找匹配的节点,这个过程会缓存很多元信息,例如它维护着一个对应 DOM 节点的 id
映射表。
然后,根据样式计算节点布局,这里又会缓存位置和屏幕定位信息,以及其他很多的元信息,浏览器会尽量避免重新计算布局,所以这些数据都会被缓存。
可以看出,整个渲染过程会耗费大量的内存和 CPU 资源。
现在回过头来想想 React ,其实它只在 diff 算法中用到了 DOM 节点,而且只用到了标签名称和部分属性。
如果用更轻量级的 JS 对象来代替复杂的 DOM 节点,然后把对 DOM 的 diff 操作转移到 JS 对象,就可以避免大量对 DOM 的查询操作。这种方式称为 Virtual DOM 。
其过程如下:
维护一个使用 JS 对象表示的 Virtual DOM,与真实 DOM 一一对应
对前后两个 Virtual DOM 做 diff ,生成变更(Mutation)
把变更应用于真实 DOM,生成最新的真实 DOM
可以看出,因为要把变更应用到真实 DOM 上,所以还是避免不了要直接操作 DOM ,但是 React 的 diff 算法会把 DOM 改动次数降到最低。
至此,React 的两大优化:diff 算法和 Virtual DOM ,均已完成。再加上 XHP 时代尝试的数据绑定,已经算是一个可用版本了。
这个时候 Facebook 做了个重大的决定,那就是把 React 开源!
React 的开源可谓是一石激起千层浪,社区开发者都被这种全新的 Web 开发方式所吸引,React 因此迅速占领了 JS 开源库的榜首。
很多大公司也把 React 应用到生产环境,同时也有大批社区开发者为 React 贡献了代码。
接下来要说的两大优化就是来自于开源社区。
著名浏览器厂商 Opera 把重排和重绘(Reflow and Repaint)列为影响页面性能的三大原因之一。
我们说 DOM 是很慢的,除了前面说到的它的复杂和庞大,还有另一个原因就是重排和重绘。
当 DOM 被修改后,浏览器必须更新元素的位置和真实像素;
当尝试从 DOM 读取属性时,为了保证读取的值是正确的,浏览器也会触发重排和重绘。
因此,反复的“读取、修改、读取、修改...”操作,将会触发大量的重排和重绘。
另外,由于浏览器本身对 DOM 操作进行了优化,比如把两次很近的“修改”操作合并成一个“修改”操作。
所以如果把“读取、修改、读取、修改...”重新排列为“读取、读取...”和“修改、修改...”,会有助于减小重排和重绘的次数。但是这种刻意的、手动的级联写法是不安全的。
与此同时,常规的 JS 写法又很容易触发重排和重绘。
在减小重排和重绘的道路上,React 陷入了尴尬的处境。
最終,社區貢獻者 Ben Alpert 使用批次的方式拯救了這個尷尬的處境。
在 React 中,開發者透過呼叫元件的 setState
方法告訴 React 目前元件要變更了。
Ben Alpert 的做法是,呼叫setState
時不立即把變更同步到Virtual DOM,而是只把對應元素打上「待更新」的標記。如果在元件內部呼叫多次 setState
,那麼都會進行相同的打標操作。
等到初始化事件被完全廣播開以後,就開始進行從頂部到底部的重新渲染(Re-Render)過程。這就確保了 React 只對元素進行了一次渲染。
這裡要注意兩點:
此處的重新渲染是指把setState
變更同步到Virtual DOM ;在這之後才進行diff 操作產生真實的DOM 變更。
與前文提到的「重新渲染整個DOM 」不同的是,真實的重新渲染僅渲染被標記的元素及其子元素,也就是說上圖中僅藍色圓圈代表的元素會被重新渲染
這也提醒開發者,應該讓擁有狀態的元件盡量靠近葉子節點,這樣可以縮小重新渲染的範圍。
隨著應用程式越來越大,React 管理的元件狀態也會越來越多,這意味著重新渲染的範圍也會越來越大。
認真觀察上面批次的過程可以發現,該Virtual DOM 右下角的三個元素其實是沒有變更的,但是因為其父節點的變更也導致了它們的重新渲染,多做了無用操作。
對於這種情況,React 本身已經考慮到了,為此它提供了bool shouldComponentUpdate(nextProps, nextState)
接口。開發者可以手動實作此介面來對比前後狀態和屬性,以判斷是否需要重新渲染。這樣的話,重新渲染就變成如下圖所示過程。
當時,React 雖然提供了shouldComponentUpdate
接口,但是並沒有提供一個預設的實作方案(總是渲染) ,開發者必須自己手動實現才能達到預期效果。
原因是,在 JS 中,我們通常使用物件來保存狀態,修改狀態時是直接修改該狀態物件的。也就是說,修改前後的兩個不同狀態指向了同一個對象,所以當直接比較兩個對像是否變更時,它們是相同的,即使狀態已經改變。
對此,David Nolen 提出了基於不可變資料結構(Immutable Data Structure)的解決方案。
此方案的靈感來自 ClojureScript ,在 ClojureScript 中,大部分的值都是不可變的。換句話說就是,當需要更新一個值時,程式不是去修改原來的值,而是基於原來的值建立一個新值,然後使用新值來賦值。
David 使用 ClojureScript 寫了一個針對 React 的不可變資料結構方案:Om ,為 shouldComponentUpdate
提供了預設實作。
不過,由於不可變資料結構並未被 Web 工程師廣為接受,所以當時並未把這項功能合併進 React 。
遺憾的是,截止到目前,shouldComponentUpdate
也仍然未提供預設實作。
但是 David 卻為廣大開發者開啟了一個很好的研究方向。
如果真想利用不可變資料結構來提升 React 效能,可以參考與 React 師出同門的 Facebook Immutable.js,它是 React 好搭檔!
React 的最佳化仍在繼續,例如React 16 中新引入Fiber,它是對核心演算法的一次重構,即重新設計了檢測變更的方法和時機,允許渲染過程可以分段完成,而不必一次完成。
受篇幅限制,本文不會深入介紹 Fiber ,有興趣的可以參考 React Fiber是什麼 。
相關推薦:
以上是簡單介紹React的詳細內容。更多資訊請關注PHP中文網其他相關文章!