AntV 开发 —— x6 图编辑引擎

其实,只是想找个轮子

前序:

碎碎念:

  • 此文个人笔记,官网文档搬运
  • 因为官网对于vue结合Element-UI的示例就几个,本意只是看中了人工智能建模 DAG 图,但是这玩意demo是React,虽然核心的东西一样,但是傻瓜式才方便,故特意按vue2写了一次,没用ts,用ts的其实转换的成本不高
  • demo 待上传中【ps:因demo已魔改,那应该是不可能上传了 →_→ 】
  • v1版本过于繁琐,想入手还是请看v2版本
  • x6 版本:1.32.8
  • x6-vue-shape 版本:1.4.0
  • composition-api 版本:1.7.0
  • vue 版本:2.6.11
  • vue-template-compiler 版本:2.6.11
  • Element-UI 版本:2.15.9

一:步骤:

1 —— 创建vue2项目:详情请看 vue开发 —— CLI(开发环境)搭建

2 —— 引入开发组件【Element-UI、antv.x6】

npm i element-ui
npm install @antv/x6 
npm install @antv/x6-vue-shape
// 在vue2.x 若你引入x6-vue-shape,目前这个版本是必须要的,因由在常见问题2
npm install @vue/composition-api --dev

2.1 —— 生成的项目目录如下:

Demo 
├─ node_modules
├─ public
     ├─ favicon.ico
     └─ index.html
├─ src
     ├─ assets
           └─ logo.png
     ├─ components
           └─ HelloWorld.vue
     ├─ App.vue
     └─ main.js
├─ .browserslistrc
├─ .eslintrc.js
├─  babel.config.js
├─ package.json
├─ package-lock.json
└─ README.md

2.2 —— 修改生成的项目【编辑package.json、编辑main.js、编辑App.vue、新增vue.config.js】

// package.json 
// 此处修改为解决常见问题一
 "dependencies": {
    "vue": "2.6.11"
  },
  "devDependencies": {
    "vue-template-compiler": "2.6.11"
  }
// main.js
import Vue from "vue";
import App from "./App.vue";
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.config.productionTip = false;
Vue.use(ElementUI);

new Vue({
  render: (h) => h(App),
}).$mount("#app");
// App.vue
<template>
  <div id="app"></div>
</template>

<script>
export default {
  name: 'App',
  components: {},
};
</script>

<style></style>
// vue.config.js
// 此处新增为解决常见问题二
module.exports = {
    runtimeCompiler: true
}

2.3 —— 开启搬运工生涯【创建画布、创建节点模板、新增节点、新增多个节点、定时改变多个节点状态、达成官网效果、搭配Element-UI、收工】

官网效果图

2.3.1 —— 创建画布

// App.vue
<template>
  <div id="app"></div>
</template>

<script>
import { Graph } from '@antv/x6';
import '@antv/x6-vue-shape'

let graph = null;

export default {
  name: 'App',
  components: {},
  mounted() {
    graph = new Graph({
      container: document.getElementById('app'),
      grid: true,
      autoResize: true,
    });
  },
};
</script>

<style>
#app {
  width: 100%;
  min-height: 500px;
}
</style>
创建画布

2.3.2 —— 创建节点模板

// components/NodeTemplate.vue
<template>
  <div :class="nodeClass" class="node">
    <img :src="logo" />
    <span class="label">{{ label }}</span>
    <span class="status">
      <img :src="statusImg" v-if="statusImg" />
    </span>
  </div>
</template>

<script>
export default {
  inject: ['getGraph', 'getNode'],
  data() {
    return {
      status: '',
      label: '',
      statusImg: '',
      logo: '',
    };
  },

  methods: {
    mapper(source, target) {
      for (let key in target) {
        target[key] = source?.[key] ?? target[key];
      }
    },
  },

  created() {
    let node = this.getNode();
    // 初始化数据绑定
    this.mapper(node.data, this.$data);
    console.info(node);
    // 节点数据变化监听,从而绑定数据
    node.on('change:data', ({ current }) => this.mapper(current, this.$data));
  },

  computed: {
    nodeClass: function () {
      let clazz = {};
      if (this.status) clazz[this.status] = true;
      return clazz;
    },
  },
};
</script>

