组件复用那些事儿 - 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仓库 和 知乎问答连接 欢迎各类形式交流。

相关文章
相关标签/搜索