This article is by IMWeb IMWeb team

The concept of server-side rendering and isomorphism has been all over the front-end world for the last two years, and it’s been mentioned in almost every front-end sharing conference. In this day and age, no matter what stack you choose, not doing a server-side render is probably not going to make it. Recently, I just implemented an isomorphic straight out application based on Act&Redux. Write an article to summarize the situation.

preface

Before we get into the process, let’s understand a few concepts (for non-novices to skip).

What is Server-side Rendering?

Server-side rendering, also known as back-end rendering or straight out.

Earlier, most websites used the traditional MVC architecture for back-end rendering, which was to implement a Controller, pull the data Model from the server side when processing the request, and render the page using the template engine combined with View, such as Java + Velocity and PHP. However, with the development of front-end script JS, with more powerful interactive capabilities, the concept of front and back end separation is proposed, that is, the operation of pulling data and rendering is completed by the front end.

For details on the front-end versus back-end rendering debate, see the reference links at the end of this article. Here are the advantages of back-end rendering:

  • Better first screen performance, no need to download a bunch of CSS and JS in advance to see the page
  • Better for SEO, spiders can grab rendered content directly

What is Isomorphic application?

Isomorphism, in this article specifically refers to the server and client isomorphism, which means that both the server and client can run the same set of code programs.

After the rise of Node, the server language, SSR isomorphism enables JS to run on the server and browser at the same time, which greatly improves the value of isomorphism:

  • Improve code reuse
  • Improve code maintainability

Based on act&Redux considerations

In fact, both Vue and React provide SSR related capabilities. We considered which technology stack to use before making the decision. The reason why we decided to use React was that it was important for the team to unify the technology stack in terms of maintainability:

  • There is already a React based UI
  • Act&redux based scaffolding already exists
  • Have some practical experience in React direct (only component isomorphism, Controller is not universal)

React provides an API that outputs Virtual DOM as HTML text;

Redux provides a solution to reuse reducers isomorphism;

Scheme and Practice

The asynchronous project directory based on React&Redux was generated using scaffolding first:

- dist/ # Build results
	- xxx.html
	- xxx_[md5].js
	- xxx_[md5].css
- src/ # source code entry
	- assets/
		- css/ # global CSS
		- template.html # Page template
	- pages/ # page source directory
		- actions.js # global actions
		- reducers.js # global reducers
		- xxx/ # page name directory
			- components/ # Page-level components
			- index.jsx # main entrance of page
			- reducers.js # page reducers
			- actions.js # page actions
	- components/ # Global-level components
- webpack.config.js
- package.json
- ...
Copy the code

As you can see, existing asynchronous projects, builds use the web-webpack-plugin to compile asynchronous HTML, JS, and CSS files for each page using all SRC /pages/ XXX /index.js as portals.

1. Add a Node Server

Since we want to do it directly, we need a Web Server first. You can use Koa. Here we use the team’s self-developed IMServer based on Koa (the author is the author of the open source tool Whistle, I can’t live without it after using whistle).

- server/
	- app/
		- controller/ # controllers
			- indexReact.js React to Controller
		- middleware/ # middleware
		- router.js   # Route Settings
	- config/
		- config.js # Project configuration
	- lib/ # Internal dependency library
	- dispatch.js # boot entry
	- package.json
	- ...
Copy the code

Since it is a multi-page application (not A SPA), the Controller logic in the previous team’s practice is not universal. That is to say, whenever the business needs to create a new page, it has to write an additional Controller by hand, and all Controllers have common logic. Each request comes through:

  1. Create a store based on the Reducer
  2. Pull the first screen data
  3. The renderings
  4. . (Other custom hooks)

So why don’t we implement a generic Controller that isomorphizes all of this logic:

// server/app/controller/indexReact.js
const react = require('react');
const { renderToString } = require('react-dom/server');
const { createStore, applyMiddleware } = require('redux');
const thunkMiddleware = require('redux-thunk').default;
const { Provider } = require('react-redux');

async function process(ctx) {
  / / create a store
  const store = createStore(
    reducer Reducer */.undefined,
    applyMiddleware(thunkMiddleware)
  );

  // Pull the first screen data
  /* 2. Homogeneous component static method getPreloadState */
  const preloadedState = await component.getPreloadState(store).then((a)= > {
    return store.getState();
  });

  / / renders HTML
  /* 2. Isomorphic component static method getHeadFragment */
  const headEl = component.getHeadFragment(store);
  const contentEl = react.createElement(
    Provider,
    { store },
    react.createElement(component)
  );
  ctx.type = 'html';
  /* 3. The template function template */ is based on the HTML compilation of the page
  ctx.body = template({
    preloadedState,
    head: renderToString(headEl),
    html: renderToString(contentEl)
  });
}

