React组件如何适配到MVC项目?

目录
文章目录隐藏
  1. 设计方案
  2. 实现落地
  3. 总结

目前公司的业务线中存在许多未进行前后端分离的 Spring MVC 项目,其中前端使用 JQuery 操作 DOM,后端使用 Freemarker 模板引擎进行渲染。由于有许多产品需求需要在 React 项目和 Spring MVC 项目中都实现,如果两边都独立开发,工作量必然会增加很多。因此,我们需要设计一种方法,将 React 组件适配应用到 Spring MVC 项目中,以降低不必要的人力成本,并为将来渐进式项目重构打下基础。

设计方案

一个常见的设计思想是将业务组件封装为一个独立的模块,其中包含挂载和卸载函数。通过使用构建工具将该模块打包成一个优化过的 JavaScript bundle,并将其上传到 CDN(内容分发网络)。当浏览器加载页面时,引入该 JavaScript bundle,然后通过调用挂载函数,将该组件动态地挂载到指定的容器元素上。

React 组件如何适配到 MVC 项目?

当涉及到大范围的组件应用于 MVCModel-View-Controller)页面时,简单的设计思想可能会面临一些挑战和不足之处:

  • 命令行工具:每个组件都需要经过适配封装、构建、版本控制以及上传 CDN 过程,如果每一个组件都采用手动处理,工作量势必会增加。为了减少手动处理,可以通过编写命令行工具,可以将这些步骤整合到一个流程中,并简化操作。这样,每次适配一个组件时,只需要运行相应的脚本或命令,自动完成封装、构建、版本控制和上传的工作。这种自动化的方式可以节省时间和精力,并确保一致性和可靠性;
  • 版本维护:需要制定一套版本管理策略,以确保 JavaScript bundle 的版本控制,每次构建生成的 bundle 都生成一个新的版本,并自动部署到 CDN 上,而不是手动维护上传版本。并且,为了方便所有应用方使用版本一致并实现同时更新,建议创建一个版本映射文件或数据库。该文件/数据库记录每个应用所使用的 bundle 版本,并提供一个统一的接口供应用方查询和更新版本;
  • 公共依赖和代码拆分:每个组件都依赖于相同的基础库(比如 React、ReactDOM、lodash、axios、@casstime/bricks 等),将这些重复的库打包进每个组件的 bundle 中不够优雅,重复的代码会导致打包后的文件体积增大,影响应用程序的加载性能。可以考虑将这些共享的基础库提取为一个公共 bundle,通过 CDN 分发,并在 MVC 页面中引入这个公共 bundle。这样可以避免重复打包这些库,减小 bundle 的大小,可以利用浏览器的缓存机制,减少重复加载的请求,提高应用程序的性能;
    ├─┬ @casstime/bre-suit-detail-popup
    │ ├── react@18.2.0
    │ ├── react-dom@18.2.0
    │ ├── lodash-es@4.17.21
    │ ├── axios@0.19.2
    │ ├── @casstime/bricks@2.13.8
    │ ├── @casstime/bre-media-preview@1.115.1
    │ │ ├── react@18.2.0
    │ │ ├── react-dom@18.2.0
    │ │ ├── @casstime/bricks@2.13.8
    │ │ └── lodash-es@4.17.21
    │ ├── @casstime/bre-thumbnail@1.102.0
    │ │ ├── react@18.2.0
    │ │ ├── react-dom@18.2.0
    │ │ ├── @casstime/bricks@2.13.8
    │ │ ├── axios@0.19.2
    │ │ └── react-dom@18.2.0
  • 样式隔离:为了确保组件样式既可以被外部修改覆盖,又能与外部样式隔离,组件内部样式定义采用 BEM(Block Element Modifier)命名规范。然而,在 MVC 项目中,BEM 命名规范并不能完全隔离组件样式与外部样式的影响。这是因为 MVC 项目中存在一些不规范的样式定义,比如使用标签选择器和频繁使用!important 来提高样式的优先级(例如.classname span { font-size: 20px !important; }),这可能会影响到组件样式的隔离性。

综合考虑之后,可以对整个架构设计进行如下调整:

架构设计

实现落地

搭建命令行工具

使用 yargs 库可以方便地搭建命令行工具

(1)安装 yargs 库:

yarn add yargs

(2)创建一个新的文件,例如 bin/r2j-cli.js,作为命令行工具的入口文件

#!/usr/bin/env node

require("yargs")
  .scriptName("r2j-cli")
  .commandDir("../commands")
  .demandCommand(1, "您最少需要提供一个参数")
  .usage("Usage: $0  [options]")
  .example(
    "$0 build r2j -n demo -v 1.0.0 -e index.tsx",
    "将业务组件转化成控件"
  )
  .example(
    "$0 ls -n demo",
    "列举指定模块的版本"
  )
  .help("h").argv;

(3)在 package.json 中添加 bin 字段,将命令行工具的入口文件关联到一个可执行的命令

{
  "bin": {
    "r2j-cli": "./bin/r2j-cli.js"
  },
}

(4)在项目根目录下创建一个名为 commands 的文件夹,并在该文件夹中创建两个命令脚本文件,build.js 和 ls.js

// build.js
const path = require("path");
const fs = require("fs");

// r2j-cli build <strategy>命令在业务组件根目录下执行
const dir = fs.realpathSync(process.cwd());
const { name = '', version = '' } = require(path.resolve(dir, 'package.json'));
const entry = path.resolve(dir, 'index.tsx');

exports.command = 'build <strategy>'; // strategy 指定构建策略,分为 bundle 和 r2j 两种
exports.desc = '将业务组件转化成控件';
exports.builder = {
  // 参数定义
  name: {
    alias: 'n',
    describe: '包名',
    type: 'string',
    demand: false,
    // 默认为业务组件根目录 package.json 的 name
    default: name
  },
  // 这里不能定义成 version,与命令行存在的参数 version 重名,会导致设置 option 不成功
  componentVersion: {
    alias: 'v',
    describe: '版本',
    type: 'string',
    demand: false,
    // 默认为业务组件根目录 package.json 的 version
    default: version
  },
  entry: {
    alias: 'e',
    describe: '入口文件路径',
    type: 'string',
    demand: false,
    // 默认入口文件为业务组件根目录下 index.tsx
    default: entry
  },
  mode: {
    alias: 'm',
    describe: '指定构建环境',
    type: 'string',
    demand: false,
    // 默认用“生产”模式构建,压缩混淆处理
    default: 'production'
  },
};

exports.handler = function () {
  /** 1、解析命令参数 */

  /** 2、版本校验 */

  /** 3、执行构建 */

  /** 4、上传构建产物 */

  /** 5、更新版本日志 */
};
// ls.js
exports.command = 'ls';
exports.desc = '列举指定模块的版本';
exports.builder = {
  name: {
    alias: 'n',
    describe: '包名',
    type: 'string',
    demand: false, // 非必需,没有提供包名列举所有模块的版本清单
  },
};

exports.handler = function () {
  /** 1、解析命令参数 */

  /** 2、获取版本日志,输出控制台 */
};

build 命令

当搭建命令行脚本框架后,你可以开始补全 build <strategy> 命令的处理函数。首先,需要处理命令参数,并将其赋值给全局环境变量,因为这些命令参数将在 webpack.config.js 配置脚本中使用。

exports.handler = function (argv) {
  /** 1、解析命令参数 */
  const { strategy, name, componentVersion, entry, mode } = argv;
  // 因为命令参数在 webpack.config.js 配置脚本中会被使用,所以此处将其赋值到全局环境变量
  process.env.STRATEGY = argv.strategy || 'r2j';
  process.env.NAME = argv.name;
  process.env.VERSION = argv.componentVersion;
  process.env.ENTRY = path.resolve(process.cwd(), argv.entry);
  process.env.MODE = argv.mode;

  /** 2、版本校验 */

  /** 3、执行构建 */

  /** 4、上传构建产物 */

  /** 5、更新版本日志 */
};

在构建之前校验指定的版本是否已经存在,你可以引入 OBS Node.js SDK 开发包,并调用提供的方法判断对象存储服务中是否已经存在该版本。如果版本已经存在,你可以在原有基础上递增版本号,并更新 package.json 文件中的 version 属性。

