[go: up one dir, main page]

DEV Community

Cover image for Creating simple static server component
denisx
denisx

Posted on • Edited on

Creating simple static server component

What this article is about?
Creating simple static server components at working project with loadable components (without next.js)

Current setup
The site works on a standard setup: SSR render static HTML with critical CSS at <head>, and has React hydration on the client to make it interactive.

Site's HTML is generated with a JSON tree, with widgets structure, coming from DB+CMS. It is a fastify app on server, with isomorphic factory. It gets JSON and includes selected components on the page. Every component has a manifest file with loadable JSON - it describe widgets names with code endpoints and import names.

Main idea
Write a simple client widget to directly render HTML from the server, and don't load widget chunk to client.

Let's start
Get one of the largest static widgets - markdown. It weight - 43kb (min+gzip)

Change the manifest by splitting the common link into the server and client.

Current setup:

  MarkdownContainer: {
    component: loadable(
      () =>
        import(/* webpackChunkName: "MarkdownContainer" */ "./desktop"),
      {
        resolveComponent: (mod) => mod.MarkdownContainer,
      }
    ),
  },

Enter fullscreen mode Exit fullscreen mode

Change to

  MarkdownContainer: {
    componentServer: loadable(
      () => import(/* webpackChunkName: "MarkdownContainer" */ "./desktop"),
      {
        resolveComponent: (mod) => mod.MarkdownContainer,
      }
    ),
    componentClient: loadable(
      () =>
        import(
          /* webpackChunkName: "MarkdownContainer" */ "~/components/fake-component"
        ),
      {
        resolveComponent: (mod) => mod.FakeTransformer,
      }
    ),
    SSRC: true,
  },
  MarkdownContainer_css: {
    component: loadable(
      () =>
        import(/* webpackChunkName: "_css_MarkdownContainer" */ "./desktop"),
      {
        resolveComponent: (mod) => mod.MarkdownContainer,
      }
    ),
  },
Enter fullscreen mode Exit fullscreen mode

Now let's look to componentServer and componentClient endpoints.
(About new widget's name MarkdownContainer_css read in the middle of this article)

We allready have 2 webpack configurations: one for the client and one for the server, resulting in 2 builds. These endpoints we transform at webpack loaders: remove componentClient from server loader, and remove componentServer from client loader. (use jscodeshift for example)
Server and client part of widget is connected via identical chunk name MarkdownContainer.

SSRC: true - it is a flag for widgets factory. At this point we must remember, that it is an isomorphic code, and first time it is working at server, and second time - at client.

At the end of standard recursive factory func add:

// server
if (SSRC) {
  reactElement = createElement(ServerTransformer, { SSRC }, reactElement);
}

// client
if (properties?.__html) {
  reactElement = createElement(ClientTransformer, { __html: properties.__html });
}
Enter fullscreen mode Exit fullscreen mode

At server render, if widget's data has SSRC key, we create wrapper with prop SSRC.

At client render, if we have html-rendered string, we create client wrapper.

Here ServerTransformer and ClientTransformer code:

const ServerTransformer = ({ children }) => (
    <section>{children}</section>
);
Enter fullscreen mode Exit fullscreen mode
const ClientTransformer = ({ __html }) => (
    <section dangerouslySetInnerHTML={{ __html }} />
);
Enter fullscreen mode Exit fullscreen mode

Component FakeTransformer just return null.

You see, that all transformers wrap codewith section tag. You can wrap by <> at server and <section> on client and change html by useEffect, but you will catch hydration mismatch.

Now, our build is ready to use. When a user requests the page, we need to perform some tasks on the server side and modify the server router.

When we transform JSON into a React tree, we need to perform some changes.

const Renderer = ({...}) => (
  <Consumer>
    {({ extractor, widgets }) => {
      const res = createReactTree(widgets, device, ...);
      if (extractor) {
        checkSSRC(res, widgets, extractor);
      }
      return res;
    }}
  </Consumer>
);
Enter fullscreen mode Exit fullscreen mode

Check tree and modify json state (it will sent to client at html body)

const checkSSRC = (tree, widgets, extractor) =>
  (tree.length ? tree : [tree]).map((elem, tree_i) => {
    if (elem?.props?.children) {
      // Recursive
      checkSSRC(elem.props.children, widgets[tree_i].children, extractor);
    }
    if (elem?.props?.SSRC) {
      const extractorClone = Object.assign(
        Object.create(Object.getPrototypeOf(extractor)),
        extractor
      );
      let __html = renderToString(extractorClone.collectChunks(elem));
      // don't need any props now
      widgets[tree_i].properties = { __html };
      // don't need any children now
      widgets[tree_i].children = [];
    }
    return elem;
  });
Enter fullscreen mode Exit fullscreen mode

As you see, we have an instance of css generator from current react node, to have a css classes. At the top of code sample, you have seen a new widget with _css postfix - it needs to get css-classes and styles for critical css. Without it, we only have classes at html, and no styles.

It's all done! Now we can check old/new HTML of the page, and see result.

HTML structure:

  1. Critical CSS
  2. HTML content
  3. React state
  4. Resources

1. Critical CSS
The same or less. If component has multi-designs, will be selected only needed classes/styles;

2. HTML content
The same, but server components wrapped with <section> tag. If you not need, you can add some code to ClientTransformer component (and have hydration mismatch?)

useEffect(() => {
  if (!ref.current) return;
  // make a js fragment element
  const fragment = window.document.createDocumentFragment();
  // move every child from our div to new fragment
  while (ref.current?.childNodes[0]) {
    fragment.appendChild(ref.current?.childNodes[0]);
  }
  // and after all replace the div with fragment
  ref?.current?.replaceWith(fragment);
}, [ref]);
Enter fullscreen mode Exit fullscreen mode

3. React state

Old

{
  "name": "Markdown",
  "properties": { "content": "Have **bold** text" }
}
Enter fullscreen mode Exit fullscreen mode

New

{
  "name": "Markdown",
  "properties": {
    "__html": "\u003Csection\u003E\u003Cp class=\"a1jIK lG2mw _2G2mw a2WsA b2WsA\"\u003EHave \u003Cspan class=\"a1jIK w1jIK\"\u003Ebold\u003C\u002Fspan\u003E text\u003C\u002Fp\u003E\u003C\u002Fsection\u003E"
  }
}
Enter fullscreen mode Exit fullscreen mode

(__html: This is a HTML-string, converted to JSON)

<section>
  <p class="a1jIK lG2mw _2G2mw a2WsA b2WsA">
    Have <span class="a1jIK w1jIK">bold</span> text
  </p>
</section>
Enter fullscreen mode Exit fullscreen mode

4. Resources

Less JS/CSS chunks.

Downloaded JS decreases from 656Kb to 570Kb (-86Kb, localhost, not minified data. In my case, was changed Text, Button and MarkDown widgets. ).


This article not a "Hello word" type, but I hope this help you to undestand some of server components, promote to know more about it, fix bugs and give me advises to optimize code...
and make the web fast again!


P.S. Look for my previous post Reduce bundle size via one-letter css classname hash strategy


The text was edited with AI to make the english stronger.


Donation: Ethereum 0x84914da79c22f4aC6fb9D577C73E29E4AaAE7622

Top comments (1)

Collapse
 
idontknowjavascript profile image
Evgeny Kolesnikov

Great article! Thank you!