A Simple React.JS extension mechanism
On backend side, extending an application is as easy as reading some file (for script languages - Node.JS, PHP, Ruby, Python, …) or loading some registration for others (Java with its well known ServiceLoader
, C#, C, C++, …).
However, on frontend side, it is always more complicated. Let see one simple option to provide some extension mechanism for a React.JS application.
The SPA extension issue
Single Page Application (S.P.A.) development changed a lot last years. We can now use server side rendering, use the same technology to do static website generation, lazy load components etc… However it is generally still about using a webpack build, i.e. a "put it all together".
By itself it is not a big deal but in practise it means that adding an extension is hard because once you managed to inject your script in the html (which is the first step), you have to find how to reuse the application components and here you can realize:
-
They are no more there (dropped at minification time because not used),
-
Their names changed,
-
You don’t have a reference to plug yourself in the application.
Make your application to be extensible
Inject your custom script into the SPA index.html
The first step to make a SPA extensible is to enable to inject a custom javascript file. The related question is where:
-
Before the application "main" script?
-
After the application "main" script?
-
Before all scripts?
In general, you will want to inject it before the main script, i.e. before the application starts, but after the libraries are loaded. This will enable you to register your extension and let the main launcher use it directly and not have to reload itself once started when your extension script is loaded.
There are multiple ways to do it but overall the idea will be to rewrite the index.html using a proxy - likely the same hosting and serving your extension script.
Just to illustrate it, here is a Java flavor - but a plain HTTPd works well too;):
@WebFilter(urlPatterns = {"/", "/home", "/page1", "/help", "/extensions/*"}) (1)
public class FrontendRouter extends HttpServlet { (2)
private byte[] indexHtmlBytes;
@Override
public void init(final ServletConfig config) {
final var extensionScript = System.getenv("APP_EXTENSION_SCRIPT"); (3)
(4)
final var out = new ByteArrayOutputStream();
try (final var in = config.getServletContext().getClassLoader()
.getResourceAsStream("META-INF/resources/index.html")) {
requireNonNull(in, "didn't find index.html").transferTo(out);
} catch (final IOException e) {
throw new IllegalArgumentException(e);
}
indexHtmlBytes = rewrite(out.toString(StandardCharsets.UTF_8)); (5)
}
@Override // note: in a real impl ETag would be handled
public void doGet(final HttpServletRequest servletRequest, final HttpServletResponse servletResponse) throws IOException {
(6)
servletResponse.setBufferSize(indexHtmlBytes.length);
servletResponse.setContentType("text/html");
try (final var out = servletResponse.getOutputStream()) {
out.write(indexHtmlBytes);
}
}
private byte[] rewrite(final String indexHtml) { (7)
final var js = configuration.getFrontendExtensionsJs();
if (js != null && !js.isBlank()) {
final int start = indexHtml.indexOf("<script src=\"/static/js/main.");
if (start < 0) {
throw new IllegalArgumentException("Unexpected html");
}
return (indexHtml.substring(0, start) +
"<script src=\"" + js + "\"></script>" +
indexHtml.substring(start)).getBytes(StandardCharsets.UTF_8);
}
return indexHtml.getBytes(StandardCharsets.UTF_8);
}
}
1 | We bind the filter on all frontend routes (react-router routes). A small trick there is to enable upfront and /extensions/* route the extensions will be able to use without having to hack the filter or add another one, |
2 | We use a HttpServlet which will server the index.html - already rewritten. Note that in complete implementation ETag and other caching headers will need to be handled but it will not drastically change the behavior (we only speak of the index.html there), |
3 | We read which custom extension script to use - here from the environment but any configuration mechanism is fine, |
4 | We load from the classpath - can be from web resources - the index.html in memory, |
5 | We rewrite the index.html to inject our custom script in it, |
6 | The doGet implementation simply serve the rewritten index.html , nothing more, |
7 | The rewrite logic of the index.html is as simple as finding the last <script> injected by webpack - it is the case if you use react-scripts - and append before it our custom script. |
Extension points registration
The next step is to add extensions points to the SPA. It has two aspects:
-
The extension point itself (for example: "you can decorate the id of the table of the view V"),
-
The extension point registration (i.e. how to inject this extension point from the previously injected script).
For the registration the simplest is to enable to use a global object the application will use.
if you care about it being hardcoded, ensure to make it configurable, ie instead of using myExtension you will use props.extensionGlobalVariableName value.
|
To do it, the idea is to register on window
an object with a known name (like myExtension
).
it is highly recommended to ensure the name is unique so something like <organization><ProjectName>Extensions is not that bad.
|
Then the extension variable will take the shape the application uses.
Personally to ensure there is a single global variable I tend to make it an object with attributes as extension points:
window.githubRmannibucauSampleAppExtensions = {
extensionPoint1: ...,
extensionPoint2: ...,
};
Add extension points in your application
Then the application must be modified to read this variable and use the extension points.
Let’s take an example to illustrate this part: we want to enable an user to decorate the id
field value of a Detail
page.
Original view can look like:
function Detail(props) {
return (
<Text>{props.data.id}</Text>
);
}
And the extension can look like:
window.githubRmannibucauSampleAppExtensions = {
decorateDetailId: function (id) {...},
};
The modified flavor will look like:
function Detail(props) {
const decorator = (window.githubRmannibucauSampleAppExtensions || {}).decorateDetailId; (1)
if (!decorator) { (2)
return (
<Text>{props.data.id}</Text>
);
}
return (
<Text>{decorator(props.data.id)}</Text> (3)
);
}
1 | We read the decorateDetailId extension point, |
2 | If there is no extension point we return the same view than before, |
3 | If extension point exists we call it to wrap the id value. |
This works but is not very convenient, in particular when to do in all views.
To make it smoother we will create an extensions.js
file which will ensure the extension object always exists and all extension points too:
const exts = {
(1)
decorateDetailId: v => v,
(2)
...(window.githubRmannibucauSampleAppExtensions || {}),
};
export default exts;
1 | We set all extension points defaults (a single one here but do it for all extension points), |
2 | We merge with the actual extension points which will override the defaults. |
Now we can import the extension object in our component and use it more simply:
import extensions from './extensions'; (1)
function Detail(props) {
return (
<Text>{extensions.decorateDetailId(props.data.id)}</Text> (2)
);
}
1 | We import our extension registry instance, |
2 | We always call the extension - default being an identity implementation. |
At this stage we already have a working extension but we miss something key to make it useful: be able to reuse the same component libraries than the application.
Make the extension points libraries aware
To make the extension points libraries aware, we will need to bind/inject the libraries we consider as part of our API/stack in the extension points.
There are multiple options there:
-
Pass them all as parameters to the extension points - works well when you don’t have a lot of extension points and they are rarely all implemented at the same time,
-
Pass them to a factory method.
So the first step is to import your library stack.
It is often a matter of importing *
of your dependencies or a set of components you want to re-export:
import React from 'react';
import * as router from 'react-router-dom';
import * as antd from 'antd';
import { myHook } from '../hooks/useJsonRpc';
Then you can pass React
, router
, antd
, myHook
, … as extension point parameters (or wrapped in an object).
To do that you can simply modify your extensions.js
file:
// previous imports
// exts declaration as before
const registry = { React, router, antd, myHook };
const extensions = {
decorateDetailId: id => exts.decorateDetailId(id, registry),
};
export default extensions;
With this simple trick, your decorateDetailId
can access the stack your selected (key one being React
and your main component library like antd
here).
If you prefer the factory option - because you have a lot of extension points - it will require to modify a bit the registration - or test if the extension point is a function
or an object
:
window.githubRmannibucauSampleAppExtensions = function (registry) {
return {
decorateDetailId: function (id) {...},
};
}
This small change makes the extension a function which takes the registry as parameter and can therefore use it in extension points later on so no need to add this decorator (extension
) in extension.js
, you just need to call the function:
// same imports
const registry = { React, router, antd, myHook };
const exts = {
// defaults
decorateDetailId: v => v,
// user/extensions
...(window.githubRmannibucauSampleAppExtensions || function (){return {};})(registry),
};
export default exts;
Extension implementation example
Assuming you used previous extension point setup, here is how to add react components with this mecanism:
window.githubRmannibucauSampleAppExtensions = {
decorateDetailId: function (id, registry) {
var React = registry.React;
var router = registry.router;
return React.createElement('span', null, [
React.createElement(router.Link, { to: { pathname: '/extensions/custom/' + id } }, 'Custom Link To ' + id),
]);
},
};
This small decorator will replace the id
value by a link to a /extensions/custom/:id
page.
This is where it becomes very interesting. If you design - as we often do - you routes as a structure and not as something hardcoded:
const routes = [
{
path: '/',
exact: true,
component: Home,
},
{
path: '/help',
component: Help,
},
];
Then it becomes easy to import the extension registry and add extension routes (/extensions/custom
):
import extensions from './extensions';
const routes = [
{
path: '/',
exact: true,
component: Home,
},
{
path: '/help',
component: Help,
},
...(extensions.routes || function () {return [];})(),
];
With this simple modification you can add routes
in your extension registration and add your custom pages:
window.githubRmannibucauSampleAppExtensions = {
routes: function (registry) {
var React = registry.React;
var e = React.createElement;
// Custom component
function Custom() {
return e('div', { className: 'custom' }, [
e('div', null, '...'),
]);
}
return [
{
path: '/extensions/custom',
component: Custom,
},
];
},
};
This enables you to implement custom components and register them as routes in the final application :).
The last tip about this extension mecanism is that if you find the React.createElement
a bit heavy and hard to write - it can be for advanced cases, you can still rely on a JSX build (with webpack potentially) to write these components.
It will just need to rely on the injected React
and libraries instead of the default ones but since JSX is not bound to React
but is fully customizable it is not something crazy to do ;).
From the same author:
In the same category: