前言
大家好,这些年来设计系统一直停留在历史的某个时间点,缺乏创新,缺乏活力,难以有让人眼前一亮的东西展现,但它并没有停滞不前,我们一直致力于从一些独特的角度重新审视和认识设计系统,通过梳理和理解组件的本质,抽象原子化组件,严格执行展示层与交互层的分离方式,使组件能够更好的融合跨端设计,同时深度结合设计语义与前端代码,确保整个系统的交互和代码唯一性,目前我们已有一套基础设施对外开源,欢迎大家的持续关注。 今天这篇文章的主题主要是与大家探讨一下我们的跨端设计实现。
问题是什么?
回想一下工作中你是否也遇到过如下场景
当我们开发了一套PC端的管理系统提供给客户使用,客户因为一些不可抗拒因素产生了一点点想法:
- 客户因为办公地点不方便配备电脑,要求一套适配手机端的管理系统。
- 销售团队给老板现场演示,使用平板电脑,要求我们适配平板电脑。
- 总部使用电脑端开单,而门店使用小程序入库。
- 前端同时维护多端,设计一次改动需要同步多端。
当你遇到这样的问题,你该怎么办?
- 放弃这类场景或用户?这基本是不可行的,难以说服,并且你同意老板也不会同意。
- 选择独立维护多端代码?同样的功能需要实现多遍,不管是开发量还是后期的维护量,都足以摧毁研发最后一根头发。
- 在同一套代码上简单的做响应式适配?既达不到很好的效果,同时也增加了代码的维护难度,复杂系统堪称地狱级难度。
那么是否有一套技术方案可以解决上面的问题?答案是有,在我们长期服务于B端管理系统建设的踩坑过程中,已经梳理并验证了一套行之有效的解决方案,也就是我们正在构建跨端组件设计系统。
什么是跨端组件设计系统?
我将通过一个组件(Select)案例对此有一个初步的理解,试着思考一下组件在不同端的交互区别?在 PC 电脑端,一个下拉组件通常是由一个触发器和一个下拉面板组成的交互组件,当我们来到手机端,同样功能的下拉组件交互形式发生了变化,最主要的区别在于下拉面板转变成了弹出面板,以及一些细节上的变化,但组件的本质没有变,依旧是一个触发器结合选择面板。
而我们的跨端组件设计系统最原本的目标是提供一套统一的组件定义,同时满足多个端的交互差异,实现开发人员不用在适配多端,写一遍代码,多端去使用的目的,就如同下面代码所展示的编写规则以及它在不同的端所呈现出来的交互方式:
tsx
function () {
return (
<Form>
<FormItem label="Select" name="select">
<Select data={types} />
</FormItem>
</Form>
)
}

