从零开始React服务器渲染(SSR)同构😏(基于Koa)

前言

自前端框架(React,Vue,Angelar)出现以来,每一个框架携带不一样理念,分为三大阵营,之前使用JQuery的时代已经成为过去,之前每一个页面就是一个HTML,引入相对应的JSCSS,同时在HTML中书写DOM。正由于是这样,每次用户访问进来,因为HTML中有DOM的存在,给用户的感受响应其实并非很慢。javascript

可是自从使用了框架以后,不管是多少个页面,就是单独一个单页面,即SPAHTML中全部的DOM元素,必须在客户端下载完js以后,经过调用执行React.render()才可以进行渲染,因此就有了不少网站上,一进来很长时间的loading动画。css

为了解决这一并非很友好的问题,社区上提出了不少方案,例如预渲染SSR同构html

固然这篇文章主要讲述的是从零开始搭建一个React服务器渲染同构前端

选择方案

方案一 使用社区精选框架Next.js

Next.js 是一个轻量级的 React 服务端渲染应用框架。有兴趣的能够去Next.js官网学习下。java

方案二 同构

关于同构有两种方案:node

经过babel转义node端代码和React代码后执行

let app = express();
app.get('/todo', (req, res) => {
     let html = renderToString(
     <Route path="/" component={ IComponent } >
        <Route path="/todo" component={ AComponent }>
        </Route>
    </Route>)
     res.send( indexPage(html) )
    }
})  

复制代码

在这里有两个问题须要处理:react

  • Node不支持前端的import语法,须要引入babel支持。
  • Node不能解析标签语法。

因此执行Node时,须要使用babel来进行转义,若是出现错误了,也无从查起,我的并不推荐这样作。webpack

因此这里采用第二种方案git

webpack进行编译处理

使用webpack打包两份代码,一份用于Node进行服务器渲染,一份用于浏览器进行渲染。github

下面具体详细说明下。

搭建Node服务器

因为使用习惯,常用Egg框架,而KoaEgg的底层框架,所以,这里咱们采用Koa框架进行服务搭建。

搭建最基本的一个Node服务。

const Koa = require('koa');
const app = new Koa();

app.listen(3000, () => {
  console.log("服务器已启动,请访问http://127.0.0.1:3000")
});
复制代码

配置webpack

众所周知,React代码须要通过打包编译才能执行的,而服务端和客户端运行的代码只有一部分相同,甚至有些代码根本不须要将代码打包,这时就须要将客户端代码和服务端运行的代码分开,也就有了两份webpack配置

webpack 将同一份代码,经过不一样的webpack配置,分别为serverConfigclientConfig,打包为两份代码。

serverConfig和clientConfig配置

经过webpack文档咱们能够知道,webpack不只能够编译web端代码还能够编译其余内容。

这里咱们将target设为node

配置入口文件和出口位置:

const serverConfig = {
  target: 'node',
  entry: {
    page1: './web/render/serverRouter.js',
  },
  resolve,
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './app/build'),
    libraryTarget: 'commonjs'
  }
 }

复制代码

注意⚠

服务端配置须要配置libraryTarget,设置commonjs或者umd,用于服务端进行require引用,否则require值为{}

在这里客户端和服务端配置没有什么区别,无需配置target(默认web环境),其余入门文件和输出文件不一致。

const clientConfig = {
  entry: {
    page1: './web/render/clientRouter.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './public')
  }
}
复制代码

配置babel

因为打包的是React代码,所以还须要配置babel
新建.babelrc文件。

{
  "presets": ["@babel/preset-react",
    ["@babel/preset-env",{
      "targets": {
        "browsers": [
          "ie >= 9",
          "ff >= 30",
          "chrome >= 34",
          "safari >= 7",
          "opera >= 23",
          "bb >= 10"
        ]
      }
    }]
  ],
  "plugins": [
    [
      "import",
      { "libraryName": "antd", "style": true }
    ] 
  ]
}
复制代码

这份配置由服务端和客户端共用,用来处理React和转义为ES5和浏览器兼容问题。

处理服务端引用问题

服务端使用CommonJS规范,并且服务端代码也并不须要构建,所以,对于node_modules中的依赖并不须要打包,因此借助webpack第三方模块webpack-node-externals来进行处理,通过这样的处理,两份构建过的文件大小已经相差甚远了。

处理css

