React SSR 多入口构建

1,125 阅读2分钟

目录和约定

demo源代码。在开始之前,首先约定一下目录和文件。

- project
  |- config                          # 配置文件
    |- utils.js                      # 通用函数
    |- webpack.config.js             # webpack通用配置
    |- webpack.development.config.js # 开发环境下的webpack配置
    |- webpack.production.config.js  # 生产环境下的webpack配置
    |- webpack.ssr.config.js         # webpack ssr 配置
  |- server                          # 服务端
    |- devServer.js                  # 开发环境服务端
    |- proServer.js                  # 生产环境服务端
    |- utils.js                      # 通用函数
  |- pages                           # react代码
    |- document.ejs                  # html模板
    |- Index                         # index模块
      |- Entry.js                    # 入口文件
      |- server.js                   # ssr入口文件
    |- List                          # list模块
      |- Entry.js                    # 入口文件
      |- server.js                   # ssr入口文件
  |- dist                            # 编译输出目录
  |- dist-server                     # ssr编译输出目录 

webpack配置

为了编译代码,首先要配置webpack编译。

使用一个通用函数来获取pages文件下的入口文件。我们需要根据路径生成name,比如/pages/index/Entry.js的入口文件生成的name为index

/* === config/utils.js === */
const path = require('path');
const glob = require('glob');
const _ = require('lodash');
const HtmlWebpackPlugin = require('html-webpack-plugin');

/**
 * 获取webpack入口文件
 * @param { string } entryFileName: 入口文件名称
 */
exports.getEntry = function getEntry(entryFileName) {
  const files = glob.sync(`pages/**/${ entryFileName }.js`); // 获取入口文件的路径
 
  // 将路径转换成object 
  return _.transform(files, function(result, value, index) {
    const res = path.parse(value);
    
    // 根据路径生成name
    const name = res
      .dir
      .replace(/^pages[\\/]/i, '')
      .replace(/[\\/]/g, '_')
      .toLocaleLowerCase();

    result[name] = [path.join(__dirname, '..', value)];
  }, {});
};

/**
 * html-webpack-plugin插件
 * @param { object } entry: 入口
 */
exports.htmlPlugins = function(entry) {
  const template = path.join(__dirname, '../pages/document.ejs');
  const keys = Object.keys(entry);

  return _.transform(entry, function(result, value, key) {
    result.push(new HtmlWebpackPlugin({
      inject: true,
      template,
      filename: `${ key }.html`,
      excludeChunks: _.without(keys, key)
    }));
  }, []);
};

项目使用了html-webpack-plugin来生成html、注入js文件。因为项目是多入口,所以还要配置excludeChunks来保证每个html文件只注入一个入口文件。

然后webpack配置如下。

/* === config/webpack.config.js === */
const path = require('path');
const { getEntry, htmlPlugins } = require('./utils');

function config() {
  const entry = getEntry('Entry'); // 获取入口文件

  return {
    entry,
    output: {
      publicPath: '/',
      path: path.join(__dirname, '../dist'),
      globalObject: 'this',
      filename: '[name].js',
      chunkFilename: '[name].js'
    },
    module: {
      rules: [
        {
          test: /^.*\.jsx?$/,
          use: [
            {
              loader: 'babel-loader',
              options: {
                presets: [
                  [
                    '@babel/preset-env',
                    {
                      targets: {
                        chrome: 70
                      },
                      debug: true,
                      modules: false,
                      useBuiltIns: false
                    }
                  ],
                  '@babel/preset-react'
                ],
                plugins: ['react-hot-loader/babel']
              }
            }
          ]
        },
        {
          test: /^.*\.(jpe?g|png|gif|webp)$/,
          use: [
            {
              loader: 'url-loader',
              options: {
                name: '[name]_[hash:5].[ext]',
                limit: 0,
                emitFile: true
              }
            }
          ]
        }
      ]
    },
    plugins: htmlPlugins(entry)
  };
}

module.exports = config;
/* === config/webpack.development.config.js === */
const merge = require('webpack-merge');
const config = require('./webpack.config');

function devConfig() {
  return {
    mode: 'development',
    module: {
      rules: [
        {
          test: /^.*\.css?$/,
          use: [
            {
              loader: 'style-loader'
            },
            {
              loader: 'css-loader',
              options: {
                modules: {
                  localIdentName: '[path][name]__[local]___[hash:base64:6]'
                },
                onlyLocals: false
              }
            }
          ]
        }
      ]
    }
  };
}

