React 提供了声明式方式,让我们可以更方便清晰的描述 UI ,但是,对于需要依赖真实 DOM 节点的第三方 js 库,例如 D3.js,我们又该怎么在 React 组件中使用呢?我们至少要思考以下三点:
-
Q:如何获取原生 DOM 节点?
A:使用
ref
获取原生 DOM 节点引用。 -
Q:如何更新原生 DOM 节点上的组件状态?
A:手动更新,React 只会维护虚拟 DOM 节点上的组件状态。
-
需要注意,在组件销毁时移除原生节点上的 DOM 事件。
我们以 D3.js
作为例子,具体聊聊~
D3.js 是什么?
D3
(Data-Driven Documents
或 D3.js
)是一个非常著名的画图的 JavaScript
库,用于使用 Web
标准将数据可视化。它是必须要对底层的 DOM
节点进行操作的,这也是选择 D3.js
的其中原因;其次,它数据驱动的属性和 React 也十分类似。
目前也有基于 D3.js
封装的 React 库,一般来说功能会收到限制,肯定不如直接使用 D3.js
得到的功能更完备。掌握 D3.js
在 React 中的使用,对于开发可视化会有很大的帮助。
创建一个仿真力模型
每个节点(Node)都是可以拖动的,并且有一个力反馈的效果。用户可以为中间的圈手动添加新的节点(Node)。
安装
- yarn
yarn add d3
- npm
npm install d3
类组件实现
创建(componentDidMount)
首页,我们定义了一组初始数据,包括节点(nodes)和节点之间的关系(links):
{
"nodes": [
{
"id": "id1",
"group": 1
},
{
"id": "id2",
"group": 2
},
{
"id": "id3",
"group": 3
},
{
"id": "id4",
"group": 4
}
],
"links": [
{
"source": "id1",
"target": "id2",
"value": 1
},
{
"source": "id1",
"target": "id3",
"value": 1
},
{
"source": "id1",
"target": "id4",
"value": 1
}
]
}
对于 d3 而言,它需要一个 DOM Node 作为画图区域。 在 React 中,可以通过 ref
得到对组件真正实例的引用,ref 属性可以设置为一个回调函数,这也是官方强烈推荐的用法:
-
组件被挂载后,回调函数被立即执行,回调函数的参数为该组件的具体实例。
-
组件被卸载或者原有的 ref 属性本身发生变化时,回调也会被立即执行,此时回调函数参数为 null,以确保内存泄露。
// d3Node 作为我们的画图区域
<div className="d3-node" ref={(node) => (this.d3Node = node)} />
在组件挂载完成后,也就是 componentDidMount,d3Node 会初始化一个 svg(包括长宽背景色等样式和一个力学仿真空间)。linksGroup
是线条的存放容器,nodesGroup
是节点的存放容器。
this.svg = d3
.select(this.d3Node)
.append('svg')
.attr('width', width)
.attr('height', height);
this.color = d3.scaleOrdinal(d3.schemeCategory10);
this.simulation = d3
.forceSimulation()
.force(
'link',
d3.forceLink().id((d) => d.id),
)
.force('charge', d3.forceManyBody())
.force('center', d3.forceCenter(width / 2, height / 2));
this.linksGroup = this.svg.append('g');
this.nodesGroup = this.svg.append('g');
关于数据渲染的逻辑,在首次绘制和后续更新中是一样的,所以我们可以共用这部分的代码,画图的主要逻辑就是 有多少个 node 就画多少个圈,他们之间的关系有 line 连接,关于 node 的位置由 d3.force API 自动生成:
updateDiagrarm() {
const { data } = this.state;
let link = this.linksGroup
.attr('class', 'links')
.selectAll('line')
.data(data.links);
link.exit().remove();
link = link
.enter()
.append('line')
.attr('stroke-width', function (d) {
return Math.sqrt(d.value);
})
.merge(link);
let node = this.nodesGroup
.attr('class', 'nodes')
.selectAll('circle')
.data(data.nodes);
node.exit().remove();
node = node
.enter()
.append('circle')
.attr('r', (d) => (d.id === 'id1' ? 24 : 16))
.attr('fill', (d) => {
return this.color(d.group);
})
.call(
d3
.drag()
.on('start', this.dragstarted)
.on('drag', this.dragged)
.on('end', this.dragended),
)
.merge(node);
this.simulation.nodes(data.nodes).on('tick', ticked);
this.simulation.force('link').links(data.links).distance(100);
this.simulation.alpha(1).restart();
function ticked() {
link
.attr('stroke', '#c7c7c7')
.attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y);
node.attr('cx', (d) => d.x).attr('cy', (d) => d.y);
}
}
dragstarted = (event, d) => {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
};
dragged = (event, d) => {
d.fx = event.x;
d.fy = event.y;
};
dragended = (event, d) => {
if (!event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
};
更新(componentDidUpdate)
基于 D3 数据驱动这个性质,新增 Node 的行为就是为 data 新增 node 和新 node 的对应关系:
handleAddNode = () => {
const id = `id${new Date().getTime()}`;
const node = { id, group: _.random(1, 9) };
this.setState({
data: {
nodes: [...this.state.data.nodes, node],
links: [
...this.state.data.links,
{ source: 'id1', target: id, value: 1 },
],
},
});
};
需要注意的是,React
只会管理到 d3Node
层,d3Node
以下的内容都是我们手动管理的,所以在 componentDidUpdate
中,我们添加更新逻辑:
componentDidUpdate(prevProps, prevState) {
if (this.state.data !== prevState.data) this.updateDiagrarm();
}
销毁
在这个例子中,因为没有为真实 Dom 绑定额外的事件,在组件销毁之后,React 会移除 d3Node 这个节点的同时,也会移除 d3Node 下的所有内容。
函数组件实现
React Hooks 中的画图逻辑和类组件中是一样的。
-
初始化 Svg
useEffect(() => { // initSvg }, []);
-
在 data 变化时,更新视图
useEffect(() => { // updateDiagrarm }, [data]);
在实践的时候,发现有两个坑需要防一下:
-
避免重复绘图,为
useEffect
添加第二个参数[]
,并不能完全避免这个问题,我们可以再加一层判断:const checkElementExist = (element) => { if (element) { element.remove(); } }; checkElementExist(getSvg().selectAll('svg'));
-
useState
必须要执行完react
整个生命周期才会获取最新值,这就导致在生命周期结束前无法操作新建的 Dom 节点。其实我们也不需要通过 useState 来自动更新视图,这里可以用 useRef 代替。const color = useRef(null); const simulation = useRef(null); const linksGroup = useRef(null); const nodesGroup = useRef(null);
完整代码
想自己上手跑一跑的朋友,可以戳这里