// config.js
exports.COMPONENT_DIRNAME = 'xxx/xxx'; // 对应桶的存放目录

// obs.js
const ObsClient = require('esdk-obs-nodejs');
const readline = require('readline');
const chalk = require('chalk');
const path = require('path');
const fs = require('fs');
const moment = require('moment');
const { COMPONENT_DIRNAME } = require('./config');

process.env.OBS_ACCESS_KEY = process.env.OBS_ACCESS_KEY || 'your OBS_ACCESS_KEY';
process.env.OBS_ACCESS_KEY_SECRET = process.env.OBS_ACCESS_KEY_SECRET || 'your OBS_ACCESS_KEY_SECRET';
process.env.ACCESS_OBS_SERVER = process.env.ACCESS_OBS_SERVER || 'your ACCESS_OBS_SERVER';
process.env.OBS_BUCKET = process.env.OBS_BUCKET || 'your OBS_BUCKET';

// 单例模式创建实例
const Singleton = (() => {
  let instance; // 单例实例
  const createInstance = () => {
    // 创建 ObsClient 实例
    return new ObsClient({
      access_key_id: process.env.OBS_ACCESS_KEY,
      secret_access_key: process.env.OBS_ACCESS_KEY_SECRET,
      server: process.env.ACCESS_OBS_SERVER,
    });
  };

  return {
    getClient: () => {
      if (instance) return instance;
      return createInstance();
    },
    closeClient: () => {
      // 关闭 obsClient
      if (instance) instance.close();
    }
  }
})();

// 检查文件在 obs 是否存在
const checkIfExists = async (fileName) => {
  try {
    // 获取对象属性
    const result = await Singleton.getClient().getObjectMetadata({
      Bucket: process.env.OBS_BUCKET,
      Key: path.join(COMPONENT_DIRNAME, fileName)
    });
    // 如果能获取到,则表明该版本存在
    if (result.CommonMsg && result.CommonMsg.Status === 200) return true;
    return false;
  } catch (error) {
    Singleton.closeClient();
    console.log(chalk.red(error));
    process.exit(1);
  }
}

// 拼接文件名,比如:@casstime/bre-upload@1.0.0
const joinedFileName = (version) => {
  const { NAME, VERSION } = process.env;
  return `${NAME}@${version || VERSION}.js`;
}

/** readline 是创建交互式命令库,创建一个 rl 实例 */
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

/** 判断版本是否已经存在 */
const checkVersion = async (version) => {
  const isExist = await checkIfExists(joinedFileName(version));
  if (isExist) {
    // 如果版本号已经存在,在现有的版本号基础上升级
    const regex = /^(\d+)\.(\d+)\.(\d+)(?:-(.*))?$/;
    let [_, major, minor, patch, preRelease] = version.match(regex);

    let versionComponents = [parseInt(major, 10), parseInt(minor, 10), parseInt(patch, 10)];
    let index = 2; // 从 patch 组件开始

    while (index >= 0) {
      if (versionComponents[index] < 100) {
        versionComponents[index]++;
        break;
      } else {
        versionComponents[index] = 0;
        index--;
      }
    }

    if (componentIndex < 0) {
      console.log(chalk.red('版本维护以达到上限!'));
      process.exit(0);
    }

    [major, minor, patch] = versionComponents;
    
    const defaultVersion = `${major}.${minor}.${patch}${preRelease ? `-${preRelease}` : ''}`;
    return await new Promise((resolve) => {
      rl.question(chalk.yellow(`${version} 该版本已存在, 请指定新的版本(如果按回车键,会默认在原来版本上升级版本号 ${defaultVersion}): `), async (newVersion) => {
        if (newVersion.trim() === '') {
          newVersion = defaultVersion; // 设置默认回答的 version
        }
        newVersion = await checkVersion(newVersion);
        resolve(newVersion);
      });
    })
  } else {
    rl.close();
    return version;
  }
}

/** 更新 package.json 中的 version */
const updatePackageVersion = (version) => {
  const pkgPath = path.resolve(fs.realpathSync(process.cwd()), 'package.json');
  // 读取 package.json 文件
  fs.readFile(pkgPath, 'utf8', (err, data) => {
    if (err) {
      console.error('Error reading package.json:', err);
      process.exit(1);
    }

    try {
      const packageData = JSON.parse(data);
      // 修改 version 字段的值
      packageData.version = version; // 设置新的版本号
      // 保存修改后的 package.json 文件
      fs.writeFile(pkgPath, JSON.stringify(packageData, null, 2), 'utf8', (err) => {
        if (err) {
          console.error('Error writing package.json:', err);
          return;
        }
        console.log(chalk.green('package.json version updated successfully.\n'));
      });
    } catch (err) {
      console.error('Error parsing package.json:', err);
    }
  });
}

// build.js
exports.handler = async function () {
  /** 1、解析命令参数 */

  /** 2、版本校验 */
  const actualVersion = await checkVersion(process.env.VERSION);
  if (process.env.VERSION !== actualVersion) {
    process.env.VERSION = actualVersion;
    // 更新 package.json 中的 version
    updatePackageVersion(actualVersion); // 异步更新 package.json 中的 version 字段
  }
  /** 3、执行构建 */

  /** 4、上传构建产物 */

  /** 5、更新版本日志 */
};

在解析参数和版本校验之后,你将获得业务组件的入口文件和构建版本号等重要参数。接下来,你可以执行 react2js-cli 项目中的构建脚本,开始构建过程。

const { execSync } = require('child_process');
// build.js
exports.handler = function () {
  /** 1、解析命令参数 */

  /** 2、版本校验 */

  /** 3、执行构建 */
  const cliRoot = path.normalize(path.resolve(__dirname, '..'));
  execSync(`cd ${cliRoot} && npm run build`); // 同步
  /** 4、上传构建产物 */

  /** 5、更新版本日志 */
};

要在 react2js-cli 项目的根目录的 package.json 文件中配置 build 命令:

{
  "scripts": {
    "build": "rimraf dist && webpack",
  }
}

要根据需求配置 webpack 的 webpack.config.js 脚本,以满足以下要求:

  • 区分构建公共模块和业务组件的导出方式:
    • 公共模块的导出应全部挂载到全局对象。
      公共模块的导出应全部挂载到全局对象
    • 业务组件的导出应放在 react2js.installModules 对象上,并进行缓存处理,避免每次重新请求拉取。
  • 配置外部依赖,使业务组件所依赖的公共模块不应包含在业务组件的构建结果中,而是在运行时从外部获取。
// utils/config
exports.CDN_DOMAIN = 'xxxxxx'; // CDN 域名

exports.ORIGIN_SITE = 'xxxxxx'; // 源站域名

exports.COMPONENT_DIRNAME = 'xxxxx/xxxxx'; // 桶存放组件的目录

exports.publicPath = `${exports.CDN_DOMAIN}/${exports.COMPONENT_DIRNAME}/`;

// postcss.config.js
module.exports = {
  plugins: [
    // postcss-flexbugs-fixes 插件的作用就是根据已知的 Flexbox 兼容性问题,自动应用修复,以确保在各种浏览器中获得更一致和可靠的 Flexbox 布局。
    require('postcss-flexbugs-fixes'),
    require('postcss-preset-env')({
      autoprefixer: {
        flexbox: 'no-2009',
      },
      stage: 3,
    }),
    require('postcss-normalize')(),
  ],
  map: {
    // source map 选项
    inline: true, // 将源映射嵌入到 CSS 文件中
    annotation: true // 为 CSS 文件添加源映射的注释
  }
}

// webpack.config.js
const path = require("path");
const loaderUtils = require("loader-utils");
const { IgnorePlugin, BannerPlugin, ProvidePlugin } = require("webpack");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const { joinedFileName } = require("./utils/obs");
const { publicPath } = require("./utils/config");

// babel-preset-react-app 预设,该预设要求明确指定环境变量的值
process.env.BABEL_ENV = "production";
process.env.MODE = process.env.MODE || "production";

