前端架构: 低代码、IDE框架,如何让页面变得更加灵活?

802 阅读8分钟

今天聊聊架构方面的思考,最近产品厚着脸提了个不受待见的需求:你看我们X平台的页面太多,我查找数据得在几个页面跳来跳去,但是呢,每个页面长的都差不多,能不能把功能聚合到主页面上,通过角色权限等方式控制?我邪魅一笑说道(骂道):没问题。(你丫的给我讲历史是不,还天下分久必合?早干嘛去了?)

image.png

下图为例,实际页面会复杂很多,但不影响框架设计思路。

image.png

需求梳理:

  • 页面布局灵活可变,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-1p-2等。

image.png

vscode插件Tailwind CSS IntelliSense已帮我们解决,安装插件后在settings.json添加配置:

"editor.quickSuggestions": { "strings": "on" },

如果不熟悉tailwind的,还可以通过线上地址查询class。

image.png

如何动态布局

动态布局类似于低代码生成场景,开源的低代码框架有阿里的LowCodeEngine、百度的Amis、腾讯的tmagic-editordooring-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:

image.png

自身需求要实现的界面相对简单,直接引入一套低代码框架,完全是大材小用,但通过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-headerdynamic-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、模块之间的解耦? image.png

对于API模式,部分研发选择的方案是通过EventBus注册事件再通知事件的方式实现,但这种方式的结局是谁都不知道哪些地方在监听事件,排查问题或者重构起来真的是举步维艰。

这种需求场景,类似IDE开源的框架中比较多见,例如vscodetheiaopen-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,非常适合动态布局这类场景,研发在布局上不用花太多的功夫考虑自定义样式。

动态布局可参考LowCodeEngineAmistmagic-editordooring-electron-lowcode等低代码框架,而模块解耦可参考vscodetheiaopensumi等类IDE开源框架。

题外话:相信大部分前端工程师都有这样的同感,当你在项目中开发了几十个模块之后恍然大悟,天天都在机械地干同样的事。自己和驴别无区别,天天围着磨转,大环境每况愈下,自己会不会同样经历"卸磨杀驴"的遭遇?研发要如何保住自己的饭碗?打铁还得自身硬,你得在公司体现自我价值,往小了说,团队需要你,往大了说,公司需要你。

我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!