Canvas原生绘制树状结构拓扑图
其实当前Web库实现Canvas绘制树状结构的组件很多,而且功能也很强大,但是难免有些场景无法实现需要自己开发,本文主要是提供一种思路
先附一个不错的拓扑图开发地址:https://www.zhihu.com/question/41026400
一、开发思路
开发最大的难点是如何计算每个节点所在的位置坐标,保证所有节点的居中对称性,如果有了坐标绘制起来就方便很多,具体可见下图
1. 将每个分支看作是一个组,比如节点1看错是一个Group,下面三个分支分别又是Group1、Group2、Group3,而Group1中又有三个Group(比如Group4 等等...)。
2. 对节点数据采用递归循环的方式找到最大的层数,列中为4层。
3. 再次递归循环源数据,判断如果当前层数据非最大层时,则自动补充一个虚拟节点到数据中,一直递归直到最大一层停止。
4. 到第3步可以说所有节点已经补充齐,然后再次递归第3步获取的数据,到最后一层节点时即向上报数,父节点收到消息后计算+1,同时给自己父节点上报消息,如此循环反复,即可准确获得每个节点所包含的最下面一层对应的节点个数,通过节点个数*节点宽度即可获得每个Group所需要的宽度,同时也就能计算各个节点自己的中心点坐标了,简易流程如图二


二、上代码
1. 首先创建三个文件element.js、arrow.js、group.js分别代表元素、箭头和组 的类,可以通过对类的继承实现不同的元素效果,本文只展示简单的要素,就不演示继承的用法了

/**
* 元素,包含元素的样式属性以及绘制范围等信息
* 每个要素所有的子元素都被包含在Group要素中
*/
import util from "./util.js";
import Group from './group.js'; export default class Element {
constructor(options) {
// 横向文字距边框宽度
this.rowPadding = 10;
// 纵向文字距边框宽度
this.coloumnPadding = 5;
// 元素外边距
this.margin = util.CON.MARGIN; this.fontSize = 20; this.width = util.CON.WIDTH; this.height = util.CON.HEIGHT; // 名称,可随机
this.name = options.name; // ID值 可随机
this.id = options.id || util.guid('element'); // 显示文本内容
this.text = options.text; // 所有子和孙辈数据个数
this.chiCount = 0; // 标识该要素展开还是收缩的
this.openFlag = false; // 元素的中心位置
this.center = this.getCenter(options.xRange, options.yRange); this.coordinates = this.getCoordinate(); this.group = this.creat(options.children || [], options.needBorder); // 收缩框数据集
this.shrink = options.shrink;
} /**
* 获取要素的中心点
* @param {*} xRange
* @param {*} yRange
*/
getCenter(xRange, yRange) {
return [(xRange[1] - xRange[0]) / 2 + xRange[0], (yRange[1] - yRange[0]) / 2 + yRange[0]];
} /**
* 根据圆心以及边框宽高获取圆心
* 规则为 [左上, 右上,右下,左下,左上]
*/
getCoordinate() {
return [
[-this.width / 2, -this.height / 2],
[this.width / 2, -this.height / 2],
[this.width / 2, this.height / 2],
[-this.width / 2, this.height / 2]
]
} /**
* 绘制矩形边框,包含圆角
*/
draw() {
const radius = 4;
const coor = this.coordinates;
const ctx = util.getContext(); ctx.save();
ctx.translate(this.center[0], this.center[1]);
ctx.lineWidth = 2;
ctx.strokeStyle = '#558dbd';
ctx.fillStyle = '#f6fafd';
ctx.shadowColor = '#a9d4f5';
ctx.shadowBlur = 5; ctx.beginPath();
ctx.arc(coor[0][0] + radius, coor[0][1] + radius, radius, Math.PI, Math.PI * 3 / 2);
ctx.lineTo(coor[1][0] -radius, coor[1][1]);
ctx.arc(coor[1][0] - radius, coor[0][1] + radius, radius, -Math.PI / 2, 0);
ctx.lineTo(coor[2][0], coor[2][1] - radius);
ctx.arc(coor[2][0] - radius, coor[2][1] - radius, radius, 0, Math.PI / 2);
ctx.lineTo(coor[3][0] + radius, coor[3][1]);
ctx.arc(coor[3][0] + radius, coor[3][1] - radius, radius, Math.PI / 2, Math.PI);
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.restore(); if (this.group) {
this.group.draw();
} this.drawText();
this.drawCircle();
} /**
* 绘制文本内容
*/
drawText() {
const ctx = util.getContext();
ctx.save();
ctx.translate(this.center[0], this.center[1]);
ctx.font = this.fontSize + 'px serif';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';
ctx.fillText(this.text, 0, 0 + this.fontSize / 3, this.width - this.rowPadding * 2);
ctx.restore();
} /**
* 绘制圆圈
*/
drawCircle() {
const circleRadius = 6;
const ctx = util.getContext();
ctx.save();
ctx.strokeStyle = '#16ade7';
ctx.lineWidth = 3;
ctx.fillStyle = '#fff';
ctx.translate(this.center[0], this.center[1]); if (this.group.children.length || this.openFlag) {
ctx.beginPath();
ctx.arc(0, this.height / 2, circleRadius, 0, Math.PI * 2);
ctx.closePath();
// ctx.strokeStyle = '#f18585';
if (this.openFlag) {
ctx.lineWidth = 1;
ctx.moveTo(0, -circleRadius + 2 + this.height / 2);
ctx.lineTo(0, circleRadius - 2 + this.height / 2);
ctx.moveTo(-circleRadius + 2, this.height / 2);
ctx.lineTo(circleRadius - 2, this.height / 2);
} else {
ctx.lineWidth = 1;
ctx.moveTo(-circleRadius + 2, this.height / 2);
ctx.lineTo(circleRadius - 2, this.height / 2);
}
ctx.fill();
ctx.stroke();
} ctx.restore();
} /**
* 根据子元素生成对应的类
*/
creat(children, needBorder) {
return new Group({ children, needBorder, parent: this });
} /**
* 计算元素的宽和高,用于后续计算元素的位置
*/
calWidthHeight() {
const style = util.getContext().measureText(this.text);
this.width = style.width + this.rowPadding * 2;
this.height = this.fontSize + this.coloumnPadding * 2;
}
}
element.js

