注:文章所写内容在星云中得到了实践
核心对象
最核心的概念有三个 Project -> Document -> Node
- Project:Project 代表一个项目,可以类比为编辑器中的“项目”,例如vscode中间的project 或是idea中的project。
- Document:一个项目(project)中可能有多个“页面”,例如表单业务中,可能有“编辑页”,“详情页”,“列表页”。大屏业务中,也可能有多个“页面”,例如多个 tab 页等。
我们将“页面”抽象成 Document,可以类比为vscode中project中的文件。每个“文件”是可以单独编辑的。Project可以拥有多个Document,在我们的设计中,Documents是一个数组,多个document有顺序之分。 - Node:Node则是这个Document中的元素,代表“组件”,Node是一颗树结构,有自己的children 和 Props,一个Document中有一个rootNode,链接了这个 Node树
状态驱动
构建了这些核心对象之后,我们需要将其与视图层绑定起来。达到修改这些对象,UI界面跟着渲染的效果。我们采用了mobx来实现
MobX 的特点
- 响应式更新:MobX 会自动追踪 observable 数据和它们的依赖关系,确保当数据变化时,UI 自动更新,而无需手动设置监听或通知。
- 简洁的语法:MobX 的 API 相对简单,无需复杂的配置,开发者可以通过少量代码实现状态管理。
- 无样板代码:相比于 Redux,MobX 省去了大量的样板代码(如 action type 和 reducer),大大简化了代码结构,适合小型到中型项目。
- 优异的性能:MobX 使用了细粒度的依赖追踪系统,确保仅重新渲染受影响的组件,从而提高了性能。
具体实现
- Project:我们来讨论下,project需要实现哪些功能?:
- 导入导出,类比一般的软件,都能导入导出一个实体的文件/格式(例如excel,.rvt),方便传输和存储。我们这里导出的是固定格式的schema(一个json对象)。因而project需要可以支持这个json对象的导入导出功能。
- 针对 documents 数组的操作(新增、删除、调整顺序等)
- 我们的设计是,同一时间只能打开一个 document。类比为 vscode 同一时间只能编辑一个文件。因而我们需要设计一个 openDoc 的方法,用来打开文档。
export class Project<T extends Services = Services> {
logger = createLogger('Project')
@obx name: string | null = null
@PropInject('Designer') designer: T['Designer']
@obx.shallow documents: T['Document'][] = []
@obx private currentDocumentId: string | undefined | null = undefined
hooks = projectHooks as ProjectHooks<T>
/**
* schema version
* @description 版本不可更改,只能由适配器去进行更新
*/
private version: Version
/**
* project version
* @description 版本不可更改,只能由适配器去进行更新
*/
private structVersion: Version
constructor(
@InjectAutoFactory('Document')
private documentFactory: (...param: any) => T['Document'],
@Inject('ComponentMetaManager')
public componentMetaManager: ComponentMetaManager,
@Inject('SetterManager')
public setterManager: SetterManager,
) {
makeObservable(this)
}
get currentDocument() {
return this.documents.find(doc => doc.id === this.currentDocumentId)
}
private setCurrentDocumentId(id?: string) {
this.currentDocumentId = typeof id === 'string' ? id : null
}
/**
* @param data 可以是 id 或 name 或 doc 实例
*/
private innerFindDocument(data: string | T['Document']) {
let doc = this.documents.find(item => item.name === data || item.id === data)
return doc
}
/**
* @param data 可以是 id 或 name 或 doc 实例
*/
@action
removeDocument(data: string | T['Document']) {
const doc = this.innerFindDocument(data)
if (!doc) return
const index = this.documents.indexOf(doc)
if (index < 0) {
return
}
if (doc.id === this.currentDocumentId) {
this.setCurrentDocumentId(undefined)
}
this.documents.splice(index, 1)
}
@action
createDocument(schema?: DocumentSchema, index = -1): T['Document'] {
// 必须为doc添加rootNode,不然拖拽不生效
let newSchema = schema
if (!newSchema)
newSchema = {
name: '未命名',
rootNode: {
componentName: 'NormalRoot',
},
}
if (!newSchema?.rootNode) {
newSchema.rootNode = {
componentName: 'NormalRoot',
}
}
const doc = this.documentFactory(this, newSchema)
if (index === -1) {
this.documents.push(doc)
} else {
this.documents.splice(index, 0, doc)
}
return doc
}
getDocumentById(id: string) {
return this.documents.find(item => item.id === id)
}
getDocumentByName(name: string) {
return this.documents.find(item => item.name === name)
}
/**
* @param data 可以是 id 或 name 或 doc 实例
*/
@action
openDocument(data: string | T['Document']) {
const doc = this.innerFindDocument(data)
if (doc) {
this.innerOpenDocument(doc.id)
return doc
}
}
private innerOpenDocument(docId: string) {
this.setCurrentDocumentId(docId)
}
/**
* 卸载当前项目数据
*/
@action
unload() {
if (this.documents.length < 1) {
return
}
this.currentDocumentId = null
this.documents.splice(0, this.documents.length)
}
exportSchema(): ProjectSchema {
const schema: ProjectSchema = {
name: this.name ?? '',
version: this.version,
structVersion: this.structVersion,
documents: this.documents.map(doc => doc.exportSchema()),
}
return schema
}
/**
* 整体设置项目 schema
*/
@action
importSchema(schema?: ProjectSchema) {
this.unload()
this.name = schema?.name ?? ''
// version
this.version = schema?.version ?? defaultVersion
this.structVersion = schema?.structVersion ?? defaultVersion
// load new document
schema?.documents?.map?.(docSchema => this.createDocument(docSchema))
}
}
- Document:
- 同样需要导入/导出功能、针对单个文档的导入导出也是有可能需要的。
- 维护节点(Node)的map,比较方便的可以通过 id 来直接查询到这个 Node,
- 可以对这个Node树进行操作,例如插入Node,移动Node,删除Node子树
- 文档的快照功能。方便实现 (撤销、重做)这些能力
- 其余工具方法
export class Document<T extends Services = Services> {
logger = createLogger('Document')
name = ''
@obx.ref rootNode: T['Node'] | null | undefined
id: string
@obx.shallow private _nodesMap = new Map<string, T['Node']>()
@obx.shallow private nodes = new Set<T['Node']>()
get nodesMap(): Map<string, T['Node']> {
return this._nodesMap
}
get active(): boolean {
return this.project.currentDocument?.id === this.id
}
history: T['History']
hooks = documentHooks as DocumentHooks<T>
constructor(
@InjectAutoFactory('Node') private nodeFactory: (...param: any) => T['Node'],
@InjectAutoFactory('History') private historyFactory: (...param: any) => T['History'],
readonly project: T['Project'],
schema?: T['DocumentSchema'],
) {
makeObservable(this)
this.id = schema?.id ?? uuid()
if (schema) {
this.importSchema(schema)
}
this.history = this.historyFactory(
() => this.exportSchema(),
schema => this.importSchema(schema),
)
// 插入节点后,记录到document中
this.hooks.afterInsertNode.tap(`recordNode_${this.id}`, ({ node, document }) => {
if (this === document) {
this._nodesMap.set(node.id, node)
this.nodes.add(node)
}
return { node, document }
})
}
private clearNodes() {
this.rootNode = null
this.batchRemoveNode(Array.from(this.nodes))
}
@action
importSchema(schema: any) {
// 删除之前的节点信息,避免与历史记录的节点冲突
this.clearNodes()
// a before hook
const name = schema?.name ?? ''
if (typeof name !== 'string') throw new Error('import schema error: schema.name should be string')
this.name = name
if (schema?.rootNode) {
this.rootNode = this.createNode(schema?.rootNode)
}
// a after hook
}
exportSchema() {
const schema: any = {
id: this.id,
name: this.name,
rootNode: this.rootNode?.exportSchema(),
}
return this.hooks.processExportedDocSchema.call({ schema, document: this as any }).schema
}
/**
* 根据 id 获取节点
*/
getNode(id: string) {
return this._nodesMap.get(id) || null
}
/**
* 是否存在节点
*/
hasNode(id: string) {
const node = this.getNode(id)
return !!node
}
/**
* 打开文档
*/
open() {
this.project.openDocument(this.id)
return this
}
/**
* 根据 schema 创建一个节点
*/
@action
createNode(data: any): T['Node'] {
const { schema } = this.hooks.beforeCreateNode.call({ schema: data, document: this as any })
if (schema?.id && this.hasNode(schema.id)) {
throw new Error(`There is already a node with ID ${schema?.id}, please remove the node first`)
}
const node: T['Node'] = this.nodeFactory(this, schema)
this._nodesMap.set(node.id, node)
this.nodes.add(node)
return this.hooks.afterCreateNode.call({ node, document: this as any })?.node
}
/**
* 移除一个节点
*/
@action
removeNode(idOrNode: string | T['Node']) {
let id: string
let node: T['Node'] | null
if (typeof idOrNode === 'string') {
id = idOrNode
node = this.getNode(id)
} else {
node = idOrNode
// @ts-ignore
id = node.id
}
if (node) {
this.innerRemoveNode(node)
}
}
@action
innerRemoveNode(node: T['Node']) {
if (node.isRoot) {
this.rootNode = null
}
if (node?.children.length > 0) {
node.children.clear()
}
if (node.parent) {
node.parent?.children.internalUnlinkChild(node)
node.internalUnlinkParent()
}
this.nodes.delete(node)
this._nodesMap.delete(node.id)
}
/**
* 批量移除节点
*/
@action
batchRemoveNode(idOrNode: (string | T['Node'])[]) {
for (const item of idOrNode) {
this.removeNode(item)
}
}
/**
* 将 node 插入到 ref 的后面,紧跟着 ref 这个 node
*/
@action
insertNodeAfter(node: T['Node'], ref: T['Node']) {
if (this.rootNode === ref) {
throw new Error(
'insertNodeAfter error: ref is root node, please insert to ref.children, There can only be one root node',
)
}
if (ref.index === -1) {
throw new Error('insertNodeAfter error: ref has no parent')
}
node.parent?.children.internalUnlinkChild(node)
ref.parent!.children!.insert(node, ref ? ref.index + 1 : null)
}
}
- Node:
- 同样需要导入/导出功能、针对单个Node的导入导出也是有可能需要的。
- 最重要的是,Node上属性的操作,例如添加属性,查询属性,删除属性,修改属性。
- 对Node的(children)子节点的操作,增加子Node,删除子Node等等
- 一些快捷方法,例如查询该Node的所有子孙Node,或者查询该Node的所有祖先节点等等
export class Node<T extends Services = Services> {
readonly isNode = true
readonly id: string
readonly componentName: string
props: T['Props']
@obx.shallow
protected _children: T['NodeChildren']
@obx.ref private _parent: Node | null = null
@computed get index(): number {
if (!this.parent) {
return -1
}
return this.parent.children!.indexOf(this)
}
/**
* 父级节点
*/
get parent(): Node | null {
return this._parent
}
/**
* 当前节点子集
*/
get children(): T['NodeChildren'] {
return this._children
}
/**
* 当前节点子集节点
*/
get childrenNodes(): Node[] {
return this._children.children
}
get isRoot() {
return this.document.rootNode === this
}
constructor(
@InjectAutoFactory('Props') private propsFactory: (...param: any) => T['Props'],
@InjectAutoFactory('NodeChildren')
private nodeChildrenFactory: (...param: any) => T['NodeChildren'],
public document: T['Document'],
nodeSchema: NodeSchema,
) {
this.document = document
const { componentName, id, children = [], props, ...extras } = nodeSchema
this.id = id || uuid() // 直接随机生成一个Node id
this.componentName = componentName
// @ts-ignore
this.props = this.propsFactory(this, props, extras)
// @ts-ignore
this._children = this.nodeChildrenFactory(this, children)
makeObservable(this)
this.initProps()
}
/**
* 节点初始化期间就把内置的一些 prop 初始化好
*/
@action
initProps() {
this.props.has(getConvertedExtraKey('isHidden')) || this.props.add(false, getConvertedExtraKey('isHidden'))
this.props.has(getConvertedExtraKey('isLocked')) || this.props.add(false, getConvertedExtraKey('isLocked'))
this.props.has(getConvertedExtraKey('title')) ||
this.props.add(this.componentMeta?.title ?? '', getConvertedExtraKey('title'))
}
/**
* 锁住当前节点
*/
hide(flag = true) {
this.setExtraPropValue('isHidden', flag)
}
/**
* 获取当前节点的锁定状态
*/
get isHidden(): boolean {
return !!this.getExtraPropValue('isHidden')
}
/**
* 锁住当前节点
*/
lock(flag = true) {
this.setExtraPropValue('isLocked', flag)
}
/**
* 获取当前节点的锁定状态
*/
get isLocked(): boolean {
return !!this.getExtraPropValue('isLocked')
}
/**
* 用于显示在左侧大纲树的标题名
*/
get title() {
const title = this.getExtraPropValue('title')
if (title) {
return title
}
return this.componentMeta?.title
}
/**
* 用于显示在左侧大纲树的标题名
*/
setTitle(title: string) {
this.setExtraPropValue('title', title)
}
/**
* 用于显示在左侧大纲树的标题名
*/
get version() {
return this.getExtraPropValue('structVersion') ?? defaultVersion
}
/**
* 内部方法,请勿使用
*/
internalSetParent(parent: Node | null) {
if (this._parent === parent) {
return
}
if (parent) {
// 建立新的父子关系,尤其注意:对于 parent 为 null 的场景,不会赋值,因为 subtreeModified 等事件可能需要知道该 node 被删除前的父子关系
this._parent = parent
}
}
internalUnlinkParent() {
this._parent = null
}
@computed
get schema(): NodeSchema {
return this.exportSchema()
}
/**
* 导出 schema
*/
exportSchema(): NodeSchema {
const baseSchema = {
id: this.id,
componentName: this.componentName,
structVersion: this.version,
}
const { props = {}, extras = {} } = this.props.export() || {}
const schema: NodeSchema = {
...baseSchema,
props,
...extras,
}
if (this.children && this.children.length > 0) {
schema.children = this.children.exportSchema()
}
return toJS(schema)
}
get componentMeta() {
return this.document.project.componentMetaManager.getComponentMetaByName(this.componentName)
}
getProp(path: string, createIfNone = true) {
return this.props.query(path, createIfNone) || null
}
/**
* 获取指定 path 的属性模型实例
*/
getExtraPropValue(key: string): any {
return this.getProp(getConvertedExtraKey(key), false)?.value
}
/**
* 设置指定 path 的属性模型实例值
*/
@action
setExtraPropValue(key: string, value: any) {
this.getProp(getConvertedExtraKey(key), true)?.setValue(value)
}
/**
* 获取单个属性值
*/
getPropValue(path: string) {
return this.getProp(path, false)?.value
}
/**
* 设置单个属性值
*/
@action
setPropValue(path: string, value: any) {
this.getProp(path, true)!.setValue(value)
}
/**
* 设置多个属性值,替换原有值
*/
@action
setProps(props: PropsSchema) {
this.props.import(props)
}
/**
* 删除额外属性 key
*/
@action
delExtraPropKey(key) {
this.props.deleteKey(getConvertedExtraKey(key))
}
/**
* 删除属性 key
*/
@action
delPropKey(key) {
this.props.deleteKey(key)
}
/**
* 删除属性
*/
@action
delProp(prop: T['Prop']) {
this.props.delete(prop)
}
/**
* 在指定位置之后插入一个节点
* insert a node befor current node
* @param node
* @param ref
* @param useMutator
*/
@action
insertAfter(node: Node, ref?: Node) {
const nodeInstance = ensureNode(node, this.document)
node.parent?.children.internalUnlinkChild(node)
this.children?.insert(nodeInstance, ref ? ref.index + 1 : null)
return nodeInstance
}
/**
* 在指定位置之前插入一个节点
* insert a node befor current node
* @param node
* @param ref
* @param useMutator
*/
insertBefore(node: Node, ref?: Node) {
const nodeInstance = ensureNode(node, this.document)
node.parent?.children.internalUnlinkChild(node)
this.children?.insert(nodeInstance, ref ? ref.index : null)
return nodeInstance
}
/**
* 在指定位置插入一个节点
* insert a node befor current node
* @param node
* @param ref
* @param useMutator
*/
insert(node: Node, at?: number) {
const nodeInstance = ensureNode(node, this.document)
node.parent?.children.internalUnlinkChild(node)
this.children?.insert(nodeInstance, at)
return nodeInstance
}
/**
* 移除当前节点
*/
@action
remove() {
this.document.removeNode(this)
}
/**
* 节点所在树的层级深度,根节点深度为 0
* @returns
*/
@computed get zLevel() {
if (!this.isLinkedInTree) {
return undefined
}
let count = -1
let current
current = this
while (current) {
count++
if (current.isRoot) {
break
}
current = current.parent
}
return count
}
@computed
get isLinkedInTree() {
let current
current = this
while (current) {
if (current.isRoot) {
return true
}
current = current.parent
}
return false
}
/**
* 获取所有祖先元素
* @returns
*/
getAncestors() {
let current: Node | null
current = this
const ancestors: Node[] = []
while (current) {
if (current.parent) {
ancestors.push(current.parent)
}
current = current.parent
}
return ancestors
}
/**
* 获取所有子孙元素
*/
getDescendants() {
const descendants: Node[] = []
const loop = (children: Node[]) => {
if (children.length > 0) {
for (const child of children) {
descendants.push(child)
loop(child.childrenNodes)
}
}
}
loop(this.childrenNodes)
return descendants
}
/**
* 是否是目标元素的祖先
* @param target
* @returns
*/
isAncestorOf(target: Node) {
let current
current = target
while (current) {
if (current === this) {
return true
}
current = current.parent
}
return false
}
/**
* 是否是目标元素的子孙
* @param target
* @returns
*/
ifDescendantOf(target: Node) {
let current
current = this
while (current) {
if (current === target) {
return true
}
current = current.parent
}
return false
}
}