笑而不语是一种豁达,痛而不言是一种历练。时间改变着一切,一切改变着我们,曾经看不惯,受不了的,如今不过淡然一笑。
成熟,不是看破,而是看淡,原先看不惯的如今习惯了,曾经想要的,现在不需要了,开始执着的,后来很晒脱了...
成长的路上,人总要沉淀下来,过一段宁静而自醒的日子,来整理自己,沉淀再沉淀,然后成为一个温柔而强大的人!
目前公司的业务线中存在许多未进行前后端分离的 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 <command> [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
项目的方案设计和落地实现。如果您在这个过程中遇到了任何错误或者有其他更好的设计思路,我很愿意与你一同交流。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。