import util from "./util.js"; /**
* 箭头类
* 主要通过箭头的起始要素和终点要素,以此来计算起点位置箭头的角度偏移量和方向
*/
export default class Arrow {
constructor(options, fromEle, toEle) {
this.id = options.id || util.guid('arrow');
// 箭头起始要素
this.from = fromEle;
// 箭头结束要素
this.to = toEle;
// 箭头坐标信息
this.coordinates = this.getCoordinate();
} /**
* 获取坐标信息
* @returns
*/
getCoordinate() {
const fromC = this.from.center;
const toC = this.to.center;
return [
[fromC[0], fromC[1] + this.from.height / 2],
[toC[0], toC[1] - (toC[1] - fromC[1]) / 2],
[toC[0], toC[1] - this.to.height / 2]
]
} /**
* 主要绘制边框等信息内容
*/
draw() {
const coor = this.coordinates;
const ctx = util.getContext();
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = '#558dbd'; ctx.setLineDash([5, 2]);
ctx.beginPath();
ctx.moveTo(coor[0][0], coor[0][1]);
ctx.lineTo(coor[1][0], coor[1][1]);
ctx.lineTo(coor[2][0], coor[2][1]);
ctx.stroke();
ctx.restore();
this.drawArrow(ctx, coor[2]);
} /**
* 绘制箭头
* @param {*} ctx
* @param {*} points
*/
drawArrow(ctx, points) {
const angle = Math.PI * 20 / 180;
const height = 12; ctx.save(); ctx.lineWidth = 1;
ctx.fillStyle = '#558dbd';
ctx.translate(points[0], points[1]);
ctx.beginPath();
ctx.lineTo(0, 0);
ctx.lineTo(Math.tan(angle) * height, -height);
ctx.arc(0, -height * 1.5, height * 0.6, Math.PI / 2 - Math.PI / 4.8, Math.PI / 2 + Math.PI / 4.8);
ctx.lineTo(-Math.tan(angle) * height, -height);
ctx.closePath();
ctx.fill(); ctx.restore();
}
}
arrow.js

