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

AI 概述
设计方案实现落地搭建命令行工具build 命令ls 命令抽离公共依赖和封装基础函数公共依赖基础函数polyfillcommon 命令样式隔离隔离处理调整挂载函数Shadow DOM 内操作 DOM总结 目前公司的业务线中存在许多未进行前后端分离的 Spring MVC 项目,其中前端使用 JQuery 操作 DOM,后端使用 Freemarker 模板引...
目录
文章目录隐藏
  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 项目的方案设计和落地实现。如果您在这个过程中遇到了任何错误或者有其他更好的设计思路,我很愿意与你一同交流。

以上关于React组件如何适配到MVC项目?的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。

「点点赞赏,手留余香」

1

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

微信微信 支付宝支付宝

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

声明:本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » React组件如何适配到MVC项目?

发表回复