{{announcement.body}}
{{announcement.title}}

Scale Your Frontend Application

DZone 's Guide to

Scale Your Frontend Application

In this article, we continue our series on working with dynamically loaded plugins in a React application to better scale the app.

· Web Dev Zone ·
Free Resource

As a continuation to my first article, I prepared an implementation of a simple project to show a workable solution. 

Before going into detail, I’d like to note that I decided to change the name to Scale instead of Pluggable, as it better highlights the potential of this architecture. The idea is not only to create plugins for an application but actually to drive the development of frontend itself from multiple sources. And the core feature of this itself has to be to split the application into pieces and not create monolith. 

The only mechanism that must be in the heart of the system is the line of communication between components, the logic that loads necessary scripts dynamically, and the process of gluing it together with defined rules. There are of course many other aspects that you must take into account like:

  • How to share the same event messages across different evolutions of the system (like versioning, etc.).
  • How to upgrade libraries across all modules in a less intrusive way.
  • How to set up the development environment of one module without a need to boot up a monster application.
  • How to set up an infrastructure for E2E tests and keep these tests in the repository of the module.
  • And many more.

Before diving into all of these challenges, in this article, I’ll introduce you to the first implementation of a simple application. Currently, the code for this article is in master. Once I polish it and start working on a more advanced part of it, I’ll move it to branch part-1.

To set up the project locally, you need to have the latest version of Node.js and yarn and gulp globally installed.

You may also like: The Fundamentals of Redux.

Once that's done, running gulp is enough to set up the entire infrastructure of the project. To make it simple for setting it up and demonstrating from the single place, I placed three modules into one GitHub repository. Though in a real-life scenario, they would be broken up into three separate repositories. 

Module architecture

Module architecture

   The example I created is for a User Management System, where it is possible to:

  • See a home page with some basic information about the site (Home) Home page
    Home page
  • View users (User)User page
    User page
  • Add new users (Admin) Admin page
    Admin page
  • Grant/revoke permissions of allowing individuals to remove users or hiding/showing admin page (Settings)
    • When the "allow to remove users” permission is enabled, a trash icon appears on the user page.
    • When “allow to view admin page” is disabled, the navigation to “Admin” disappears.
      Setting admin permissions
      Setting admin permissions

“Home" and “User” pages come together with the main application and two others are loaded during system boot-up.

First, we need to configure our backend to read metadata about our core modules and provide the API to load. In this project, we created folder  “modules”  for that. Here, the compiled modules thta are ready for loading are distributed. 

The only file created manually is modules-metadata.json. Again, this is done for simplicity. In a real-world scenario, it will be inside of each module, like  modules/admin/metadata.json with snippet

JSON
 




x


 
1
{
2
  "name": "admin",
3
  "entry": "/modules/admin/index.js",
4
  "options": {
5
    "tab": {
6
      "title": "Admin"
7
    }
8
  }
9
}



And modules/setting/metadata.json with snippet:

JSON
 




x


1
{
2
  "name": "settings",
3
  "entry": "/modules/settings/index.js",
4
  "options": {
5
    "tab": {
6
      "title": "Settings"
7
    }
8
  }
9
}



Application workflow

Application workflow


In Express, to make this folder easily accessible from our frontend, we need to make this folder serve a static folder.

JavaScript
 




x


1
gulp.task('server:start', (cb) => {
2
    app.use(express.json());
3
    app.get('/', (req, res) => {
4
        res.sendFile(`${paths.distDir}/index.html`);
5
    });
6
    registerDatabaseApi();
7
    app.use(express.static(`${paths.projectDir}/dist`));
8
    app.use('/modules', express.static(`${paths.projectDir}/modules`)); // that happens here
9
    app.listen(serverPort, () => {
10
        log.info(`DSFA is started on port ${serverPort}!`);
11
        cb();
12
    });
13
});



On the frontend, we need to be aware that this file has to be there and request it in api-resource.js.

JavaScript
 




xxxxxxxxxx
1


 
1
export const getModulesMetadata = () => httpRequest('GET', 'modules/modules-metadata.json');



That call is done from redux-saga, so once data is received, it’s going to be saved in the redux store.

JavaScript
 




