React SSR 实践过程(一)

3,876 阅读6分钟

前言

可能我们如果在公司不是基础架构方面工作,基本不会让我们去做react ssr这些事儿,一般都是公司框架都已经搞好了。但是,作为往高级开发努力的人来说,react ssr还是非常有必要去了解去实践的。

文章会贴一些代码,但是不会全贴(篇幅会太长),每一部分的完整代码,都可以到github上看。

然后,本文用到的技术就是react node webpack

搭建开发环境

完成一个简单ssr

先初始化项目

$ npm init -y

安装一些依赖

$ npm i react react-dom express
$ npm i @babel/{cli,core,preset-env,preset-react} babel-loader webpack webpack-cli webpack-node-externals -D

当前目录结构:

├── dist // 打包生产目录
│ ├── client // 前端
│ | ├── index.js // 打包后的文件
│ ├── server // 服务端
│ | ├── index.js // node启动入口
├── src // 源文件
│ ├── client //前端文件夹
│ | ├── pages // 页面文件
| | ├── index.js // 前端入口
│ ├── server //服务端文件夹
| | ├── index.js // 服务端入口

客户端简单写一个组件

const Index = () => {
  return (
    <div>
      i am fruit
    </div>
  )
};
ReactDOM.hydrate(
  <Fruit />,
  document.getElementById('root')
);

react16提供了hydrate,而renderhydrate的区别在于:

  • render会遵守客户端渲染结果,即如果客户端和服务端渲染结果不一致,会覆盖服务端渲染结果,采用客户端渲染结果。
  • hydrate在服务端渲染的时候,会最大程度保留服务端渲染结果。

服务端便采用引入renderToString将组件渲染成原始html

import { renderToString } from 'react-dom/server';

const app = express();

app.get('*', (req, res) => {
  const reactStr = renderToString(<Fruit />);

  const html = `<!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <title></title>
  </head>
  <body>
      <div id="root">${reactStr}</div>
  </body>
  </html>`;

  return res.send(html);
});

因为是自己初始化项目,所以还需要配置webpack,对于客户端和服务端,分别配置两个webpack。客户端,没有什么注意点,正常配置即可。服务端,需要注意配置targetexternals,前者表示部署目标,webpack会编译为用于node/web环境;后者排除不需要打包的模块,减小打包体积。

服务端externals采用的是引入webpack-node-externals来排除不需要打包的模块。

因为目前,客户端和服务端的webpack配置差不多,就只贴服务端配置,可去github上看完整代码。

const WebpackNodeExternals = require('webpack-node-externals')
const { resolvePath } = require('./util');
// const resolvePath = pathStr => path.join(__dirname, pathStr);

module.exports = {
  mode: 'development',
  target: 'node',   //node环境
  entry: resolvePath('../src/server/index.js'),
  output: {
    filename: 'index.js',
    path: resolvePath('../dist/server')
  },
  externals: [WebpackNodeExternals()],  //排除不需要的打包模块
  module: {
    rules: [{
      test: /.jsx?$/,
      use: 'babel-loader',
      exclude: /node_modules/
    }]
  }
};

最后就是babelrc配置:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react"
  ]
}

最后通过npm命令去运行我们的项目,就实现了一个最基本的react ssr

{
  "client:dev": "webpack --config ./webpack/webpack.client.dev.js",
  "server:dev": "webpack --config ./webpack/webpack.server.dev.js",
  "node:dev": "node ./dist/server/index.js"
}

目前完整代码(ssr-basic)

当然目前是非常简陋的,关键没有对代码监听,每次修改,都要重复以上过程,简直难受。

完善开发体验

安装一些依赖

$ npm i nodemon webpack-merge clean-webpack-plugin -D

因为目前代码,前端和服务端的webpack配置有很多重复,用webpack-merge复用配置项。

module.exports = {  //抽离相同部分
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [{
      test: /.jsx?$/,
      use: 'babel-loader',
      exclude: /node_modules/
    }]
  },
  plugins: [
    new CleanWebpackPlugin()
  ]
}
const merge = require('webpack-merge');
const base = require('./webpack.base');

module.exports = merge(base, {
    //...
});

对于前端&服务端的源代码,采用webpack --watch去监听代码改动。对于服务端打包后的代码的运行,采用nodemon去运行监听修改。

{
  "client:dev": "webpack  --watch --config ./webpack/webpack.client.dev.js",
  "server:dev": "webpack  --watch --config ./webpack/webpack.server.dev.js",
  "node:dev": "nodemon ./dist/server/index.js -w ./dist",
  "dev": "npm run client:dev & npm run server:dev & npm run node:dev"
}

但是这样还是会有个缺陷,当目录下,没有dist时,即第一次运行npm run devnodemon会找不到./dist/server/index.js文件,于是会变成nodemon __dirname/index.js,发生错误。

解决办法:也比较简单,写一个node脚本,判断是否已经有了这个文件,若没有,我们先新建文件夹&文件。

