React 懒加载

2,198 阅读6分钟

一以贯之,从源码的角度去看,懒加载是什么。

懒加载是什么?

懒加载是一种在页面加载时延迟加载一些非关键资源的技术,换句话说就是按需加载。

我们之前看到的懒加载一般是这样的形式:

  • 浏览一个网页,准备往下拖动滚动条
  • 拖动一个占位图片到视窗
  • 占位图片被瞬间替换成最终的图片

为什么使用懒加载而不直接加载?

  • 浪费流量。在不计流量收费的网络,这可能不重要;在按流量收费的网络中,毫无疑问,一次性加载大量图片就是在浪费用户的钱。
  • 消耗额外的电量和其他的系统资源,并且延长了浏览器解析的时间。因为媒体资源在被下载完成后,浏览器必须对它进行解码,然后渲染在视窗上,这些操作都需要一定的时间。

懒加载图片和视频,可以减少页面加载的时间、页面的大小和降低系统资源的占用,这些对于性能都有显著地提升。总体来讲,就是改善用户体验,增强页面性能。

再牵涉一条术语:代码分割

代码分割

(1)为什么要进行代码分割?

现在前端项目基本都采用打包技术,比如 Webpack,JS逻辑代码打包后会产生一个 bundle.js 文件,而随着我们引用的第三方库越来越多或业务逻辑代码越来越复杂,相应打包好的 bundle.js 文件体积就会越来越大,因为需要先请求加载资源之后,才会渲染页面,这就会严重影响到页面的首屏加载。

而为了解决这样的问题,避免大体积的代码包,我们则可以通过技术手段对代码包进行分割,能够创建多个包并在运行时动态地加载。现在像 Webpack、 Browserify等打包器都支持代码分割技术。

(2)什么时候应该考虑进行代码分割?

这里举一个平时开发中可能会遇到的场景,比如某个体积相对比较大的第三方库或插件(比如JS版的PDF预览库)只在单页应用(SPA)的某一个不是首页的页面使用了,这种情况就可以考虑代码分割,增加首屏的加载速度。

懒加载 示例代码

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() { 
    return ( 
    <div>
        <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense>
    </div>
    );
}

通过 import()React.lazy 和 Suspense 共同一起实现了 React 的懒加载,核心还是 import() 的功能。调用import()函数将会返回一个promise对象。

function importModule(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);
    script.type = "module";
    script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;

    script.onload = () => {
      resolve(window[tempGlobal]);
      delete window[tempGlobal];
      script.remove();
    };

    script.onerror = () => {
      reject(new Error("Failed to load module script with URL " + url));
      delete window[tempGlobal];
      script.remove();
    };

    document.documentElement.appendChild(script);
  });
}

接下来JS通过将import函数作为参数的形式来实现需要时加载。

React懒加载源码

export function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized,
    _result: ctor,
  };

  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE,
    _payload: payload,
    _init: lazyInitializer,
  };

  if (__DEV__) {
      ......
  }

  return lazyType;
}

上面的代码返回lazyType,在lazyType中调用_init属性方法,在lazyInitializer函数中调用import()参数。