xxxxxxxxxx
1


 
1
export function* loadCustomModulesSaga() {
2
    try {
3
        const {data: {modules = []}} = yield call(getModulesMetadata);
4
        yield putResolve(customModulesActions.set(modules));
5
    } catch (exception) {
6
        yield put(toastrActions.show('Error', exception, TOASTR_TYPE.error));
7
    }
8
}



The main application component listens to changes, and when it finds changes in modules, it reacts to them (app.js).

JavaScript
 




xxxxxxxxxx
1


 
1
const mapStateToProps = (state) => ({
2
    bootstrappedModules: state.bootstrap.bootstrappedModules,
3
    permissions: state.permissions,
4
    modules: state.customModules, // here
5
    users: state.users
6
});
7
 
          
8
@connect(mapStateToProps)
9
export class App extends Component {


 
There are two places that are adapted to module changes:

HTML
 




xxxxxxxxxx
1


 
1
<CustomLinks modules={modules} restrictedModuleNames={restrictedModuleNames}></div>



This part adds more navigational components, on which user can click and go there.

JavaScript
 




xxxxxxxxxx
1


 
1
<CustomRoutes
2
    bootstrappedModules={bootstrappedModules}
3
    modules={modules}
4
    restrictedModuleNames={restrictedModuleNames}
5
></div>



This component actually loads the plugin through an API call, attaches it to the HTML page, pulls all sources from that bundle, and connects it to the appropriate parts of the core module. CustomRoutes uses the LoadModule component to perform these actions.  

Let’s watch this process piece-by-piece:

First, we load the script and wait until it is mounted to the page

JavaScript
 




xxxxxxxxxx
1
19


 
1
useEffect(() => {
2
    if (R.not(R.includes(moduleName, bootstrappedModules))) {
3
        const script = document.createElement('script’); 
4
        script.src = scriptUrl; // that’s a backend URL by which the JS bundle of custom module is accessible. 
5
        script.async = true; // no need to do it synchronously. 
6
        script.onload = () => {
7
            setLoadedModuleName(moduleName); // once module is loaded we can proceed with connecting it to the core module
8
        };
9
        document.body.appendChild(script); 
10
    } else {
11
        setLoadedModuleName(moduleName);
12
    }
13
 
          
14
    return () => {
15
        if (context.saga) { // once component is unmounted, we also cancel all running sagas.
16
            context.saga.cancel();
17
        }
18
    };
19
}, []);



Then, we get code out of a mounted bundle and incorporating it into a core module 

JavaScript
 




xxxxxxxxxx
1
18


 
1
if (R.complement(R.isNil)(loadedModuleName)) {
2
    const {
3
        component: Component,
4
        reducers,
5
        saga
6
    } = window[loadedModuleName].default;
7
 
          
8
    if (R.not(R.includes(loadedModuleName, bootstrappedModules))) {
9
        if (saga) { // it makes not compulsory for module to expose sagas, if it doesn’t have it 
10
            context.saga = window.dsfaSaga.run(saga); // window.dsfaSaga - is a reference to a joint middleware created in core module, so then we can run sagas brought by this custom module.
11
        }
12
        if (reducers) { // // it makes not compulsory for module/plugin to expose reducers, if it doesn’t have it
13
            window.dsfaReducerRegistry.register(reducers); // window.dsfaReducerRegistry - is a joint register, created by a core module, and is used to register/unregister reducers from the entire system. Here we add new reducers brought by this custom module.
14
        }
15
        dispatch(applicationActions.moduleBootstrapped(loadedModuleName)); // marks that current module is loaded and prevents of loading it once again.
16
    }
17
    return <Component></div>;
18
}


    

Dynamically loading components workflow

Dynamically loading components workflow

To understand where window.dsfaReducerRegistry  and  window.dsfaSaga come from, have a look at  configure-store.js  file:

JavaScript
 




xxxxxxxxxx
1


 
1
const sagaMiddleware = createSagaMiddleware();
2
window.dsfaSaga = sagaMiddleware;
3
 
          
4
export const reducerRegistry = new ReducerRegistry(allReducers);
5
window.dsfaReducerRegistry = reducerRegistry;



For making it possible to register reducers from different parts of an application a bit of wrapper is created, as you noticed that ReducerRegister:

JavaScript
 




xxxxxxxxxx
1
24


 
1
export class ReducerRegistry {
2
    constructor(initialReducers = {}) {
3
        this.reducers = {...initialReducers};
4
        this.emitChange = null;
5
    }
6
 
          
7
    register(newReducers) {
8
        this.reducers = {...this.reducers, ...newReducers};
9
        if (this.emitChange !== null) {
10
            this.emitChange(this.getReducers());
11
        }
12
    }
13
 
          
14
    getReducers() {
15
        return {...this.reducers};
16
    }
17
 
          
18
    setChangeListener(listener) {
19
        if (this.emitChange !== null) {
20
            throw new Error('Can only set the listener for a ReducerRegistry once.');
21
        }
22
        this.emitChange = listener;
23
    }
24
}



And when the store is created, you need to create a reducer:

JavaScript
 




xxxxxxxxxx
1
10


 
1
export const configureStore = (history) => {
2
    const mainReducer = configureReducers(reducerRegistry.getReducers());
3
    const store = createStoreWithMiddleware(history)(mainReducer);
4
 
          
5
    reducerRegistry.setChangeListener((reducers) => {
6
        store.replaceReducer(configureReducers(reducers));
7
    });
8
    window.dsfaStore = store;
9
    return store;
10
};



As new reducers come, they are replaced in the store. And the same thing happens here with sharing the Redux store via a window with custom modules. Why use a window? Because that’s a way how is it possible to make a shared objects between different modules compiled individually by webpack.

Let’s have a look at this configuration, which is required to be done in custom module webpack.config.plugin.common.js

JavaScript
 




xxxxxxxxxx
1


 
1
output: {
2
    filename: 'index.js',
3
    globalObject: 'window’, // That’s the way how to make some points of the system shared between bundles.  
4
    library: pluginName,
5
    libraryTarget: 'umd’, // ! globalObject will work only if target is udm
6
    path: `${paths.modulesDistDir}/${moduleName}`,
7
    publicPath: '/'
8
},
9
 
          



And also externals, to not duplicate third-party libraries:

JavaScript
 




xxxxxxxxxx
1
13


 
1
const createItem = (key, globalName) => ({
2
    [key]: {
3
        amd: key,
4
        commonjs: key,
5
        commonjs2: key,
6
        root: globalName
7
    }
8
});
9
 
          
10
export const externals = {
11
    ...createItem('react', 'React'),
12
    ...createItem('react-dom', 'ReactDOM')
13
};



That scrapes the bundle of React and react-dom from a custom plugin. React doesn’t complain about the two versions of itself in the application (and crashes with react-redux in that case) and makes plugin really slight in comparison with a fully-packed version provided in case of iframe usage. The list of these externals will grow with the number of libraries you will use.

On the side of core module, you need to register such modules globally globals.js

JavaScript
 




xxxxxxxxxx
1


 
1
import React from 'react';
2
import ReactDOM from 'react-dom';
3
 
          
4
window.React = React;
5
window.ReactDOM = ReactDOM;



If in some cases plugin can’t be loaded successfully, to not crash the whole application, we have to isolate it, catch exception and show to the user information about it. For that, the CatchError component is created.

JavaScript
 




xxxxxxxxxx
1
19


1
export default class CatchError extends PureComponent {
2
    static propTypes = {children: PropTypes.element};
3
 
          
4
    constructor(props) {
5
        super(props);
6
        this.state = {hasError: false};
7
    }
8
 
          
9
    componentDidCatch() { // This part does the logic to keep the application alive.
10
        this.setState({hasError: true});
11
    }
12
 
          
13
    render() {
14
        if (this.state.hasError) {
15
            return <h1>Plugin has not been loaded successfully due to found errors</h1>;
16
        }
17
        return this.props.children;
18
    }
19
}



This is, in essence, all of the major parts of the application described above. The rest of the code is to make the application more or less appealing with bare minimum functionality. I encourage you to download the working scenario; setup is super easy. Play around with it! Add some goal, to enhance it with some small feature and feel how is the development going for you. For example, take that you are a developer who is asked to add "history" module with own tab and show all performed actions (adding/removing users). 

Easy level

With the current given implementation what is the bare minimum you will modify in core module and what will you add to a history-module? How are you going to communicate in an efficient way?

Medium level

How core plugin has to be rewritten so that new history or any other potential functionality won't require changes in the core module? As this is an ultimate goal of this architecture. 

I'm curious and excited to see your forks/PRs/smart and genius ideas regarding that. Looking forward for feedbacks and comments as well!


Further Reading

Topics:
redux ,webpack 4 ,react.js

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}