const judgeFolder = (pathStr) => {  //判断是否存在文件夹,若无则建
  if (!fs.existsSync(resolvePath(pathStr))) {
    fs.mkdirSync(resolvePath(pathStr))
  };
}

const buildFile = () => {   
  const aimPath = resolvePath('../../dist/server/index.js');
  if (!fs.existsSync(aimPath)) {    //判断是否存在文件

    judgeFolder('../../dist');
    judgeFolder('../../dist/server');

    fs.writeFileSync(aimPath, "console.log('build done')");
  }
};

buildFile();

为了防止该node脚本与我们的npm run server:dev发生异常,我让他们运行顺序改成,先运行node脚本,再并行运行三个监听文件操作。

{
  "pre:file": "node ./webpack/scripts/pre-file.js",
  "dev": "npm run pre:file && npm run client:dev & npm run server:dev & npm run node:dev"
}

这里的&&&的区别是:&&是继发执行;&并发执行。

目前虽然会自动编译,但是,浏览器端需要手动刷新,可以通过webpack-dev-serverreact-hot-loader实现热更新。因为文章主要是react ssr实践过程,就不对此方法再加以详述了。

完整代码(ssr-dev)

同构

所谓同构,就是一套代码既可以在服务端运行又可以在客户端运行,也就是服务端直出和客户端渲染相结合,在服务端直出组件后,由浏览器接管页面。

那么基于以上,现在服务端直出组件后,需要让浏览器接管,需要将客户端打包的js文件输出。

修改目录结构:

├── src // 源文件
│ ├── constant //放置一些常量
| | ├── index.js // 常量文件
│ ├── server //服务端文件夹
| | ├── middleware // 中间件文件夹
| | ├── util // 函数方法文件
| | ├── index.js // 服务端入口

通过express.static托管静态文件,将服务器直出组件写出中间件

const app = express();

app.use(express.static('./dist/client'));   // 托管静态文件
app.use(ssr);
export default (req, res, next) => {    // ssr中间件
  const { path, url } = req;

  if (url.indexOf('.') > -1) {  // 加个简单处理
    return;
  };

  const reactStr = renderToString(<Fruit />);
  const htmlInfo = {
    reactStr,
  };
  const html = handleHtml(htmlInfo);    // 就是之前的html拼接
  res.send(html);

  return next();
};

因为express.static('./dist/client')托管了静态文件,而index.js就在./dist/client/index.js,所以html拼接需要加上:

<script type="text/javascript" src="/index.js"></script>

路由同构

路由同构即,前后端采用同一套路由,前端还是和之前spa一样写路由;服务端,则通过当前请求的path查找到组件,然后输出。

安装一些依赖

$ npm i react-router react-router-dom

定义一个路由配置文件route.config.js

[{
  component: Fruit,
  path: '/',
  exact: true,
  name: 'Fruits'    
},{...}]

前端入口,和之前写spa一样的写法,没啥好讲的。

const App = () => {
  return (
    <Fragment>
      <Header />    // 我定义的一个,<link>跳转组件
      <Switch>
        {
          routeConfig.map(v => {
            const { name, ...rest } = v;
            return <Route key={v.path} {...v} />
          })
        }
      </Switch>
    </Fragment>
  )
};
ReactDOM.hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('root')
);

服务端则通过react-router4提供的StaticRouter完成路由查找,该组件主要接受两个参数: location,传入请求的path完成路由查找context,可以传入一些初始数据,同时如果内部路由有判定为Redirect跳转等等,就会在其对象上增加url字段,服务端可以通过这个判断404 302/301等等,后续会用到。

export default (req, res, next) => {
  const { path, url } = req;

  if (url.indexOf('.') > -1) {
    return;
  };

  const reactStr = renderToString(
    <StaticRouter location={path}>
      <App />
    </StaticRouter>
  );

  const htmlInfo = {
    reactStr,
  };

  const html = handleHtml(htmlInfo);
  res.send(html);

  return next();
};

那么至此,路由同构便完成了。

完整代码(ssr-router)

数据同构

数据同构是非常重要的一环,即用同一套代码请求数据,用同一个数据去渲染。那么就牵扯到三个问题:

  • 服务器直出组件的时候,需要完成请求,携带数据;
  • 浏览器接管页面的时候,需要有这个数据,不至于重新请求或者就没数据;
  • 如果是浏览器接管后,从其他路由跳转到该路由,那么需要浏览器发起请求;

安装依赖

$ npm i react-router-config
$ npm i @babel/plugin-transform-runtime -D

.babelrc配置增加plugins,编译async await

{
  ...
  "plugins": [
    "@babel/plugin-transform-runtime"
  ]
}

自定义一个数据文件,模拟请求:

export const fruitData = {  // src/client/pages/data/index.js
  name: 'fruitData'
};

fruit.js模拟请求数据

