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

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

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

這篇文章將從懶加載時機、組件複用手段、代碼實例三方面來分析,happy reading!前端

按需加載場景設計分析

一個典型的頁面以下圖:java

頁面構成

它包含了如下幾個區塊:react

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

對應代碼示例:git

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

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

以下圖,在正常邏輯狀況下,代碼覆蓋率層面,咱們看到 1.1MB/1.5MB (76%) 的代碼並無應用到。redux

代碼覆蓋率

另外,並非全部資源都須要進行懶加載,咱們在設計層面上須要考慮如下幾點:api

  • 不要按需加載首屏內容。這很好理解,首屏時間相當重要,用戶可以越早看到越好。那麼如何定義首屏內容?這須要結合用戶終端,站點佈局來考慮;
  • 預先懶加載。咱們應該避免給用戶呈現空白內容,所以預先懶加載,提早執行腳本對於用戶體驗的提高很是明顯。好比下圖,在圖片出如今屏幕 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倉庫 和 知乎問答連接 歡迎各類形式交流。

相關文章
相關標籤/搜索