module.exports = merge(config(), devConfig());
/* === config/webpack.production.config.js === */
const merge = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const config = require('./webpack.config');

function devConfig() {
  return {
    mode: 'production',
    module: {
      rules: [
        {
          test: /^.*\.css?$/,
          use: [
            {
              loader: MiniCssExtractPlugin.loader
            },
            {
              loader: 'css-loader',
              options: {
                modules: {
                  localIdentName: '[path][name]__[local]___[hash:base64:6]'
                },
                onlyLocals: false
              }
            }
          ]
        }
      ]
    },
    plugins: [new MiniCssExtractPlugin()]
  };
}

module.exports = merge(config(), devConfig());

webpack SSR配置

服务端的SSR代码也需要配置webpack编译。

/* === webpack.ssr.config.js === */
const path = require('path');
const process = require('process');
const { getEntry } = require('./utils');

function config() {
  const entry = getEntry('server');

  return {
    mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
    target: 'async-node', // 配置为async-node或node以保证代码可以在node的环境中运行
    node: {
      __filename: true,
      __dirname: true
    },
    entry,
    output: {
      path: path.join(__dirname, '../dist-server'),
      globalObject: 'this',
      filename: '[name].js',
      chunkFilename: '[name].js',
      libraryTarget: 'umd'  // 文件输出为umd模块,保证node环境内能通过require函数加载模块
    },
    module: {
      rules: [
        {
          test: /^.*\.jsx?$/,
          use: [
            {
              loader: 'babel-loader',
              options: {
                presets: [
                  [
                    '@babel/preset-env',
                    {
                      targets: {
                        chrome: 70
                      },
                      debug: true,
                      modules: 'commonjs',  // 配置为commonjs,保证依赖是通过node的require函数来加载
                      useBuiltIns: false
                    }
                  ],
                  '@babel/preset-react'
                ]
              }
            }
          ]
        },
        {
          test: /^.*\.css?$/,
          use: [
            {
              loader: 'css-loader',
              options: {
                modules: {
                  localIdentName: '[path][name]__[local]___[hash:base64:6]'
                },
                onlyLocals: true // 配置为true,不生成css文件
              }
            }
          ]
        },
        {
          test: /^.*\.(jpe?g|png|gif|webp)$/,
          use: [
            {
              loader: 'url-loader',
              options: {
                name: '[name]_[hash:5].[ext]',
                limit: 0,
                emitFile: false
              }
            }
          ]
        }
      ]
    }
  };
}

module.exports = config();

在配置项中,css-loaderonlyLocals选项可以只导出className,不生成css文件。
url-loaderfile-loaderemitFile选项可以只生成文件路径,不生成资源。
这两个选项对于预渲染来说是很有用处的,因为不需要服务端的代码编译出静态资源。

配置开发环境服务

在开发环境,我们需要实现热更新、热替换的功能。所以需要我们使用中间件koa-webpack,来用于服务的开发。因为ejshtml-webpack-plugin作为模板解析,所以我们使用nunjucks作为服务端的模板。

/* === server/devServer.js === */
const path = require('path');
const fs = require('fs');
const http = require('http');
const Koa = require('koa');
const Router = require('@koa/router');
const koaWebpack = require('koa-webpack');
const webpack = require('webpack');
const mime = require('mime-types');
const nunjucks = require('nunjucks');
const _ = require('lodash');
const webpackDevConfig = require('../config/webpack.development');
const webpackSSRDevConfig = require('../config/webpack.ssr.config');
const { cleanRequireCache, requireModule } = require('./utils');

const app = new Koa();
const router = new Router();
const distSSR = path.join(__dirname, '../dist-server');

nunjucks.configure({
  autoescape: false
});