Index.preFetch = async () => {
  const fetchData = () => {
    return new Promise(res => {
      setTimeout(() => {
        res({
          data: fruitData
        })
      }, 300);  // 300ms模拟请求数据延迟
    });
  };

  const data = await fetchData();
  return data;
};

react-router-config提供matchRoutes可以通过传入path和路由配置数组,查找到组件。获取到组件后,判断组件是否存在静态方法preFetch,若有则请求获取数据。在通过StaticRoutercontext传递数据。那么,服务器就获取到了数据,同时服务器直出组件携带了数据。

服务器在直出组件的同时,还要将数据传递给客户端,这个过程称之为注水。

通过textarea传递数据,当然如果不想明文,可进行加密处理。

export default async (req, res, next) => {
  const { path, url } = req;
  if (url.indexOf('.') > -1) {  // 简单处理下
    return;
  };

  const branch = matchRoutes(routeConfig, path)[0];
  let component = {};
  if (branch) { 
    component = branch.route.component;
  };

  let initialData = {}
  if (component.preFetch) { // 判断组件有无请求
    initialData = await component.preFetch();
  };

  const context = {
    initialData
  };
  const reactStr = renderToString( 
    <StaticRouter location={path} context={context}>    //context传递数据
      <App />
    </StaticRouter>
  );
  const htmlInfo = {
    reactStr,
    initialData: JSON.stringify(initialData)
  };
  const html = handleHtml(htmlInfo);

  res.send(html);

  return next();
};

对于html拼接,也是加上这段,进行注水

<textarea id="textareaSsrData" style="display: none">${initialData}</textarea>

那么对于客户端而言,需要进行脱水操作,然后将脱水得到的数据,传递给相应组件。可以通过Routerender属性传递。

const pathname = document.location.pathname;
const initialData = JSON.parse(document.getElementById('textareaSsrData').value);
// 获取到当前path和数据,传递到App组件里
ReactDOM.hydrate(
  <BrowserRouter>
    <App pathname={pathname} initialData={initialData} />
  </BrowserRouter>,
  document.getElementById('root')
);
const App = ({ pathname, initialData }) => {
  return (
    <Fragment>
      <Header />
      <Switch>
        {
          routeConfig.map(v => {
            const { name, ...rest } = v;
            if (pathname === v.path) {  // 判断路由
              const { component: Component, ..._rest } = rest;
              return <Route key={v.path} {..._rest} render={(props) => {
                props.initialData = initialData;    // 传递数据
                return <Component {...props} />
              }} />
            } else {
              return <Route key={v.path} {...rest} />
            }
          })
        }
      </Switch>
    </Fragment>
  )
};

那么至此,无论是服务端请求到的数据,还是脱水得到的数据,都已经传递给组件了。那么引出了新的问题,对于组件而言,他是不知道自己在服务端还是客户端,那么需要一个字段来表明,他现在在服务端还是客户端,然后去相应的地方获取数据。

可以通过webpack.definePlugin定义一个全局常量,来表明现在是在什么环境。

plugins: [  // 配置webpack
    new webpack.DefinePlugin({
      '__isServer': true,   // 服务端设置true,客户端设置false
    }),
]

那么在src/client下新建一个文件夹util,保存通用方法:判断当前是什么环境,从而去哪里获取数据。

export const envInitialData = (props) => {  // 参数传入props
  let initialData;

  if (__isServer) { // StaticRouter的context传入到了props.staticContext
    initialData = props.staticContext.initialData;
  } else {  // Route的render (props) => ...传入到props.xxx
    initialData = props.initialData;
  };

  return initialData || {};
};

那么相应组件就调用该方法,来获取初始值,从而使得两端渲染结果一致。

const Index = (props) => {
  const [info, setInfo] = useState(envInitialData(props).data || {});
  ...
  return (
    <div onClick={click}>
      page: Fruit
      <span>I am {info.name}</span>
    </div>
  )
};

那么到现在为止,之前三个问题的前两个问题已经解决了。还剩下第三个问题:从其他路由跳转到这个路由,需要浏览器发起请求。这个可以加个判断,若没有数据,则发起请求。

useEffect(() => {
  const getData = async () => {
  const { data } = await Index.preFetch();
    setInfo(data);
  };
  // void 0表示 undefined,一般来说,若无name,会设置成null,所以问题不大
  if (info.name === void 0) { 
    getData();
  }
}, []);

那么至此,数据同构算是完成了。

301/302 404等情况

之前提到,如果StaticRouter发生了路由跳转,可以进行处理301/302/404等等情况,服务端处理也比较简单。通过判断context.url是否存在,然后再对url进行判断即可。

if (context.url) {  // 我这边就简单判断下,只要有,就进行302跳转
  res.writeHead(302, {
    location: context.url
  });
  res.end();
} else {
  const html = handleHtml(htmlInfo);
  res.send(html);
}

完整代码(ssr-data)

其他章节:

React SSR 实践过程(二)

React SSR 实践过程(三)