2.3.3 —— 新增节点

// App.vue
<template>
  <div id="app"></div>
</template>

<script>
import { Graph } from '@antv/x6';
import '@antv/x6-vue-shape';
import nodetemplate from '@/components/NodeTemplate';

let graph = null;

export default {
  name: 'App',

  data() {
    return {
      node: {
        id: '1',
        shape: 'vue-shape',
        component: 'nodetemplate',
        width: 180,
        height: 36,
        x: 290,
        y: 110,
        data: {
          label: '读数据',
          status: 'success',
          statusImg: '',
          logo: 'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*evDjT5vjkX0AAAAAAAAAAAAAARQnAQ',
        },
        ports: {
          groups: {
            top: {
              position: 'top',
              attrs: {
                circle: {
                  r: 4,
                  magnet: true,
                  stroke: '#C2C8D5',
                  strokeWidth: 1,
                  fill: '#fff',
                },
              },
            },
            bottom: {
              position: 'bottom',
              attrs: {
                circle: {
                  r: 4,
                  magnet: true,
                  stroke: '#C2C8D5',
                  strokeWidth: 1,
                  fill: '#fff',
                },
              },
            },
          },
          items: [
            {
              id: '1-1',
              group: 'bottom',
            },
          ],
        },
      },
    };
  },

  mounted() {
    graph = new Graph({
      container: document.getElementById('app'),
      grid: true,
      autoResize: true,
    });
    // 注册 nodeTemplate
    Graph.registerVueComponent(
      'nodetemplate',
      {
        template: `<nodetemplate />`,
        components: {
          nodetemplate,
        },
      },
      true
    );

    graph.addNode(this.node);

    graph.centerContent();
  },
};
</script>

<style>
#app {
  width: 100%;
  min-height: 500px;
}

.node {
  display: flex;
  align-items: center;
  width: 100%;
  height: 100%;
  background-color: #fff;
  border: 1px solid #c2c8d5;
  border-left: 4px solid #5f95ff;
  border-radius: 4px;
  box-shadow: 0 2px 5px 1px rgba(0, 0, 0, 0.06);
}
.node img {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  margin-left: 8px;
}
.node .label {
  display: inline-block;
  flex-shrink: 0;
  width: 104px;
  margin-left: 8px;
  color: #666;
  font-size: 12px;
}
.node .status {
  flex-shrink: 0;
}
.node.success {
  border-left: 4px solid #52c41a;
}
.node.failed {
  border-left: 4px solid #ff4d4f;
}
.node.running .status img {
  animation: spin 1s linear infinite;
}
.x6-node-selected .node {
  border-color: #1890ff;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #d4e8fe;
}
.x6-node-selected .node.success {
  border-color: #52c41a;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #ccecc0;
}
.x6-node-selected .node.failed {
  border-color: #ff4d4f;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #fedcdc;
}
.x6-edge:hover path:nth-child(2) {
  stroke: #1890ff;
  stroke-width: 1px;
}

.x6-edge-selected path:nth-child(2) {
  stroke: #1890ff;
  stroke-width: 1.5px !important;
}

@keyframes running-line {
  to {
    stroke-dashoffset: -1000;
  }
}
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>
单个节点

2.3.4 —— 新增多个节点

// App.vue
// 因一些固定数据过于庞大,省略的部分请复制上一节的代码数据
<template>
  <!--  -->
  <div id="app"></div>
</template>

<script>
import { Graph } from '@antv/x6';
import '@antv/x6-vue-shape';
import nodetemplate from '@/components/NodeTemplate';

let graph = null;