async function main() {
  /* webpack ssr */
  const compiler = webpack(webpackSSRDevConfig);

  compiler.watch({
    aggregateTimeout: 500
  }, function callback(err, stats) {
    if (err) {
      console.error(err);
    } else {
      console.log(stats.toString({
        colors: true
      }));
    }
  });

  /* router */
  app.use(router.routes())
    .use(router.allowedMethods());

  /* webpack中间件配置 */
  const koaWebpackMiddleware = await koaWebpack({
    compiler: webpack(webpackDevConfig),
    hotClient: {
      host: {
        client: '*',
        server: '0.0.0.0'
      },
      allEntries: true // 这个配置保证所有入口都能够热更新
    },
    devMiddleware: {
      serverSideRender: true
    }
  });

  app.use(koaWebpackMiddleware);

  /* index路由 */
  router.get('/*', async (ctx, next) => {
    try {
      // 因为koa中间件只能获取到静态文件,所以需要处理
      // 获取path
      const ctxPath = ctx.path;
      const formatPath = ctx.path === '/' ? '/Index' : ctx.path; // 默认路由
      const mimeType = mime.lookup(ctxPath);

      ctx.routePath = ctxPath; // 保存旧的path

      // 根据path解析name
      const name = formatPath
        .replace(/^\//, '')
        .replace(/[\\/]/g, '_')
        .toLocaleLowerCase();

      //  根据path,将path修改成html文件的地址,获取html
      if (mimeType === false) {
        ctx.path = `/${ name }.html`;
      }

      await next();

      if (ctx.type === 'text/html') {
        // ssr
        const modulePath = path.join(distSSR, `${ name }.js`); // 加载ssr模块

        // 判断模块是否存在
        if (fs.existsSync(modulePath)) {
          cleanRequireCache(modulePath); // 清除模块缓存

          const module = requireModule(modulePath); // 运行模块
          const body = await module();

          // 模板渲染
          ctx.body = nunjucks.renderString(ctx.body.toString(), {
            render: body.toString()
          });
        }
      }
    } catch (err) {
      ctx.status = 500;
      ctx.body = err.toString();
    }
  });

  http.createServer(app.callback())
    .listen(5050);
}

main();

server端的模块入口写成一个函数。

/* === pages/**/server.js === */
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';

function server() {
  return renderToString(<App />);
}

export default server;

由于模块是umd,需要一个能够兼容的require函数来加载模块。开发环境还需要每次加载模块时清除模块的缓存。

/* === server/utils.js === */

/* 清除模块缓存(只用于开发环境) */
exports.cleanRequireCache = function cleanRequireCache(id) {
  const modulePath = require.resolve(id);

  if (module.parent) {
    module.parent.children.splice(module.parent.children.indexOf(id), 1);
  }

  delete require.cache[modulePath];
};

/* 模块导入 */
exports.requireModule = function requireModule(id) {
  const module = require(id);

  return 'default' in module ? module.default : module;
};

html模板。

<!-- pages/document.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
<div id="app">{{ render }}</div>
</body>
</html>

生产环境服务

生产环境可以使用静态资源的中间件koa-static-cache,不过过滤了html文件。

const path = require('path');
const fs = require('fs');
const http = require('http');
const Koa = require('koa');
const Router = require('@koa/router');
const staticCache = require('koa-static-cache');
const nunjucks = require('nunjucks');
const _ = require('lodash');
const { requireModule } = require('./utils');

const app = new Koa();
const router = new Router();
const dist = path.join(__dirname, '../dist');
const distSSR = path.join(__dirname, '../dist-server');

nunjucks.configure({
  autoescape: false
});

function main() {
  /* 缓存 */
  app.use(staticCache(dist, {
    maxAge: 0,
    filter: (file) => !/^.*\.html$/.test(file)
  }));

  /* router */
  app.use(router.routes())
    .use(router.allowedMethods());

  /* index路由 */
  router.get('/*', async (ctx, next) => {
    try {
      // 获取path
      const ctxPath = ctx.path;
      const formatPath = ctx.path === '/' ? '/Index' : ctx.path;

      ctx.routePath = ctxPath;

      // 根据path解析name
      const name = formatPath
        .replace(/^\//, '')
        .replace(/[\\/]/g, '_')
        .toLocaleLowerCase();

      await next();

      if (ctx.type === '' && _.isNil(ctx.body)) {
        // ssr
        const html = await fs.promises.readFile(path.join(dist, `${ name }.html`));
        const modulePath = path.join(distSSR, `${ name }.js`);
        const module = requireModule(modulePath);
        const body = await module();

        if (fs.existsSync(modulePath)) {
          ctx.body = nunjucks.renderString(html.toString(), {
            render: body.toString()
          });
        } else {
          ctx.body = html.toString();
        }

        ctx.status = 200;
        ctx.type = 'text/html';
      }
    } catch (err) {
      ctx.status = 500;
      ctx.body = err.toString();
    }
  });

  http.createServer(app.callback())
    .listen(5050);
}

main();