function lazyInitializer<T>(payload: Payload<T>): T {
  if (payload._status === Uninitialized) {
    const ctor = payload._result;
    // 加载组件
    const thenable = ctor();
    // Transition to the next state.
    // This might throw either because it's missing or throws. If so, we treat it
    // as still uninitialized and try again next time. Which is the same as what
    // happens if the ctor or any wrappers processing the ctor throws. This might
    // end up fixing it if the resolution was a concurrency bug.
    // 对加载组件是否成功的处理:处理详情见上述描述。
    // import()返回的是一个promise对象。
    thenable.then(
      moduleObject => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved;
          resolved._result = moduleObject;
        }
      },
      error => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected;
          rejected._result = error;
        }
      },
    );
    if (payload._status === Uninitialized) {
      // In case, we're still uninitialized, then we're waiting for the thenable
      // to resolve. Set it as pending in the meantime.
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    if (__DEV__) {
      if (moduleObject === undefined) {
        console.error(
          'lazy: Expected the result of a dynamic imp' +
            'ort() call. ' +
            'Instead received: %s\n\nYour code should look like: \n  ' +
            // Break up imports to avoid accidentally parsing them as dependencies.
            'const MyComponent = lazy(() => imp' +
            "ort('./MyComponent'))\n\n" +
            'Did you accidentally put curly braces around the import?',
          moduleObject,
        );
      }
    }
    if (__DEV__) {
      if (!('default' in moduleObject)) {
        console.error(
          'lazy: Expected the result of a dynamic imp' +
            'ort() call. ' +
            'Instead received: %s\n\nYour code should look like: \n  ' +
            // Break up imports to avoid accidentally parsing them as dependencies.
            'const MyComponent = lazy(() => imp' +
            "ort('./MyComponent'))",
          moduleObject,
        );
      }
    }
    return moduleObject.default;
  } else {
    throw payload._result;
  }
}

Suspense

Suspense源码(先去理解fiber才能有效阅读Suspense源码),我们依然可以先获取数据,而且可以给上面流程的 2、3 步骤调换顺序:

  1. 开始获取数据
  2. 开始渲染
  3. 结束获取数据

有了 Suspense,我们不必等到数据全部返回才开始渲染。实际上,我们是一发送网络请求,就马上开始渲染:

// 这不是一个 Promise。这是一个支持 Suspense 的特殊对象。
const resource = fetchProfileData();
function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  // 尝试读取用户信息,尽管信息可能未加载完毕
  const user = resource.user.read();  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  // 尝试读取博文数据,尽管数据可能未加载完毕
  const posts = resource.posts.read();  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}
  1. 我们一开始就通过 fetchProfileData() 发出请求。这个方法返回给我们一个特殊的对象“resource”,而不是一个 Promise。在现实的案例中,这个对象是由 Relay 通过集成了 Suspense 来提供的。
  2. React 尝试渲染 <ProfilePage>。该组件返回两个子组件:<ProfileDetails> 和 <ProfileTimeline>
  3. React 尝试渲染 <ProfileDetails>。该组件调用了 resource.user.read(),但因为读取的数据还没被获取完毕,所以组件会处于一个“挂起”的状态。React 会跳过这个组件,继续渲染组件树中的其他组件。
  4. React 尝试渲染 <ProfileTimeline>。该组件调用了 resource.posts.read(),和上面一样,数据还没获取完毕,所以这个组件也是处在“挂起”的状态。React 同样跳过这个组件,去渲染组件树中的其他组件。
  5. 组件树中已经没有其他组件需要渲染了。因为 <ProfileDetails> 组件处于“挂起”状态,React 则是显示出距其上游最近的 <Suspense> fallback:<h1>Loading profile...</h1> 。渲染到这里就结束了。

这里的 resource 对象表示尚未存在但最终将被加载的数据。当我们调用 read() 的时候,我们要么获取数据,要么组件处于“挂起”状态。

随着更多数据的到来,React 将尝试重新渲染,并且每次都可能渲染出更加完整的组件树。 当 resource.user 的数据获取完毕之后,<ProfileDetails> 组件就能被顺利渲染出来,这时,我们就不再需要展示 <h1>Loading profile...</h1> 这个 fallback 了。当我们拿到全部数据之后,所有的 fallbacks 就都可以不展示了。

这意味着一个有趣的事实,即使我们使用 GraphQL 客户端来收集单个请求中需要的所有数据,流式响应也可以使我们尽早显示更多的内容在数据获取时(render-as-we-fetch) (而不是全部数据获取)渲染,因此,如果 user 在响应中比 posts 出现得更早,我们则可以在响应结束之前“解锁”外层的 <Suspense> 边界。我们之前并没有意识到这一点,即便是 fetch-then-render(接收到全部数据之后渲染)这个解决方案,在数据获取和渲染之间也有“瀑布”问题。Suspense 没有这个“瀑布”问题,像 Relay 这样的库就利用了这个优势。