const getLibrary = () => {
  if (process.env.STRATEGY === "bundle") return "";
  /** 
   * output.library 配置项的字符串或数组形式来定义层级变量
   * 你可以使用点号(.)来表示层级关系 `react2js.installModules.${process.env.NAME}@${process.env.VERSION}`
   * 当页面加载了组件模块后,会缓存在 react2js.installModules 变量中,待页面再次使用该组件模块时,可以使用缓存数据,防止页面多次使用一个组件模块多次加载请求
   */
  return [
    "react2js",
    "installModules",
    `${process.env.NAME}@${process.env.VERSION}`,
  ];
};

/**
 * 配置 external
 * 在构建业务组件时,公共模块不参与构建,因此配置 external
 */
const getExternals = () => {
  if (process.env.STRATEGY === "bundle") return {};
  return {
    react: "React",
    "react-dom": "ReactDOM",
    axios: "axios",
    lodash: "_",
    "@casstime/bricks": "bricks", // bricks.Button
  };
};

// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    {
      loader: require.resolve("style-loader"),
      options: {
        injectType: "singletonStyleTag",
      },
    },
    {
      loader: require.resolve("css-loader"),
      options: cssOptions,
    },
    {
      loader: require.resolve("postcss-loader"),
    },
  ].filter(Boolean);
  if (preProcessor) {
    loaders.push(
      {
        // resolve-url-loader 是一个用于处理 CSS 文件中相对路径的 Webpack 加载器。它可以为 CSS 文件中的相对路径解析和处理,以确保在使用 CSS 中的相对路径引用文件时,能够正确地解析和定位这些文件
        loader: require.resolve("resolve-url-loader"),
        options: {
          sourceMap: true,
        },
      },
      {
        loader: require.resolve(preProcessor),
        options: {
          sourceMap: true,
        },
      }
    );
  }
  return loaders;
};

const getCSSModuleLocalIdent = (
  context,
  localIdentName,
  localName,
  options
) => {
  // Use the filename or folder name, based on some uses the index.js / index.module.(css|scss|sass) project style
  const fileNameOrFolder = context.resourcePath.match(
    /index\.module\.(css|scss|sass)$/
  )
    ? "[folder]"
    : "[name]";
  // Create a hash based on a the file location and class name. Will be unique across a project, and close to globally unique.
  const hash = loaderUtils.getHashDigest(
    path.posix.relative(context.rootContext, context.resourcePath) + localName,
    "md5",
    "base64",
    5
  );
  // Use loaderUtils to find the file or folder name
  const className = loaderUtils.interpolateName(
    context,
    fileNameOrFolder + "_" + localName + "__" + hash,
    options
  );
  // remove the .module that appears in every classname when based on the file.
  return className.replace(".module_", "_");
};

module.exports = {
  target: "web", // 指定打包后的代码的运行环境,,默认选项 web
  mode: process.env.MODE,
  devtool: false,
  entry: process.env.ENTRY, // 入口文件路径
  output: Object.assign(
    {
      filename: joinedFileName(), // 输出文件名,@casstime/bre-upload@1.1.1
      path: path.resolve(__dirname, "dist"), // 输出目录路径
      publicPath, // 指定在浏览器中访问打包后资源的公共路径
      libraryTarget: "umd",
    },
    process.env.STRATEGY === "bundle" ? {} : { library: getLibrary() }
  ),
  module: {
    rules: [
      {
        oneOf: [
          {
            test: /\.(js|mjs|jsx|ts|tsx)$/, // 匹配以 .js 结尾的文件
            // exclude: /node_modules/, // 排除 node_modules 目录
            use: {
              // 使用 babel-loader 进行处理
              loader: "babel-loader",
              options: {
                cacheDirectory: true,
                presets: [
                  [
                    /**
                     * babel-preset-react-app 是一个由 Create React App (CRA) 提供的预设,用于处理 React 应用程序的 Babel 配置。
                     * 它是一个封装了一系列 Babel 插件和预设的预配置包,旨在简化 React 应用程序的开发配置。
                     * babel-preset-react-app 预设,该预设要求明确指定环境变量的值
                     */
                    require.resolve("babel-preset-react-app"),
                    {
                      // 当 useBuiltIns 设置为 false 时,构建工具将不会自动引入所需的 polyfills 或内置函数。这意味着您需要手动在代码中引入所需的 polyfills 或使用相应的内置函数。
                      useBuiltIns: false,
                    },
                  ],
                ],
                plugins: [
                  // 可选:您可以在这里添加其他需要的 Babel 插件
                  ["@babel/plugin-transform-class-properties", { loose: true }],
                  ["@babel/plugin-transform-private-methods", { loose: true }],
                  [
                    "@babel/plugin-transform-private-property-in-object",
                    { loose: true },
                  ],
                ].filter(Boolean),
              },
            },
          },
          {
            test: cssRegex,
            exclude: cssModuleRegex,
            use: getStyleLoaders({
              importLoaders: 1,
              sourceMap: false,
              // 启用 ICSS 模式。这将使每个 CSS 类名被视为全局唯一,并且可以在不同的模块之间共享。
              modules: {
                mode: "icss",
              },
            }),
            // Don't consider CSS imports dead code even if the
            // containing package claims to have no side effects.
            // Remove this when webpack adds a warning or an error for this.
            // See https://github.com/webpack/webpack/issues/6571
            // sideEffects 是一个用于配置 JavaScript 模块的标记,用于向编译工具(如 Webpack)提供关于模块副作用的信息。它的作用是帮助编译工具进行优化,以删除不必要的模块代码或执行其他优化策略。
            // 在许多情况下,编译工具默认假设所有模块都具有副作用,因此不会执行某些优化,以确保模块的行为不受影响。然而,许多模块实际上是没有副作用的,这给优化带来了机会。
            sideEffects: true,
          },
          // Adds support for CSS Modules (https://github.com/css-modules/css-modules)
          // using the extension .module.css
          {
            test: cssModuleRegex,
            use: getStyleLoaders({
              importLoaders: 1,
              sourceMap: false,
              modules: {
                // local(局部模式):在局部模式下,每个 CSS 类名都将具有局部作用域,只在当前模块中有效。这种模式下,Webpack 会为每个模块生成唯一的类名,以确保样式的隔离性和避免全局命名冲突。
                // global(全局模式):在全局模式下,所有的 CSS 类名都是全局唯一的,可以在整个项目中共享和重用。这种模式下,Webpack 不会对类名进行修改或局部化,而是将其视为全局定义的样式。
                mode: "local",
                getLocalIdent: getCSSModuleLocalIdent,
              },
            }),
          },
          // Opt-in support for SASS (using .scss or .sass extensions).
          // By default we support SASS Modules with the
          // extensions .module.scss or .module.sass
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: getStyleLoaders(
              {
                importLoaders: 3,
                sourceMap: false,
                modules: {
                  mode: "icss",
                },
              },
              "sass-loader"
            ),
            // Don't consider CSS imports dead code even if the
            // containing package claims to have no side effects.
            // Remove this when webpack adds a warning or an error for this.
            // See https://github.com/webpack/webpack/issues/6571
            sideEffects: true,
          },
          // Adds support for CSS Modules, but using SASS
          // using the extension .module.scss or .module.sass
          {
            test: sassModuleRegex,
            use: getStyleLoaders(
              {
                importLoaders: 3,
                sourceMap: false,
                modules: {
                  mode: "local",
                  getLocalIdent: getCSSModuleLocalIdent,
                },
              },
              "sass-loader"
            ),
          },
          /**
           * parser 属性用于配置资源模块的解析器选项。在这里,我们使用了 dataUrlCondition 选项来设置转换为 data URL 的条件。maxSize 属性设置为 8 * 1024,表示文件大小小于等于 8KB(8192 字节)的文件将被转换为 data URL,超过该大小的文件将被输出为独立的文件。
           * 请注意,这段配置利用了 Webpack 5 的内置处理能力,不再需要额外的加载器(如 url-loader 或 file-loader)。Webpack 5 的 asset 模块类型提供了更简洁和集成化的资源处理方式。
           * asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
           * asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
           * asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
           * asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。
           */
          {
            test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
            type: "asset",
            generator: {
              filename: "[name].[hash][ext]",
            },
            parser: {
              dataUrlCondition: {
                maxSize: 4 * 1024, // 4kb
              },
            },
          },
          {
            test: /\.svg$/,
            use: [
              {
                // 用于将 SVG 文件转换为 React 组件的 Webpack loader
                loader: require.resolve("@svgr/webpack"),
                options: {
                  prettier: false,
                  svgo: false,
                  svgoConfig: {
                    plugins: [{ removeViewBox: false }],
                  },
                  titleProp: true,
                  ref: true,
                },
              },
            ],
            issuer: {
              and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
            },
          },
          {
            exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
            type: "asset/resource",
            generator: {
              filename: "[name].[hash:8][ext]",
            },
          },
        ].filter(Boolean),
      },
    ],
  },
  plugins: [
    // WebpackManifestPlugin 是一个 Webpack 插件,用于生成一个 manifest 文件,其中包含构建过程中生成的文件和它们的映射关系。这个 manifest 文件可以用于在运行时动态地获取构建生成的文件路径。
    new WebpackManifestPlugin({
      // 指定生成的 manifest 文件的名称
      fileName: "asset-manifest.json",
      // 指定生成的 manifest 文件中的所有文件路径的基本路径。默认情况下,路径是相对于输出目录的。您可以使用此参数来指定其他基本路径
      basePath: path.resolve(__dirname, "dist"),
      // 指定生成的 manifest 文件中的所有文件的公共路径前缀。这可以在需要将资源部署到 CDN 或其他不同位置的情况下非常有用。
      publicPath,
      // 一个布尔值,指定是否将清单文件写入磁盘,默认为 true。如果设置为 false,清单文件将只存在于内存中,不会写入磁盘。
      writeToFileEmit: true,
      // 指定哪些文件将被包含在生成的 manifest 文件中。默认情况下,所有输出的文件都会被包含。您可以通过设置此参数为一个函数来进行更细粒度的控制。
      generate: (seed, files, entrypoints) => {
        // 返回一个对象,包含特定的文件和入口点
        // 根据需要自定义生成逻辑
        return {
          files: files.reduce((manifest, file) => {
            return Object.assign(manifest, { [file.name]: file.path });
          }, seed),
          entrypoints: entrypoints.main.map((fileName) =>
            path.join(publicPath, fileName)
          ),
        };
      },
    }),
    new IgnorePlugin({
      resourceRegExp: /^\.\/locale$/,
      contextRegExp: /moment$/,
    }),
    new BannerPlugin({
      banner: path.dirname(joinedFileName(), ".js"), // 版本信息文本
      entryOnly: true, // 只在入口文件中添加版本信息
    }),
  ].filter(Boolean),
  // 配置模块解析的规则
  resolve: {
    // 指定可以省略的模块扩展名
    extensions: [".ts", ".tsx", ".jsx", ".mjs", ".js", ".json"],
  },
  // externals 选项告诉 Webpack 不将它们打包进最终的输出文件中,而是在运行时从外部获取
  externals: getExternals(),
};

