今天聊聊架构方面的思考,最近产品厚着脸提了个不受待见的需求:你看我们X平台的页面太多,我查找数据得在几个页面跳来跳去,但是呢,每个页面长的都差不多,能不能把功能聚合到主页面上,通过角色权限等方式控制?我邪魅一笑说道(骂道):没问题。(你丫的给我讲历史是不,还天下分久必合?早干嘛去了?)
下图为例,实际页面会复杂很多,但不影响框架设计思路。
需求梳理:
- 页面布局灵活可变,A看到的是布局一,B看到的是布局二,不同人看到的布局不一样;
- 每个模块的功能动态扩展,例如面板
Left Panel
,包含10+功能,并支持后续扩展; - 交互设计得考虑冲突,例如同时打开
Toolbar
下的功能A、B,都会影响Canvas上的交互,得考虑功能间的互斥
;
总结出几个关键词:动态布局、权限控制、功能扩展、交互优先级。
项目使用Vite
创建。为了提升开发效率,推荐使用Cusor
IDE,大部分功能直接丢给AI写。由于需要动态布局,css更适合使用tailwindcss
。
项目准备
Vite创建项目
使用vite
指令创建项目,选择Vue、TypeScript。
npm create vite@latest
要支持动态布局,jsx
写法更合适,因此需要安装vite插件@vitejs/plugin-vue-jsx
。
npm install -D @vitejs/plugin-vue-jsx
在vite.config.js
文件配置插件:
export default defineConfig({
plugins: [
vue(),
vueJsx()
],
})
tsconfig.json添加:
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue" //
...
}
安装tailwindcss
安装tailwindcss
:
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
生成配置文件:
npx tailwindcss init -p
执行完毕后,会在根目录自动生成配置文件tailwind.config.js
and postcss.config.js
。
在tailwind.config.js
文件中将purge: []
替换为:
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
新建css文件./src/index.css
, 使用指令@tailwind
包含tailwinds
的base、components、utilities样式。
/* ./src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
最后在main.ts
文件中引入index.css文件。
import './index.css'
现在,可以放心在项目中使用tailwind
的style了。但配置还没结束,在写class当然是希望有智能提示,例如输入字母p
,hi提示p-1
、p-2
等。
vscode插件Tailwind CSS IntelliSense
已帮我们解决,安装插件后在settings.json
添加配置:
"editor.quickSuggestions": { "strings": "on" },
如果不熟悉tailwind
的,还可以通过线上地址查询class。
如何动态布局
动态布局类似于低代码
生成场景,开源的低代码框架有阿里的LowCodeEngine
、百度的Amis
、腾讯的tmagic-editor
、dooring-electron-lowcode
等等。低代码框架一般会提供设计页面,通过拖拽方式设计完成后,会生成相应的JSON Schema
,最后再通过低代码引擎加JSON Schema
渲染为最终页面。
例如腾讯的tmagic-editor
提供类似于如下的JSON Schema
:
[
{
text: "文本",
name: "text"
},
{
type: "number",
text: "计数器",
name: "number"
},
{
type: "row",
items: [
{
type: "date",
text: "日期",
name: "date"
},
{
type: "checkbox",
text: "多选框",
name: "checkbox"
}
]
},
{
type: "fieldset",
name: "fieldset",
legend: "分组",
items: [
{
type: "select",
text: "下拉选项",
name: "select",
options: [
{
text: "选项1",
value: 1
},
{
text: "选项2",
value: 2
}
]
}
]
}
]
对应的UI:
自身需求要实现的界面相对简单,直接引入一套低代码框架,完全是大材小用,但通过JSON Schema
+ Render Engine
这套模式值得参考。
定义JSON Schema
假如有一个已实现的组件,可以反过来考虑如何将其使用JSON Schema
表达,先定义几个布局需要的组件或Layout:
- root-layout:根layout;
- dynamic-layout:动态layout, 包含权限验证等功能;
- draggable-panel:支持大小可调整的panel面板,例如
Left Panel
支持宽度调整; - my-header: Header组件
- tree:
Left Panel
包含的树组件; - toolbar: 工具栏组件;
- my-map: 地图组件;
假设由以上组件组成的template为:
<root-layout class="h-full flex flex-col">
<dynamic-layout layout-id="module:header">
<my-header class="h-10 border-2"></my-header>
</dynamic-layout>
<dynamic-layout class="flex flex-1" layout-id="module:content">
<dynamic-layout class="border-2">
<draggable-panel>
<tree class="w-20"></tree>
</draggable-panel>
</dynamic-layout>
<dynamic-layout class="flex flex-1 border-2">
<dynamic-layout class="flex-1 border-2">
<dynamic-layout layout-id="module:toolbar">
<toolbar></toolbar>
</dynamic-layout>
<dynamic-layout layout-id="module:map">
<my-map></my-map>
</dynamic-layout>
</dynamic-layout>
<dynamic-layout layout-id="module:extend">
<span>扩展</span>
</dynamic-layout>
</dynamic-layout>
</dynamic-layout>
</root-layout>
分析template,除了使用的组件component外,还包含class、layout-id等属性,以及children层级关系,考虑通用性,可将属性提炼为:
- component: 组件类型
- class:样式class集合
- attrs: 元素属性
- props: 组件Props
- children: 子组件列表
也就是说每个元素可由component、class、layout-id、children等属性组成,将其配置为如下的JSON Schema
:
export default {
component: 'RootLayout',
class: 'h-full flex flex-col',
children: [
{
component: 'DynamicLayout',
attrs: { 'module-id': 'module:header' },
children: [
{
component: 'MyHeader',
class: 'h-10 border-2'
}
]
},
{
component: 'DynamicLayout',
attrs: { 'module-id': 'module:content' },
class: 'flex flex-1',
children: [
{
component: 'DynamicLayout',
class: 'border-2',
auth: true,
children: [
{
component: 'DraggablePanel',
class: 'h-full',
children: [
{
component: 'Tree',
class: 'w-20'
}
]
}
]
},
{
component: 'DynamicLayout',
class: 'flex flex-1 border-2',
children: [
{
component: 'DynamicLayout',
class: 'flex-1 border-2',
children: [
{
component: 'DynamicLayout',
attrs: { 'module-id': 'module:toolbar' },
children: [
{
component: 'Toolbar'
}
]
},
{
component: 'DynamicLayout',
attrs: { 'module-id': 'module:map' },
children: [
{
component: 'MyMap'
}
]
}
]
},
{
component: 'DynamicLayout',
attrs: { 'module-id': 'module:extend' },
children: [
{
component: '<span>扩展</span>',
}
]
}
]
}
]
}
]
}
通过修改、扩展以上的配置,可方便地实现想要的功能。接下来的事就是将JSON Schema
交给Render Engine
。
Render Engine
要将上文的JSON Schema
渲染为组件,需提供render函数,函数定义如下:
export function render(config: any) {
const { component, props = {}, class: className, attrs = {}, children = [] } = config
const mergedProps = {
...props,
class: className,
...attrs
}
...
}
component属性包含三种情况:
- 自定义组件:如
my-header
、dynamic-layout
等; - 文件路径:
./components/tree.vue
; - 原生html: 如
<span>扩展</span>
;
提供resolveComponent(component)
函数,查找自定义组件,例如my-header、tree、my-map等。如果未找到自定义组件,则直接将其渲染为html。逻辑如下:
const Component = resolveComponent(component);
if (Component) {
return <Component {...mergedProps}>
{children.map((c: any) => render(c) )}
</Component>;
}
return <div props={mergedProps} {...mergedProps} v-html={component}></div>
resolveComponent
函数需要支持组件名称、组件路径、原生html渲染,实现如下:
export function resolveComponent(type: string) {
if (!type) {
return
}
let component = componentMap.get(type)
if (component) {
return component
}
if (type.endsWith('.vue')) {
component = import(`./${type}`)
.then(c => {
componentMap.set(type, c);
});
return component
}
return component
}
以上实现的渲染引擎render
函数,还处于alpha版本,需求实现过程再不断扩展。
命令模式实现解耦
先回到UI,Toolbar中包含非常多的编辑工具,当点击某个工具时,会开启Canvas
的各种绘制功能,如何实现UI、模块之间的解耦?
对于API模式,部分研发选择的方案是通过EventBus
注册事件再通知事件的方式实现,但这种方式的结局是谁都不知道哪些地方在监听事件,排查问题或者重构起来真的是举步维艰。
这种需求场景,类似IDE开源的框架中比较多见,例如vscode
、theia
、open-sumi
,这些框架都会基于命令模式对模块外发送消息或者接受消息。
我们就以代码格式化
为例:在工具栏中选择格式化
菜单,当前激活的代码文件将被格式化。框架上会涉及几个概念:
- Service: 格式化代码的服务
- Contribution: 贡献点,例举的场景可理解为格式化菜单项
- EditComponent:编辑组件
- Command:命令
以下几部分代码都为伪代码,提供命名模式的实现思路,仅供参考。
FormatterService
Service使用依赖注入引入类型为CommandRegistry的对象commandRegistry
,通过命名就可看出其作用就是注册ID为editor.format
的命令,为其提供execute
方法,调用this.formatCode()
格式化代码并返回格式化结果。
import { injectable } from 'inversify';
import { CommandRegistry } from '@core/command-registry';
@injectable()
export class FormatterService {
constructor(
@inject(CommandRegistry) private commandRegistry: CommandRegistry
) {
this.registerCommands();
}
private registerCommands() {
this.commandRegistry.registerCommand('editor.format', {
execute: () => this.formatCode()
});
}
formatCode() {
// 实现代码格式化逻辑
console.log('代码已格式化');
}
}
Service注册editor.format
命令后,可通过菜单触发执行。
FileMenuContribution
package @ui/menu
提供有MenuModeRegistry,其作用是提供menu行为注册,调用registeMenuAction
可在菜单上显示label为格式化代码
的选项,点击菜单时触发commandId对应的Command。
import { injectable } from 'inversify';
import { MenuModelRegistry } from '@ui/menu';
@injectable()
export class FileMenuContribution {
constructor(
@inject(MenuModelRegistry) private menuRegistry: MenuModelRegistry
) {
this.registerMenuItems();
}
private registerMenuItems() {
this.menuRegistry.registerMenuAction('file', {
commandId: 'editor.format',
label: '格式化代码'
});
}
}
CodeEditorComponent
到目前格式化触发、生成格式化逻辑都有了,剩下的是将格式化结果显示到Editor
中。CodeEditorComponent
属于UI模块,可通过MessageBus
来监听指令,例如通过this.messageBus.on('editor.format', () => {...})
来监听ID为editor.format
的命名,当editor.format
命名被触发,则能够拿到格式化结果result
。
import { injectable } from 'inversify';
import { MessageBus } from '@core/message-bus';
@injectable()
export class CodeEditorComponent {
constructor(
@inject(MessageBus) private messageBus: MessageBus,
@inject(FormatterService) private formatterService: FormatterService
) {
this.subscribeToFormatCommand();
}
private subscribeToFormatCommand() {
this.messageBus.on('editor.format', (result) => {
this.formatCurrentDocument(result);
});
}
private formatCurrentDocument(formattedContent: string) {
// 更新编辑器内容
this.updateContent(formattedContent);
}
// ... 其他方法
}
CommandExecutor
命令的触发统一交给CommandExecutor
, 先通过getCommand
获取命名对象,在执行command的executeCommand
函数。
import { injectable } from 'inversify';
import { CommandRegistry } from '@core/command-registry';
import { MessageBus } from '@core/message-bus';
@injectable()
export class CommandExecutor {
constructor(
@inject(CommandRegistry) private commandRegistry: CommandRegistry,
@inject(MessageBus) private messageBus: MessageBus
) {}
executeCommand(commandId: string) {
const command = this.commandRegistry.getCommand(commandId);
if (command) {
command.execute();
this.messageBus.emit(commandId);
}
}
}
总结
实话实说,Cursor
IDE在AI方面的优势体现的很明显,像UI交互或者通用功能,Cursor
几乎能达到80%的功能覆盖,真是开发提效的好帮手,小伙伴们赶紧用起来吧。
tailwindcss
提供了丰富的layout style,非常适合动态布局这类场景,研发在布局上不用花太多的功夫考虑自定义样式。
动态布局可参考LowCodeEngine
、Amis
、tmagic-editor
、dooring-electron-lowcode
等低代码框架,而模块解耦可参考vscode
、theia
、opensumi
等类IDE开源框架。
题外话:相信大部分前端工程师都有这样的同感,当你在项目中开发了几十个模块之后恍然大悟,天天都在机械地干同样的事。自己和驴别无区别,天天围着磨转,大环境每况愈下,自己会不会同样经历"卸磨杀驴"的遭遇?研发要如何保住自己的饭碗?打铁还得自身硬,你得在公司体现自我价值,往小了说,团队需要你,往大了说,公司需要你。
我是
前端下饭菜
,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!