import Element from "./element.js";
import Arrow from "./arrow.js";
import util from "./util.js"; /**
* 组类
* 用于存储子节点和子节点的箭头对象
*/
export default class Group {
constructor(options) {
this.id = options.id || util.guid('group'); // 是否需要绘制组边框
this.needBorder = options.needBorder || false; // 当前组所有子和孙节点的个数
this.chiCount = options.chiCount; // 组下的子元素以及箭头
this.createObject(options.children, options.parent); // 计算中心点
this.center = this.getCenter(); // 计算坐标点信息
this.coordinates = this.getCoordinate();
} /**
* 根据子元素获取子元素最大最小中心坐标用于计算Group的中心坐标和边框
* @returns
*/
getMaxMin() {
const coordX = [];
const coordY = [];
this.children.forEach(e => {
coordX.push(e.center[0]);
coordY.push(e.center[1]);
}) const maxX = Math.max(...coordX);
const minX = Math.min(...coordX);
const maxY = Math.max(...coordY);
const minY = Math.min(...coordY);
return { maxX, minX, maxY, minY };
} /**
* 获取要素的中心点
*/
getCenter() {
const maxMin = this.getMaxMin();
return [(maxMin.maxX + maxMin.minX) / 2, (maxMin.maxY + maxMin.minY) / 2];
} /**
* 获取坐标信息
* @returns
*/
getCoordinate() {
const maxMin = this.getMaxMin(); const maxX = maxMin.maxX;
const minX = maxMin.minX;
const maxY = maxMin.maxY;
const minY = maxMin.minY; const width = util.CON.WIDTH / 2;
const height = util.CON.HEIGHT / 2;
const margin = util.CON.MARGIN / 3;
const betaLong = (maxX - minX + width * 2) / 2;
const betaHeight = (maxY - minY + height * 2) / 2; return [
[-betaLong - margin, -betaHeight - margin],
[betaLong + margin, -betaHeight - margin],
[betaLong + margin, betaHeight + margin],
[-betaLong - margin, betaHeight + margin],
]
} /**
* 创建子对象,包括Element和Arrow对象
* @param {*} children
* @param {*} parent
*/
createObject(children, parent) {
const arrows = [];
const child = [];
children.forEach(e => {
if (!e.buildSelf) {
const ele = new Element(e);
child.push(ele);
arrows.push(new Arrow(e, parent, ele));
}
})
this.arrows = arrows;
this.children = child;
} /**
* 主要绘制边框等信息内容
*/
draw() {
this.children.forEach(e => e.draw());
this.arrows.forEach(e => e.draw()); this.drawBorder();
} /**
* 绘制边框
* @returns
*/
drawBorder() {
if (this.children.length === 0 || !this.needBorder) {
return;
}
const radius = 10;
const coor = this.coordinates;
const ctx = util.getContext(); ctx.save();
ctx.translate(this.center[0], this.center[1]);
ctx.lineWidth = 2;
ctx.strokeStyle = '#99745e'; ctx.beginPath();
ctx.arc(coor[0][0] + radius, coor[0][1] + radius, radius, Math.PI, Math.PI * 3 / 2);
ctx.lineTo(coor[1][0] -radius, coor[1][1]);
ctx.arc(coor[1][0] - radius, coor[0][1] + radius, radius, -Math.PI / 2, 0);
ctx.lineTo(coor[2][0], coor[2][1] - radius);
ctx.arc(coor[2][0] - radius, coor[2][1] - radius, radius, 0, Math.PI / 2);
ctx.lineTo(coor[3][0] + radius, coor[3][1]);
ctx.arc(coor[3][0] + radius, coor[3][1] - radius, radius, Math.PI / 2, Math.PI);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
}
group.js
2. 再创建一个util.js文件,主要是对源数据做虚拟节点的增加和计算最低一层的子节点个数

const CANVASINFO = {};
import Element from './element.js';
const CON = {
WIDTH: 120, // Element元素的宽度
HEIGHT: 40, // Element元素的高度
MARGIN: 30, // 两个Element元素的高度
OUTERHEIGHT: 200 // 每行Element的高度
}
/**
* 设置canvas对象,便于其他组件使用
*/
function setSanvas(canvas) {
CANVASINFO.obj = canvas;
CANVASINFO.context = canvas.getContext('2d');
CANVASINFO.width = canvas.offsetWidth;
CANVASINFO.height = canvas.offsetHeight;
}
/**
* 获取canvas对象
*/
function getCanvas() {
return CANVASINFO.obj;
}
function getContext() {
return CANVASINFO.context;
}
/**
* 获取UUID
* @returns
*/
function guid(prefix) {
return prefix + '_xxxx-xxxx-yxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 获取最大的层级值,同时标记每层要素的层级值,方便后续计算虚拟节点使用
* @param {*} src
* @returns
*/
function getMaxLevel(src) {
const INITLEVEL = 0;
let maxLevel = INITLEVEL;
function cal(src, level) {
if (level > maxLevel) {
maxLevel = level;
}
src.forEach(e => {
e.topo_level = level;
if (e.children && e.children.length) {
cal(e.children, level + 1);
}
})
}
if (Array.isArray(src)) {
cal(src, INITLEVEL);
} else {
cal([src], INITLEVEL);
}
return maxLevel;
}
/**
* 计算每个节点包含的所有最大层级的子和孙子节点的个数
* 原理是循环到最下层的Element对象,每个Element元素向上汇报计算+1
* @param {*} data
*/
function countNum(data) {
const INITLEVEL = 0;
let maxLevel = getMaxLevel(data);
function count(src, level, parents = []) {
src.forEach(e => {
if (Number.isNaN(e.chiCount) || e.chiCount === undefined) {
e.chiCount = 0;
}
if (e.children && e.children.length) {
// 此处添加parents时不可以使用push,否则将会导致部分parent重复
count(e.children, level + 1, parents.concat([e]));
} else if (level < maxLevel) {
// 通过buildSelf标识自插入属性,该对象不创建Ele对象
e.children.push({ children: [], level: level + 1, buildSelf: true });
count(e.children, level + 1, parents.concat([e]));
} else if (level === maxLevel) {
for (let i = parents.length - 1; i >= 0; i--) {
parents[i].chiCount++;
}
}
})
}
if (Array.isArray(data)) {
count(data, INITLEVEL);
} else {
count([data], INITLEVEL);
}
return data;
}
/**
* 将每个分支看作一组,计算每组的横纵坐标范围
* @param {*} data
*/
function calRange(data) {
let eleWidth = CON.WIDTH + CON.MARGIN;
const startX = 0;
const startY = 0;
function range(src, start) {
src.forEach((e, i) => {
e.yRange = [e.topo_level + startY, (e.topo_level + 1) * CON.OUTERHEIGHT + startY];
if (e.children) {
if (i === 0) {
e.xRange = [start, start + eleWidth * (e.chiCount === 0 ? 1 : e.chiCount)];
range(e.children, start);
} else {
e.xRange = [src[i - 1].xRange[1], src[i - 1].xRange[1] + eleWidth * (e.chiCount === 0 ? 1 :e.chiCount)];
range(e.children, src[i - 1].xRange[1]);
}
}
});
}
if (Array.isArray(data)) {
range(data, startX);
} else {
range([data], startX);
}
return data;
}
/**
* 获取无子节点的Element
*/
function flatElement(data) {
const arr = [];
function flat(src) {
if (!src.group || src.group.children.length === 0) {
arr.push(src);
} else {
src.group.children.forEach(e => flat(e));
}
}
flat(data);
return arr;
}
/**
* 克隆数据
* @param {*} data
* @returns
*/
function clone(data) {
return JSON.parse(JSON.stringify(data));
}
export default {
CON,
setSanvas,
getCanvas,
getContext,
guid,
clone,
countNum,
calRange,
flatElement,
};
util.js
3. 创建一个main.js文件,主要是和用来创建元素对象以及绘制页面等功能
main.js
import util from "./util.js";
import Element from "./element.js";
let data = [];
let arrows = [];
function init(options) {
util.setSanvas(document.getElementById(options.id));
addEvent();
// 克隆数据,避免数据污染
const cloneData = util.clone(options.data);
// 计算每个父节点包含的所有子和孙节点数据个数
const numData = util.countNum(cloneData);
// 计算每个节点的横纵坐标范围
const rangeData = util.calRange(numData);
// 创建要素集
data = new Element(rangeData);
redraw();
}
/**
* 重新绘制
*/
function redraw() {
const ctx = util.getContext();
ctx.clearRect(0, 0, 1800, 800);
arrows.forEach(e => e.draw());
data.draw();
}
function addEvent() {
let initWidth = 1800;
let initHeight = 800;
let init = 1;
const beta = 0.1;
const dom = util.getCanvas();
const ctx = util.getContext();
let startPos = [0, 0];
let down = false;
let lastPos = [0, 0];
/**
* 点击事件,计算合并或者展开
* @param {*} event
*/
dom.onclick = (event) => {
const clickX = event.offsetX;
const clickY = event.offsetY;
let selectEle = null;
function getEle(e) {
const upX = e.center[0];
const upY = e.center[1] + e.height / 2;
if (Math.pow(clickX - upX, 2) + Math.pow(clickY - upY, 2) < 10) {
selectEle = e;
}
if (!selectEle && e.group && e.group.children.length) {
e.group.children.forEach(e => {
getEle(e);
})
}
}
getEle(data);
if (selectEle) {
if (selectEle.openFlag) {
selectEle.openFlag = false;
selectEle.group.children = selectEle.srcChildren;
selectEle.group.arrows = selectEle.srcArrows;
delete selectEle.srcArrows;
delete selectEle.srcChildren;
} else {
selectEle.openFlag = true;
selectEle.srcChildren = selectEle.group.children;
selectEle.srcArrows = selectEle.group.arrows;
selectEle.group.children = [];
selectEle.group.arrows = [];
}
redraw();
}
}
}
export default { init }
4. 新增一个页面index.html,用来渲染要素点

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas绘制树状结构</title>
<script src="./arrow.js" type="module"></script>
<script src="./element.js" type="module"></script>
<script src="./group.js" type="module"></script>
<script src="./main.js" type="module"></script>
<script src="./util.js" type="module"></script>
</head>
<body>
<canvas id="canvas" width="1800" height="800" style="width: 1800px;height: 800px;border: 1px solid gray;margin: auto;display: flex;"></canvas>
</body>
</html>
<script type="module">
import main from './main.js';
// 数据格式如下,主要是children字段
var s = {
level: -1,
children: [
{
level: 0,
children: [
{
level: 1,
needBorder: true,
children: [
{
level: 2,
children: []
},
{
level: 2,
children: []
}
]
}
]
},
{
level: 0,
children: [
{
level: 1,
children: [
{
level: 2,
children: [
],
},
]
}
]
},
{
level: 0,
children: []
}
]
}; function setAttr(data, params, index) {
data.text = params.text + '_' + index;
if (data.children) {
data.children.forEach((e, i) => {
setAttr(e, { text: data.text }, i);
})
}
}
window.onload = (() => {
setAttr(s, {text: '' }, 0);
main.init({
data: s,
id: 'canvas'
});
})
</script>
index.html
三、效果图展示,节点可以点击进行收缩(图二)


Canvas原生绘制树状结构拓扑图的更多相关文章
- 原生JS实现树状结构列表
树状结构列表,这个技术点之前有写过了,是基于vue讲解,但似乎都没有解决痛点,最基础的原生JS该怎么实现呢? 这篇文章会全面详细的介绍树状结构列表的实现,从数据处理成树状结构,到动态生成dom节点渲染 ...
- 树状结构Java模型、层级关系Java模型、上下级关系Java模型与html页面展示
树状结构Java模型.层级关系Java模型.上下级关系Java模型与html页面展示 一.业务原型:公司的组织结构.传销关系网 二.数据库模型 很简单,创建 id 与 pid 关系即可.(pid:pa ...
- 分享使用NPOI导出Excel树状结构的数据,如部门用户菜单权限
大家都知道使用NPOI导出Excel格式数据 很简单,网上一搜,到处都有示例代码. 因为工作的关系,经常会有处理各种数据库数据的场景,其中处理Excel 数据导出,以备客户人员确认数据,场景很常见. ...
- 由简入繁实现Jquery树状结构
在项目中,我们经常会需要一些树状结构的样式来显示层级结构等,比如下图的样式,之前在学.net的时候可以直接拖个服务端控件过来直接使用非常方便.但是利用Jquery的一些插件,也是可以实现这些效果的,比 ...
- php实现树状结构无级分类
php实现树状结构无级分类 ).",'树2-1-1-2')";mysql_query($sql);?>
- Android无限级树状结构
通过对ListView简单的扩展.再封装,即可实现无限层级的树控件TreeView. package cn.asiontang.nleveltreelistview; import android.a ...
- 使用Map辅助拼装树状结构,消除递归调用
目前菜单或其他树状结构在数据库中的存储,多数是以一个parentid作为关联字段,以一维形式存储.使用时全部查询出来,然后在内存中拼装成树状结构.现在主要涉及的是拼装方法的问题. 一般可以进行 递归调 ...
- lua 怎样输出树状结构的table?
为了让游戏前端数据输出更加条理,做了一个简单树状结构来打印数据. ccmlog.lua local function __tostring(value, indent, vmap) local str ...
- js List<Map> 将偏平化的数组转为树状结构并排序
数据格式: [ { "id":"d3e8a9d6-e4c6-4dd8-a94f-07733d3c1b59", "parentId":&quo ...
- 浅谈oracle树状结构层级查询之start with ....connect by prior、level及order by
浅谈oracle树状结构层级查询 oracle树状结构查询即层次递归查询,是sql语句经常用到的,在实际开发中组织结构实现及其层次化实现功能也是经常遇到的,虽然我是一个java程序开发者,我一直觉得只 ...
随机推荐
- 组合式api-计算属性computed的使用
计算属性在vue3中和vue2的思想概念都是一样,唯一区别就是在使用组合式api时候的语法稍有不同. 使用步骤: 导入computed函数 import {computed} from 'vue' 使 ...
- IIS下使用SSL证书
IIS下使用SSL证书 本文介绍windowsServer下SSL证书配置及IIS站点配置 1. 生成SSL证书 在阿里云申请免费SSL证书 登录阿里云管理控制台,打开SSL证书管理 选择免费证 ...
- 数字孪生为何需要融合GIS?以智慧城市项目为例说明
数字孪生和地理信息系统(GIS)是两个在现代科技中崭露头角的概念,它们的融合为智慧城市项目带来了革命性的机会.本文将解释数字孪生为何需要融合GIS,并以智慧城市项目为例进行说明. 数字孪生是一种虚拟模 ...
- 笔记本安装linux
下载 桌面版 Ubuntu 镜像 服务器版 Ubuntu 镜像 使用 Balena Etcher 制作系统安装盘 (1)官方网站下载: 点我下载 (2)下载完毕软件之后,打开软件,选择我们下载好的系统 ...
- Springboot3核心特性
一.简介 1. 前置知识 Java17 Spring.SpringMVC.MyBatis Maven.IDEA 2. 环境要求 环境&工具 版本(or later) SpringBoot 3. ...
- IDEA将JavaFx打包为exe Win+Mac+Linux多平台分发等等
IDEA将JavaFx打包为exe Win+Mac+Linux多平台分发等等 前面介绍到了用packr打包javafx17为exe多平台分发,比较复杂,这篇则介绍用idea直接打包exe,比较简单,但 ...
- 花了1块钱体验一把最近很火的ChatGPT
前言 最近 OpenAI 发布了 ChatGPT,一经发布就在科技圈火得不行. ChatGPT是什么呢? 简单得说,ChatGPT,是一种基于对话的 AI 聊天工具.我们来看看ChatGPT自己得回答 ...
- react+echarts出现“There is a chart instance already initialized on the dom.”
写了一个关于echatrs组件,报错dom重复 配置信息从props拿 let chart; useEffect(() => { if (chart) { updateChartView(); ...
- 技术实践丨基于MindSpore的ResNet-50蘑菇“君”的识别应用体验
本文分享自华为云社区<基于MindSpore的ResNet-50蘑菇"君"的识别应用体验>,原文作者:Dasming. 摘要:基于华为MindSpore框架的ResNe ...
- 应用架构步入“无服务器”时代 Serverless技术迎来新发展
摘要:以"原生蓄力,云领未来"为主题的2021年云原生产业大会上,华为云Serverless函数服务产品经理分享了"华为云Serverless函数服务,让开发上云极简高效 ...