在执行完构建后,需要将生成的资源产物上传至 OBS(对象存储服务),使用 OBS Node.js SDK 实现上传操作。

// obs.js
const upload = async (source, destination) => {
  try {
    const result = await Singleton.getClient().putObject({
      Bucket: process.env.OBS_BUCKET,
      // objectKey 是指 bucket 下目的地
      Key: destination,
      // 文件目录文件
      SourceFile: source  // localfile 为待上传的本地文件路径,需要指定到具体的文件名
    });
    if (result.CommonMsg && result.CommonMsg.Status === 200) return true;
    return false;
  } catch (error) {
    Singleton.closeClient();
    console.log(chalk.red(error));
    return false;
  }
}
// 上传构建产物清单
const uploadManifest = async () => {
  try {
    const assetManifest = require(path.resolve(__dirname, '../dist/asset-manifest.json'));
    const result = await Promise.all(Object.keys(assetManifest.files).map((key) => {
      const basename = path.basename(assetManifest.files[key]);
      const destination = path.join(COMPONENT_DIRNAME, basename);
      const dirname = path.dirname(key);
      const source = path.join(dirname, basename);

      return upload(source, destination);
    }));

    if (result.every(r => !!r)) {
      const uploadFilesStr = Object.values(assetManifest.files).map((value) => path.basename(value)).join('\n');
      console.log(chalk.green(`文件上传成功~\n${uploadFilesStr}\n`));
    } else {
      console.log(chalk.red('文件上传失败~'));
    }
  } catch (error) {
    console.log(chalk.red(error));
    process.exit(1);
  }
};

// build.js
const { uploadManifest } = require('../utils/obs');

exports.handler = async function () {
  /** 1、解析命令参数 */

  /** 2、版本校验 */

  /** 3、执行构建 */
  
  /** 4、上传构建产物 */
  await uploadManifest();
  /** 5、更新版本日志 */
};

在完成构建产物上传至 OBS 后,需要更新版本映射文件来记录版本信息。下面是描述版本数据结构的设计:

  • 版本映射文件可以采用 JSON 格式进行存储,以便于读写和解析。
  • 使用对象数组的形式表示不同组件的版本信息。
  • 每个组件对象包含名称、描述和版本数组。
  • 每个版本对象包含版本号、日期等属性,用于记录具体的版本信息。
[
  {
    "name": "demo",
    "description": "这是一个组件插件",
    "versions": [
      {
        "version": "1.1.2",
        "date": "2023-09-26 14:00:00"
      },
      {
        "version": "1.1.1",
        "date": "2023-09-25 14:00:00"
      }
    ]
  },
  {
    "name": "example",
    "description": "这是一个示例组件",
    "versions": [
      {
        "version": "2.0.0",
        "date": "2023-09-26 15:30:00"
      },
      {
        "version": "1.9.3",
        "date": "2023-09-25 16:45:00"
      }
    ]
  }
]

通过在 OBS 中维护版本映射文件记录每个组件的版本号。每当有新版本的组件产物上传至 OBS 时,通过读取版本映射文件,更新相应组件的版本数组,并将更新后的版本映射文件保存回 OBS

// 文本下载
const download = async (objectname) => {
  try {
    const result = await Singleton.getClient().getObject({
      Bucket: process.env.OBS_BUCKET,
      Key: objectname
    });
    if (result.CommonMsg.Status < 300 && result.InterfaceResult) {
      // 读取对象内容 
      return result.InterfaceResult.Content;
    } else {
      Singleton.closeClient();
      console.log(chalk.red('该包名还没有版本日志'));
      return '';
    }
  } catch (error) {
    Singleton.closeClient();
    console.log(chalk.red(error));
    process.exit(1);
  }
}
/**
 * 生成版本日志文件
 */
const updateLogs = async () => {
  try {
    const isExist = await checkIfExists('logs.json');
    const pkgPath = path.resolve(fs.realpathSync(process.cwd()), 'package.json');
    const pkg = require(pkgPath);
    let logJson = '';
    if (isExist) {
      // 存在
      const content = await download(path.join(COMPONENT_DIRNAME, 'logs.json'));
      if (content) {
        let flag = false;
        const logs = JSON.parse(content);
        logs.forEach((log) => {
          if (log.name === process.env.NAME) {
            flag = true;
            log.versions = [{ version: joinedFileName(), date: moment().format('YYYY-MM-DD HH:mm:ss') }].concat(log.versions);
          }
        })
        if (!flag) {
          logs.push({ name: process.env.NAME, desc: pkg.description, versions: [{ version: joinedFileName(), date: moment().format('YYYY-MM-DD HH:mm:ss') }] });
        }
        logJson = logs;
      }
    } else {
      // 不存在
      logJson = [{ name: process.env.NAME, desc: pkg.description, versions: [{ version: joinedFileName(), date: moment().format('YYYY-MM-DD HH:mm:ss') }] }];
    }

    if (logJson) {
      // 写入本地,并且上传至 obs
      fs.writeFileSync(path.resolve(__dirname, '../dist/logs.json'), JSON.stringify(logJson), 'utf8', (err) => {
        if (err) {
          console.error('Error writing JSON file:', err);
        } else {
          console.log('logs.json has been successfully generated.');
        }
      });
      // 上传至
      const source = path.resolve(__dirname, '../dist/logs.json');
      const destination = path.join(COMPONENT_DIRNAME, 'logs.json');
      await upload(source, destination);
      console.log(chalk.green(`logs.json 版本日志文件上传成功,您可以使用 r2j-cli ls [options]命令列举出版本清单`));
    }
  } catch (error) {
    console.log(chalk.red(error));
    process.exit(1);
  }
}
// build.js
const { uploadManifest } = require('../utils/obs');

