大家好,最近接了一个需求:基于已有系统拆出一个适配移动端的h5页面,并提供sdk引入方式,完成之后写下此文作为总结,如感兴趣请往下看。
项目背景
- 本来的项目是umi框架快捷生成的项目,考虑到拆除页面需要轻量级,所以不会沿用umi,cra也不再提供支持,所以整个项目的构建直接手动搭建
- sdk包需要同时支持原本项目的接入,所以sdk会是独立的一个项目,h5页面单独一个项目
- 项目仅需要支持一对一聊天即可,后端接口沿用原项目,页面相对简单,打包不会有负担(如果需要打包产出有明显的体积优势,可选择rollup),所以h5页面的打包选择是webpack(社区支持较为丰富,配置较为简单)
- 最终项目的技术栈是react+webpack+less+ts+websocket 可以先看看项目的最终结构是:
项目入口准备
首先是package.json文件,这是整个项目的管理文件,我的文件内容如下:
{
"name": "demo",//项目名称
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",//项目代码采用esm,可选esm或cjs
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --mode development",//打包并区分环境
"build:pro": "webpack --mode production",
"start": "webpack serve --open --config webpack.config.js",//通过自己定义的webpack配置启动项目
"format": "prettier --cache --write .",//代码格式检查
"prepare": "husky",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"upload": "chmod +x up.sh && ./up.sh",//上传打包结果到服务器的sh脚本
"upload:pro": "chmod +x up.sh && ./up-pro.sh"
},
"dependencies": {
"antd-mobile": "^5.37.1",
"axios": "^1.7.7",
"moment-timezone": "^0.5.46",
"react": "18.x",
"react-dom": "18.x",
"rxjs": "^7.8.1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
...(内容过多省略了)
"terser-webpack-plugin": "^5.3.10",
"ts-loader": "^9.5.1",
"tslib": "^2.7.0",
"typescript": "^5.6.3",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
}
}
这个入口文件没有太多需要赘述的,注意一下项目命令行脚本配置就好
webpack配置及环境变量
这里可以先构思一下我们需要什么样的配置:
- 项目后续要上线,所以请求路径需要通过环境变量管理(这里gpt告诉我可以用dotenv,但是webpack可以自己配置环境管理,并且在尽量少依赖的前提下,我选择不用其他依赖)
- 开发环境下,发网络请求需要通过devServer解决跨域问题,所以需要devServer
- ts+less项目,需要对应loader(ts-loader, less-loader, css-loader)支持编译,项目采用的是模块化less,所以还需要style-loader
- 项目主要用于移动端,需要做适配(px转为rem),所以还需要做postcss相关配置
梳理完这些点后,其实配置已经大体清晰了,首先是环境变量:
// config/env.js
const envConfig = {
development: {
API_BASE_URL: '/api',
WS_URL: 'websocke地址',
},
production: {
API_BASE_URL: '生产接口请求路径',
WS_URL: '生产websocket地址',
}
};
export default envConfig;
// config/index.js
const config = {
apiBaseUrl: process.env.API_BASE_URL,
wsUrl: process.env.WS_URL,
isDev: process.env.NODE_ENV === 'development',
};
console.info('baseUrl', process.env.API_BASE_URL)
export default config;
然后是关于rem的配置(配置并且引入webpack之后,就可以在启动项目后审查元素查看效果了):
// postcss.config.js
import pxtorem from 'postcss-pxtorem';
export default {
plugins: [
pxtorem({
rootValue: 16, // 你的根元素字体大小,可以根据设计稿调整
propList: ['*'], // 可以从 px 转换到 rem 的属性列表
selectorBlackList: [], // 过滤掉一些不转换的选择器
// 只对 src/styles 文件夹内的样式文件进行转换
include: /src\/pages\/Welcome/,
// 排除 node_modules 下的所有文件
exclude: /node_modules/,
})
]
};
最后就是webpack的配置,我认为需要注意的一些点都写在注释里:
import path from 'path';
import url from 'url';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import envConfig from './config/env.js';
import webpack from 'webpack';
// 获取当前模块的目录名
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default (env, argv) => {
const mode = argv.mode || 'development';
const currentEnv = envConfig[mode];
return {
mode,
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.[contenthash].js',//打包产出文件添加hash,避免上传服务器后被认为文件没改变从而使用缓存,不立即生效的问题
clean: true, // 启用清理
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
alias: {//路径别名,方便开发,尽量规避相对路径
"@pages": path.resolve(__dirname, 'src/pages'),
"@services": path.resolve(__dirname, 'src/services'),
"@utils": path.resolve(__dirname, 'src/utils'),
"@components": path.resolve(__dirname, 'src/components'),
"@public": path.resolve(__dirname, 'src/public'),
"@contexts": path.resolve(__dirname, 'src/contexts'),
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
//对于less文件的处理,这里需要注意loader的顺序是从下到上,
//并且这份配置只支持模块化(import styles from './index.less'这种形式),
//如果想要支持普通方式(import './index.less'),还需修改
test: /\.less$/,
use: [
{
loader: 'style-loader',
options: { esModule: false }
},
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[local]--[hash:base64:5]'
},
esModule: false,
}
},
'postcss-loader',
'less-loader'
],
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,//图片资源
type: 'asset/resource',
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,//字体图标资源
type: 'asset/resource',
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.html',//打包时产出的html文件使用这里定义的html模板
favicon: path.resolve(__dirname, 'src/public/favicon.ico'), // 添加这行
}),
new CleanWebpackPlugin(),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
new webpack.DefinePlugin({
'process.env': {//注入环境变量
NODE_ENV: JSON.stringify(mode),
...Object.fromEntries(
Object.entries(currentEnv).map(([key, value]) => [
key,
JSON.stringify(value)
])
)
}
})
].filter(Boolean),
optimization: {//生产环境打包体积优化
minimize: mode === 'production',
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
},
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
compress: true,
port: 8080,
hot: true,
proxy: [
{
context: ['/api'],
target: 'https://your.url.com',//后端接口地址,devServer帮你解决跨域问题
changeOrigin: true,
secure: false,
pathRewrite: { '^/api': '' },
},
]
}
};
};
以及项目的html模板和入口样式文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- 下面的meta标签参数是解决移动端键盘弹出引起页面缩放的问题 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Typex</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
//index.css
html,
body {
width: 100%;
height: 100%;
overscroll-behavior: none;
//这里是为了解决移动端部分浏览器的地址栏/工具栏遮挡页面的问题
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
最后是当项目完成之后,需要打包发布到测试/生产环境的脚本:
#!/bin/bash
# 获取当前脚本所在目录
SCRIPT_DIR=$(cd "$(dirname "$0")"; pwd)
# 远程服务器信息
REMOTE_USER="ubuntu"
REMOTE_HOST="16.your.host.address"
REMOTE_PATH="/var/www/html/h5/" # 这是服务器文件位置,需要和nginx配置保持同步
# 使用 rsync 将 dist 目录同步到远程服务器
rsync -avz --delete --exclude 'sdk/*' "${SCRIPT_DIR}/dist/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
那么项目的基本配置到此就结束了,还有一些例如eslint的配置因人而异,这里就不贴了 接下来就是项目内的逻辑代码的实现了,那么h5这块就已经完成了。
SDK
SDK这里主要是通过iframe连接到需要引入的页面(例如我们前面的h5页面),并暴露一些使用者可自定义的配置,SDK包提供cdn引入模式(不提供下载模式) 这里我们的SDK通过rollup打包,主要实现方式是:
- 封装一个类,用户使用时new出我们的实例
- 将需要给用户使用的方法暴露出来
- 将内部系统的iframe封装到类内部私有化,只提供方法调用,不允许修改
从下图可以看出,这个sdk的结构非常简单:
这里rollup配置也特别简单(如下),因为rollup是一个插件化的打包工具,所以大家使用的时候可能更多需要注意的是,当你需要某个功能的时候对于插件的选择,目前来讲我使用过的有一些插件官方不提供,社区支持又不够完善,这是一个比较麻烦的事情:
import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser';
import copy from 'rollup-plugin-copy';
export default {
input: 'src/index.ts',
output: [
{
file: './SDK/typeX.min.js',
format: 'iife',
name: 'TypeXSDK',
sourcemap: true
}
],
plugins: [
typescript(),
terser(),
copy({
targets: [
{ src: 'src/assets/icon.png', dest: 'SDK/assets' }, // 复制图像文件到 dist/assets
{ src: 'index.html', dest: 'SDK' }
],
hook: 'writeBundle' // 指定在何时复制文件
})
]
};
完成写逻辑功能的前置事项之后,来到我们主要的sdk类(这里我认为最麻烦的是:我们日常开发使用框架习惯了,通过原生来实现页面及样式会感觉有点吃力😥):
interface SDKOptions {
type: 'pc' | 'h5';
mode: 'icon' | 'normal';
iconUrl?: string;
iconPosition?: { bottom: string; right: string };
draggable?: boolean;
iframeSize?: { width: string; height: string };
iframePosition?: 'follow' | 'center'; // 新增
mask?: boolean; // 新增
draggableIframe?: boolean; // 新增
styles?: object; // 新增
showCloseIcon?: boolean;
source?: string; // 接入来源
code: string; // 登录状态交换
}
class DemoSDK {
private options: SDKOptions;
private icon: HTMLElement | null = null;
private iframe: HTMLIFrameElement | null = null;
private isDragging: boolean = false; // 添加标志以跟踪拖动状态
private iframeWrapper: HTMLElement | null = null; // iframe 的包裹层
private maskElement: HTMLElement | null = null; // 遮罩层
private dragOffsetX: number = 0; // 用于拖拽的偏移
private dragOffsetY: number = 0;
private isFullScreen: boolean = false; // 全屏状态标志;
static currentInstance: TypeXSDK | null = null; // 静态属性跟踪当前实例
constructor(options: Partial<SDKOptions>) {
if (TypeXSDK.currentInstance) {
TypeXSDK.currentInstance.destroy();
}
TypeXSDK.currentInstance = this; // 更新当前实例
this.options = {
type: 'h5',
mode: 'normal',
iconUrl: 'data:image/png;base64,就是一个url的base64编码==',
iconPosition: { bottom: '20px', right: '20px' },
draggable: false,
iframeSize: { width: '1080px', height: '768px' },
mask: false,
code: '',
draggableIframe: false,
iframePosition: 'follow', // 默认居中
showCloseIcon: false, // 默认不打开h5关闭按钮
styles: {},
...options
};
this.init();
}
private init(): void {
if (this.options.code.length < 1) {
console.error("TypeXSDK init failed, code is empty");
return
}
if (this.options.mode === 'icon') {
this.createIcon();
}
}
// 创建图标的函数
private createIcon(): void {
const icon = document.createElement('img');
icon.src = this.options.iconUrl!;
icon.style.position = 'fixed';
icon.style.bottom = this.options.iconPosition!.bottom;
icon.style.right = this.options.iconPosition!.right;
icon.style.width = '50px'; // Default icon size
icon.style.height = '50px';
icon.style.cursor = 'pointer';
icon.style.zIndex = '1000';
if (this.options.draggable) {
this.makeDraggable(icon);
}
document.body.appendChild(icon);
this.icon = icon;
}
private makeDraggable(element: HTMLElement): void {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
let threshold = 10; // 设置一个阈值,超过这个值才认为是拖拽
let moved = false; // 用于跟踪是否有显著移动
const dragMouseDown = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
// 判断是触摸事件还是鼠标事件,并据此获取位置
pos3 = 'touches' in e ? e.touches[0].clientX : e.clientX;
pos4 = 'touches' in e ? e.touches[0].clientY : e.clientY;
this.isDragging = false;
moved = false;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
document.ontouchend = closeDragElement;
document.ontouchmove = elementDrag;
}
element.onmousedown = dragMouseDown;
element.ontouchstart = dragMouseDown;
const elementDrag = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
let currentX = 'touches' in e ? e.touches[0].clientX : e.clientX;
let currentY = 'touches' in e ? e.touches[0].clientY : e.clientY;
pos1 = pos3 - currentX;
pos2 = pos4 - currentY;
// 如果移动的距离小于阈值,则还没有发生拖拽
if (!moved && Math.abs(pos1) < threshold && Math.abs(pos2) < threshold) {
return; // 不更新位置,也不视为拖拽
}
moved = true; // 一旦移动超过阈值,标记为拖拽
this.isDragging = true; // 更新拖拽状态为真
pos3 = currentX;
pos4 = currentY;
element.style.top = (element.offsetTop - pos2) + "px";
element.style.left = (element.offsetLeft - pos1) + "px";
}
const closeDragElement = (e: any) => {
document.onmouseup = null;
document.onmousemove = null;
document.ontouchend = null;
document.ontouchmove = null;
// 检查是否发生了显著移动,如果没有则执行点击逻辑
if (!moved) {
this.toggleIframe(e);
}
// 重置移动标志
moved = false;
}
}
// 处理iframe的显示和隐藏
private toggleIframe(e: any): void {
if (!this.iframeWrapper) {
if (this.options.type === 'h5') {
this.createIframe();
} else {
this.createIframeWrapper(e);
}
if (this.icon) {
this.icon.style.display = 'none';
}
} else {
const isVisible = this.iframeWrapper.style.display !== 'none';
this.iframeWrapper.style.display = isVisible ? 'none' : 'block';
if (this.maskElement) {
this.maskElement.style.display = isVisible ? 'none' : 'block';
}
if (this.icon) {
this.icon.style.display = isVisible ? 'block' : 'none';
}
}
}
private createIframe(): void {
if (!this.iframeWrapper) {
this.iframeWrapper = document.createElement('div');
this.iframeWrapper.style.position = 'fixed';
this.iframeWrapper.style.top = '0';
this.iframeWrapper.style.left = '0';
}
if (this.options.mode === 'normal' || this.options.type === 'h5') {
this.iframeWrapper.style.width = '100vw';
this.iframeWrapper.style.height = '100vh';
}
this.iframeWrapper.style.backgroundColor = 'rgba(0,0,0,0.5)';
this.iframeWrapper.style.zIndex = '999';
this.iframe = document.createElement('iframe');
this.iframe.src = this.getUrl();
this.iframe.style.width = '100%';
this.iframe.style.height = '100%';
this.iframe.style.border = 'none';
this.iframe.style.border = 'none';
document.body.appendChild(this.iframeWrapper);
this.iframeWrapper.appendChild(this.iframe);
if (this.options.type === 'h5' && this.options.showCloseIcon) {
const closeButton = document.createElement('div');
closeButton.style.position = 'absolute';
closeButton.style.top = '20px';
closeButton.style.right = '20px';
closeButton.style.width = '20px';
closeButton.style.height = '20px';
closeButton.style.color = '#ffffff';
closeButton.style.zIndex = '1000';
closeButton.style.display = 'flex';
closeButton.style.justifyContent = 'center';
closeButton.style.alignItems = 'center';
const close = document.createElement('img');
close.src = "data:image/png;base64,这里也是一个url的base64编码=";
close.style.width = '20px';
close.style.height = '20px';
closeButton.appendChild(close);
closeButton.style.display = 'flex';
closeButton.style.justifyContent = 'center';
closeButton.style.alignItems = 'center';
closeButton.style.fontSize = '20px';
closeButton.addEventListener('click', () => {
this.destroy();
})
this.iframeWrapper.appendChild(closeButton);
}
}
// 创建蒙层和iframe的容器
private createIframeWrapper(e: any): void {
// 创建蒙层
if (this.options.mask) {
this.maskElement = document.createElement('div');
this.maskElement.style.position = 'fixed';
this.maskElement.style.top = '0';
this.maskElement.style.left = '0';
this.maskElement.style.width = '100vw';
this.maskElement.style.height = '100vh';
this.maskElement.style.backgroundColor = 'rgba(0,0,0,0.5)';
this.maskElement.style.zIndex = '999';
document.body.appendChild(this.maskElement);
}
// 创建 iframe 包裹层
this.iframeWrapper = document.createElement('div');
this.iframeWrapper.style.position = 'fixed';
this.iframeWrapper.style.zIndex = '1000';
this.iframeWrapper.style.width = this.options.iframeSize!.width;
this.iframeWrapper.style.height = this.options.iframeSize!.height;
this.applyIframePosition(e); // 应用 iframe 的位置设置
Object.assign(this.iframeWrapper.style, this.options.styles);
document.body.appendChild(this.iframeWrapper);
this.createIframe()
this.createControls(); // 创建顶部控制条
if (this.options.draggableIframe) {
this.makeIframeDraggable();
}
}
// 创建顶部控制栏,支持关闭和全屏
private createControls(): void {
const controls = document.createElement('div');
controls.style.position = 'absolute';
controls.style.top = '0';
controls.style.left = '0';
controls.style.right = '0';
controls.style.height = '30px';
controls.style.backgroundColor = 'rgba(255,255,255, 0)';
controls.style.color = '#000000';
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.justifyContent = 'space-between';
controls.style.padding = '0 10px';
controls.style.cursor = 'move'; // 使控制栏可拖拽
const closeButton = document.createElement('div');
closeButton.style.width = '30px';
closeButton.style.height = '30px';
closeButton.style.position = 'absolute';
closeButton.style.top = '0';
closeButton.style.right = '20px';
closeButton.style.zIndex = '1';
closeButton.style.display = 'flex';
closeButton.style.justifyContent = 'center';
closeButton.style.alignItems = 'center';
closeButton.style.cursor = 'pointer';
const close = document.createElement('img');
close.src = "data:image/png;base64,这里也是一个url的base64编码";
close.style.width = '20px';
close.style.height = '20px';
closeButton.appendChild(close);
closeButton.onclick = () => this.destroy();
const fullscreenButton = document.createElement('div');
fullscreenButton.style.width = '30px';
fullscreenButton.style.height = '30px';
fullscreenButton.style.position = 'absolute';
fullscreenButton.style.top = '0';
fullscreenButton.style.right = '50px';
fullscreenButton.style.zIndex = '1';
fullscreenButton.style.display = 'flex';
fullscreenButton.style.justifyContent = 'center';
fullscreenButton.style.alignItems = 'center';
fullscreenButton.style.cursor = 'pointer';
const fullscreen = document.createElement('img');
fullscreen.src = "data:image/png;base64,这里也是一个url的base64编码==";
fullscreen.style.width = '20px';
fullscreen.style.height = '20px';
fullscreenButton.appendChild(fullscreen);
fullscreenButton.onclick = () => {
if (!this.isFullScreen) {
this.iframeWrapper!.style.width = "100vw";
this.iframeWrapper!.style.height = "100vh";
this.iframeWrapper!.style.top = "0";
this.iframeWrapper!.style.left = "0";
this.isFullScreen = true
} else {
this.iframeWrapper!.style.width = this.options.iframeSize!.width;
this.iframeWrapper!.style.height = this.options.iframeSize!.height;
this.isFullScreen = false
}
};
controls.appendChild(closeButton);
controls.appendChild(fullscreenButton);
this.iframeWrapper?.appendChild(controls);
}
// 应用iframe的位置:居中或跟随
private applyIframePosition(e: any): void {
if (this.options.iframePosition === 'center') {
this.iframeWrapper!.style.top = '50%';
this.iframeWrapper!.style.left = '50%';
this.iframeWrapper!.style.transform = 'translate(-50%, -50%)';
} else if (this.options.iframePosition === 'follow') {
// this.iframeWrapper!.style.right = '20px';
// this.iframeWrapper!.style.bottom = '20px';
const mouseX = e.clientX;
const mouseY = e.clientY;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
const width = parseInt(this.options.iframeSize!.width);
const height = parseInt(this.options.iframeSize!.height);
let left = mouseX;
let top = mouseY;
// 边界处理
if (mouseX + width > screenWidth) {
left = screenWidth - width;
}
if (mouseY + height > screenHeight) {
top = screenHeight - height;
}
this.iframeWrapper!.style.top = `${top}px`;
this.iframeWrapper!.style.left = `${left}px`;
}
}
// 使iframe可拖拽
private makeIframeDraggable(): void {
const controls = this.iframeWrapper!.querySelector('div');
let dragging = false;
const mouseDown = (e: MouseEvent) => {
dragging = true;
this.dragOffsetX = e.clientX - this.iframeWrapper!.offsetLeft;
this.dragOffsetY = e.clientY - this.iframeWrapper!.offsetTop;
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseUp);
};
const mouseMove = (e: MouseEvent) => {
if (dragging) {
this.iframeWrapper!.style.left = `${e.clientX - this.dragOffsetX}px`;
this.iframeWrapper!.style.top = `${e.clientY - this.dragOffsetY}px`;
}
};
const mouseUp = () => {
dragging = false;
document.removeEventListener('mousemove', mouseMove);
document.removeEventListener('mouseup', mouseUp);
};
controls!.addEventListener('mousedown', mouseDown);
}
private getUrl(): string {
const urls = {
h5: `https://your.project.com/?user_code=${this.options.code}${this.options.source ? `&source=${this.options.source}` : ''}`,
pc: `https://your.project.com/?user_code=${this.options.code}${this.options.source ? `&source=${this.options.source}` : ''}`
};
return urls[this.options.type];
}
// 移除iframe和遮罩层
public destroy(): void {
if (this.icon) {
this.icon.style.display = 'block'; // 重新显示图标
}
if (this.maskElement) {
this.maskElement.remove();
this.maskElement = null;
}
if (this.iframeWrapper) {
this.iframeWrapper.remove();
this.iframeWrapper = null;
}
TypeXSDK.currentInstance = null;
}
public showDemo(): void {
if (!this.iframe || this.options.code.length > 0) {
this.createIframe()
} else {
console.error('DemoSDK init failed, code is empty')
}
}
}
export default DemoSDK;
总结
对于整个项目的搭建到这里就结束了,我觉得像配置文件之类的实现哪里都能搜到,关键在于当我们遇到这样的情况时,如何去构思,如何去选择。 最后,感谢阅读。