preface
What is the SSR
SSR has two modes, single page mode and non-single page mode. The first mode is single-page application with the first rendering at the back end, and the second mode is back-end template rendering mode with the complete use of back-end routing. They differ in the degree to which back-end routing is used.
advantage
Why is the first load fast? A normal single-page application will need to load all the related static resources for the first time, and then the core JS will start executing. This process will take some time, and then the network interface will be requested, and finally the complete rendering will be completed.
Note: the page can be displayed quickly, but because the current return is just a simple display of DOM, CSS, JS related events and so on in the client are not bound, so it is necessary to render the current page again after LOADING JS, which is called isomorphism. So SSR is faster to show the content of the page first, so that users can see it first.
Why is SEO friendly? Because when the search engine crawler crawls the page information, it will send HTTP request to obtain the web content, and the data rendered by our server for the first time is returned by the back end, which has already rendered the title, content and other information, so that the crawler can easily grab the content.
How to implement
- Select a single page framework (I currently choose React)
- Select the Node server framework (I currently choose KOA2)
- Implement the core logic that allows the Node server to route and render single-page components (this is broken down into small implementation points, described below)
- Automated Build tools for optimized development and release environments (Webpack)
1. The react applications
2. The server application
3. Core implementation
- 1) The back end intercepts the route and finds the React page component X that needs to be rendered according to the path
- 2) Invoke the interface required for component X initialization, and after synchronizing the data, use react renderToString method to render the component to render node strings.
- 3) The back end gets the basic HTML file, inserts the rendered node string into the body, and also operates the title, script and other nodes in it. Return the complete HTML to the client.
- 4) The client gets the HTML returned by the backend, displays and loads the JS in it, and finally completes react isomorphism.
import Index from ".. /pages/index";
import List from ".. /pages/list";
const routers = [
{ exact: true.path: "/".component: Index },
{ exact: true.path: "/list".component: List }
];Copy the code
http://localhost:9999/,The back end service gets the current path of /, so we want the back end to find the Index component with path of ‘/’ and render it.
Create two JS files index and Pages in the router folder of client:
Configure routing path and component mapping in Pages, the code is roughly as follows, so that it can be used by both client and server routes.
import Index from ".. /pages/index";
import List from ".. /pages/list";
const routers = [
{ exact: true.path: "/".component: Index },
{ exact: true.path: "/list".component: List }
];
// Register page and import component, stored in object, server route match after rendering
export const clientPages = (() = > {
const pages = {};
routers.forEach(route= > {
pages[route.path] = route.component;
});
returnpages; }) ();export default routers;Copy the code
After the server receives the GET request, the server matches the path. If the path has a mapping page component, the server obtains the component and renders it. This is the first step: the back end intercepts the route and finds the React page component to render according to the path.
import { clientPages } from ". /.. /.. /client/router/pages";
router.get("*", (ctx, next) => {
let component = clientPages[ctx.path];
if (component) {
const data = await component.getInitialProps();
// Because component is a variable, create is required
const dom = renderToString(
React.createElement(component, {
ssrData: data
})
)
}
})Copy the code
This step is important, why do we need a static method instead of writing the request directly in willmount? Because when renderToString is used on the server side to render components, the life cycle will only execute to the first render after Willmount. Inside Willmount, the request is asynchronous, and when the first render is completed, none of the asynchronous data is retrieved. At this point, renderToString has already returned. The initialization data for our page is gone, and the HTML returned is not what we expected. Therefore, a static method is defined. The method is obtained before the component is instantiated and executed synchronously. After the data is obtained, the data is passed to the component for rendering through props.
So how does this approach work? Let’s look at base.js from the code screenshot:
import React from "react";
export default class Base extends React.Component {
// Override gets the asynchronous data that requires the server to render for the first time
static async getInitialProps() {
return null;
}
static title = "react ssr";
// Do not rewrite constructor in the page component
constructor(props) {
super(props);
// If static state is defined, state should take precedence over ssrData by lifecycle
if (this.constructor.state) {
this.state = { ... this.constructor.state }; }// If it is the first rendering, ssrData will be retrieved
if (props.ssrData) {
if (this.state) {
this.state = { ... this.state, ... props.ssrData }; }else {
this.state = { ... props.ssrData }; }}}async componentWillMount() {
// The client is running
if (typeof window! ="undefined") {
if (!this.props.ssrData) {
// Non-first rendering, i.e. single-page route state changes, calls static methods directly
// We are not sure if there is any asynchronous code, if getInitialProps returns an initialization state, then it should be executing synchronously, because await is not executing synchronously, causing state confusion
// It is recommended that state be initialized in the class attribute, defined using the static static method, and incorporated into the instance when constructor is used.
// Why not add static instead of state, since the default property is executed after constructor, overwriting the state defined by constructor
const data = await this.constructor.getInitialProps(); // Static method, obtained by constructor
if (data) {
this.setState({ ...data });
}
}
// Set the title
document.title = this.constructor.title; }}}Copy the code
A static method getInitialProps is used to create a base component that inherits from react.component.http. The base component inherits from react.component.http. This method mainly returns asynchronous data needed for component initialization. If there is an initial Ajax request, it should be overridden in this method and return the data object.
Constructor determines whether the page component has an initialized state static property, passing it to the component-instantiated state object if it does, and passing ssrData to the component state object if the props has ssrData.
Base componentWillMount determines whether the getInitialProps method is still needed. If the server rendering has been synchronized to the props before the component is instantiated, it is ignored.
If in a client environment, there are two cases.
The first: When the user enters the page for the first time, it is the server side that requests the data. After obtaining the data, the server side renders the component on the server side. At the same time, it also stores the data in the HTML script code and defines a global variable ssrData, as shown in the following figure. React registers a single page application and passes global ssrData to the page component. In this case, the page component can continue to use the data from the server during the client’s isomorphic rendering. In this way, the consistency of isomorphism is maintained and repeated requests are avoided.
In the second case, if the current user switches routes in a single page and there is no server rendering, the getInitialProps method is executed, returning the data directly to state, which is almost the same as executing the request in Willmount. This encapsulation allows us to use one set of code that is compatible with both server-side and single-page rendering.
client/app.js
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}
}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);Copy the code
Index inherits Base and defines static state. The constructor method passes this object to the state object instantiated by the component. To ensure that the default state defined by the interface is passed to the props data, the interface requests the props data to be passed to the props data.
Why not just write the state property instead of static, because the state property executes after constructor, which overrides the state defined by constructor, which overrides the data returned by getInitialProps?
export default class Index extends Base {
// Look at the comment: base about getInitialProps
static state = {
desc: "Hello world~"
};
/ / replace componentWillMount
static async getInitialProps() {
let data;
const res = await request.get("/api/getData");
if(! res.errCode) data = res.data;return{ data }; }}Copy the code
Note: When renderToString is executed on the server, the component is instantiated and the DOM is returned as a string. The React component’s life cycle only executes to render after Willmount.
3) We write an HTML file that looks something like this. Now that the node string has been rendered, the back end needs to return HTML text containing the title, the node, and finally the packed JS that needs to be loaded to replace the HTML placeholder.
<! DOCTYPE html><html lang="en">
<head>
<title>/*title*/</title>
</head>
<body>
<div id="root">??</div>
<script>
/*getInitialProps*/
</script>
<script src="/*app*/"></script>
<script src="/*vendor*/"></script>
</body>
</html>Copy the code
server/router.js
indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
"/*getInitialProps*/".`window.ssrData=The ${JSON.stringify(data)}; window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();Copy the code
4) Finally, when the client JS is loaded, react will run and reactdom.hydrate will be executed instead of the usual reactdom.render.
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);Copy the code
Below is an overview of the first rendering process, click to see a larger image
CSS
We have now completed the core logic, but there is a problem. I found that the style-loader would give an error when rendering the component at the back end. The style-loader would find the CSS that the component depends on and load the style into the HTML header when the component is loaded. However, when we render the component at the server side, there is no window object. Therefore, the style-loader internal code will report an error.
The server webpack needs to remove the style-loader and replace it with another method. Later, I assign the style to the static variable of the component and then render it back to the front end through the server. However, the problem is that I can only get the style of the current component, not the style of the child component. It would be too much trouble to find another way to get it.
Later, I found a library isomorphic-style-loader that could support the functions we wanted, looked at its source code and usage method, assigned styles to components through higher-order functions, and then used react Context to get styles of all components that needed to be rendered. Finally, the style is inserted into the HTML, which solves the problem that the child component styles cannot be imported. However, I found it a bit troublesome. First, I needed to define the higher-order functions of all components and import the library, then I needed to write relevant code in the Router to collect the style, and finally insert it into the HTML.
I then define a ProcessSsrStyle method that takes the style file as an input and the logic is to determine the environment, if it is the server that loads the style into the DOM of the current component, if it is the client that does not process it (because the client has a style-loader). Implementation and use are very simple, as follows:
ProcessSsrStyle.js
import React from "react";
export default style => {
if (typeof window! ="undefined") {
/ / the client
return;
}
return <style>{style}</style>;
};Copy the code
Use:
render() {
return (
<div className="index">
{ProcessSsrStyle(style)}
</div>
);
}Copy the code
When the react isomorphism is complete, the DOM will be replaced with a pure DOM, because ProcessSsrStyle will not print a style on the client. Finally, after style-loader is executed, the header will also have styles, and the page will not have inconsistent changes, which will be insensitive to the user.
At this point, the core features were implemented, but later in development, I found that things were not that simple, as the development environment seemed too unfriendly and inefficient, requiring manual restarts.
The development environment
Let’s start with how the initial development environment worked:
- NPM run dev starts the development environment
- Webpack.client-dev.js packages the server code, which is packaged into dist/ Server
- Webpack.server-dev.js packages the client code, which is packaged into dist/client
- Start the server application on port 9999
- Start webpack-dev-server on port 8888
After webpack is packaged, two services are started, one is the app application on the server side with port 9999, and the other is the dev-server on the client side with port 8888. Dev-server will listen and package the client code, so that when the client code is updated, Hot update front-end code in real time.
When accessing localhost:9999, the server will return HTML. Our server will return the JS script path in HTML pointing to the address of the dev-serve port, as shown in the following figure. That is, the client program and the server program are packaged separately and run two different port services.
In a production environment, since dev-server is not required to listen and hot update, a single service is sufficient, as shown in the figure below, where the server registers the static resource folder:
server/app.js
app.use(
staticCache("dist/client", {
cacheControl: "no-cache,public".gzip: true}));Copy the code
Current build systems, which distinguish between production and development environments, have no problems building development environments today. But the development environment problem is more obvious, the biggest problem is that the server does not have hot update or repackage restart. This will lead to many problems, the most serious is that the front-end component has been updated, but the server has not been updated, so there will be inconsistency in the isomorphism, which will lead to errors, some errors will affect the operation, the solution is to restart. The development experience was unbearable. Then I started thinking about doing hot updates on the server side.
Listen, package, restart
My initial approach was to listen for changes, package and restart the application. Remember our client/router/pages. Js file, which is imported into both client and server routes, so both server and client package dependencies have pages. Js, so all component-related dependencies of Pages can be listened to by the client and server. Now that dev-server has helped us listen to and hot update the client code, it’s up to us to deal with how to update and restart the server code.
In fact, the method is very simple, is in the server packaging configuration to enable listening, and then in the plug-in configuration, write a restart plug-in, plug-in code is as follows:
plugins: [
new function() {
this.apply = compiler= > {
// Create a custom register hook function. Watch listens for changes and after compiling, done is triggered, callback must execute, otherwise the subsequent process will not be executed
compiler.hooks.done.tap(
"recomplie_complete",
(compilation, callback) => {
if (serverChildProcess) {
console.log("server recomplie completed");
serverChildProcess.kill();
}
serverChildProcess = child_process.spawn("node", [
path.resolve(cwd, "dist/server/bundle.js"),
"dev"
]);
serverChildProcess.stdout.on("data", data => {
console.log(`server out: ${data}`);
});
serverChildProcess.stderr.on("data", data => {
console.log(`server err: ${data}`); }); callback && callback(); }); }; } ()]Copy the code
When WebPack runs for the first time, the plugin starts a child process, runs app.js, and when the file changes, compiles again to determine whether there are children, and if there are children, kills them, and then restarts them, thus implementing an automatic restart. Because the client and server are two different packages and configuration, when a file is modified, they will be recompiled at the same time, in order to ensure the compiled operation in line with expectations, to ensure that the server first compiled, compiled after the client, so in the client’s watch configuration, add a little delay, as the chart, the default is 300 milliseconds, So the server compiled 300 milliseconds later, and the client compiled 1000 milliseconds later.
watchOptions: {
ignored: ["node_modules"].aggregateTimeout: 1000 // optimize to ensure that the back-end repackaging is performed first
}Copy the code
Now the restart problem is solved, but I think it is not enough, because during most of the development time, the components in the pages. So I thought I’d optimize it again.
Remove client/ Router/Pages and package them separately
Add a webpack.server-dev-pages.js configuration file, listen and package dist/pages separately, server code determines if it is a development environment, in the route listener method to fetch the dist/ Pages package again each time. The server listening configuration ignores the client folder.
This may seem confusing, but the end result is that when the components that pages relies on are updated, webpack.server-dev-pages.js is recompiled and packaged into dist/ Pages, and the server app is not compiled and restarted. Simply fetching the latest Dist/Pages package in the server app route ensures that the server application updates all client components without the server application compiling and restarting. When the server’s own code changes, it will compile and restart automatically.
So our development environment ended up with three packaged configurations to boot
- webpack.server-dev-pages
- webpack.server-dev
- webpack.client-dev
Server /router, how to clear and update the Pages package
const path = require("path");
const cwd = process.cwd();
delete __non_webpack_require__.cache[
__non_webpack_require__.resolve(
path.resolve(cwd, "dist/pages/pages.js"))]; component = __non_webpack_require__( path.resolve(cwd,"dist/pages/pages.js")
).clientPages[ctx.path];Copy the code
At this point, the more satisfactory development environment is basically realized. Later, I decided that it was unnecessary to repack the pages on the backend every time I updated the CSS, plus the CSS is not consistent when isomorphic, just a warning, no real impact, so I ignored the less file in server-dev-Pages (because I used less). This leads to a problem because pages is not updated, so the page will refresh to show the old style and then become the new style immediately after isomorphism, which is acceptable in a development environment for a moment and doesn’t matter. But it avoids unnecessary compilation.
watchOptions: {
ignored: ["**/*.less"."node_modules"] // Ignore less, style changes do not affect isomorphism
}Copy the code
Things not done
- Encapsulate into a more enveloping three-way scaffolding
- CSS scope control
- A more encapsulated WebPack configuration
- In the development environment, the image path may be inconsistent
The original purpose of doing their own station is to learn, plus their own use, so there are too many personality things. I have removed a lot of packages and code from my site in order to make the core code easier to understand. There are a lot of comments in the code to help others understand, if you want to use the current library to develop a website of your own, it is completely possible, but also to help you better understand it. Nextjs is recommended for commercial projects.
CSS does not have scope control, so if you want to isolate scopes, manually add upper-layer CSS isolation, such as.index{….. } wrap a layer, or try importing a tripartite package yourself.
The general configuration of WebPack can be packaged into a file, then imported in each file and personalized. But when I looked at other code before, I found that this method, which makes it more difficult to read, plus the configuration itself is not much, so it looks more intuitive without encapsulation.
In the development environment, the image path may appear inconsistent, for example, the client address request address is localhost… Assets /xx.jpg, while the server is assets/xx.jpg, there may be a warning, but it does not affect. Because there’s only one absolute path and one relative path.
The last
For the SSR server rendering implementation or quite satisfied, but also spent a lot of time. Feel the load speed and welcome to dashiren.cn/. Some pages have interface requests, such as dashiren.cn/space, which still load quickly.
The repository is ready, download it and try it out. After installing the dependencies, run the command. Github.com/zimv/react-…
The code word is not easy, give it a thumbs up