exports.handler = async function () {
  /** 1、解析命令参数 */

  /** 2、版本校验 */

  /** 3、执行构建 */
  
  /** 4、上传构建产物 */

  /** 5、更新版本日志 */
  await updateLogs();
};

维护版本映射文件

ls 命令

完成补全 build <strategy> 命令的处理函数,接下来再来补全简单的 ls 命令的处理函数。这里使用 treeify 将扁平的版本数据转换为树状结构,以更好地组织和展示版本之间的关系。

const path = require("path");
const treeify = require('treeify');
const chalk = require('chalk');
const { download } = require("../utils/obs");
const { COMPONENT_DIRNAME } = require('../utils/config');

exports.command = 'ls';

exports.desc = '列举指定模块的版本';

exports.builder = {
  name: {
    alias: 'n',
    describe: '包名',
    type: 'string',
    demand: false,
  },
}

exports.handler = async (argv) => {
  /** 1、解析命令参数 */
  const componentName = argv.name; // 包名
  /** 2、获取版本日志,输出控制台 */
  const content = await download(path.join(COMPONENT_DIRNAME, 'logs.json'));
  if (content) {
    try {
      let logs = JSON.parse(content);
      if (componentName) {
        // 列举出指定包名的版本清单
        logs = logs.filter((log) => log.name === componentName);
      }

      if (logs.length) {
        const logTree = logs.reduce((obj, log) => {
          obj[log.name] = {
            desc: log.desc,
            versions: log.versions.reduce((versionObj, item) => {
              versionObj[item.version] = item.date;
              return versionObj;
            }, {})
          }
          return obj;
        }, {});
        console.log(chalk.green(treeify.asTree(logTree, true)));
      } else {
        console.log(chalk.red('没有查询到版本日志'));
      }
    } catch (error) {
      console.log(chalk.red(error));
    }
  }
  process.exit(0);
}

当执行 r2j-cli ls -n demo 可以列举出 demo 所有的版本清单:

所有的版本清单

抽离公共依赖和封装基础函数

我们已经完成开发了一个命令行工具,可以将业务组件构建为 JavaScript bundle,并将其存储到 OBS(Object Storage Service)上。其中,为了优化业务组件的 JavaScript bundle 大小,计划将这些公共依赖模块集成到一个公共模块中,以减少业务组件的 bundle 大小,并确保页面只需要引入一次。

此外,公共模块还需提供一些基础函数(如组件加载、挂载、更新和卸载等)以及一些 polyfill 来兼容旧版本的浏览器。

公共依赖

// ./common/index.js
export * as React from 'react';
export * as ReactDOM from 'react-dom';
export * as axios from 'axios';
export * as _ from 'lodash-es';
import './polyfill';
export * as react2js from './base'; // 基础函数
export * as bricks from '@casstime/bricks';
import '@casstime/bricks/dist/bricks.production.css';

基础函数

// ./common/base.tsx
const { CDN_DOMAIN, COMPONENT_DIRNAME } = require("../utils/config");

// 加载函数
export const load = (moduleId) => {
  if (!moduleId) {
    console.error('模块 id 不能为空');
    return null;
  }

  // 模块是否已经加载过,如是,则返回缓存数据
  if (moduleId in react2js.installModules) {
    return Promise.resolve(react2js.installModules[moduleId]);
  }

  return new Promise((resolve) => {
    const src = `${CDN_DOMAIN}/${COMPONENT_DIRNAME}/${moduleId}.js`;
    const onload = () => {
      if (moduleId in react2js.installModules) {
        resolve(react2js.installModules[moduleId]);
      } else {
        resolve(undefined);
      }
    }
    
    const script = document.createElement('script');
    script.src = src;
    script.type = "text/javascript";
    script.onload = onload;

    document.head.appendChild(script);
  })
}

// 创建实例,通过实例挂载、更新、卸载
export const createInstance = (C, props, container) => {
  if (!C) return null;

  const renderChildren = (children) => {
    if (typeof children === 'string') {
      return <span>{children}</span>;
    }

    if (React.isValidElement(children)) {
      return children;
    }

    if (Array.isArray(children)) {
      return children.map((child, index) => (
        <div key={index}>{renderChildren(child)}</div>
      ));
    }

    return null;
  }

  // 用组件包裹一层,控件内层组件的更新
  const Warp = (props) => {
    const [state, setState] = React.useState(props);
    const ref = React.useRef();

    React.useEffect(() => {
      instance.getRef = () => ref;
      instance.getProps = () => state;
      instance.updateState = (newState) => setState(Object.assign(state, newState));
    }, [])

    return <C {...state} ref={ref}>{props.children ? renderChildren(props.children) : null}</C>
  }

  const instance = {
    ele: React.createElement(Warp, props),
    root: null,
    getRef: () => void 0,
    getProps: () => void 0,
    updateState: () => void 0,
    mount: (container) => {
      if (container instanceof HTMLElement) {
        const root = ReactDOM.createRoot(container);
        instance.root = root;
        root.render(instance.ele);
      }
    },
    unmount: () => {
      if (instance.root) {
        instance.root.unmount()
      }
    }
  };

  if (container instanceof HTMLElement) {
    instance.mount(container);
  }

  return instance;
}

polyfill

// ./common/polyfill.js
/** 打包 polyfill,react-app-polyfill 去除 core-js */

if (typeof Promise === "undefined") {
  // Rejection tracking prevents a common issue where React gets into an
  // inconsistent state due to an error, but it gets swallowed by a Promise,
  // and the user has no idea what causes React's erratic future behavior.
  require("promise/lib/rejection-tracking").enable();
  self.Promise = require("promise/lib/es6-extensions.js");
}

// Make sure we're in a Browser-like environment before importing polyfills
// This prevents `fetch()` from being imported in a Node test environment
if (typeof window !== "undefined") {
  // fetch() polyfill for making API calls.
  require("whatwg-fetch");
}

// Object.assign() is commonly used with React.
// It will use the native implementation if it's present and isn't buggy.
Object.assign = require("object-assign");

require("raf").polyfill();
require("regenerator-runtime/runtime");

common 命令

然后在 react2js-cli 项目的根目录的 package.json 文件中配置 common 命令:

{
  "scripts": {
    "common": "node ./scripts/common-cli.js"
  }
}

common-cli.js 命令脚本执行构建并且上传公共模块:

const path = require("path");
const chalk = require("chalk");
const { execSync } = require("child_process");
const { checkIfExists, upload, download, updateLogs } = require("../utils/obs");
const { COMPONENT_DIRNAME } = require("../utils/config");

/**
 * 执行命令格式:node ./scripts/common-cli.js --STRATEGY=bundle --NAME=bundle --ENTRY=./bundle/index.js --MODE=development --VERSION=1.0.0
 */

// 解析键值对参数
const parseKeyValueArgs = (args) => {
  const keyValueArgs = {};

  for (let i = 0; i < args.length; i++) {
    const arg = args[i];

    // 检查参数是否是键值对格式
    if (arg.startsWith("--") && arg.includes("=")) {
      const [key, value] = arg.slice(2).split("=");
      keyValueArgs[key] = value;
    }
  }

  return keyValueArgs;
};

// 获取最新 bundle 版本
const fetchCommonVersion = async (componentName) => {
  const content = await download(path.join(COMPONENT_DIRNAME, "logs.json"));
  if (content) {
    try {
      const logs = JSON.parse(content);
      // 列举出指定包名的版本清单
      const { versions } = logs.filter((log) => log.name === componentName)[0];
      const { version } = versions[0];
      const vsr = path.basename(version, ".js").split("@")[1];
      const regex = /^(\d+)\.(\d+)\.(\d+)(?:-(.*))?$/;
      let [_, major, minor, patch, preRelease] = vsr.match(regex);
      if (parseInt(patch, 10) < 100) {
        patch = parseInt(patch, 10) + 1;
      } else {
        if (parseInt(minor, 10) < 100) {
          minor = parseInt(minor, 10) + 1;
        } else {
          if (parseInt(major, 10) < 100) {
            major = parseInt(major, 10) + 1;
          } else {
            console.log(chalk.red("版本维护以达到上限!"));
            process.exit(1);
          }
        }
      }
      const defaultVersion = `${major}.${minor}.${patch}${preRelease ? `-${preRelease}` : ""
        }`;
      return defaultVersion;
    } catch (error) {
      console.log(chalk.red(error));
      process.exit(1);
    }
  }
  console.log(chalk.red("不存在版本日志文件"));
  process.exit(1);
};