export default {
  name: 'App',

  data() {
    return {
      nodeData: [
        {
          id: '1',
          shape: 'vue-shape',
          x: 290,
          y: 110,
          data: {
            label: '读数据',
            status: 'success',
          },
          ports: [
            {
              id: '1-1',
              group: 'bottom',
            },
          ],
        },
        {
          id: '2',
          shape: 'vue-shape',
          x: 290,
          y: 225,
          data: {
            label: '逻辑回归',
            status: 'success',
          },
          ports: [
            {
              id: '2-1',
              group: 'top',
            },
            {
              id: '2-2',
              group: 'bottom',
            },
            {
              id: '2-3',
              group: 'bottom',
            },
          ],
        },
        {
          id: '3',
          shape: 'vue-shape',
          x: 170,
          y: 350,
          data: {
            label: '模型预测',
            status: 'success',
          },
          ports: [
            {
              id: '3-1',
              group: 'top',
            },
            {
              id: '3-2',
              group: 'bottom',
            },
          ],
        },
        {
          id: '4',
          shape: 'vue-shape',
          x: 450,
          y: 350,
          data: {
            label: '读取参数',
            status: 'success',
          },
          ports: [
            {
              id: '4-1',
              group: 'top',
            },
            {
              id: '4-2',
              group: 'bottom',
            },
          ],
        },
        {
          id: '5',
          shape: 'dag-edge',
          source: {
            cell: '1',
            port: '1-1',
          },
          target: {
            cell: '2',
            port: '2-1',
          },
          zIndex: 0,
        },
        {
          id: '6',
          shape: 'dag-edge',
          source: {
            cell: '2',
            port: '2-2',
          },
          target: {
            cell: '3',
            port: '3-1',
          },
          zIndex: 0,
        },
        {
          id: '7',
          shape: 'dag-edge',
          source: {
            cell: '2',
            port: '2-3',
          },
          target: {
            cell: '4',
            port: '4-1',
          },
          zIndex: 0,
        },
      ],
      nodeImage: {
        logo: 'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*evDjT5vjkX0AAAAAAAAAAAAAARQnAQ',
        success:
          'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*6l60T6h8TTQAAAAAAAAAAAAAARQnAQ',
        failed:
          'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SEISQ6My-HoAAAAAAAAAAAAAARQnAQ',
        running:
          'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*t8fURKfgSOgAAAAAAAAAAAAAARQnAQ',
      },
    };
  },

  methods: {
    init(data) {
      let cells = [];
      data.forEach((item) => {
        if (item.shape === 'vue-shape') {
          item.width = 180;
          item.height = 36;
          item.component = 'nodetemplate';
          item.data.logo = this.nodeImage.logo;
          item.data.statusImg = '';
          if (item.ports)
            item.ports = {
              groups: {
                top: {
                  position: 'top',
                  attrs: {
                    circle: {
                      r: 4,
                      magnet: true,
                      stroke: '#C2C8D5',
                      strokeWidth: 1,
                      fill: '#fff',
                    },
                  },
                },
                bottom: {
                  position: 'bottom',
                  attrs: {
                    circle: {
                      r: 4,
                      magnet: true,
                      stroke: '#C2C8D5',
                      strokeWidth: 1,
                      fill: '#fff',
                    },
                  },
                },
              },
              items: item.ports,
            };
          cells.push(graph.createNode(item));
        } else {
          cells.push(graph.createEdge(item));
        }
      });
      graph.resetCells(cells);
    },
  },

  mounted() {
    graph = new Graph({
      container: document.getElementById('app'),
      grid: true,
      autoResize: true,
    });
    // 注册 nodeTemplate
    Graph.registerVueComponent(
      'nodetemplate',
      {
        template: `<nodetemplate />`,
        components: {
          nodetemplate,
        },
      },
      true
    );

    // 注册 nodeTemplate 的链接线
    Graph.registerEdge(
      'dag-edge',
      {
        inherit: 'edge',
        attrs: {
          line: {
            stroke: '#C2C8D5',
            strokeWidth: 1,
            targetMarker: null,
          },
        },
      },
      true
    );

    this.init(this.nodeData);

    graph.centerContent();
  },
};
</script>

<style>
/* 和上一节一样  */
</style>
多个节点

2.3.5 —— 定时改变多个节点状态

// App.vue
// 因一些固定数据过于庞大,省略的部分请复制上一节的代码数据
<template>
  <div id="app"></div>
</template>

<script>
import { Graph } from '@antv/x6';
import '@antv/x6-vue-shape';
import nodetemplate from '@/components/NodeTemplate';

let graph = null;