为什么之前没有?
那么回过头来,既然存在这样的问题,为什么截止目前市面上没有相关的解决方案?出于自身现有的认知,我梳理了几点可能的原因:
- 这样的场景并非普遍性,可能仅仅是极小部分业务需要,并没有痛到那么痛?
- 当我们实现了多端的交互体验,如何能做到资源不浪费,每个端都各自仅加载端所必要的资源?
- 一套代码实现多端呈现,如何才能让开发没有端的感知,如同仅编写 PC 一样?
- 跨端的规则应该如何制定才能达到普适性?这是一个巨大的挑战。
这些问题就是我们这套解决方案所需要面临和解决的问题。
解决方案是什么?
我们有了前面的基础理解,我将由组件(Select)使用方角度出发,介绍整个解决方案。
跨端基础设施的落地
首先,我们期望的理想的组件使用方式如下:
tsx
// 引入下拉组件
import { Select } from "@/components/select";
function Example() {
const data = [
{ label: 'Option1', value: 'option1' },
{ label: 'Option2', value: 'option2' },
];
// 使用组件并且无端的感知
return <Select defaultValue="option1" data={data} />
}
整个使用过程中,用户不会感知到自己正在完成一个业务跨端项目,就如同以前一样,以为自己只是在完成某个产品,这很好,也是我们持续朝向的一个方向。
我们设想,它一定拥有着一套针对多端的统一使用规范,对应着代码中的组件属性定义:
ts
interface Option {
label: string;
value: string;
}
// 这里的定义只是为了阐述方案写的一个简化版本,实际组件会有更多的定义。
interface SelectProps {
// 值
value?: string;
// 默认值
defaultValue?: string;
// 下拉框数据
data?: Option[];
}
同时一个 Select
组件的背后需要承载多个端的交互规则,考虑到每个端都拥有自己独一无二的交互体验,期望在一套代码中实现多套交互,这已经相当的不合理,不仅会导致资源增大,同时还会使组件过于臃肿难以维系,因此它一定是满足由多套代码独立实现,统一使用的规则,基于这层规则,一个组件的目录结构也就清晰明了了:
ts
src/components
├── select 组件根目录,基于约定方式构建跨端组件
| ├── pc 约定为PC端的组件实现
| | ├── Select.tsx
| | ├── Select.scss
| | └── index.ts 统一的资源导出文件
| ├── mobile 约定为Mobile端的组件实现
| | ├── Select.tsx
| | ├── Select.scss
| | └── index.ts 统一的资源导出文件
| └── button.ts 组件的统一定义,不同端我们都需要遵循统一的组件定义,以保证业务使用的统一性
接下来我们需要考虑的是如何在运行时根据用户的浏览器环境判断端类型,从而返回对应的组件资源,通过直觉能够快速想到的方式是在组件根目录下新增 index.ts
文件,通过条件选择导出不同的端资源:
ts
import { exportCrossComponent } from "@/src/core";
import PCSelect from "./pc";
import MobileSelect from "./mobile";
// 导出跨端组件
export const Select = exportCrossComponent(PCSelect, MobileSelect);
咋一看,这样是满足了先前所期望的用户使用行为,但是仔细一想会发现,它触犯了之前章节所列出的问题,如何能做到资源不浪费?
上面的方案在打包过程中 PC 和移动端的资源都会被打成一个 Bundle 资源包,因此也没办法做到根据端去加载对应的资源,我们需要将组件的资源包按照端纬度独立打包,回顾一下现有社区方案发现 Module Federation
与我们的方案契合,如果对这项技术没有基本概念的,请先自行学习一下。
基于这个思路,我们开始将跨端组件独立成一个共享组件项目,这个项目命名为 wis
, 然后在业务项目 application
中注册远程项目 wis
并导入,并且我们简化了一部分 Module Federation
的配置,具体的细节不在本文章讨论范围内,不做细化,大致如下:
ts
// 业务应用提炼的配置文件
// application/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";
export default const config: WisConfig = {
remotes: {
// 注册wis项目,这样我们就可以在本项目中使用
// 这里假设wis项目监听在4000端口上
wis: "http://localhost:4000",
},
};
因此相应的之前的使用方式产生了变化,如下所示:
tsx
// 主要是这里产生了变化,导入的方式也变化了,通过共享的项目中导入
import { Select } from "wis/select";
function Example() {
const data = [
{ label: 'Option1', value: 'option1' },
{ label: 'Option2', value: 'option2' },
];
return <Select defaultValue="option1" data={data} />
}
接下来就顺利了起来,我们来看看 wis
项目中组件是如何导出的:
ts
// 业务应用提炼的配置文件
// wis/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";
export default const config: WisConfig = {
// 对外共享的名称
name: "wis",
// 配置导出的组件
exposes: {
"./select/pc": "./src/components/select/pc",
"./select/mobile": "./src/components/select/mobile",
},
};
这样已经达到了将组件按照端的纬度进行打包导出,但这样的注册方式不完美,存在割裂感,不像是一个完整的组件体系,只是正好两个导出名都包含了组件名 ./select/pc
./select/mobile
用户完全可以很随意的改成 /selectPc
./selectMobile
这并不存在规则,不利于在运行时检查端类型,匹配对应的端资源。
因此我们继续探索,通过配置解析功能,形成了最终的导出格式:
ts
// 业务应用提炼的配置文件
// wis/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";
export default const config: WisConfig = {
// 对外共享的名称
name: "wis",
// 配置导出的组件
exposes: {
"./select": {
pc: "./src/components/select/pc",
mobile: "./src/components/select/mobile",
},
},
};
通过配置转换器,我们依旧可以获得如下配置,然后交由 Module Federation
进行解析处理:
ts
{
"./select/pc": "./src/components/select/pc",
"./select/mobile": "./src/components/select/mobile",
}
这样就确保了配置的关联性和一致,一切就绪,只欠东风,而这里的东风指的是运行时如何才能确保加载正确的端资源,要实现这个能力,我们需要借助 Module Federation
中的一个运行时插件机制,在模块解析完成后,获取模块 Id,同时获取当前用户运行的浏览器端信息,也就是浏览器 UA
标识,主动拼接跨端模块 id,在模块列表中查找,存在则修改当前模块 id 为对应的端模块 id,否则什么也不做,大致的代码流程如下:
ts
import type { FederationRuntimePlugin } from "@module-federation/enhanced/runtime";
interface RemoteModule {
modulePath: string;
moduleName: string;
}
type RuntimePlugin = () => FederationRuntimePlugin;
const crossPlugin: RuntimePlugin = () => {
return {
name: "cross-plugin",
afterResolve(data) {
// 获取当前所有模块的列表
const modules: RemoteModule[] = data.remoteSnapshot?.modules || [];
// 获取UA标识,如pc / mobile / pad
// 这个函数的实现在这里就省略了
const agent = getBrowerAgent();
// 端模块id 比如 ./select/pc
const crossModuleExpose = `${data.expose}/${agent}`;
// 匹配当前模块是否存在端模块
const isMatched = modules.some(mod => {
return mod.modulePath === crossModuleExpose
});
// 匹配到端模块,修改为当前端的模块
if (isMathced) {
data.expose = moduleCrossExpose;
}
return data;
},
};
};
到这里,一套完整的跨端解决方案基础设施有了,我们只剩下最后一个问题。
跨端组件一定要单独起一个项目么,不能和业务代码放在一个项目中么?
跨端组件支持自引用模式,你可以像下面一样使用自己导出的跨端组件:
ts
// 业务应用提炼的配置文件
// application/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";
export default const config: WisConfig = {
// 配置共享的名称
name: "application",
// 配置导出的组件
exposes: {
"./com": {
pc: "./src/components/com/pc",
mobile: "./src/components/com/mobile",
}
},
};
使用方式:
tsx
// application/pages/example/Example.page.tsx
// 从自身项目中引入
import { Com } from "application/com";
function Example() {
return <Com />
}
跨端的规则应该如何制定才能达到普适性?
这是一个相对比较宽泛的问题,这里面所涉及到的细节非常多,牵涉到每一个组件本质抽象以及规则定制,在整个生态组件的构建过程中我们一直遵循着一套基本原则,基于本质理解,组件的规则和属性不在是基于表象,也就是说不应该基于样式定义,而是更加底层的原则,落在每一个组件中理解组件实际是什么,它在整个系统中所起到的作用和定位,这个将会贯穿在我们整个 wis
组件库的开发过程中,最终通过结果反过来看它的定义是否达到普适性,也是我们与其它社区组件库之间的一个巨大差异点,这一部分不在这篇文章中详细讨论,后期考虑单独写一篇文章做介绍。
为了帮助大家更好的理解,我们起了一个项目,目的是通过一个简单的 Todo List
应用演示跨端组件的构建过程以及所遵循的原则,同时也是快速理解这套方案一个有效方法。
Demo 访问地址:demo.wis.design/#/todo 访问该地址,并尝试在 PC 和手机端访问以查看不同的交互效果。 Demo 源码地址:github.com/wisdesignsy...
总结
感谢你能静下心来读完整个方案,即使该方案可能对你来说还没有场景,我相信该方案也会对你产生一些启发。我们目前已经完成了基础设施的建设,正在逐步完成跨端组件库的建设,现阶段里程碑目标主要集中在打造 PC 端的组件规则使其能够满足现有业务单据的需求,移动端的建设还未开始,目前已经将现有的代码开源出来,如果你对我们的方案和目标感兴趣,请关注我们,大家的每一个支持和 star
都将给予我们巨大的动力前行,我们也一直在寻找设计师、工程师来帮助我们修复错误、构建新组件、书写项目文档,想要了解更多信息,你可以访问我们的官网wis.design 或者关注我们的项目仓库 github.com/wisdesignsy...