// 上传文件
const uploadCommon = async () => {
  const manifest = require(path.resolve(
    __dirname,
    "../dist/asset-manifest.json"
  ));
  const fileName = path.basename(manifest.entrypoints[0]);
  const isExist = await checkIfExists(fileName);
  if (isExist) {
    console.log(
      chalk.yellow(`${fileName} 文件已存在,请重新设置版本号构建上传`)
    );
    process.exit(0);
  }
  console.log(chalk.blue(`正在上传 ${fileName} ...`));
  const result = await Promise.all(
    Object.keys(manifest.files).map((key) => {
      const source = path.join(
        path.dirname(key),
        path.basename(manifest.files[key])
      );
      const destination = path.join(
        COMPONENT_DIRNAME,
        path.basename(manifest.files[key])
      );

      return upload(source, destination);
    })
  );

  if (result.every((r) => !!r)) {
    console.log(chalk.green(`${fileName} 文件上传成功~`));
    /** 更新版本日志 */
    const [NAME, VERSION] = path.basename(fileName, ".js").split("@");
    process.env.NAME = NAME;
    process.env.VERSION = VERSION;
    await updateLogs();
    console.log(chalk.green(`版本日志已更新~`));
  } else {
    console.log(chalk.red(`${fileName} 文件上传失败!`));
  }

  process.exit(0);
};

const bootstrap = async () => {
  const cliRoot = path.normalize(path.resolve(__dirname, ".."));
  // 切换到根目录下
  execSync(`cd ${cliRoot}`);

  // 删除 dist 目录
  execSync("rimraf dist");

  // 注入环境变量、执行构建
  const args = process.argv.slice(2);
  const keyValueArgs = parseKeyValueArgs(args); // 解析键值对参数

  const STRATEGY = keyValueArgs.STRATEGY || "bundle";
  const NAME = keyValueArgs.NAME || "bundle";
  const ENTRY = keyValueArgs.ENTRY || "./bundle/index.js";
  const MODE = keyValueArgs.MODE || "development";
  const VERSION = keyValueArgs.VERSION || (await fetchCommonVersion(NAME));

  execSync(
    `cross-env STRATEGY=${STRATEGY} NAME=${NAME} ENTRY=${ENTRY} MODE=${MODE} VERSION=${VERSION} webpack`
  );

  // 上传公共模块
  uploadCommon();
};

bootstrap();

在 react2js-cli 项目的根目录执行 npm run common

根目录执行 npm run common

样式隔离

太棒了!现在我们可以举一个例子来演示如何将 React 组件构建为一个 bundle,并将其用于原生页面。假设我们有一个简单的 React 组件叫做 Demo,它可以控制弹出一个简单的模态框。

// index.tsx
import React, { useState } from 'react';
import { Button, Modal } from '@casstime/bricks';

import '@casstime/bricks/dist/bricks.production.css';

const Demo = () => {

  const [visible, setVisible] = useState(false);
  const showModal = () => {
    setVisible(true);
  };

  const hideModal = () => {
    setVisible(false);
  };

  const onCancel = () => {
    setVisible(false);
  };

  const onOk = () => {
    setVisible(false);
  };

  return (
    <div>
      <Button onClick={showModal}><span>Open</span></Button>
      <Modal
        keyboard={hideModal}
        visible={visible}
        onClose={hideModal}
        onOk={onOk}
        onCancel={onCancel}
        title="我是标题"
      >
        <div>弹窗内容</div>
      </Modal>
    </div>
  );
}

export default Demo;

在 Demo/package.json 中添加构建命令:

{
  "scripts": {
    "r2j": "r2j-cli build r2j -n demo -m development",
  }
}

在 /Demo 目录下执行 npm run r2j 命令:

目录下执行 npm run r2j 命令

在 /react2js-cli 目录下执行 npm run common 命令:

目录下执行 npm run common 命令

接下来,创建一个 index.html 文件,并在其中引入 bundle.js@1.0.83 和 demo@1.0.42

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://xxxx/test/components/bundle@1.0.83.js"></script>
  <style>
    div, span {
      color: #222;
    }
  </style>
</head>

<body>
  <div id="root"></div>

  <script>
    react2js.load("demo@1.0.42").then((module) => {
      console.log('module', module);
      if (module) {
        react2js.createInstance(module.App.default, {}, document.getElementById("root"));
      }
    })
  </script>
</body>
</html>

现在,使用任何浏览器打开 index.html 文件,可以看到点击按钮时会弹出模态框。

点击按钮时会弹出模态框

