react-ssr

160 阅读4分钟

ssr

ssr与csr的区别

  • ssr为服务端渲染,csr为客户端渲染
<!-- csr -->
<body>
</body>
<script>
    document.querySelector('body').innerHTML = `<div>111</div>`
</script>

<!-- ssr -->
<body>
    <div>111</div>
</body>
  • 明显的区别就是:
    • csr的逻辑为请求到html,然后获取js,js执行渲染dom
    • ssr为请求到的html内dom已经存在渲染了

ssr解决了什么问题

  • 随着需求的迭代spa项目编译出来的包的体积会越来越大,那么获取页面获取下来后,是一个空架子,这时候获取js的时间比较长的话,会造成很明显的白屏效果
  • 比如官网运营类项目需要考虑seo,而百度爬虫或自己产品线爬虫识别不出来js渲染的内容,无法达到预期的效果,比如检索、推广等

实现一个ssr项目

  • 分为2部分,serve和client
  • 我们需要了解一个内容:以react的ssr项目举例,服务端渲染出html骨架后,客户端接收到,会将获取的js和html骨架进行水合,也就是注入逻辑

client

  • 为客户端渲染部分
  • 新建src目录,创建main.tsx
import { createRoot, hydrateRoot } from 'react-dom/client';

// 要渲染的组件
import App from './App';

if (process.env.NODE_ENV == 'development') {
  const root = createRoot(document.querySelector('#root') as any);
  root.render(<App />);
} else {
  hydrateRoot(document.querySelector('#root') as any, <App />);
}
// App.tsx,内容随意
import { useState, Suspense, lazy } from 'react';
import Style from './styles/App';
import A from '@/components/A';
import B from '@/components/B';
import { DatePicker, Button } from 'antd';
import { Helmet } from 'react-helmet';
const App = () => {
  const [show, setShow] = useState(false);
  return (
    <Style>
      <Helmet>
        <title>服务标题</title>
        <meta name="keywords" content="关键字关键字" />
        <meta name="description" content="描述描述"></meta>
        <link
          rel="shortcut icon"
          href="//mapopen-website-wiki.cdn.bcebos.com/LOGO/lbsyunlogo_icon.ico"
          type="image/x-icon"
        />
      </Helmet>
      <DatePicker />
      <Button>11</Button>
      <div>父级容器</div>
      <button
        onClick={() => {
          setShow(!show);
        }}
      >
        取反
      </button>
      {show ? <A /> : <B />}
    </Style>
  );
};

export default App;

工程化部分

  • 统一放在config目录下,便于管理
  • 抽离出公用逻辑,便于复用
// base.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
  resolve: {
    // 依次补充文件后缀
    extensions: ['.tsx', '.jsx', '.ts', '.js'],
    // 缩减地址前缀,可以@/xxx
    alias: {
      '@': path.resolve('src'),
    },
  },
  module: {
    rules: [
      {
        test: /\.(js|ts|tsx|jsx)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              // js编译降级
              '@babel/preset-env',
              // 编译react,并且给每个文件引入jsx,不需要每次都手动引入react了   
              ['@babel/preset-react', { runtime: 'automatic' }],
              //编译ts   
              '@babel/preset-typescript',
            ],
          },
        },
      },
    ],
  },
  plugins: [
    new webpack.DefinePlugin({
      // 给项目加一个变量,main.tsx里用来区分当前使用哪种模式开发
      NODE_ENV: process.env.NODE_ENV,
    }),
  ],
};

  • 客户端编译
  • 我们需要了解到每个模块之间的依赖关系,便于后续我们对资源进行导入,所以使用assets-webpack-plugin来记录,它会生成一个json
  • react和react-dom使用cdn引入,减少包体积和编译时长
const base = require('./base');
const { merge } = require('webpack-merge');
const AssetsPlugin = require('assets-webpack-plugin');

module.exports = merge(base, {
  entry: './src/main.tsx',
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
  output: {
    clean: true,
    filename: '[name].js?[contenthash]',
    publicPath: '/',
  },
  plugins: [
    new AssetsPlugin({
      entrypoints: true,
      integrity: true,
      prettyPrint: true,
      includeFilesWithoutChunk: true,
      includeAuxiliaryAssets: true,
      includeDynamicImportedAssets: true,
    }),
  ],
});
  • 客户端编译
    • 代码里使用了es module语法,默认是不识别的,所以编译下
    • node自带包不需要编译进去,使用webpack-node-externals给它排除掉,也减少包体积和编译时间
// serve.js
const path = require('path');
const base = require('./base');
const nodeExternals = require('webpack-node-externals');

const { merge } = require('webpack-merge');
module.exports = merge(base, {
  mode: 'production',
  target: 'node',
  entry: path.resolve(__dirname, '../serve/index.js'),
  output: {
    filename: 'serve.js',
  },
  externals: nodeExternals(),
});
  • 服务端和客户端都有了,但是他们都是生成环境使用的,我本地开发不需要这么麻烦,所以再来一个dev环境
//dev.js
const path = require('path');
const HtmlPlugin = require('html-webpack-plugin');
const base = require('./base');
const { merge } = require('webpack-merge');

module.exports = merge(base, {
  mode: 'development',
  entry: './src/main.tsx',
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  output: {
    clean: true,
    filename: '[name].js?[contenthash]',
    publicPath: '/',
  },
  plugins: [
    new HtmlPlugin({
      template: './public/index.html',
    }),
  ],
  devServer: {
    historyApiFallback: true,
    hot: true,
  },
});

