For a recent project I needed to be able to render React components (or really, just React-like components) into plain HTML strings. Of course, React provides a mechanism to do this: renderToStaticMarkup in its ReactDOM
library. But wow, that library is a really big dependency to import if I don’t want any of the DOM reconciliation stuff.
Also, it turns out I don’t even need React lifecycle events for my project. I don’t need component state either. I just want to render a bunch of stateless components with props and children. Something like this:
import React from 'react'; | |
export function Header( { title, subtitle, className } ) { | |
return ( | |
<div className={ className }> | |
<h1>{ title }</h1> | |
<span>{ subtitle }</span> | |
</div> | |
); | |
} |
Well, maybe there’s a way to just import part of the rendering engine… but then I realized I had yet another requirement: I need to be able to modify the string version of every component as it is created. I can’t do that with React. I need a custom renderer.
Happily, with some experimentation I learned that it’s not that hard to create one! Below you can see my version of renderToString()
. It accepts both stateless functional components and component classes.
Of course, the version below is a bit naive. I’m certain there’s many edge cases of rendering which it does not cover, and like I said above, it does not support state or lifecycle events at all. That said, it works very well for my own purposes and I learned a lot about how React components are put together in the doing!
The following also includes a full test suite to show how it works.
/* globals describe, it */ | |
import React from 'react'; | |
import { expect } from 'chai'; | |
// Using React.createElement works too! | |
import { renderToString, createElement } from './renderer'; | |
describe( 'renderToString()', function() { | |
it( 'returns a string with a tag wrapping plain text for a component with one text child', function() { | |
const component = createElement( 'p', null, 'hi' ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<p>hi</p>' ); | |
} ); | |
it( 'returns a string with a tag wrapping plain text for a function component', function() { | |
const MyEm = ( { children } ) => createElement( 'em', null, children ); | |
const component = createElement( MyEm, null, 'hi' ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<em>hi</em>' ); | |
} ); | |
it( 'returns a string with a tag wrapping a function component for a function component', function() { | |
const MyEm = ( { children } ) => createElement( 'em', null, children ); | |
const component = createElement( MyEm, null, createElement( MyEm, null, 'hi' ) ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<em><em>hi</em></em>' ); | |
} ); | |
it( 'returns a string with a tag wrapping plain text for an object component', function() { | |
class MyStrong extends React.Component { | |
render() { | |
return createElement( 'strong', null, this.props.children ); | |
} | |
} | |
const component = createElement( MyStrong, null, 'hi' ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<strong>hi</strong>' ); | |
} ); | |
it( 'returns a string with a tag wrapping joined text for a component with many text children', function() { | |
const component = createElement( 'p', null, [ 'hello', 'world' ] ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<p>helloworld</p>' ); | |
} ); | |
it( 'returns a string with a tag wrapping another tag for a component with one component child', function() { | |
const child = createElement( 'em', null, 'yo' ); | |
const component = createElement( 'p', null, child ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<p><em>yo</em></p>' ); | |
} ); | |
it( 'returns a string with a tag wrapping other tags for a component with many component children', function() { | |
const child = createElement( 'em', null, 'yo' ); | |
const text = createElement( 'span', null, 'there' ); | |
const component = createElement( 'p', null, [ child, text ] ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<p><em>yo</em><span>there</span></p>' ); | |
} ); | |
it( 'returns a string with a tag wrapping other tags and text for a component with component and text children', function() { | |
const child = createElement( 'em', null, 'yo' ); | |
const component = createElement( 'p', null, [ child, ' there' ] ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<p><em>yo</em> there</p>' ); | |
} ); | |
it( 'returns a string with a tag that expands its props to attributes for a text component', function() { | |
const component = createElement( 'a', { href: 'foo', target: 'top' }, 'link' ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<a href="foo" target="top">link</a>' ); | |
} ); | |
it( 'returns a string with a tag that expands its className prop to a class attribute for a text component', function() { | |
const component = createElement( 'a', { className: 'cool' }, 'link' ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<a class="cool">link</a>' ); | |
} ); | |
it( 'returns a string with a tag that uses its props to create a child for a function component', function() { | |
const MyEm = ( { name } ) => createElement( 'em', null, name ); | |
const component = createElement( MyEm, { name: 'hi' } ); | |
const out = renderToString( component ); | |
expect( out ).to.equal( '<em>hi</em>' ); | |
} ); | |
} ); |
export function createElement( type, props = null, children = null ) { | |
if ( ! props ) { | |
props = {}; | |
} | |
if ( children ) { | |
props.children = children; | |
} | |
return { type, props }; | |
} | |
export function renderToString( component ) { | |
if ( component.length ) { | |
return component; | |
} | |
return renderNonText( component ); | |
} | |
function renderNonText( component ) { | |
if ( typeof component.type !== 'function' ) { | |
return renderHtmlTag( component.type, component.props, component.props.children ); | |
} | |
if ( component.type.prototype.render ) { | |
return renderObject( component.type, component.props ); | |
} | |
return renderFunction( component.type, component.props ); | |
} | |
function renderChildren( children ) { | |
return getChildrenArray( children ).map( child => renderToString( child ) ).join( '' ); | |
} | |
function renderFunction( func, props ) { | |
return renderToString( func( props ) ); | |
} | |
function renderObject( Type, props ) { | |
const instance = new Type( props ); | |
return renderToString( instance.render() ); | |
} | |
function renderHtmlTag( type, props, children ) { | |
return children ? `<${ type + renderPropsAsAttrs( props ) }>${ renderChildren( children ) }</${ type }>` : `<${ type + renderPropsAsAttrs( props ) } />`; | |
} | |
function renderPropsAsAttrs( props ) { | |
if ( ! props ) { | |
props = {}; | |
} | |
const exclude = [ 'children' ]; | |
const keys = Object.keys( props ).filter( key => exclude.indexOf( key ) === –1 ); | |
if ( keys.length < 1 ) { | |
return ''; | |
} | |
return ' ' + keys.map( attr => renderPropAsAttr( attr, props[ attr ] ) ).join( ' ' ); | |
} | |
function renderPropAsAttr( prop, value ) { | |
const attr = prop === 'className' ? 'class' : prop; | |
return `${ attr }="${ value }"`; | |
} | |
function getChildrenArray( children = [] ) { | |
return Array.isArray( children ) ? children : [ children ]; | |
} |
export function createElement( type, props = null, children = null ) { | |
if ( ! props ) { | |
props = {}; | |
} | |
if ( children ) { | |
props.children = children; | |
} | |
return { type, props }; | |
} | |
export function renderToString( component ) { | |
if ( component.length ) { | |
return component; | |
} | |
return renderNonText( component ); | |
} | |
function renderNonText( component ) { | |
if ( typeof component.type !== 'function' ) { | |
return renderHtmlTag( component.type, component.props, component.props.children ); | |
} | |
if ( component.type.prototype.render ) { | |
return renderObject( component.type, component.props ); | |
} | |
return renderFunction( component.type, component.props ); | |
} | |
function renderChildren( children ) { | |
return getChildrenArray( children ).map( child => renderToString( child ) ).join( '' ); | |
} | |
function renderFunction( func, props ) { | |
return renderToString( func( props ) ); | |
} | |
function renderObject( Type, props ) { | |
const instance = new Type( props ); | |
return renderToString( instance.render() ); | |
} | |
function renderHtmlTag( type, props, children ) { | |
return children ? `<${ type + renderPropsAsAttrs( props ) }>${ renderChildren( children ) }</${ type }>` : `<${ type + renderPropsAsAttrs( props ) } />`; | |
} | |
function renderPropsAsAttrs( props ) { | |
if ( ! props ) { | |
props = {}; | |
} | |
const exclude = [ 'children' ]; | |
const keys = Object.keys( props ).filter( key => exclude.indexOf( key ) === –1 ); | |
if ( keys.length < 1 ) { | |
return ''; | |
} | |
return ' ' + keys.map( attr => renderPropAsAttr( attr, props[ attr ] ) ).join( ' ' ); | |
} | |
function renderPropAsAttr( prop, value ) { | |
const attr = prop === 'className' ? 'class' : prop; | |
return `${ attr }="${ value }"`; | |
} | |
function getChildrenArray( children = [] ) { | |
return Array.isArray( children ) ? children : [ children ]; | |
} |