module.exports = process;
Copy the code

The above code is equivalent to a process hook, as long as the isomorphic code provides the corresponding hook.

Of course, you also need to generate the route according to the page:

// server/app/router.js
const config = require('.. /config/config');
const indexReact = require('./controler/indexReact');

module.exports = app= > {
  // The direct out page routing configuration is required
  const { routes } = config;

  // IMServer calls this method, passing in the koA-Router instance
  return router= > {
    Object.entries(routes).forEach(([name, v]) = > {
      const { pattern } = v;

      router.get(
        name, // Directory name XXX
        pattern, // Directory routing configuration, such as '/course/:id'
        indexReact
      );
    });
  };
};
Copy the code

At this point, the server-side code is almost complete.

2. Isomorphic construction gets through

The previous server-side code relies on several isomorphic copies.

  • The page data pure function reducer.js
  • Page component main entry component.js
  • Based on theweb-webpack-pluginThe generated page xxx.html is recompiled by the template function template

I chose to build these files, rather than introduce babel-Register directly into the front-end code on the server side, because I wanted to keep the freedom that builds can do more than Babel-Register can do.

// webpack-ssr.config.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const write = require('write');
const webpack = require('webpack');
const FilterPlugin = require('filter-chunk-webpack-plugin');
const { rootDir, serverDir, resolve } = require('./webpack-common.config');
const ssrConf = require('./server/ssr.config');

const { IgnorePlugin } = webpack;