服务端和客户端的区别,可能就在于一个默认处理,一个须要将CSS单独提取出为一个文件,和处理CSS前缀。

服务端配置

{
    test: /\.(css|less)$/,
    use: [
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1
        }
      },
      {
        loader: 'less-loader',
      }
    ]
  }
复制代码

客户端配置

{
    test: /\.(css|less)$/,
    use: [
      {
        loader: MiniCssExtractPlugin.loader,
      },
      {
        loader: 'css-loader'
      },
      {
        loader: 'postcss-loader',
        options: {
          plugins: [
            require('precss'),
            require('autoprefixer')
          ],
        }
      },
      {
        loader: 'less-loader',
        options: {
          javascriptEnabled: true,
          // modifyVars: theme //antd默认主题样式
        }
      }
    ],
  }
复制代码

SSR 中客户端渲染与服务器端渲染路由代码的差别

实现 ReactSSR 架构,咱们须要让相同的代码在客户端和服务端各自执行一遍,可是这里各自执行一遍,并不包括路由端的代码,形成这种缘由主要是由于客户端是经过地址栏来渲染不一样的组件的,而服务端是经过请求路径来进行组件渲染的。
所以,在客户端咱们采用BrowserRouter来配置路由,在服务端采用StaticRouter来配置路由。

客户端配置

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from "react-router-dom";
import Router from '../router';

function ClientRender() {
  return (
      <BrowserRouter > <Router /> </BrowserRouter>
  )
}

复制代码

服务端配置

import React from 'react';
import { StaticRouter } from 'react-router'
import Router from '../router.js';

function ServerRender(req, initStore) {

  return (props, context) => {
    return (
        <StaticRouter location={req.url} context={context} > <Router /> </StaticRouter>
    )
  }
}

export default ServerRender;

复制代码

再次配置Node进行服务器渲染

上面配置的服务器,只是简单启动个服务,没有深刻进行配置。

引入ReactDOMServer

const Koa = require('koa');
const app = new Koa();
const path = require('path');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const koaStatic = require('koa-static');
const router = new KoaRouter();

const routerManagement = require('./app/router');
const manifest = require('./public/manifest.json');
/** * 处理连接 * @param {*要进行服务器渲染的文件名默认是build文件夹下的文件} fileName */
function handleLink(fileName, req, defineParams) {
  let obj = {};
  fileName = fileName.indexOf('.') !== -1 ? fileName.split('.')[0] : fileName;

  try {
    obj.script = `<script src="${manifest[`${fileName}.js`]}"></script>`;
  } catch (error) {
    console.error(new Error(error));
  }
  try {
    obj.link = `<link rel="stylesheet" href="${manifest[`${fileName}.css`]}"/>`;
    
  } catch (error) {
    console.error(new Error(error));
  }
  //服务器渲染
  const dom = require(path.join(process.cwd(),`app/build/${fileName}.js`)).default;
  let element = React.createElement(dom(req, defineParams));
  obj.html = ReactDOMServer.renderToString(element);

  return obj;
}

/** * 设置静态资源 */
app.use(koaStatic(path.resolve(__dirname, './public'), {
  maxage: 0, //浏览器缓存max-age(以毫秒为单位)
  hidden: false, //容许传输隐藏文件
  index: 'index.html', // 默认文件名,默认为'index.html'
  defer: false, //若是为true,则使用后return next(),容许任何下游中间件首先响应。
  gzip: true, //当客户端支持gzip时,若是存在扩展名为.gz的请求文件,请尝试自动提供文件的gzip压缩版本。默认为true。
}));

