浅析react-loadable与React.lazy + React.Suspense方案

3,989 阅读4分钟

〇、关于Code Splitting

考虑一个场景,如果应用中有个地图组件,有可能用户永远不会切换到这个组件,我们就不必要为整个路由加载一个庞大的地图库,这个就是为什么我们要进行代码拆分的原因。

react-loadable与React.lazy+React.Suspense方案都支持基于组件的拆分方式。

一、Webpack 2+对import()的处理

基于动态导入提案,我们可以修改一个组件,使它异步加载Bar模块

class MyComponent extends React.Component {
  state = {
    Bar: null
  };

  componentWillMount() {
    import('./components/Bar').then(Bar => {
      this.setState({ Bar: Bar.default });
    });
  }

  render() {
    let {Bar} = this.state;
    if (!Bar) {
      return <div>Loading...</div>;
    } else {
      return <Bar/>;
    };
  }
}

webpack有三种通用的code splitting方法

  1. 入口点: 使用配置手动拆分代码
  2. 防止重复: 使用依赖项或者SplitChunksPlugin
  3. 动态导入:使用模块内的内联函数拆分代码

对于匹配动态导入模式的文件,webpack会将对应的模块单独分离到一个包中去。 下面代码提供了类似import()的功能

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);
  });
}

二、react-loadable实现原理

react-loadable实际上只是将部分功能进行了封装,解决了几个场景的问题。

  1. import失败
  2. 服务端渲染
  3. loading过程显示其它组件 import()方法是用户调用的时候传入的,所以在react-loadable源码中甚至不存在import。

核心代码

function load(loader) {
  var promise = loader();

  var state = {
    loading: true,
    loaded: null,
    error: null
  };

  state.promise = promise.then(function (loaded) {
    state.loading = false;
    state.loaded = loaded;
    return loaded;
  }).catch(function (err) {
    state.loading = false;
    state.error = err;
    throw err;
  });

  return state;
}

核心代码实际上并不复杂, 先执行loader,初始化state, promise返回的组件传给state.loaded

    LoadableComponent.prototype.render = function render() {
      if (this.state.loading || this.state.error) {
        return React.createElement(opts.loading, {
          isLoading: this.state.loading,
          pastDelay: this.state.pastDelay,
          timedOut: this.state.timedOut,
          error: this.state.error,
          retry: this.retry
        });
      } else if (this.state.loaded) {
        return opts.render(this.state.loaded, this.props);
      } else {
        return null;
      }
    };

然后就是浅显易懂的render方法,loading或者error则render loading组件并传入对应参数,加载完成则render上面的state.loaded.

三、React.lazy实现原理

项目中使用的是react 16.14.0

function lazy(ctor) {
  var lazyType = {
    $$typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    // React uses these fields to store the result.
    _status: -1,
    _result: null
  };

  {
    // In production, this would just set it on the object.
    var defaultProps;
    var propTypes;
    Object.defineProperties(lazyType, {
      defaultProps: {
        configurable: true,
        get: function () {
          return defaultProps;
        },
        set: function (newDefaultProps) {
          error('React.lazy(...): It is not supported to assign `defaultProps` to ' + 'a lazy component import. Either specify them where the component ' + 'is defined, or create a wrapping component around it.');

          defaultProps = newDefaultProps; // Match production behavior more closely:

          Object.defineProperty(lazyType, 'defaultProps', {
            enumerable: true
          });
        }
      },
      propTypes: {
        configurable: true,
        get: function () {
          return propTypes;
        },
        set: function (newPropTypes) {
          error('React.lazy(...): It is not supported to assign `propTypes` to ' + 'a lazy component import. Either specify them where the component ' + 'is defined, or create a wrapping component around it.');

          propTypes = newPropTypes; // Match production behavior more closely:

          Object.defineProperty(lazyType, 'propTypes', {
            enumerable: true
          });
        }
      }
    });
  }

  return lazyType;
}
    case LazyComponent:
      {
        var elementType = workInProgress.elementType;
        return mountLazyComponent(current, workInProgress, elementType, updateExpirationTime, renderExpirationTime);
      }
