組件復用那些事兒 - React 實現按需載入輪子

組件化在當今前端開發領域中是一個非常重要的概念。著名的前端類庫,比如 React、Vue 等對此概念都倍加推崇。確實,組件化復用性(reusability)和模塊性(modularization)的優點對於複雜場景需求具有先天優勢。組件就如同樂高積木、建築石塊一般,一點點拼接構成了我們的應用。

同時,懶載入(Lazy-loading)/按需載入概念至關重要。它對於頁面性能優化,用戶體驗提升提供了新思路。在必要情況下,我們請求的資源更少、解析的腳本更少、執行的內容更少,達到效果也就越好。

這篇文章將從懶載入時機、組件復用手段、IntersectionObserver、代碼實例三方面來分析,

happy reading!

按需載入場景設計分析

一個典型的頁面如下圖:

頁面構成

它包含了以下幾個區塊:

  • 一個頭部 header;
  • 圖片展示區;
  • 地圖展現區;
  • 頁面 footer。

對應代碼示例:

const Page = () => { <div> <Header /> <Gallery /> <Map /> <Footer /> </div>};

當用戶來訪時,如果不滾動頁面,只能看見頭部區域。但在很多場景下,我們都會載入所有的 JavaScript 腳本、 CSS 資源以及其他資源,進而渲染了完整頁面。這明顯是不必要的,消耗了更多帶寬,延遲了頁面 load 時間。為此,前端歷史上做過很多懶載入探索,很多大公司的開源作品應勢而出:比如 Yahoo 的 YUI Loader,Facebook 的 Haste, Bootloader and Primer等。時至今日,這些實現懶載入腳本的代碼仍有學習意義。這裡不再展開。

再看下圖,在傳統邏輯下,代碼覆蓋率層面,我們看到 1.1MB/1.5MB (76%) 的代碼並沒有應用到。

代碼覆蓋率

另外,並不是所有資源都需要進行懶載入,我們在設計層面上需要考慮以下幾點:

  • 不要按需載入首屏內容。這很好理解,首屏時間至關重要,用戶能夠越早看到越好。那麼如何定義首屏內容?這需要結合用戶終端,站點布局來考慮;
  • 預先懶載入。我們應該避免給用戶呈現空白內容,因此預先懶載入,提前執行腳本對於用戶體驗的提升非常明顯。比如下圖,在圖片出現在屏幕前 100px 時,提前進行圖片請求和渲染;

預先載入

  • 懶載入對 SEO 的影響。這裡面涉及到內容較多,需要開發者了解搜索引擎爬蟲機制。以 Googlebot 為例,它支持 IntersectionObserver,但是也僅僅對視口裡內容起作用。這裡不再詳細展開,感興趣的讀者可以通過測試頁面以及測試頁面源碼,並結合 Google 站長工具:Fetch as Google 進行試驗。

React 組件復用技術

提到組件復用,大多開發者應該對高階組件並不陌生。這類組件接受其他組件,進行功能增強,並最終返回一個組件進行消費。React-redux 的 connect 即是一個 currying 化的典型應用,代碼示例:

const MyComponent = props => ( <div> {props.id} - {props.name} </div>);// ...const ConnectedComponent = connect(mapStateToProps, mapDispatchToProps)( MyComponent );

同樣,Function as Child Component 或者稱為 Render Callback 技術也較為常用。很多 React 類庫比如 react-media 和 unstated 都有廣泛使用。以 react-media 為例:

const MyComponent = () => ( <Media query="(max-width: 599px)"> {matches => matches ? ( <p>The document is less than 600px wide.</p> ) : ( <p>The document is at least 600px wide.</p> ) } </Media>);

Media 組件將會調用其 children 進行渲染,核心邏輯為:

class Media extends React.Component { ... render() { React.Children.only(children) }}

這樣,子組件並不需要感知 media query 邏輯,進而完成復用。

除此之外,還有很多組件復用技巧,比如 render props 等,這裡不再一一分析。

感興趣的讀者可以在我的新書中找到相關內容。

代碼實戰

下面讓我們動手實現一個按需載入輪子。

首先需要設計一個 Observer 組件,這個組件將會去檢測目標區塊是否在視口之中可見。為了簡化不必要的邏輯,我們使用 Intersection Observer API,這個方法非同步觀察目標元素的可視狀態。其兼容性可以參考這裡。

