Search code examples
javascriptreactjswebpackisomorphic-javascript

React code splitting and server side rendering with System.import or require.ensure


I am investigating code splitting for use in a React application.

I can't seem to find a way to introduce code splitting (and importing) for server side render which carries through to the client side cleanly.

FYI: I know there is a way to do this with React Router, but I think this is a more general issue and not everyone wants to use it. Also, I feel that code splitting is not synonymous with routes necessarily.

Here is a very basic example of a class that will load and render the contents of a split code bundle SplitComponent.

If the route that is rendered server side includes this component then componentWillMount will ensure that the code is loaded synchronously with require before the render is called. It checks to see if it's server side so it doesn't do this client side.

Then for the client side, componentDidMount will asynchronously load SplitComponent with System.import.

The result of this is that the server side renders the correct page and the client will display it, but then immediately the componentDidMount will cause the client side to load the SplitComponent, during which time it will display (however briefly depends on load times) nothing. Finally, SplitComponent will load and be rendered. But there is the potential for flicker as it gets removed, then added again. This detracts from the advantages of doing the rendering on the server.

Is there a better way to handle this?

import React from 'react';

const canUseDOM = !!(
  (typeof window !== 'undefined' &&
  window.document && window.document.createElement)
);

class Lazy extends React.Component {
  constructor() {
    super();
    this.state = {
      module: null
    };
  }

  componentWillMount() {
    if (!canUseDOM) {
      const m = require('./SplitComponent');
      this.setState({
        module: m.default
      });
    }
  }

  componentDidMount() {
    if (!this.state.module) {
      System.import('./SplitComponent').then(m => {
        this.setState({
          module: m.default
        });
      });
    }
  }

  render() {
    const { module } = this.state;
    console.log('Rendering Lazy', module);
    if (module) {
      return React.createElement(module);
    }

    return null;
  }
}

export default Lazy;

Solution

  • This seems to be a tricky problem, but I have a solution which seems to work. It's not ideal and I would dearly like to see alternatives.

    The basic idea is that one React component can trigger the import of another in order to facilitate code splitting. This is reasonably simple, but extending this to support server side rendering added a lot of complexity.

    The rules:

    1. Import must be synchronous on the server side as there is only a single render.
    2. Server side must be able to inform the client side which bundles are required for whatever view is being rendered by the server.
    3. Client must then load any bundles that the server informed it about, before React begins rendering.
    4. Client then can continue ordinary code-splitting practice from this point onwards. Bundles are loaded asynchronously and once loaded, React rerenders to include them in the rendering.

    Here is the Lazy class which is responsible for managing code splitting for the SplitComponent. It makes use of 2 functions from split.js

    When Lazy is rendered on the server side, componentWillMount is run and checks if it is actually the server side. If it is, it causes the loading of the SplitComponent synchronously. The module default that is loaded is stored in the state of the Lazy component so that it can then immediately be rendered. It also dispatches an action to Redux to register the fact that this bundle is required for the view that is being rendered.

    The server side will successfully render the application and the redux store will contain the fact that the bundle containing ./SplitComponent is required on the client side.

    //Lazy.jsx
    import React from 'react';
    import { connect } from 'react-redux';
    import { splitComponent, splitComponentSync } from './split';
    
    const canUseDOM = !!(
      (typeof window !== 'undefined' &&
      window.document && window.document.createElement)
    );
    
    class Lazy extends React.Component {
    
      constructor() {
        super();
        this.state = {
          module: null
        };
      }
    
      componentWillMount() {
    
        // On server side only, synchronously load
        const { dispatch } = this.props;
    
        if (!canUseDOM) {
    
          // Also, register this bundle with the current component state as on
          // the server there is only a single render and thus the redux state
          // available through mapStateToProps is not up-to-date because it was
          // requested before the above dispatch.
          this.setState({
            module: splitComponentSync(dispatch)
          });
    
        }
      }
    
      componentDidMount() {
        const { dispatch, modules } = this.props;
    
        if (!modules.hasOwnProperty('./SplitComponent')) {
          splitComponent(dispatch);
        }
      }
    
      render() {
        const { module } = this.state;
        const { modules } = this.props;
    
        // On server side, rely on everything being loaded
        if (!canUseDOM && module) {
          return React.createElement(module);
    
        // On client side, use the redux store
        } else if (modules.hasOwnProperty('./SplitComponent') && modules['./SplitComponent']) {
          return React.createElement(modules['./SplitComponent']);
        }
    
        return null;
      }
    }
    
    
    function mapStateToProps(state) {
    
      const modules = state.modules;
    
      return {
        modules
      };
    }
    
    export default connect(mapStateToProps)(Lazy);
    
    //split.js
    export const splitComponent = dispatch => {
      return System.import('./SplitComponent').then((m) => {
        dispatch({
          type: 'MODULE_IMPORT',
          moduleName: './SplitComponent',
          module: m.default
        });
      });
    };
    
    export const splitComponentSync = dispatch => {
      // This must be an expression or it will cause the System.import or
      // require.ensure to not generate separate bundles
      const NAME = './SplitComponent';
      const m = require(NAME);
    
      // Reduce into state so that the list of bundles that need to be loaded
      // on the client can be, before the application renders. Set the module
      // to null as this needs to be imported on the client explicitly before
      // it can be used
      dispatch({
        type: 'MODULE_IMPORT',
        moduleName: './SplitComponent',
        module: null
      });
    
      // Also, register this bundle with the current component state as on
      // the server there is only a single render and thus the redux state
      // available through mapStateToProps is not up-to-date because it was
      // requested before the above dispatch.
      return m.default;
    };
    
    //reducer.js (Excerpt)
    export function modules(
    
        state={}, action) {
          switch (action.type) {
            case 'MODULE_IMPORT':
              const newState = {
                ...state
              };
              newState[action.moduleName] = action.module;
              return newState;
          }
          return state;
        }
    

    The client initialise as per the usual procedure for incorporating the redux store from server rendering.

    Once that has happened, it is necessary to ensure that any required bundles are imported before rendering can begin. We examine the redux store modules to see what is required. I look them up in a simple if statement here. For each bundle that is required, it is loaded asynchronously, it's module default stored in the redux store and a Promise returned. Once all those promises are resolved, then React will be allowed to render.

    //configureStore.js (Excerpt)
    let ps;
    if (initialState && initialState.hasOwnProperty('modules')) {
      ps = Object.keys(initialState.modules).map(m => {
        if (m === './SplitComponent') {
          return splitComponent(store.dispatch);
        }
      });
    }
    
    // My configureStore.js returns a Promise and React only renders once it has resolved
    return Promise.all(ps).then(() => store);
    

    Going forward, whenever Lazy+SplitComponent are used, no code loading is required because it already exists in the redux store.

    In the case when the initial application did not include Lazy+SplitComponent, then at the point when Lazy is rendered by React, componentDidMount will fire an asynchronous action to import ./SplitComponent and register this with redux. Like any redux action, this change in state will cause the Lazy component to attempt to rerender and as the SplitComponent is now loaded and registered, it can do so.