function mountLazyComponent(_current, workInProgress, elementType, updateExpirationTime, renderExpirationTime) {
  if (_current !== null) {
    // A lazy component only mounts if it suspended inside a non-
    // concurrent tree, in an inconsistent state. We want to treat it like
    // a new mount, even though an empty version of it already committed.
    // Disconnect the alternate pointers.
    _current.alternate = null;
    workInProgress.alternate = null; // Since this is conceptually a new fiber, schedule a Placement effect

    workInProgress.effectTag |= Placement;
  }

  var props = workInProgress.pendingProps; // We can't start a User Timing measurement with correct label yet.
  // Cancel and resume right after we know the tag.

  cancelWorkTimer(workInProgress);
  var Component = readLazyComponentType(elementType); // Store the unwrapped component in the type.

  workInProgress.type = Component;
  var resolvedTag = workInProgress.tag = resolveLazyComponentTag(Component);
function readLazyComponentType(lazyComponent) {
  initializeLazyComponentType(lazyComponent);

  if (lazyComponent._status !== Resolved) {
    throw lazyComponent._result;
  }

  return lazyComponent._result;
}
var Uninitialized = -1;
var Pending = 0;
var Resolved = 1;
var Rejected = 2;
function refineResolvedLazyComponent(lazyComponent) {
  return lazyComponent._status === Resolved ? lazyComponent._result : null;
}
function initializeLazyComponentType(lazyComponent) {
  if (lazyComponent._status === Uninitialized) {
    lazyComponent._status = Pending;
    var ctor = lazyComponent._ctor;
    var thenable = ctor();
    lazyComponent._result = thenable;
    thenable.then(function (moduleObject) {
      if (lazyComponent._status === Pending) {
        var defaultExport = moduleObject.default;

        {
          if (defaultExport === undefined) {
            error('lazy: Expected the result of a dynamic import() call. ' + 'Instead received: %s\n\nYour code should look like: \n  ' + "const MyComponent = lazy(() => import('./MyComponent'))", moduleObject);
          }
        }

        lazyComponent._status = Resolved;
        lazyComponent._result = defaultExport;
      }
    }, function (error) {
      if (lazyComponent._status === Pending) {
        lazyComponent._status = Rejected;
        lazyComponent._result = error;
      }
    });
  }
}

按照lazy函数的定义,_ctor赋值为传入的参数,_status初始化值就是Uninitialized(-1)。所以在initializeLazyComponentType中直接进入第一个if,执行var thenable = ctor(), 当thenable这个promise转为fulfilled状态时,status转为resolved状态,将_result赋值为moduleObject.default,并在上层函数(readLazyComponentType)中返回。如果thenable转为rejected状态,则将_result赋值为error,并在上层函数(readLazyComponentType)中throw出来。

由React.Suspense组件对thenable的pending, fulfilled, rejected状态进行处理。

四、为什么我们选择React.lazy+ React.Suspense方案

(前提:项目不支持SSR,并且项目当前的react版本已经支持React.lazy及React.Suspense, 否则就要考虑更新代价)

  1. react-loadable已经不再维护,对出现的安全问题以及未来react的新feature也不会再做处理。并且在有可替代方案的情况下,我们应该减少对第三方库的依赖,避免维护以及后续更新问题。
  2. 减少打包大小, 提升一点点点点点点性能。那么删除react-loadable到底会提升多少性能,可以使用source-map-explorer对打包后react-loadable的大小做一下分析

截屏2022-03-25 上午12.58.21.png 在我们项目中,如果删除react-loadable理论上足足可以减少惊人的4.27kb.......

五、替换前与替换后的代码

react-loadable

export default function AsyncLoad(importFn) {
  return Loadable({
    loader  : importFn,
    loading : Loading
  });
}

React.lazy + React.Suspense

export default function AsyncLoad(importFn) {
  const AsyncLoadComponent = React.lazy(importFn);
  return (props) => (
    <React.Suspense fallback={<Loading />}>
      <AsyncLoadComponent {...props} />
    </React.Suspense>
  )

六、使用React.lazy与React.Suspense后出现的问题

当前项目使用的仍然是React 16.14.0, 而React 18的Suspense支持SSR, 应该不会出现下面的问题 项目使用Jest+enzyme进行测试,在使用新写法后单元测试报错

ReactDOMServer does not yet support Suspense.

ReactDOMServer does not yet support lazy-loaded components

在这种情况下之前react-loadable始终会返回Loading组件,所以这里我简单做了一下处理

jest.mock('react', () => {
  return {
    ...jest.requireActual('react'),
    lazy: () => () => null,
    Suspense: ({ children }) => <div>{children}</div>,
  }
})

mock掉lazy与Suspense,update对应的snapshot。

参考

深入理解React:懒加载(lazy)实现原理

tc39/proposal-dynamic-import

jamiebuilds/react-loadable