React组件如何适配到MVC项目?
目前公司的业务线中存在许多未进行前后端分离的 Spring MVC
项目,其中前端使用 JQuery
操作 DOM
,后端使用 Freemarker
模板引擎进行渲染。由于有许多产品需求需要在 React
项目和 Spring MVC
项目中都实现,如果两边都独立开发,工作量必然会增加很多。因此,我们需要设计一种方法,将 React
组件适配应用到 Spring MVC
项目中,以降低不必要的人力成本,并为将来渐进式项目重构打下基础。
设计方案
一个常见的设计思想是将业务组件封装为一个独立的模块,其中包含挂载和卸载函数。通过使用构建工具将该模块打包成一个优化过的 JavaScript bundle,并将其上传到 CDN(内容分发网络)。当浏览器加载页面时,引入该 JavaScript bundle,然后通过调用挂载函数,将该组件动态地挂载到指定的容器元素上。
当涉及到大范围的组件应用于 MVC
(Model-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
样式隔离
太棒了!现在我们可以举一个例子来演示如何将 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
命令:
在 /react2js-cli
目录下执行 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
,组件的样式规则只适用于组件内部,不会泄漏到外部文档的样式中,也不会受到全局样式的干扰。
在点击按钮触发模态框(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
形式将组件挂载到容器元素;如果不支持,则直接将组件挂载到容器元素。
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
组件时也需要进行 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
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项目?