/** * 处理响应 * * **/
app.use((ctx) => {
    let obj = handleLink('page1', ctx.req, {});
    ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>koa-React服务器渲染</title> ${obj.link} </head> <body> <div id='app'> ${obj.html} </div> </body> ${obj.script} </html> `
})

app.listen(3000, () => {
  console.log("服务器已启动,请访问http://127.0.0.1:3000")
});
复制代码

这里涉及一个manifest文件,这个文件是webpack插件webpack-manifest-plugin生成的,里面包含编译后的地址和文件。大概结构是这样:

{
  "page1.css": "page1.css",
  "page1.js": "page1.js"
}
复制代码

咱们把他引入到clientConfig中,添加以下配置:

...
plugins: [
    // 提取样式,生成单独文件
    new MiniCssExtractPlugin({
        filename: `[name].css`,
        chunkFilename: `[name].chunk.css`
    }),
    new ManifestPlugin()
]
复制代码

在上述服务端代码中,咱们对于ServerRender.js进行了柯里化处理,这样作的目的在于,咱们在ServerRender中,使用了服务端能够识别的StaticRouter,并配置了location参数,而location须要参数URL
所以,咱们须要在renderToString中传递req,以让服务端可以正确解析React组件。

let element = React.createElement(dom(req, defineParams));
  obj.html = ReactDOMServer.renderToString(element);
复制代码

经过handleLink的解析,咱们能够获得一个obj,包含三个参数,linkcss连接),scriptJS连接)和html(生成Dom元素)。

经过ctx.body渲染html

renderToString()

React 元素渲染到其初始 HTML 中。 该函数应该只在服务器上使用。 React 将返回一个 HTML 字符串。 您可使用此方法在服务器上生成 HTML ,并在初始请求时发送标记,以加快网页加载速度,并容许搜索引擎抓取你的网页以实现 SEO 目的。

若是在已经具备此服务器渲染标记的节点上调用 ReactDOM.hydrate()React 将保留它,而且只附加事件处理程序,从而使您拥有很是高性能的第一次加载体验。

renderToStaticMarkup()

相似于 renderToString ,除了这不会建立 React 在内部使用的额外DOM属性,如 data-reactroot。 若是你想使用React 做为一个简单的静态页面生成器,这颇有用,由于剥离额外的属性能够节省一些字节。

可是若是这种方法是在浏览访问以后,会所有替换掉服务端渲染的内容,所以会形成页面闪烁,因此并不推荐使用该方法。

renderToNodeStream()

React 元素渲染到其最初的 HTML 中。返回一个 可读的 流(stream) ,即输出 HTML 字符串。这个 流(stream) 输出的 HTML 彻底等同于 ReactDOMServer.renderToString 将返回的内容。

咱们也可使用上述renderToNodeSteam将其改造下:

let element = React.createElement(dom(req, defineParams));
  
  ctx.res.write(' <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>koa-React服务器渲染</title> </head><body><div id="app">');
  
  // 把组件渲染成流,而且给Response
  const stream = ReactDOMServer.renderToNodeStream(element);
  stream.pipe(ctx.res, { end: 'false' });
  
  // 当React渲染结束后,发送剩余的HTML部分给浏览器
  stream.on('end', () => {
    ctx.res.end('</div></body></html>');
  });
复制代码

renderToStaticNodeStream()

相似于 renderToNodeStream ,除了这不会建立 React 在内部使用的额外DOM属性,如 data-reactroot 。 若是你想使用 React 做为一个简单的静态页面生成器,这颇有用,由于剥离额外的属性能够节省一些字节。

这个 流(stream) 输出的 HTML 彻底等同于 ReactDOMServer.renderToStaticMarkup 将返回的内容。

添加状态管理redux

以上开发一个静态网站,或者一个相对于比较简单的项目已经OK了,可是对于复杂的项目,这些还远远不够,这里,咱们再给它加上全局状态管理Redux

服务器渲染中其顺序是同步的,所以,要想在渲染时出现首屏数据渲染,必须得提早准备好数据。

  • 提早获取数据
  • 初始化store
  • 根据路由显示组件
  • 结合数据和组件生成 HTML,一次性返回

对于客户端来讲添加redux和常规的redux并没有太大差异,只是对于store添加了一个初始的window.__INIT_STORE__

let initStore = window.__INIT_STORE__;
let store = configStore(initStore);

function ClientRender() {
  return (
    <Provider store={store}> <BrowserRouter > <Router /> </BrowserRouter> </Provider>

  )
}
复制代码

而对于服务端来讲在初始数据获取完成以后,能够采用Promise.all()来进行并发请求,当请求结束时,将数据填充到script标签内,命名为window.__INIT_STORE__

`<script>window.__INIT_STORE__ = ${JSON.stringify(initStore)}</script>`
复制代码

而后将服务端的store从新配置下。

function ServerRender(req, initStore) {
  let store = CreateStore(JSON.parse(initStore.store));

  return (props, context) => {
    return (
      <Provider store={store}> <StaticRouter location={req.url} context={context} > <Router /> </StaticRouter> </Provider>
    )
  }
}
复制代码

整理Koa

考虑后面开发的便利性,添加以下功能:

  • Router功能
  • HTML模板

添加Koa-Router

/** * 注册路由 */
const router = new KoaRouter();
const routerManagement = require('./app/router');
...
routerManagement(router);
app.use(router.routes()).use(router.allowedMethods());

复制代码

为了保证开发时,接口规整,这里将全部的路由都提到一个新的文件中进行书写。并保证如如下格式:

/** * * @param {router 实例化对象} router */

const home = require('./controller/home');

module.exports = (router) => {
  router.get('/',home.renderHtml);
  router.get('/page2',home.renderHtml);
  router.get('/favicon.ico',home.favicon);
  router.get('/test',home.test);
}

复制代码

处理模板

html放入代码中,给人感受并非很友好,所以,这里一样引入了服务模板koa-nunjucks-2

同时在其上在套一层中间件,以便传递参数和处理各类静态资源连接。

...
const koaNunjucks = require('koa-nunjucks-2');
...
/** * 服务器渲染,渲染HTML,渲染模板 * @param {*} ctx */
function renderServer(ctx) {
  return (fileName, defineParams) => {
    let obj = handleLink(fileName, ctx.req, defineParams);
    // 处理自定义参数
    defineParams = String(defineParams) === "[object Object]" ? defineParams : {};
    obj = Object.assign(obj, defineParams);
    ctx.render('index', obj);
  }
}

...

/** * 模板渲染 */
app.use(koaNunjucks({
  ext: 'html',
  path: path.join(process.cwd(), 'app/view'),
  nunjucksConfig: {
    trimBlocks: true
  }
}));

/** * 渲染Html */
app.use(async (ctx, next) => {
  ctx.renderServer = renderServer(ctx);
  await next();
});
复制代码

在用户访问该服务器时,经过调用renderServer函数,处理连接,执行到最后,调用ctx.render完成渲染。

/** * 渲染react页面 */

 exports.renderHtml = async (ctx) => {
    let initState = ctx.query.state ? JSON.parse(ctx.query.state) : null;
    ctx.renderServer("page1", {store: JSON.stringify(initState ? initState : { counter: 1 }) });
 }
 exports.favicon = (ctx) => {
   ctx.body = null;
 }

 exports.test = (ctx) => {
   ctx.body = {
     data: `测试数据`
   }
 }

复制代码

关于koa-nunjucks-2中,在渲染HTML时,会将有< >进行安全处理,所以,咱们还需对咱们传入的数据进行过滤处理。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>koa-React服务器渲染</title>
  {{ link | safe }}
</head>

<body>
  <div id='app'>
    {{ html | safe }}
  </div>
</body>
<script> window.__INIT_STORE__ = {{ store | safe }} </script>
{{ script | safe }}
</html>
复制代码

文档结构

├── README.md
├── app  //node端业务代码
│   ├── build
│   │   ├── page1.js
│   │   └── page2.js
│   ├── controller
│   │   └── home.js
│   ├── router.js
│   └── view
│       └── index.html
├── index.js
├── package.json
├── public //前端静态资源
│   ├── manifest.json
│   ├── page1.css
│   ├── page1.js
│   ├── page2.css
│   └── page2.js
├── web  //前端源码
│   ├── action //redux -action
│   │   └── count.js
│   ├── components  //组件
│   │   └── layout
│   │       └── index.jsx
│   ├── pages //主页面
│   │   ├── page
│   │   │   ├── index.jsx
│   │   │   └── index.less
│   │   └── page2
│   │       ├── index.jsx
│   │       └── index.less
│   ├── reducer //redux -reducer
│   │   ├── counter.js
│   │   └── index.js
│   ├── render  //webpack入口文件
│   │   ├── clientRouter.js
│   │   └── serverRouter.js
│   ├── router.js //前端路由
│   └── store //store
│       └── index.js
└── webpack.config.js
复制代码

最后

目前这个架构目前只能手动启动Koa服务和启动webpack

若是须要将Koa和webpack跑在一块,这里就涉及另一个话题了,在这里能够查看我一开始写的文章。

骚年,Koa和Webpack了解一下?

若是须要了解一个完整的服务器须要哪些功能,能够了解我早期的文章。

如何建立一个可靠稳定的Web服务器

最后GITHUB地址以下:

基于koa的react服务器渲染

参考资料:

React中文文档
Webpack中文文档
React 中同构(SSR)原理脉络梳理
Redux

相关文章
相关标签/搜索