服务端部分

  • 统一放在serve目录下
  • 用express起了一个本地服务
    • dist作为静态资源目录
// index.js主入口,./client为工具函数
import React from 'react';
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { ServerStyleSheet } from 'styled-components';
import App from '../src/App';
import html from './client/html';
import getJs from './client/getJs';
import getHead from './client/getHead';
import { Helmet } from 'react-helmet';

const fs = require('fs');
const path = require('path');
const assets = JSON.parse(
  fs.readFileSync(path.resolve(__dirname, '../webpack-assets.json'), 'utf-8')
);
let jsStr = getJs(assets['main'].js);

const express = require('express');
const app = express();
app.use(express.static('dist'));

app.get('*', (req, res) => {
  const cache = createCache();
  const sheet = new ServerStyleSheet();
  const helmet = Helmet.renderStatic();
  let body = renderToString(
    sheet.collectStyles(
      <StaticRouter>
        <StyleProvider cache={cache}>
          <App />
        </StyleProvider>
      </StaticRouter>
    )
  );
  const styles = sheet.getStyleTags();
  const styleText = extractStyle(cache);
  res.send(
    html({ styles: styles + styleText, body, head: getHead(helmet), js: jsStr })
  );
});
app.listen(3000, () => {
  console.log('http://127.0.0.1:3000');
});
//./client/*

//getHead.js
const getHead = (helmet) => {
  return (
    helmet.title.toString() +
    helmet.meta.toString() +
    helmet.link.toString() +
    helmet.style.toString()
  );
};
export default getHead;

//getJs.js
const getJs = (strOrarg) => {
  if (typeof strOrarg === 'string')
    return `<script src="${strOrarg}"></script>`;
  return strOrarg.map((str) => `<script src="${str}"></script>`).join('');
};

export default getJs;

//html.js
/**
 * Html
 * 这个 Html.js 文件充当了一个模板,我们将所有生成的应用程序代码插入其中
 * 成的应用程序字符串插入进去。
 */
const Html = ({ body, styles, head, js }) => `
  <!DOCTYPE html>
  <html>
    <head>
      ${head}
      <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
      <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>  
      ${styles}
    </head>
    <body><div id="root">${body}</div></body>
  </html>
  ${js}
`;

export default Html;

统一安装下依赖

  • 直接贴package.json
{
  "name": "ssr",
  "version": "1.0.0",
  "description": "react的ssr方案",
  "main": "index.js",
  "scripts": {
    "build": "webpack --config config/client.js --mode=production",
    "dev": "cross-env NODE_ENV=develpoment webpack serve -c config/dev.js",
    "serve:build": "webpack -c config/serve.js",
    "serve:preview": "node dist/serve.js",
    "preview": "npm-run-all --sequential build serve:**"
  },
  "keywords": [
    "ssr",
    "react-ssr"
  ],
  "author": "cuishoulong",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.20.12",
    "@babel/preset-env": "^7.20.2",
    "@babel/preset-react": "^7.18.6",
    "@babel/preset-typescript": "^7.18.6",
    "@types/node": "^18.11.18",
    "@types/react": "^18.0.27",
    "@types/react-dom": "^18.0.10",
    "@types/react-helmet": "^6.1.6",
    "@types/react-router": "^5.1.20",
    "@types/react-router-dom": "^5.3.3",
    "@types/styled-components": "^5.1.26",
    "@typescript-eslint/eslint-plugin": "^5.50.0",
    "@typescript-eslint/parser": "^5.50.0",
    "assets-webpack-plugin": "^7.1.1",
    "babel-loader": "^9.1.2",
    "cross-env": "^7.0.3",
    "css-loader": "^6.7.3",
    "eslint-config-prettier": "^8.6.0",
    "eslint-plugin-prettier": "^4.2.1",
    "eslint-plugin-react": "^7.32.2",
    "html-webpack-plugin": "^5.5.0",
    "npm-run-all": "^4.1.5",
    "prettier": "^2.8.3",
    "style-loader": "^3.3.1",
    "webpack": "^5.75.0",
    "webpack-cli": "^5.0.1",
    "webpack-dev-server": "^4.11.1",
    "webpack-merge": "^5.8.0",
    "webpack-node-externals": "^3.0.0"
  },
  "dependencies": {
    "antd": "^5.1.7",
    "prism-react-renderer": "^1.3.5",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-helmet": "^6.1.0",
    "react-router": "^6.8.0",
    "react-router-dom": "^6.8.0",
    "styled-components": "^5.3.6"
  }
}

增加ts类型提示

  • 现在使用的是@babel/preset-typescript来编译的ts文件,它相比ts-loader快上不少,但缺乏ts的校验,那么我们加一个tsconfig.json来进行语法提示,但不阻塞编译
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "noImplicitAny": false, //默认标注any
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"]
}

一些注意点

  • react的服务端渲染这里使用了renderToString

seo

  • seo部分使用了react-helmet,在gethead函数里可以注意到,它的使用方法就是实例.标签.toString(),目前支持所有的head原生标签类型

css

  • 当浏览器渲染html骨架的时候,我们期望它将css加载,这样页面就不会有闪动的效果,也可以看到更美观的页面,所以需要在服务端处理css
    • 使用styled-components来写样式,提取使用ServerStyleSheet方法
  • antd的5版本使用了css in js版本,默认支持了按需引入,所以以前的babel-plugin-import在antd里没有意义了

启动项目

  • 本地开发 npm run dev
  • 本地编译 npm run build
  • 预览 npm run preview