元件化在當今前端開發領域中是一個非常重要的概念。著名的前端類庫,比如 React、Vue 等對此概念都倍加推崇。確實,元件化複用性(reusability)和模組性(modularization)的優點對於複雜場景需求具有先天優勢。元件就如同樂高積木、建築石塊一般,一點點拼接構成了我們的應用。
同時,懶載入(Lazy-loading)/按需載入概念至關重要。它對於頁面效能優化,使用者體驗提升提供了新思路。在必要情況下,我們請求的資源更少、解析的指令碼更少、執行的內容更少,達到效果也就越好。
這篇文章將從懶載入時機、元件複用手段、程式碼例項三方面來分析,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 時間線如下:
總結
這篇文章介紹涉及到元件複用、按需載入(懶載入)實現內容。更多相關知識,可以關注作者新書。 同時這篇文章擷取於 José M. Pérez 的 Improve the Performance of your Site with Lazy-Loading and Code-Splitting,部分內容有所改動。
廣告時間: 如果你對前端發展,尤其對 React 技術棧感興趣:我的新書中,也許有你想看到的內容。關注作者 Lucas HC,新書出版將會有送書活動。
Happy Coding!
PS: 作者 Github倉庫 和 知乎問答連結 歡迎各種形式交流。
我的其他幾篇關於React技術棧的文章:
React Redux 中介軟體思想遇見 Web Worker 的靈感(附demo)
從setState promise化的探討 體會React團隊設計思想