《如何写出高质量的前端代码》学习笔记
低代码编辑器实现教程
1. 项目概述
1.1 需求分析
这是一个简单的低代码大屏编辑器项目,主要功能包括:
- 物料区:展示预制的图形元素(圆形、矩形、三角形等),支持拖拽图形到画布,每个图形都有默认的样式配置
- 画布区:使用 canvas 作为绘制容器,支持选中图形(点击选中),支持移动图形(拖拽移动),支持删除图形(键盘删除),选中图形时显示高亮边框
- 属性配置区:展示选中图形的属性,支持实时编辑属性(位置、大小、颜色等),属性修改后画布实时更新
- 数据存储:画布基础信息:宽度、高度、背景色,节点列表:存储所有图形的信息
1.2 数据结构设计
画布数据采用 JSON 格式存储:
js
const graphData = {
width: 300, // 画布宽度
height: 300, // 画布高度
backgroundColor: '#fff', // 画布背景色
nodes: [
{
id: 'node1', // 节点唯一标识
type: 'circle', // 节点类型:circle/rect/triangle
attributes: {
x: 10, // 左上角x坐标
y: 10, // 左上角y坐标
radius: 20, // 圆形半径
// 其他属性...
}
},
// 更多节点...
]
}
2. 核心类设计
2.1 Node 基类
Node 类是所有图形节点的基类,定义了共同的属性和方法:
-
定义所有图形共有的属性(id、type等)
-
提供属性操作方法(get/set)
-
定义图形操作接口(由子类实现):
- 判断点是否在图形内
- 绘制图形
- 获取外围盒子
- 绘制选中边框
- 移动图形
js
class Node {
constructor({graphView, dataModel, id, attributes={}}) {
this.graphView = graphView; // 画布引用
this.id = id;
this.type = 'node'; // 子类需重写类型
this.attributes = attributes;
}
// 属性操作
getAttributes() { /* ... */ }
getAttribute(key) { /* ... */ }
setAttributes(attributes) { /* ... */ }
setAttribute(key, value) { /* ... */ }
// 图形操作(子类实现)
containsPoint(pointX, pointY) { /* 判断点是否在图形内 */ }
draw() { /* 绘制图形 */ }
getBoundingBox() { /* 获取外围盒子 */ }
drawBoundingBox() { /* 绘制选中边框 */ }
move(dx, dy) { /* 移动图形 */ }
}
2.2 具体图形节点类
以圆形节点为例:
对每个子类,通过多态实现了containsPoint
、draw
、getBoundingBox
、move
方法的重写。
js
class CircleNode extends Node {
constructor(graphView, id, {x, y, radius}) {
super(graphView, id, {x, y, radius});
this.type = 'circle';
}
// 判断点击是否在圆内
containsPoint(pointX, pointY) {
const {x, y, radius} = this.attributes;
const distance = Math.sqrt(
Math.pow(pointX - x, 2) +
Math.pow(pointY - y, 2)
);
return distance <= radius;
}
// 绘制圆形
draw() {
const ctx = this.graphView.getCtx();
const {x, y, radius} = this.attributes;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// 其他方法实现...
}
2.3 画布类(GraphView)
负责 canvas DOM 操作和事件处理:
- 创建canvas的DOM
- 绘制编辑器的背景
- 监听canvas上的鼠标事件,比如点击、拖动等
- 监听键盘事件,比如支持delete或backspace删除选中的节点
js
class GraphView {
constructor(dom, options = {}) {
this.dom = dom;
this.canvas = null;
this.ctx = null;
this.width = options.width;
this.height = options.height;
this.backgroundColor = options.backgroundColor;
this.scale = window.devicePixelRatio;
this.observer = {};
this.init();
}
init() {
// 创建canvas
this.canvas = document.createElement('canvas');
this.canvas.width = this.width * this.scale;
this.canvas.height = this.height * this.scale;
this.dom.appendChild(this.canvas);
// 初始化context
this.ctx = this.canvas.getContext('2d');
this.ctx.scale(this.scale, this.scale);
// 绑定事件
this.bindEvents();
}
bindEvents() {
// 鼠标事件
this.canvas.addEventListener('mousedown', this.handleMouseDown);
this.canvas.addEventListener('mousemove', this.handleMouseMove);
this.canvas.addEventListener('mouseup', this.handleMouseUp);
// 键盘事件
document.addEventListener('keydown', this.handleKeyDown);
}
// 其他方法...
}
2.4 数据模型类(DataModel)
DataModel应该是整个项目的核心,它负责调度各个模块,有节点列表nodes,提供方法操作节点列表,查询节点,根据id查询节点,查询整个画布JSON格式数据,初始化将JSON格式的节点数据变成图形节点。
js
class DataModel {
constructor(options = {}) {
this.nodes = [];
this.dirty = true;
this.initNodes(options.nodes);
this.startDirtyCheck();
}
// 初始化节点
initNodes(nodesData) {
nodesData?.forEach(data => {
const node = this.createNode(data);
this.nodes.push(node);
});
}
// 创建节点
createNode(data) {
switch(data.type) {
case 'circle':
return new CircleNode(this.graphView, data.id, data.attributes);
case 'rect':
return new RectNode(this.graphView, data.id, data.attributes);
// ...其他类型
}
}
// 脏检查机制
startDirtyCheck() {
requestIdleCallback(() => {
if(this.dirty) {
this.render();
this.dirty = false;
}
this.startDirtyCheck();
});
}
// 其他方法...
}
2.5 选择器类(Selector)
管理选中状态:
- 添加选中的节点
- 查询选中的节点
- 清空当前选中节点
- 选择事件变化后要能够通知外部监听者
js
class Selector {
constructor() {
this.selectedNodes = [];
this.observers = [];
}
add(node) {
if(!this.selectedNodes.includes(node)) {
this.selectedNodes.push(node);
this.notifyObservers();
}
}
clear() {
this.selectedNodes = [];
this.notifyObservers();
}
notifyObservers() {
this.observers.forEach(cb => cb(this.selectedNodes));
}
// 其他方法...
}
3. 编辑器组装
将以上组件组装成完整编辑器:
-
GraphView:负责画布的绘制、事件监听等
-
DataModel:负责画布上的节点数据管理
-
Selector:负责画布上的选中节点管理
-
Node: 图形节点
- CircleNode:圆形节点
- RectNode:矩形节点
- PolyNode:多边形节点
js
function createEditor(dom, graphData = {}) {
// 初始化组件
const graphView = new GraphView(dom, {
width: graphData.width || 300,
height: graphData.height || 300,
backgroundColor: graphData.backgroundColor || '#fff',
});
const selector = new Selector();
const dataModel = new DataModel(graphData, graphView, selector);
// 事件处理
let mouseDown = false;
graphView.addEventListener('mousedown', (event, point) => {
mouseDown = true;
const clickedNode = dataModel.getClickedNode(point);
if (clickedNode) {
selector.add(clickedNode);
} else {
selector.clear();
}
});
graphView.addEventListener('mousemove', (event, point) => {
if (mouseDown) {
selector.getSelectedNodes().forEach(node =>
node.move(event.movementX, event.movementY)
);
}
});
// 返回编辑器实例
return {
graphView,
dataModel,
selector,
}
}
通过面向对象构造的类,通过面向过程方式来调度,所以编程范式并不存在谁高级谁低级的问题,每个范式都有自己的适用场景。
4. 拖动图形到画布
4.1 物料区实现
js
<template>
<div class="material-select-wrapper">
<div v-for="item in materialList"
:key="item.type"
class="material-item"
draggable
@dragstart="onDragStart($event, item)">
<img :src="item.icon" :alt="item.label">
<span>{{ item.label }}</span>
</div>
</div>
</template>
<script>
export default {
data() {
return {
materialList: [
{
type: 'circle',
label: '圆形',
icon: 'circle.png',
defaultAttributes: {
radius: 30,
backgroundColor: '#ff0000'
}
},
// 其他图形配置...
]
}
},
methods: {
onDragStart(event, item) {
event.dataTransfer.setData('config', JSON.stringify(item));
}
}
}
</script>
4.2 画布区接收拖拽
js
<template>
<div class="canvas-wrapper"
@drop="onDrop"
@dragover.prevent>
<canvas ref="canvas"></canvas>
</div>
</template>
<script>
export default {
methods: {
onDrop(event) {
const config = JSON.parse(
event.dataTransfer.getData('config')
);
// 创建新节点
const node = this.createNode(config);
// 设置节点位置
const point = this.getCanvasPoint(event);
node.setCenter(point.x, point.y);
// 添加到画布
this.dataModel.addNode(node);
this.selector.select(node);
}
}
}
</script>
5. 属性编辑实现
js
<template>
<div class="attributes-panel">
<template v-if="selectedNode">
<el-form :model="attributes">
<!-- 通用属性 -->
<el-form-item label="背景色">
<el-color-picker
v-model="attributes.backgroundColor"
@change="updateAttribute('backgroundColor', $event)"
/>
</el-form-item>
<!-- 特定图形属性 -->
<template v-if="selectedNode.type === 'circle'">
<el-form-item label="半径">
<el-input-number
v-model="attributes.radius"
@change="updateAttribute('radius', $event)"
/>
</el-form-item>
</template>
</el-form>
</template>
</div>
</template>
<script>
export default {
data() {
return {
selectedNode: null,
attributes: {}
}
},
mounted() {
this.selector.addObserver(nodes => {
this.selectedNode = nodes[0];
this.attributes = {...this.selectedNode?.attributes};
});
},
methods: {
updateAttribute(key, value) {
this.selectedNode?.setAttribute(key, value);
}
}
}
</script>
至此,一个简单的低代码大屏编辑器就实现完成了。
6. 最佳实践总结
-
职责分离
- 每个类都有明确的单一职责
- 通过接口而非直接访问进行通信
-
可扩展性
- 基类抽象共性,子类实现特性
- 新增图形只需继承 Node 类
-
性能优化
- 使用脏检查机制避免频繁重绘
- 事件委托处理用户交互
-
代码复用
- 通用逻辑在基类中实现
- 组件间通过观察者模式解耦
-
面向对象的优势
- 代码组织清晰
- 扩展维护方便
- 复用性好
这个项目很好地展示了面向对象在前端中的应用价值,通过合理的抽象和封装,使得代码结构清晰,易于维护和扩展。