🌋 WebIDE 的开发记录其二(核心架构)
[图文无关]
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
基础架构就是实现什么核心功能,搭建一个什么样的接口,定义什么样的扩展开发方式。核心的开发模式很多,我选择的也并不是最优解,只是这个项目的实现,最后结果至少还不错,下面从三个方面说一下通用的层面做了哪些工作。
每个 extension 里面都按照标准接口定义,注册数据模型、UI 渲染和命令机制。
- 【数据模型】需要对扩展内的全部数据进行管理,包括网络数据请求本地数据更新等;
- 【UI 渲染】需要根据全局的数据模型和本扩展数据,在 pannel 里面渲染具体的界面;
- 【命令机制】是注册到全局的命令函数,可以在任何地方被调用,执行一系列动作来修改数据模型。
这样三个机制能够保证数据的正常流转,很多这样的扩展完成了整个大项目的更新:
用户操作执行命令 -> 命令修改数据模型 -> 数据模型响应式的通知 UI 层渲染
这三个基础架构的开发,反复推敲花了一些时间,但是后续的整个开发模式更加清楚,功能的添加都是水平扩展,能效更高,而且为之后的插件机制提供了很好的切入点。
大型项目中一个集中数据驱动的机制是非常有必要的,组件众多、交互复杂、配置繁多,所以将驱动的数据部分抽取出来,按照模块划分归置,单一数据源,并以此构建驱动更新机制。
react 生态中,
redux
或者 mobx
都是很优秀的方案,redux
开发模式整个太模板化,mobx
不太熟悉,所以考虑再三使用了 dva
作为数据层第一版。dva
封装的 redux
方案,使用 model
一个数据中心处理各个概念,理解和书写上都好的多。数据层本身是使用的
dva
,足够简单和清晰,其中 model
的概念非常好,能定义状态和状态相关的变更逻辑,一方面基础架构几个数据模块可以使用,另一方面扩展机制的每一个 extension 也分别定义 model
,统一管理数据和方法,简直完美。例如状态栏扩展的支持
// 状态栏的数据模型import React, { ComponentClass, FunctionComponent } from 'react';import { Model } from 'dva';export type TStatusConfig = {com: string | ComponentClass | FunctionComponent;command?: string | boolean;position?: 'left' | 'right';order?: number,id?: string;}export interface IFormState {statusPool: { [index: string]: TStatusConfig };}const model: Model = {namespace: 'statusBar',state: {statusPool: {}} as IFormState,effects: {*register({ payload }, { call, put, select }) {const statusPool = yield select(state => state.statusBar.statusPool);yield put({ type: 'save', payload: { statusPool: {...statusPool, [payload.id]: payload.widget} }});},*unregister({ payload }, { call, put, select }) {const names = payload;const statusPool = yield select(state => state.statusBar.statusPool);names.forEach((name: string) => {if (statusPool[name]) delete statusPool[name];});yield put({ type: 'save', payload: { statusPool: { ...statusPool } } });},},reducers: {save(state, action) {return { ...state, ...action.payload };},},};export default model;// 在 connect 函数中 mapDispatchToProps 后调用this.props.dispatch({type: 'statusBar/register',payload: {id: 'helloworld',widget: () => (<div>Hello World!</div>)}});
不过可能因为我
redux
用的不多,或者用的不对,或者是早起版本,感觉 dva
使用中有很多不顺手的地方:- 很多概念特性,
put
、call
、payload
、action
、dispatch
等逻辑一个没少,而且没办法推断; action type
字符串或者常量类型,没办法代码提示,没办法推断;payload
作为参数,没办法代码提示,没办法推断;- 继承自
redux-saga
的Generator
Iterator
的effects
,用到yield *
异步处理方式。不习惯、不喜欢;
因为这些原因,在后期全面转向
TypeScript
时,一直想把这块替换掉。数据层的需求一般也很简单,就是能储存、能改变、能响应基本上就可以了。考虑到
dva
不顺手的地方和简单需求后自己开发替换了数据抽象层的实现。更详细的实现请看 📦 实现一个简单朴素的 react 数据管理层 NOVUS。
其中包括:
- dva 中的
router
的使用处理多页面数据监听,与目前项目的数据层不太相关,而且需求简单,直接分离成组件,监听hashChange
事件渲染不同界面即可; - 数据源
state
按照逻辑集中数据,能定义ts type
- 能定义
action
方法改变state
- 不需要
devtool
管理历史,没有时间漫游的需求,也不刻意强调修改状态逻辑的干净、简单,所以不特意设置reducer
概念,避免语法啰嗦 - 不特意设置
reducer
,但是仍然可以封装setState
为纯函数的处理来实现reducer
相似的功能,这里因为对数据的流转不刻意强调,所以不预设 - 能通过
connect
或者useHooks
方法响应数据的变化通知,从而渲染组件。 - 最大的驱动是能够用
ts
的类型限制和推断,自动提示数据的访问和action
的参数和返回。
用起来还是挺舒服的,集中式的 state 管理,可推断的数据和方法,没那么多约定,在实现上区分副作用和状态等。当然无论用什么方案都不是重点,重点是有一个数据驱动组件的机制。
上两节说了驱动的机制,那么具体哪些呢?刚开始只定义了以下几个核心的数据模型:
name | desc |
---|---|
layout | [核心] 负责视图配置的数据支持,渲染界面主要结构,主要是 UI 总管 |
statusBar | [核心] 负责底部状态栏的数据支持 |
menu | [核心] 负责菜单栏和右键菜单的数据支持 |
workspace | [核心] 负责整体的布局和各个扩展的界面插槽 |
os | [核心] 负责项目配置、基础信息等,用来提供最基本项目信息、容器信息等 |
fileManager | (扩展) 负责项目文件树和文件的数据模型 |
gitManager | (扩展) 负责项目 git 信息 |
output | (扩展) 负责全部的调试和输出信息数据模型 |
writer | (扩展) 负责用户打开的文件数据管理 |
pluginManager | (扩展) 负责外围非核心插件的数据模型 |
这几个数据模型,描述了最基础的功能支撑,配合数据开发出布局 UI 和样式,剩下的就是在框架上添加高级功能了。
IDE 的框架和一般的布局不太相同,更倾向于一个桌面应用,视图上有菜单栏、状态栏功能,有多个区域划分,其中还有 tab 和对应的 panel。所以界面布局上的实现费了点精力,尤其刚开始想实现比较灵活的布局形式,但是由于数据和交互的费力导致难度上升,而且确实没有必要。最后敲定一个方案:
- menu 顶部菜单栏,展示所有的功能分类和列表
- layout
- left + right + bottom + dealer
- stack
- panel + tab(extensions / plugin 接管)
- stack
- left + right + bottom + dealer
- statusBar 底部状态栏,用来展示具体状态数据功能
每一个界面上的功能和显示都是被 layout 的数据驱动,每一个 panel 的视图占位都由基础框架管理,然后的扩展和插件只需要负责 panel 的内容和交互定义即可。这样大大降低了功能和界面布局的耦合。
非常类似现在的微服务的概念,只是这里面的服务支持并不需要区分的那么清楚,还是有一些数据交互和依赖的。
commands 负责提供一个动作注册、储存和触发的机制,是一个可以全局触发的功能列表,也是参考 VSCode 的 action 一个模式,这里和数据模型的动作
action
避免混乱,使用 Command
关键词。比如下面这个实例:
import { novus } from '@novus';const { layoutModel, fileManagerModel } = novus.models;export const commands = {openNewTab: {trigger: () => {layoutModel.actions.openPanel("fileManager");}},copyFile: {label: "复制",trigger: () => {const file = fileManagerModel.state.onContextMenuFile;if (file) {fileManagerModel.actions.setStates({ copyPool: file });updateContextMenu(file);}}},pasteFile: {label: "粘贴",when: () => {return !!fileManagerModel.state.copyPool;},trigger: async () => {const {onContextMenuFile, copyPool} = fileManagerModel.state;const {isDir, relative, parent} = onContextMenuFile;if (onContextMenuFile && copyPool) {const targetDir = isDir ? relative : (parent === "." ? "/" : parent);await fileManagerModel.actions.copyTo(copyPool.relative, targetDir);}}},}
这个命令的要求比较严格,尽量只能调用数据模型的方法,属于数据的驱动部分。整个项目内这种命令上百个,基本涵盖了用户的操作和响应式的方法等。
命令本身有固定的执行流程,比如可以定义命令的【前置条件】,达不到前置条件将不能执行。
commandsPool.dashboard.openNewTab.trigger()// 等同于类似方法commandsManager.trigger('dashboard/openNewTab')
actions 是命令式机制的储存地方,提供的功能非常简单,但是却驱动了整个项目运行。
- 有时候一个机制、模式并不是很复杂的提供很多功能,很可能只是一个约定,约定按照什么数据结构和方法储存和调用,配套提供几个操作函数可能就能在其上搭建上层了。
- 业内优秀的框架和解决方案很多,但是实际项目使用的时候还是趁手的更好用。
- 每个产品都有核心功能和外围功能,事先设计、预留设计空间能够方面以后持续的开发体验。
- 复杂项目需要先解耦,各个依赖和模块需要设计组合起来的模式,才能让项目复杂度水平扩张。
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/workpad-part-2)