Reputation: 11347
I've got a React application, in which I need to display an HTML Popup with some dynamic content generated by the server. This content also contains some JSX markup which I want to be rendered into real components.
<MyButton onClick={displayInfo}/>
...
async displayInfo() {
let text = await fetch(...)
console.log(text) // "<SomeComp onClick={foo}><OtherComp..... etc
let component = MAGIC(text)
ReactDom.render(component, '#someDiv')
}
Due to how my server app is structured, ReactServerDOM und hydration is not an option.
Is there a way to implement this on the client side only?
Upvotes: 9
Views: 2181
Reputation: 461
Actually, you don't need a compiler, but a parser.
Here's the demo, and the process is parsing string to dom, and transform dom to react elements, then render to react tree.
const { Parser } = require("htmlparser2");
const { DomHandler } = require("domhandler");
const React = require('react');
const rawHtml = "<SomeComp onClick={foo}><div>foo</div></SomeCompo>";
const handler = new DomHandler((error, nodes) => {
if (error) {
// Handle error
} else {
// Parsing completed, do something
// TODO: store all available component here
const componentMapping = {};
// TOOD: store all available props for dom
const currentContext = {};
const getComponent = (tag) => {
// html tag
if (tag.toLowerCase() === tag) {
return tag;
}
// TODO: add default tag for testing
return componentMapping[tag] || tag;
};
const getComponentProps = (attrs) => {
return Object.keys(attrs).reduce((p, c) => {
const value = attrs[c];
const match = /^{(.+)}$/.exec(value);
if (match) {
// TODO: here need to consider number, string, object, function types
p[c] = currentContext[match[1]] || match[1];
} else {
p[c] = value;
}
return p;
}, {});
};
const walk = (dom, parent) => {
// if is text node
if (dom.type === 'text') {
parent.push(dom.data);
return;
}
const Component = getComponent(dom.name);
const current = [];
for (let child of dom.children) {
walk(child, current);
}
const node = React.createElement(Component, {
children: current,
...getComponentProps(dom.attribs),
});
if (parent) {
parent.push(node);
} else {
return node;
}
};
const result = walk(nodes[0], null);
console.log(result);
// TODO: render react element to current
}
});
const parser = new Parser(handler, {
lowerCaseTags: false,
lowerCaseAttributeNames: false,
recognizeSelfClosing: true,
});
parser.write(rawHtml);
parser.end();
Upvotes: 0
Reputation: 10886
As others mentioned, this has code smell. JSX is an intermediary language intended to be compiled into JavaScript, and it's inefficient to compile it at run time. Also it's generally a bad idea to download and run dynamic executable code. Most would say the better way would be to have components that are driven, not defined, by dynamic data. It should be possible to do this with your use case (even if the logic might be unwieldy), though I'll leave the pros/cons and how to do this to other answers.
But if you trust your dynamically generated code, don't mind the slower user experience, and don't have time/access to rewrite the backend/frontend for a more efficient solution, you can do this with Babel, as mentioned by BENARD Patrick and the linked answer. For this example, we'll use a version of Babel that runs in the client browser called Babel Standalone.
There needs to be some way to run the compiled JavaScript. For this example, I'll import it dynamically as a module, but there are other ways. Using eval
can be briefer and runs synchronously, but as most know, is generally considered bad practice.
function SomeComponent(props) {
// Simple component as a placeholder; but it can be something more complicated
return React.createElement('div', null, props.name);
}
async function importJSX(input) {
// Since we'll be dynamically importing this; we'll export it as `default`
var moduleInput = 'export default ' + input;
var output = Babel.transform(
moduleInput,
{
presets: [
// `modules: false` creates a module that can be imported
["env", { modules: false }],
"react"
]
}
).code;
// now we'll create a data url of the compiled code to import it
var dataUrl = 'data:text/javascript;base64,' + btoa(output);
return (await import(dataUrl)).default;
}
(async function () {
var element = await importJSX('<SomeComponent name="Hello World"></SomeComponent>');
var root = ReactDOM.createRoot(document.getElementById('root'));
root.render(element);
})();
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<div id="root"></div>
For a better user experience, you'd probably want to move the compilation to a web worker. For example:
worker.js
importScripts('https://unpkg.com/@babel/standalone@7/babel.min.js'); self.addEventListener('message', function ({ data: { input, port }}) { var moduleInput = 'export default ' + input; var output = Babel.transform( moduleInput, { presets: [ // `modules: false` creates a module that can be imported ["env", { modules: false }], "react" ] } ).code; port.postMessage({ output }); });
And in your main script:
var worker = new Worker('./worker.js'); async function importJSX(input) { var compiled = await new Promise((resolve) => { var channel = new MessageChannel(); channel.port1.onmessage = (e) => resolve(e.data); worker.postMessage({ input, port: channel.port2 }, [ channel.port2 ]); }); var dataUrl = 'data:text/javascript;base64,' + btoa(compiled.output); return (await import(dataUrl)).default; } (async function () { var root = ReactDOM.createRoot(document.getElementById('root')); var element = await importJSX('<div>Hello World</div>'); root.render(element); })();
This assumes React
and ReactDOM
are imported already, and there's a HTML element with id root
.
It's worth mentioning that if you're generating JSX dynamically, it's usually only slightly more complex to instead generate what that JSX would compile to.
For instance:
<SomeComponent onClick={foo}>
<div id="container">Hello World</div>
</SomeComponent>
When compiled for React is something like:
React.createElement(SomeComponent,
{
onClick: foo
},
React.createElement('div',
{
id: 'container'
},
'Hello World'
)
);
(See https://reactjs.org/docs/jsx-in-depth.html for more information on how JSX gets compiled and https://babeljs.io/repl for an interactive website to compile JSX to JS using Babel)
While you could run Babel server-side to do this (which would add additional overhead) or you could find/write a pared-down JSX compiler, you also probably can also just rework the functions that return JSX code to ones that return regular JavaScript code.
This offers significant performance improvements client-side since they wouldn't be downloading and running babel.
To actually then use this native JavaScript code, you can export it by prepending export default
to it, e.g.:
export default React.createElement(SomeComponent,
...
);
And then dynamically importing it in your app, with something like:
async function displayInfo() {
let component = (await import(endpointURL)).default;
ReactDom.render(component, '#someDiv');
}
Upvotes: 8
Reputation: 3975
As others have pointed out, Browsers don't understand JSX. Therefore, there's no runtime solution you can apply that will actually work in a production environment.
What you can do instead:
<ComponentBuilder />
is nothing but a switch statement that renders one or more of those pre-defined components from step 1 based on what ever you pass to the switch statement.<ComponentBuilder />
where ever you want to render components decided by the server.// Component Builder code
const ComponentBuilder = () => {
const [apiData, setApiData] = useState()
useEffect(() => {
// make api call.
const result = getComponentBuilderDataFromAPI()
if (result.data) {
setApiData(result.data)
}
}, [])
switch (apiData.componentName) {
case 'testimonial_cp':
return <Testimonials someProp={apiData.props.someValue || ''} />
case 'about_us_cp':
return <AboutUs title={apiData.props.title || 'About Us'} />
// as many cases as you'd like
default:
return <Loader />
}
}
This will be a lot more efficient, maintainable and the server ends up deciding which component to render.
Just make sure not to mix up responsibilities. What is to be on the front end must be on the front end.
eg: don't let the server send across HTML or CSS directly. Instead have a bunch of predefined styles on the front-end and let the server just send the className
. That way the server is in control of deciding the styles but the styles themselves live on the front-end. Else, it becomes really difficult to make changes and find bugs.
The server sends a bunch of CSS properties. You have a bunch of component styles. There are a bunch of global styles. You also use Tailwind or Bootstrap. You will not know what exactly is setting the style or even who's controlling it. A nightmare to debug and maintain! Saying this from experience.
The above solution should work for you provided you can make the required changes to your architecture! Because it's working in a production environment as I write this answer.
Upvotes: 1