class Observer extends Component { constructor() { super(); this.state = { isVisible: false }; this.io = null; this.container = null; } componentDidMount() { this.io = new IntersectionObserver([entry] => { this.setState({ isVisible: entry.isIntersecting }); }, {}); this.io.observe(this.container); } componentWillUnmount() { if (this.io) { this.io.disconnect(); } } render() { return ( // 這裡也可以使用 findDOMNode 實現,但是不建議 <div ref={div => { this.container = div; }} > {Array.isArray(this.props.children) ? this.props.children.map(child => child(this.state.isVisible)) : this.props.children(this.state.isVisible)} </div> ); }}

如上,

  • 該組件具有 isVisible 狀態,表示目標元素是否可見。
  • this.io 表示當前 IntersectionObserver 實例;
  • this.container 表示當前觀察元素,它通過 ref 來完成目標元素的獲取。
  • componentDidMount 方法中,我們進行 this.setState.isVisible 狀態的切換;在 componentWillUnmount 方法中,進行垃圾回收。

很明顯,這種復用方式為前文提到的 Function as Child Component。

注意,對於上述基本實現,我們完全可以進行自定義的個性化設置。IntersectionObserver 支持 margins 或者 thresholds 的選項。我們可以在 constructor 里實現配置項目初始化,在 componentWillReceiveProps 生命周期函數中進行更新。

這樣一來,針對前文頁面內容,我們可以進行 Gallery 組件和 Map 組件懶載入處理:

const Page = () => { <div> <Header /> <Observer> {isVisible => <Gallery isVisible />} </Observer> <Observer> {isVisible => <Map isVisible />} </Observer> <Footer /> </div>}

我們將 isVisible 狀態進行傳遞。相應消費組件可以根據 isVisible 進行選擇性渲染。具體實現:

class Map extends Component { constructor() { super(); this.state = { initialized: false }; this.map = null; }initializeMap() { this.setState({ initialized: true }); // 載入第三方 Google map loadScript("https://maps.google.com/maps/api/js?key=<your_key>", () => { const latlng = new google.maps.LatLng(38.34, -0.48); const myOptions = { zoom: 15, center: latlng }; const map = new google.maps.Map(this.map, myOptions); }); }componentDidMount() { if (this.props.isVisible) { this.initializeMap(); } }componentWillReceiveProps(nextProps) { if (!this.state.initialized && nextProps.isVisible) { this.initializeMap(); } }render() { return ( <div ref={div => { this.map = div; }} /> ); }}

只有當 Map 組件對應的 container 出現在視口時,我們再去進行第三方資源的載入。

同樣,對於 Gallery 組件:

class Gallery extends Component { constructor() { super(); this.state = { hasBeenVisible: false }; } componentDidMount() { if (this.props.isVisible) { this.setState({ hasBeenVisible: true }); } } componentWillReceiveProps(nextProps) { if (!this.state.hasBeenVisible && nextProps.isVisible) { this.setState({ hasBeenVisible: true }); } } render() { return ( <div> <h1>Some pictures</h1> Picture 1 {this.state.hasBeenVisible ? ( <img src="http://example.com/image01.jpg" width="300" height="300" /> ) : ( <div className="placeholder" /> )} Picture 2 {this.state.hasBeenVisible ? ( <img src="http://example.com/image02.jpg" width="300" height="300" /> ) : ( <div className="placeholder" /> )} </div> ); }}

也可以使用無狀態組件/函數式組件實現:

const Gallery = ({ isVisible }) => ( <div> <h1>Some pictures</h1> Picture 1 {isVisible ? ( <img src="http://example.com/image01.jpg" width_="300" height="300" /> ) : ( <div className="placeholder" /> )} Picture 2 {isVisible ? ( <img src="http://example.com/image02.jpg" width_="300" height="300" /> ) : ( <div className="placeholder" /> )} </div>);

這樣無疑更加簡潔。但是當元素移出視口時,相應圖片不會再繼續展現,而是復現了 placeholder。

如果我們需要懶載入的內容只在頁面生命周期中記錄一次,可以設置 hasBeenVisible 參數:

const Page = () => { ... <Observer> {(isVisible, hasBeenVisible) => <Gallery hasBeenVisible /> // Gallery can be now stateless } </Observer> ...}

或者直接實現 ObserverOnce 組件:

class ObserverOnce extends Component { constructor() { super(); this.state = { hasBeenVisible: false }; this.io = null; this.container = null; } componentDidMount() { this.io = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { this.setState({ hasBeenVisible: true }); this.io.disconnect(); } }); }, {}); this.io.observe(this.container); } componentWillUnmount() { if (this.io) { this.io.disconnect(); } } render() { return ( <div ref={div => { this.container = div; }} > {Array.isArray(this.props.children) ? this.props.children.map(child => child(this.state.hasBeenVisible)) : this.props.children(this.state.hasBeenVisible)} </div> ); }}

這些都非常靈活。

更多場景使用

上面我們使用了 Observer 組件去載入資源。包括了 Google Map 第三方內容和圖片。我們同樣可以完成「當組件出現在視口時,才展現元素動畫」的需求。

仿照 React Alicante 網站,我們實現了類似的按需執行動畫需求。具體可見 codepen 地址。

IntersectionObserver polyfilling

前面提到了 IntersectionObserver API 的兼容性,這自然就繞不開 polyfill 話題。

一種處理兼容性的選項是「漸進增強」(progressive enhancement),即只有在支持的場景下實現按需載入,否則永遠設置 isVisible 狀態為 true:

class Observer extends Component { constructor() { super(); this.state = { isVisible: !(window.IntersectionObserver) }; this.io = null; this.container = null; } componentDidMount() { if (window.IntersectionObserver) { this.io = new IntersectionObserver(entries => { ... } } }}

這樣顯然不能實現按需的目的,我更加推薦 w3c 的 IntersectionObserver polyfill:

class Observer extends Component { ... componentDidMount() { (window.IntersectionObserver ? Promise.resolve() : import(intersection-observer) ).then(() => { this.io = new window.IntersectionObserver(entries => { entries.forEach(entry => { this.setState({ isVisible: entry.isIntersecting }); }); }, {}); this.io.observe(this.container); }); } ...}

當瀏覽器不支持 IntersectionObserver 時,我們動態 import 進來 polyfill,這就需要支持 dynamic import,此為另外話題,這裡不再展開。

最後試驗一下,在不支持的 Safari 瀏覽器下,我們看到 Network 時間線如下:

時間線

從時間線上,我們看到 intersection-observer.js 腳本實現了延遲按需載入。

總結

本文介紹涉及到組件復用、按需載入(懶載入)實現內容。更多相關知識,可以關注作者新書。

這篇文章截取於 José M. Pérez 的 Improve the Performance of your Site with Lazy-Loading and Code-Splitting,部分內容有所改動。

廣告時間:

如果你對前端發展,尤其對 React 技術棧感興趣:我的新書中,也許有你想看到的內容。關注作者 Lucas HC,新書出版將會有送書活動。

Happy Coding!

PS: 作者 Github倉庫 和 知乎問答鏈接 歡迎各種形式交流。

我的其他幾篇關於React技術棧的文章:

  • 中間件思想遇見 Web Worker 的靈感(附demo)
  • React 模態框秘密和「輪子」漸進設計
  • 了解 Twitter 前端架構 學習複雜場景數據設計
  • React 探秘 - React Component 和 Element(文末附彩蛋demo和源碼)
  • 從setState promise化的探討 體會React團隊設計思想
  • 通過實例,學習編寫 React 組件的「最佳實踐」
  • React 組件設計和分解思考
  • 從 React 綁定 this,看 JS 語言發展和框架設計
  • React 服務端渲染如此輕鬆 從零開始構建前後端應用
  • 做出Uber移動網頁版還不夠 極致性能打造才見真章
  • React+Redux打造「NEWS EARLY」單頁應用 一個項目理解最前沿技術棧真諦

推薦閱讀:

為什麼 GUI 編程中,Web 平台的技術革新特別火爆,而 Android 和 iOS 沒什麼成果?
將 React 應用優化到 60fps
React 16 的異常/錯誤處理
React 的許可協議到底發生了什麼問題?
2016 前端個人回顧

TAG:前端工程師 | React | 前端開發 |