export default {
  name: 'App',

  data() {
    return {
     // 和上节一样
      nodeStatusList: [
        [
          {
            id: '1',
            status: 'running',
          },
          {
            id: '2',
            status: 'default',
          },
          {
            id: '3',
            status: 'default',
          },
          {
            id: '4',
            status: 'default',
          },
        ],
        [
          {
            id: '1',
            status: 'success',
          },
          {
            id: '2',
            status: 'running',
          },
          {
            id: '3',
            status: 'default',
          },
          {
            id: '4',
            status: 'default',
          },
        ],
        [
          {
            id: '1',
            status: 'success',
          },
          {
            id: '2',
            status: 'success',
          },
          {
            id: '3',
            status: 'running',
          },
          {
            id: '4',
            status: 'running',
          },
        ],
        [
          {
            id: '1',
            status: 'success',
          },
          {
            id: '2',
            status: 'success',
          },
          {
            id: '3',
            status: 'success',
          },
          {
            id: '4',
            status: 'failed',
          },
        ],
      ],
    };
  },

  methods: {
  // 和上节一样
    showNodeStatus(statusList) {
      let status = statusList.shift();
      status?.forEach((item) => {
        let { id, status } = item;
        let node = graph.getCellById(id);
        let data = node.getData();
        node.setData({
          ...data,
          status: status,
          statusImg: this.nodeImage[status],
        });
      });
      setTimeout(() => {
        this.showNodeStatus(statusList);
      }, 3000);
    },
  },

  mounted() {
    // 和上节一样
    this.init(this.nodeData);
    this.showNodeStatus(this.nodeStatusList);
    graph.centerContent();
  },
};
</script>

<style>
  // 和上节一样
</style>

定时改变多个节点状态

2.3.6 —— 添加属性达成官网效果

// App.vue
// 因一些固定数据过于庞大,省略的部分请复制上一节的代码数据
<template>
  <div id="app"></div>
</template>

<script>
import { Graph, Path } from '@antv/x6';
import '@antv/x6-vue-shape';
import nodetemplate from '@/components/NodeTemplate';

let graph = null;

export default {
  name: 'App',

  data() {
    return {
       // 和上节一样
    };
  },

  methods: {
    // 和上节一样
  },

  mounted() {
    graph = new Graph({
      container: document.getElementById('app'),
      grid: true,
      autoResize: true,
      panning: {
        enabled: true,
        eventTypes: ['leftMouseDown', 'mouseWheel'],
      },
      mousewheel: {
        enabled: true,
        modifiers: 'ctrl',
        factor: 1.1,
        maxScale: 1.5,
        minScale: 0.5,
      },
      highlighting: {
        magnetAdsorbed: {
          name: 'stroke',
          args: {
            attrs: {
              fill: '#fff',
              stroke: '#31d0c6',
              strokeWidth: 4,
            },
          },
        },
      },
      connecting: {
        snap: true,
        allowBlank: false,
        allowLoop: false,
        highlight: true,
        connector: 'algo-connector',
        connectionPoint: 'anchor',
        anchor: 'center',
        validateMagnet({ magnet }) {
          return magnet.getAttribute('port-group') !== 'top';
        },
        createEdge() {
          return graph.createEdge({
            shape: 'dag-edge',
            attrs: {
              line: {
                strokeDasharray: '5 5',
              },
            },
            zIndex: -1,
          });
        },
      },
      selecting: {
        enabled: true,
        multiple: true,
        rubberEdge: true,
        rubberNode: true,
        modifiers: 'shift',
        rubberband: true,
      },
    });
    // 注册 nodeTemplate
    Graph.registerVueComponent(
      'nodetemplate',
      {
        template: `<nodetemplate />`,
        components: {
          nodetemplate,
        },
      },
      true
    );

    // 注册 nodeTemplate 的链接线
    Graph.registerEdge(
      'dag-edge',
      {
        inherit: 'edge',
        attrs: {
          line: {
            stroke: '#C2C8D5',
            strokeWidth: 1,
            targetMarker: null,
          },
        },
      },
      true
    );

    // 注册 nodeTemplate 的链接之间的线样式
    Graph.registerConnector(
      'algo-connector',
      (s, e) => {
        const offset = 4;
        const deltaY = Math.abs(e.y - s.y);
        const control = Math.floor((deltaY / 3) * 2);

        const v1 = { x: s.x, y: s.y + offset + control };
        const v2 = { x: e.x, y: e.y - offset - control };

        return Path.normalize(
          `M ${s.x} ${s.y}
       L ${s.x} ${s.y + offset}
       C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y - offset}
       L ${e.x} ${e.y}
      `
        );
      },
      true
    );

    // 控制线链接时的样式
    graph.on('edge:connected', ({ edge }) => {
      edge.attr({
        line: {
          strokeDasharray: '',
        },
      });
    });

    // 控制节点数据变更时线的样式
    graph.on('node:change:data', ({ node }) => {
      let edges = graph.getIncomingEdges(node);
      let { status } = node.getData();
      edges?.forEach((edge) => {
        if (status === 'running') {
          edge.attr('line/strokeDasharray', 5);
          edge.attr('line/style/animation', 'running-line 30s infinite linear');
        } else {
          edge.attr('line/strokeDasharray', '');
          edge.attr('line/style/animation', '');
        }
      });
    });

    this.init(this.nodeData);
    this.showNodeStatus(this.nodeStatusList);
    graph.centerContent();
  },
};
</script>

