手搓低代码框架(3)- 核心对象与状态驱动

202 阅读7分钟

注:文章所写内容在星云中得到了实践

核心对象

最核心的概念有三个 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 的特点
  1. 响应式更新:MobX 会自动追踪 observable 数据和它们的依赖关系,确保当数据变化时,UI 自动更新,而无需手动设置监听或通知。
  2. 简洁的语法:MobX 的 API 相对简单,无需复杂的配置,开发者可以通过少量代码实现状态管理。
  3. 无样板代码:相比于 Redux,MobX 省去了大量的样板代码(如 action type 和 reducer),大大简化了代码结构,适合小型到中型项目。
  4. 优异的性能:MobX 使用了细粒度的依赖追踪系统,确保仅重新渲染受影响的组件,从而提高了性能。

具体实现

  • Project:我们来讨论下,project需要实现哪些功能?:
  1. 导入导出,类比一般的软件,都能导入导出一个实体的文件/格式(例如excel,.rvt),方便传输和存储。我们这里导出的是固定格式的schema(一个json对象)。因而project需要可以支持这个json对象的导入导出功能。
  2. 针对 documents 数组的操作(新增、删除、调整顺序等)
  3. 我们的设计是,同一时间只能打开一个 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
  1. 同样需要导入/导出功能、针对单个文档的导入导出也是有可能需要的。
  2. 维护节点(Node)的map,比较方便的可以通过 id 来直接查询到这个 Node,
  3. 可以对这个Node树进行操作,例如插入Node,移动Node,删除Node子树
  4. 文档的快照功能。方便实现 (撤销、重做)这些能力
  5. 其余工具方法
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
  1. 同样需要导入/导出功能、针对单个Node的导入导出也是有可能需要的。
  2. 最重要的是,Node上属性的操作,例如添加属性,查询属性,删除属性,修改属性。
  3. 对Node的(children)子节点的操作,增加子Node,删除子Node等等
  4. 一些快捷方法,例如查询该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
 }
}