仔细观擦你会发现添加的全局样式 div, span { color: #222222; } 覆盖了组件内部样式,导致按钮的字体颜色变为黑色,而正常情况下应该是白色。这种情况很常见,特别是在引入全局样式库(如 Bootstrap)时,其中可能包含修改原生标签样式的规则,从而影响到组件内部样式。

另外,项目中使用 !important 提高样式优先级的写法也可能影响到组件内部样式。

样式隔离

所以,样式隔离显得尤其重要。在处理样式隔离时,通常有多种方法可供选择。例如,可以使用 BEM 规范编写样式,使用 CSS Modules 将样式限定在组件的作用域内,或者使用 CSS-in-JS 库(如 styled-components 或 Emotion),它们可以将样式直接嵌入到组件中。

然而,这些方法都无法完全避免全局标签样式对组件内部样式的影响。这就是为什么我们需要一种天然的作用域隔离机制,就像 iframe 一样。而原生的 Web 标准提供了这种能力,即 Shadow DOM

Shadow DOM

Shadow DOM 允许将组件的样式、结构和行为封装在一个独立的作用域内,与外部文档的样式和元素隔离开来。通过使用 Shadow DOM,组件的样式规则只适用于组件内部,不会泄漏到外部文档的样式中,也不会受到全局样式的干扰。

Shadow DOM

在点击按钮触发模态框(Modal)组件时,通常存在两种挂载方式。一种是将模态框挂载到 body 元素下,另一种是挂载到指定的元素下。不论使用哪种方式,模态框元素和按钮元素是分离的。因此,在实现样式隔离时,不仅需要将按钮(Button)元素包裹在 Shadow DOM 中,还需要将模态框(Modal)元素进行包裹。

同时,为了确保样式的自治性,还需要将组件样式应用于按钮和模态框。通过 Shadow DOM 将这些以 style 标签的形式插入到 head 中的组件样式应用于按钮和模态框。这样可以确保按钮和模态框在样式上具有独立性,不受全局样式的影响。

隔离处理

组件样式库 @casstime/bricks/dist/bricks.production.css 中存在一些全局标签和类名样式

隔离处理

为了确保组件的样式不会影响外部,并且外部样式不会影响组件的内部,可以采取以下处理方式:

1、在加载公共模块或挂载组件时,将样式以 <style> 标签的形式插入到 <head> 元素中。为了限定样式的影响范围,可以给每个样式规则添加一个父类 react-component,例如将 span { color: #222; } 变成 .react-component span { color: #222; }。同时,给组件的容器元素添加类名 react-component,这样就限制了组件样式的影响范围。然而,需要注意的是,在 bricks.production.css 中可能含有 html, body {} 样式规则,直接将其变为 .react-component html, .react-component body {} 是没有意义的。为了解决这个问题,可以直接将 html, body {} 样式赋予容器元素 .react-component {}

// patch-css.scss
.react-component {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
  color: #2a2b2c;
  font-size: 12px;
  font-family: "Microsoft Yahei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Roboto, Arial, "PingFang SC", "Hiragino Sans GB", SimSun, sans-serif;
  line-height: 1.5;
}

// index.js
export * as React from 'react';
export * as ReactDOM from 'react-dom';
export * as axios from 'axios';
export * as _ from 'lodash-es';
import './polyfill';
export * as react2js from './base';
export * as bricks from '@casstime/bricks';
import '@casstime/bricks/dist/bricks.production.css';
import './patch-css.scss';

给每个样式规则添加一个父类 react-component

// postcss.config.js
module.exports = {
  plugins: [
    // postcss-prefix-selector 为选择器添加 .parent 类名前缀
    require('postcss-prefix-selector')({
      prefix: '.react-component',
      exclude: [/\.react-component/],
      transform: function (prefix, selector, prefixedSelector, filePath, rule) {
        return prefixedSelector;
      }
    }),
    // postcss-flexbugs-fixes 插件的作用就是根据已知的 Flexbox 兼容性问题,自动应用修复,以确保在各种浏览器中获得更一致和可靠的 Flexbox 布局。
    require('postcss-flexbugs-fixes'),
    require('postcss-preset-env')({
      autoprefixer: {
        flexbox: 'no-2009',
      },
      stage: 3,
    }),
    require('postcss-normalize')(),
  ],
  map: {
    // source map 选项
    inline: true, // 将源映射嵌入到 CSS 文件中
    annotation: true // 为 CSS 文件添加源映射的注释
  }
}

父类 react-component 不能被模块化:

const getCSSModuleLocalIdent = (
  context,
  localIdentName,
  localName,
  options
) => {
  // 通过 postcss 添加的父类 react-component 不能被模块化
  if (localName === "react-component") return localName;
  // Use the filename or folder name, based on some uses the index.js / index.module.(css|scss|sass) project style
  const fileNameOrFolder = context.resourcePath.match(
    /index\.module\.(css|scss|sass)$/
  )
    ? "[folder]"
    : "[name]";
  // Create a hash based on a the file location and class name. Will be unique across a project, and close to globally unique.
  const hash = loaderUtils.getHashDigest(
    path.posix.relative(context.rootContext, context.resourcePath) + localName,
    "md5",
    "base64",
    5
  );
  // Use loaderUtils to find the file or folder name
  const className = loaderUtils.interpolateName(
    context,
    fileNameOrFolder + "_" + localName + "__" + hash,
    options
  );
  // remove the .module that appears in every classname when based on the file.
  return className.replace(".module_", "_");
};

2、将以 <style> 标签的形式插入到 <head> 元素中的组件样式应用于 Shadow DOM 中。在构建过程中,可以为这些样式元素添加一个独有的类名,例如 react-component-style,以便在应用样式时进行识别。

{
  loader: require.resolve("style-loader"),
  options: {
  injectType: "singletonStyleTag",
    attributes: { class: "react-component-style" },
  },
},

然后,在挂载组件时将这些样式应用于 Shadow DOM

const styleSheets = [];
const globalStyles = document.querySelectorAll(".react-component-style");
globalStyles.forEach((ele) => {
  const styleSheet = new CSSStyleSheet();
  styleSheet.replaceSync(ele.textContent);
  styleSheets.push(styleSheet);
})
shadowRoot.adoptedStyleSheets = styleSheets;

Shadow DOM

调整挂载函数

在挂载函数中,你需要检查当前浏览器是否支持 Shadow DOM,并据此选择挂载方式。如果支持 Shadow DOM,则以 Shadow DOM 形式将组件挂载到容器元素;如果不支持,则直接将组件挂载到容器元素。

mount: (container) => {
  if (container instanceof HTMLElement) {
    if (document.body.attachShadow) {
      const shadowHost = container;
      // 当 mode 设置为 "open" 时,页面中的 JavaScript 可以通过影子宿主的 shadowRoot 属性访问影子 DOM 的内部
      const shadowRoot = shadowHost.shadowRoot || shadowHost.attachShadow({ mode: "open" });

      // 判断是否已经挂载,如果没有挂载则创建包裹元素
      if (!shadowRoot.querySelector(".react-component")) {
        const wrap = document.createElement("div");
        wrap.classList.add("react-component");
        shadowRoot.appendChild(wrap);
      }

      // 应用全局样式
      if (shadowHost.getAttribute("data-isAdopted") !== "true") {
        const styleSheets = [];
        const globalStyles = document.querySelectorAll(".react-component-style");
        globalStyles.forEach((ele) => {
          const styleSheet = new CSSStyleSheet();
          styleSheet.replaceSync(ele.textContent);
          styleSheets.push(styleSheet);
        })
        shadowRoot.adoptedStyleSheets = styleSheets;
        shadowHost.setAttribute("data-isAdopted", "true");
      }

      // 挂载到包裹元素
      const root = ReactDOM.createRoot(shadowRoot.querySelector(".react-component") || container);
      instance.root = root;
      root.render(instance.ele);
    } else {
      // 直接将组件挂载到容器元素
      container.classList.add("react-component");
      const root = ReactDOM.createRoot(container);
      instance.root = root;
      root.render(instance.ele);
    }
  }
},

点击按钮触发模态框(Modal)组件,可以看到 Modal 部分还没有包裹在 Shadow DOM 中,并且样式也有问题。

触发模态框(Modal)组件

样式问题

在挂载 Modal 组件时也需要进行 Shadow DOM 封装,以确保 Modal 组件的样式不会受到外部样式的影响,可以先不直接对 @casstime/bricks 中 Modal 组件修改升级,暂时使用 patch-package 工具对 @casstime/bricks 组件库进行补丁处理。

1、安装 patch-package

# 使用 npm 安装
npm install -g patch-package

# 使用 Yarn 安装
yarn global add patch-package

2、创建补丁文件

Modal 依赖 Portal 实现模态框,对 Portal 的 render 方法作如下修改:

// 修改前
Portal.prototype.render = function () {
    var children = this.props.children;
    return this.container && children
        ? ReactDOM.createPortal(children, this.container)
        : null;
};

// 修改后
Portal.prototype.render = function () {
    var children = this.props.children;
    if (this.container && children) {
        if (document.body.attachShadow) {
            // div.react-component-host,所有 modal 共用一个 shadowHost
            let shadowHost = this.container.querySelector(".react-component-host");
            let shadowRoot = null;
            if (shadowHost) {
                shadowRoot = shadowHost.shadowRoot;
            } else {
                shadowHost = document.createElement("div");
                shadowHost.classList.add("react-component-host");
                this.container.appendChild(shadowHost);

                shadowRoot = shadowHost.attachShadow({ mode: "open" });
            }

            // div.react-component
            if (!shadowRoot.querySelector(".react-component")) {
                const wrap = document.createElement("div");
                wrap.classList.add("react-component");
                shadowRoot.appendChild(wrap);
            }

            // 应用全局样式
            if (shadowHost.getAttribute("data-isAdopted") !== "true") {
                const styleSheets = [];
                const globalStyles = document.querySelectorAll(".react-component-style");
                globalStyles.forEach((ele) => {
                    const styleSheet = new CSSStyleSheet();
                    styleSheet.replaceSync(ele.textContent);
                    styleSheets.push(styleSheet);
                })
                shadowRoot.adoptedStyleSheets = styleSheets;
                shadowHost.setAttribute("data-isAdopted", "true");
            }

            return ReactDOM.createPortal(children, shadowRoot.querySelector(".react-component"));
        } else {
            return ReactDOM.createPortal(React.createElement('div', { className: 'react-component' }, children), this.container);
        }
    } else {
        return null;
    }
};

在项目的根目录下,运行以下命令 npx patch-package <package-name> 创建补丁文件,这将在项目的根目录下创建一个名为 patches 的目录,并在其中创建与 <package-name> 包对应的补丁文件。例如,如果你要对 @casstime/bricks 组件库进行补丁。

npx patch-package @casstime/bricks

创建补丁文件

然后,重新执行构建 npm run common

重新执行构建 npm run common

Shadow DOM 内操作 DOM

我们将 demo 修改一下,在弹出模态框时修改提示内容。

import React, { useState } from 'react';
import { Button, Modal } from '@casstime/bricks';

import '@casstime/bricks/dist/bricks.production.css';

const App = () => {

  const [visible, setVisible] = useState(false);
  const showModal = () => {
    setVisible(true);
    setTimeout(() => {
      // 修改弹窗文案
      (document.getElementById("modal-content") as HTMLDivElement).innerText = 'hello world';
    }, 200);
  };

  const hideModal = () => {
    setVisible(false);
  };

  const onCancel = () => {
    setVisible(false);
  };

  const onOk = () => {
    setVisible(false);
  };

  return (
    <div>
      <Button onClick={showModal}><span>Open</span></Button>
      <Modal
        keyboard={hideModal}
        visible={visible}
        onClose={hideModal}
        onOk={onOk}
        onCancel={onCancel}
        title="我是标题"
      >
        <div id='modal-content'>弹窗内容</div>
      </Modal>
    </div>
  );
}

export default App;

你会发现内容并没有被修改,控制台报错指出 document.getElementById("modal-content") 没有找到指定的元素。

控制台报错

没有找到指定的元素

在使用 Shadow DOM 时,无法直接使用 document 对象从外层获取 Shadow DOM 内部的元素。为了解决这个问题,并兼容两种挂载方式,你可以考虑对所有操作 DOM 的地方进行拦截代理。通过创建一个代理对象,你可以拦截对 document 对象的操作,比如 document.querySelector("xxxx"); 使用代理对象代替 document 对象,eleProxy(document).querySelector("xxxx");

需要对所有针对元素的操作进行拦截代理时,我们编写一个 Babel 插件来实现这个功能。

// transform-ele.js
module.exports = function ({ types: t }) {
  return {
    visitor: {
      MemberExpression(path) {
        const targetProxyProps = [
          "getElementById",
          "getElementsByClassName",
          "getElementsByName",
          "getElementsByTagName",
          "getElementsByTagNameNS",
          "querySelector",
          "querySelectorAll",
        ];
        // console.log('path.node.property.name', path.node.property.name);
        if (targetProxyProps.includes(path.node.property.name)) {
          path.node.object = t.callExpression(t.identifier("eleProxy"), [
            path.node.object,
          ]);
        }
      },
    },
  };
};

在 webpack.config.js 中配置使用:

{
  test: /\.(js|mjs|jsx|ts|tsx)$/, // 匹配以 .js 结尾的文件
  // exclude: /node_modules/, // 排除 node_modules 目录
  use: {
    // 使用 babel-loader 进行处理
    loader: "babel-loader",
    options: {
      cacheDirectory: true,
      presets: [
        [
          /**
           * babel-preset-react-app 是一个由 Create React App (CRA) 提供的预设,用于处理 React 应用程序的 Babel 配置。
           * 它是一个封装了一系列 Babel 插件和预设的预配置包,旨在简化 React 应用程序的开发配置。
           * babel-preset-react-app 预设,该预设要求明确指定环境变量的值
           */
          require.resolve("babel-preset-react-app"),
          {
            // 当 useBuiltIns 设置为 false 时,构建工具将不会自动引入所需的 polyfills 或内置函数。这意味着您需要手动在代码中引入所需的 polyfills 或使用相应的内置函数。
            useBuiltIns: false,
          },
        ],
      ],
      plugins: [
        // 可选:您可以在这里添加其他需要的 Babel 插件
        ["@babel/plugin-transform-class-properties", { loose: true }],
        ["@babel/plugin-transform-private-methods", { loose: true }],
        [
          "@babel/plugin-transform-private-property-in-object",
          { loose: true },
        ],
        // 操作 dom 元素代理
        process.env.STRATEGY === "r2j" && require.resolve("./plugins/transform-ele"),
      ].filter(Boolean),
    },
  },
},

实现一个名为 eleProxy 的方法,用于包裹元素并返回代理对象,以拦截元素获取操作,可以按照以下方式编写代码:

/**
 * eleProxy.js
 * 1、【外部获取内部】在 shadow dom 中通过 document 获取元素;
 * 2、【内部获取内部】在 shadow dom 中通过 shadow dom 内部节点获取元素;
 * 3、【外部获取外部】document 获取外部元素;
 */
export const eleProxy = (obj) => {
  return new Proxy(obj, {
    get(target, prop) {
      const targetProxyProps = [
        "getElementById",
        "getElementsByClassName",
        "getElementsByName",
        "getElementsByTagName",
        "getElementsByTagNameNS",
        "querySelector",
        "querySelectorAll",
      ];
      if (targetProxyProps.includes(prop)) {
        if (target instanceof Node) {
          const shadowRoot = target.getRootNode();
          const isInShadowDOM = shadowRoot instanceof ShadowRoot;
          if (isInShadowDOM) {
            // 内部获取内部
            return function (selectors) {
              return target[prop](selectors);
            };
          } else {
            return function (selectors) {
              const ele = target[prop](selectors);
              if (ele instanceof HTMLCollection && ele.length) {
                // 外部获取外部,获取多个 getElementsByClassName
                return ele;
              } else if (ele instanceof NodeList && ele.length) {
                // 外部获取外部,获取多个 querySelectorAll
                return ele;
              } else if (ele instanceof HTMLElement) {
                // 外部获取外部,获取一个
                return ele;
              } else {
                // 外部获取内部
                const getAllShadowRoots = (root) => {
                  const shadowRoots = [];

                  const walker = document.createTreeWalker(
                    root,
                    NodeFilter.SHOW_ELEMENT,
                    {
                      acceptNode(node) {
                        if (node.shadowRoot) {
                          return NodeFilter.FILTER_ACCEPT;
                        }
                        return NodeFilter.FILTER_SKIP;
                      },
                    }
                  );

                  while (walker.nextNode()) {
                    shadowRoots.push(walker.currentNode.shadowRoot);
                  }
                  return shadowRoots;
                };

                const shadowRoots = getAllShadowRoots(document);
                for (let shadowRoot of shadowRoots) {
                  // 自定义的 getElementsByName 方法
                  shadowRoot.getElementsByName = function (name) {
                    const elements = this.querySelectorAll(`[name="${name}"]`);
                    return elements;
                  };

                  // 自定义的 getElementsByClassName 方法
                  shadowRoot.getElementsByClassName = function (className) {
                    const elements = this.querySelectorAll(`.${className}`);
                    return elements;
                  };

                  // 自定义的 getElementsByTagName 方法
                  shadowRoot.getElementsByTagName = function (tagName) {
                    const elements = this.querySelectorAll(tagName);
                    return elements;
                  };

                  // 自定义的 getElementsByTagNameNS 方法
                  shadowRoot.getElementsByTagNameNS = function (
                    namespaceURI,
                    tagName
                  ) {
                    const elements = this.querySelectorAll(
                      `${namespaceURI}|${tagName}`
                    );
                    return elements;
                  };

                  // shadowRoot 原型链上有 getElementById、querySelector
                  const ele = shadowRoot[prop](selectors);
                  if (ele instanceof HTMLCollection && ele.length) {
                    return ele;
                  } else if (ele instanceof NodeList && ele.length) {
                    return ele;
                  } else if (ele instanceof HTMLElement) {
                    return ele;
                  }
                }

                // 没有获取到
                if (
                  [
                    "getElementsByClassName",
                    "getElementsByName",
                    "getElementsByTagName",
                    "getElementsByTagNameNS",
                  ].includes(prop)
                ) {
                  return [];
                } else {
                  return null;
                }
              }
            };
          }
        } else {
          return function (selectors) {
            return target[prop](selectors);
          };
        }
      }
    },
  });
};

然后,作为公共模块导出:

export * as React from 'react';
export * as ReactDOM from 'react-dom';
export * as axios from 'axios';
export * as _ from 'lodash-es';
import './polyfill';
export * as react2js from './base';
export * as bricks from '@casstime/bricks';
export { eleProxy } from './eleProxy';
import '@casstime/bricks/dist/bricks.production.css';
import './patch-css.scss';

最后,执行构建运行页面,可以看到能正确获取元素:

正确获取元素

测试更多案例,都能正常运行

测试更多案例

测试更多案例

总结

至此,已经完成了对 React 组件如何应用于 MVC 项目的方案设计和落地实现。如果您在这个过程中遇到了任何错误或者有其他更好的设计思路,我很愿意与你一同交流。

「点点赞赏,手留余香」

1

给作者打赏,鼓励TA抓紧创作!

微信微信 支付宝支付宝

还没有人赞赏,快来当第一个赞赏的人吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。
码云笔记 » React组件如何适配到MVC项目?

发表回复