const componentsEntry = {};
const reducersEntry = {};
glob.sync('src/pages/*/').forEach(dirpath= > {
  const dirname = path.basename(dirpath);
  const options = { realpath: true };
  componentsEntry[dirname] = glob.sync(
    `${dirpath}/isomorph.{tsx,ts,jsx,js}`,
    options
  )[0];
  reducersEntry[dirname] = glob.sync(
    `${dirpath}/reducers.{tsx,ts,jsx,js}`,
    options
  )[0];
});
const ssrOutputConfig = (o, dirname) = > {
  return Object.assign({}, o, {
    path: path.resolve(serverDir, dirname),
    filename: '[name].js'.libraryTarget: 'commonjs2'
  });
};
const ssrExternals = [/assets\/lib/];
const ssrModuleConfig = {
  rules: [{test: /\.(css|scss)$/.loader: 'ignore-loader'
    },
    {
      test: /\.jsx? $/.loader: 'babel-loader? cacheDirectory'.include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')]}, {test: /\.(gif|png|jpe? g|eot|woff|ttf|pdf)$/.loader: 'file-loader'}};const ssrPages = Object.entries(ssrConf.routes).map(([pagename]) = > {
  return `${pagename}.js`;
});

const ssrPlugins = [
  new IgnorePlugin(/^\.\/locale$/, /moment$/),
  new FilterPlugin({
    select: true.patterns: ssrPages
  })
];

const ssrTemplatesDeployer = assets= > {
  Object.entries(assets).forEach(([name, asset]) = > {
    const { source } = asset;

    // ssr template
    if (/.html$/.test(name)) {
      const content = source()
        // eslint-disable-next-line
        .replace(/(<head[^>]*>)/.'$1${head}')
        .replace(
          /(<\/head>)/.// eslint-disable-next-line
          "<script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}</script>$1"
        )
        .replace(/(<div[^>]*id="react-body"[^>]*>)/.'$1${html}'); // eslint-disable-line

      write.sync(path.join(serverDir, 'templates', name), content); }}); };const devtool = 'source-map';

function getSSRConfigs(options) {
  const { mode, output } = options;

  return [
    {
      mode,
      entry: componentsEntry,
      output: ssrOutputConfig(output, 'components'),
      resolve,
      devtool,
      target: 'node'.externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    },
    {
      mode,
      entry: reducersEntry,
      output: ssrOutputConfig(output, 'reducers'),
      resolve,
      devtool,
      target: 'node'.externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    }
  ];
}

module.exports = {
  ssrTemplatesDeployer,
  getSSRConfigs
};
Copy the code

The code above packages the homogeneous modules and files required by the Controller into the server/ directory:

src/
	- pages/
		- xxx
			- template.html # Page template
			- reducers.js Reducer entry
			- isomorph.jsx # page server main entry
server/
	- components/
		- xxx.js
	- reducers/
		- xxx.js
	- templates
		- xxx.html Read from Node and compile into a template function
Copy the code

3. Implement isomorphic hooks

You also need to implement common Controller conventions in homogeneous modules.

// src/pages/xxx/isomorph.tsx
import * as React from 'react';
import { bindActionCreators, Store } from 'redux';
import * as actions from './actions';
import { AppState } from './reducers';
import Container, { getCourceId } from './components/Container';

Object.assign(Container, {
  getPreloadState(store: Store<AppState>) {
    type ActionCreatorsMap = {
      fetchCourseInfo: (x: actions.CourseInfoParams) = > Promise<any>;
    };

    const cid = getCourceId();
    const { fetchCourseInfo } = bindActionCreators<{}, ActionCreatorsMap>(actions, store.dispatch);

    return fetchCourseInfo({ course_id: cid })
  },

  getHeadFragment(store: Store<AppState>) {
    const cid = getCourceId();
    const { courseInfo } = store.getState();
    const { name, summary, agency_name: agencyName } = courseInfo.data;
    const keywords = ['Tencent Classroom', name, agencyName].join(', ');
    const canonical = `//ke.qq.com/course/${cid}`;

    return( <> <title>{name}</title> <meta name="keywords" content={keywords} /> <meta name="description" itemProp="description" content={summary} /> <link rel="canonical" href={canonical} /> </> ); }}); export default Container;Copy the code

So far isomorphism has been basically through.

4. Asynchronous portal & Dr

That’s all that’s left to do, use reactdom.hydrate in the asynchronous JS entry:

// src/pages/xxx/index.tsx
import * as React from 'react';
import { hydrate } from 'react-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { Provider } from 'react-redux';
import reducers from './reducers';
import Container from './components/Container';
import './index.css';

let store;
const preloadState = window.__PRELOADED_STATE__;

if (process.env.NODE_ENV === 'production') {
  store = createStore(reducers, preloadState, applyMiddleware(thunkMiddleware));
} else {
  store = createStore(
    reducers,
    preloadState,
    compose(
      applyMiddleware(thunkMiddleware),
      window.devToolsExtension ? window.devToolsExtension() : (f: any) = > f
    )
  );
}

hydrate(
  <Provider store={store}>
    <Container />
  </Provider>.window.document.getElementById('react-body'));Copy the code

hydrate() Same as render(), but is used to hydrate a container whose HTML contents were rendered by ReactDOMServer. React will attempt to attach event listeners to the existing markup.

React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them.

Disaster recovery refers to when the server fails for some reason, because we still have to build asynchronous pages of XXx. HTML, we can make a disaster recovery scheme on the Nginx layer, when the upper Svr error, the asynchronous page.

Hit the pit

  • Business logic that cannot be isomorphic

You should be aware of things like binding events to componentDidMount because of the different life cycle, and not being able to access the DOM API where the server can execute it. In fact, you probably only need to implement the most basic isomorphic modules:

  1. Accessing the Location module
  2. Accessing the cookie module
  3. Access the userAgent module
  4. Request module
  5. Modules such as localStorage and window.name can only be degraded (try to avoid using them in the first screen logic)

Of course, THERE are some modules that rely on the client’s ability, such as WX SDK, QQ SDK and so on.

A quick note here is that I originally designed it to be as simple as possible without breaking the team’s existing coding practices. Modular methods such as location and cookie should get different values every time a request comes in. How to do this is based on TSW: Tswjs.org/doc/api/glo… Node’s Domain module makes this design possible.

Still, avoid writing module local variables (I wrote a separate article on this topic).

  • useignore-loaderIgnore dependent CSS files
  • core-jsPackages cause memory leaks
    {
      test: /\.jsx? $/.loader: 'babel-loader? cacheDirectory'.// Kill babel-Runtime, which relies on core-js source code for global['__core-js_shared__'] operations causing memory leaks
      options: {
        babelrc: false.presets: [['env', {
            targets: {
              node: true}}].'stage-2'.'react'].plugins: ['syntax-dynamic-import']},include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')]}Copy the code

The issue on this section of core-js also explains why:

Github.com/babel/babel…

In fact, es6 features are supported on Node, and the packaged isomorphic modules need to be as simple as possible.

Subsequent thinking

  • Keep up with Nextjs

This whole design abstracts the build capability, and the hook can be configured to become a straight out framework. Of course, you can also implement some Document components like Nextjs to use.

  • The inconvenience of publishing

In the current design, because the Server code relies on the constructed isomorphic module, in daily development, it is common for the front end to make some page modifications, such as modifying some event listeners, and at this time, because the changes of JS and CSS resource MD5 value lead to the change of template.html. As a result, server packages need to be published. If services have multiple nodes, they must be restarted without damage. There must be a way to publish code without restarting the Node service.

  • Performance Issues (TODO)

This is all the content of this article, please comment, welcome to exchange (the code is basically deleted) ~

References:

  • Close reading of the front and back end render debate
  • React+Redux isomorphic application development
  • Talk about front-end “isomorphism”