<style>
    // 和上节一样
</style>

达成效果

2.3.6 —— 废话了这么多,就是和一开始引入的Element-UI无关,客官莫急,菜来也

// components/EleTemplate.vue
<template>
  <el-alert title="潇风剑易水" type="warning" close-text="赛雷"> </el-alert>
</template>
// App.vue
import eletemplate from '@/components/EleTemplate';
   // 注册 eletemplate
    Graph.registerVueComponent(
      'eletemplate',
      {
        template: `<eletemplate />`,
        components: {
          eletemplate,
        },
      },
      true
    );

    graph.addNode({
      id: '1',
      shape: 'vue-shape',
      component: 'eletemplate',
      width: 180,
      height: 36,
      x: 290,
      y: 110,
    });
    
    graph.centerContent();
搭配Element-UI效果图

2.3.7 —— 收工,翻归卖豉油

二:常见问题:

1 —— 运行时编译报错如下:

Vue packages version mismatch:

- vue@2.6.14 (D:\x6-dag\node_modules\vue\dist\vue.runtime.common.js)
- vue-template-compiler@2.7.5 (D:\x6-dag\node_modules\vue-template-compiler\package.json)

This may cause things to work incorrectly. Make sure to use the same version for both.
If you are using vue-loader@>=10.0, simply update vue-template-compiler.
If you are using vue-loader@<10.0 or vueify, re-installing vue-loader/vueify should bump vue-template-compiler to the latest.

Error: 

Vue packages version mismatch:

- vue@2.6.14 (D:\x6-dag\node_modules\vue\dist\vue.runtime.common.js)
- vue-template-compiler@2.7.5 (D:\x6-dag\node_modules\vue-template-compiler\package.json)

This may cause things to work incorrectly. Make sure to use the same version for both.
If you are using vue-loader@>=10.0, simply update vue-template-compiler.
If you are using vue-loader@<10.0 or vueify, re-installing vue-loader/vueify should bump vue-template-compiler to the latest.

解决方法:

// package.json
// 仅修改 package.json的dependencies节点的vue版本和devDependencies节点的vue-template-compiler版本,
// 均需要去掉^,保持2者的版本一致,重新npm i 再启动
 "dependencies": {
    "vue": "2.6.11"
  },
  "devDependencies": {
    "vue-template-compiler": "2.6.11"
  }

2 —— 运行时浏览器报错如下:

[Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

解决方法:

// 项目根目录创建vue.config.js并添加如下内容
// vue.config.js
module.exports = {
    runtimeCompiler: true
}

2 —— 编译时报错如下:

This dependency was not found:

* @vue/composition-api in ./node_modules/vue-demi/lib/index.esm.js

To install it, you can run: npm install --save @vue/composition-api

解决方法:

npm install --save @vue/composition-api

3 —— 当卸载composition-api在运行时不会报错正常运行,问题2不会在提示,同时缺失这个,导致vue-demi的判断会一直是isVue2为false,isVue3为true,从而当渲染vue模板时会一直走vue3的方法,个人认为vue-demi应该去检查运行中的vue对象,由vue对象的某些vue3特性来判断是否走vue3内容,vue-demi是x6-vue-shape的依赖非本项目的依赖:

4 —— 拖曳创建的节点,使用node.setData不触发绑定的change:data事件,但是实际上打印出来的node的data的确改动了,在数据为null的情况下,这个的确生效,但是并不是通过change:data事件触发的,暂时从源码也没法看出错误点,还没排除新版本是否已修复,源码看的新版本的,后续会更新此版本写的demo:setData为updateData

问题因由:并不是updateData还是setData的没生效的问题或者拖曳生成或者版本的问题(源码版本搜索里面也可以找到和版本有一点点的关系,毕竟之前是没加这个相同就不更新的原则),而是通过node.getData()直接操作了数据,导致setData比较了一致就不更新,其实这也是可以后期修改下,直接通知vue组件更新的同时更新node数据,脱离这种setData的内置方法,但是仅仅是一个权宜之计,也是自己写的时候太过于不顾前后导致的低级bug
解决方法:
不要直接操纵node的data数据,需要通过其自身提供的方法比如setData或者updateData,但是在考虑到x6它自身这种结合vue这种框架的事件绑定脱离了这种框架定义的东西时,其实可以适当改造,让x6更符合vue的写法,而不是遵循它自身这种数据变化的写法

5 —— 关于html元素如何拥有链接桩的功能请看另一篇博文,待更,因由为现成的链接桩自定义过于麻烦,且那些算法排定位,其实对于html元素固有的元素布局来看并不是很适用,除非是大规模改进用自定义,那无疑回到了原始的画图状态,增加了复杂性,故简单而为是用现有的元素进行划分为链接桩,但是这无疑脱离了x6所管理链接桩的机制,后续会看看要不要对接上这种机制,还是它那个自定义链接桩就是这种玩法,我只不过在绕个圈,但是文档关于这部分很模糊,毕竟脱离了它ponts定义的机制,而是添加了成为链接桩的特征去实现链接状功能的做法

6 —— 关于右键菜单自定义问题,因为菜单这一块,很不好的是这玩意是要引入React组件,其实为何会有问题5和6,明显就是依赖过重,抽象层不应该牵扯太多具体的框架,即使明明知道这玩意靠监听事件就能处理右键菜单的问题,这一块的自定义也是基于html元素来完成,会有新的一篇博文来说明这个,待更

三:吹水:

1 首先这次的排版不是很好,毕竟本意是要循序渐进,和状态机一样,一步步到达终点,总感觉这里的makadown定制化太少了,不能尽情表达一些动态效果

2 然后是x6-vue-shape这个感觉只是意思下而已,没有x6-react-shape和React的配合好,毕竟主打的方向就不是vue的,这从数据的传递到组件可以看出,单单用注入方法是不太好的,毕竟你的data变化还需要监听一层,何不在写template时传参prop,由prop去更新里面的状态或者附加到data,通过父组件通知子组件更新的方式,减少使用者对这一层的处理,然后就是注册vue模板时,那些属性不能像普通那些传参进去,必须要addNode带进去的才符合,这明显与结构和数据分离不相符合,我觉得在addNode时,应该是各自变化的数据,而不是一些基础数据,然后节点命名那一块需要注意,大小驼峰写法都不行,仅首字母大写和全小写,因为时间有限我暂时都用全小写去规避组件名称找不到问题,还有那个ports节点,官网的数据为数组形式,但是因为注册vue模板这个玩意加了ports是没效的,为了省事,只能是addNode时进行改变ports结构进行处理了,当然也可以改节点数据,其实这短短的几个效果就需要这么多的东西支撑,还是不太好玩,后续会自行改写,以便适应生产

3 要是没浏览器的兼容考虑,还是用vue3去搭建,而且用上ts,让继承来处理这种很绕的复用关系,毕竟写出来是给人看的

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容