0. 一些感想
这还是我第一次撰写技术类博客,想想还有些小激动~,PS: 我尽量不把这篇文章写的像小论文一样哈哈哈。也许正在看这篇博客的你是一位前端大佬,那就当是对 React 的一次回顾吧。但假如你还是一位初入前端的萌新,我相信在看完这篇博客之后,你会觉得干货满满~。不过,在阅读本博客之前,我还是希望你拥有一定的 React Hooks、Reducer、TypeScript、以及 Canvas 的基础。废话不多说,咱们直接切入正题吧!
1. 引言
大家可能对标题中“又一次碰撞”较为好奇,相信大家对现代前端主流框架 Vue 或 React 并不陌生,它们在本质上都更接近于声明式编程,而 Canvas 画布的绘制则更偏向于命令式编程,即每一步都必须告知浏览器如何利用暴露出的 2D context 进行绘制。在上述分析后,两者看起来属于不同的范畴,但仔细一想,它们其实已经有过结合的案例,并且使用场景在前端领域已经非常广泛 (例如大屏大数据展示),对!那就是 ECharts。ECharts 的初始化过程中,可以指定渲染器 renderer [1],是使用 Canvas 还是 SVG 来渲染,但无论是基于何种渲染方式,大多数 coder 还是关注于 ECharts 开箱即用的便捷特性,以及各种丰富 API 的调用,很少有人会去关注底层的绘制逻辑。而本博客就带大家领略一下上述两者与 ECharts 完全不同的使用场景——文档手写签批,并且结合底层的绘制及数据管理逻辑,详细阐述两者是如何做到完美结合的。
2. 事件监听
在整个签批过程中,涉及的鼠标 (PC端) 及手势 (移动端) 无非是由落下、拖拽、抬起这三个动作组成,签批中相关状态的改变也都是因这三个动作而起。因此,在创建画布后,首先需要对这三个动作涉及的事件进行监听,大致如下:
/* React Functional Component */
export default memo(function CanvasLayer(props: ICanvasLayerProps) {
// ......
// 监听鼠标/手指的落下
const canvasRef = useRef({} as HTMLCanvasElement) // 定义空ref对象,断言为Canvas元素类型
/* PC端事件监听对应的函数对象 */
const onMouseDown = useCallback(() => {/* do something... */}, [...deps])
const onMouseMove = useCallback(() => {/* do something... */}, [...deps])
const onMouseUp = useCallback(() => {/* do something... */}, [...deps])
/* 移动端事件监听对应的函数对象 */
const onTouchStart = useCallback(() => {/* do something... */}, [...deps])
const onTouchMove = useCallback(() => {/* do something... */}, [...deps])
const onTouchEnd = useCallback(() => {/* do something... */}, [...deps])
useEffect(() => {
if (canvasRef && canvasRef.current) {
const canvasEl = canvasRef.current // 获取canvas元素
/* 为三大动作事件添加上面定义的事件处理函数 */
canvasEl.onmousedown = onMouseDown
canvasEl.onmousemove = onMouseMove
canvasEl.onmouseup = onMouseUp
canvasEl.ontouchstart = onTouchStart
canvasEl.ontouchmove = onTouchMove
canvasEl.ontouchend = onTouchEnd
}
}, [canvasRef, onMouseDown, onMouseMove, onMouseUp, onTouchStart, onTouchMove, onTouchEnd]) // 必须正确的设置依赖
// ......
return useMemo(() => {
return (
// JSX ......
// 此处将canvasRef对象绑定到canvas元素,pageWidth和pageHeight是需要签批的某一页的宽和高
<CanvasWrapper ref={canvasRef} width={pageWidth} height={pageHeight} signState={false} />
)
}, [...deps])
})
/* Styled Component */
/* 样式的定义可以采用 CSS Module 或是 Styled-Component,后者的哲学遵从 all in JS,即 React 将 HTML 写为了 JSX,而 Styled-Component 又将 CSS 写为了 JS,并且与 React 组件能够完美融合,因此我使用后者,感兴趣的可以参考一下官网[2] 以及了解一下 ES6 的【标签模块字符串】新特性,此处不过多赘述 */
import styled from 'styled-component'
interface IProps {
width: number;
height: number;
}
export const CanvasLayerWrapper = styled.canvas.attrs<IProps>((props) => ({
width: props.width,
height: props.height,
}))<{ signState: boolean }>`
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
// 绘制过程中提升Canvas层级
z-index: ${props => props.signState ? '99' : '9'};
`;
可以看到,一旦外部状态 (包括签批绘制的类型、绘制的颜色、绘制的粗细、绘制的大小) 发生改变 (对应于 useCallback 这一 hook 的依赖项),事件处理函数也将响应式的发生改变,从而实现各种绘制效果,这也体现了结合现代框架的优势所在。
当然,你可能觉得事件处理函数在外围包裹一层
useCallback有些多此一举,每次组件因state、context、props、redux中数据改变而重新渲染,事件处理函数对象的引用不是也随之变化了吗?确实是这样,但useCallback存在的意义就是为了做性能优化,这一点在本博客后面会有所提及~
3. 数据结构设计
数据结构对于大家来说肯定再熟悉不过了,无论是大学课程,还是考研、工作面试,数据结构始终是考察的重点和难点。无论是使用何种语言进行coding,对数据格式的定义及处理都几乎贯穿于整个过程:前端需要对后端返回的数据进行再加工和处理,转为自己的数据结构存储到中央状态管理器中,后端也需要对前端发送的数据 (例如表单、图像等) 进行再处理,存储到数据库中,而深度学习也同样需要对数据集中的训练数据进行预处理,构造成网络期待的或易使训练更快收敛的输入格式后,再馈送至相应网络。
相信讲到这里,其重要性已经不言而喻了吧。因此,一个设计良好的数据结构能够在最大程度上降低后续的维护成本,提升整体健壮性。
那么,对于前端 H5 页面的手写签批,其数据结构应如何设计呢?首先,由于项目全部使用TypeScript,数据结构中的各字段也理应由TypeScript进行类型约束。从微分的角度来看,一条平面坐标轴上的曲线实际上是由许多个(x, y)的坐标点构成的点集。假如你使用过python中的matplotlib.pyplot绘制过曲线,最简单的 demo 也需要定义两组一维数组,即x轴及y轴分别对应的坐标点,更多的选项类似于曲线颜色、曲线粗细、曲线类型都是可选的。而当需要在某坐标轴绘制多条曲线时,则最好定义两组二维数组。而仔细分析后,手写签批与其也有很多类似之处:
Canvas也有自己的坐标轴,它可以通过操纵ctx任意旋转 (rotate) 和 偏移 (translate);Canvas中也需要设置绘制的填充色fillStyle或 粗细lineWidth;- 签批绘制出的单条笔画可视为
Canvas坐标轴上的一条曲线,只不过这条曲线是非常不规则的,多条笔画也就自然对应于多条曲线。
因需要在多个页面 (但为了便于描述,以下均默认为单个页面) 实现签批,而每个页面又对应多条笔画,每条笔画又对应着自己的 (x, y) 点集,因此,最终的数据结构是一个三维数组,最里面 (维度最低) 的一层中,各元素又是一个个的对象 (绘制的点的各属性的描述)。在上述的分析之后,我将该对象的接口类型定义如下:
interface Point {
x: number; // x, y坐标值相对的是canvas的左上角角点坐标
y: number;
t?: number; // 时间戳(可选),从三大动作的event中取出,用于计算判定绘制的快慢程度
f?: number; // 功能类型(可选),因点并非全部是绘制过程中被记录,也可能是拖拽时被记录,但关于拖拽本博客不在叙述,其本质都是对上述三大动作的监听
}
而外层对应的数组,各元素就是一条条的笔画了。我又将各笔画对应的接口类型定义如下:
interface Figure {
initX: number; // 笔画各点围成的最小外接矩形框的左上角角点坐标
initY: number;
initW: number;
initH: number;
paintStyle: PaintStyle; // 绘制类型,本博客默认就是画笔
points: Point[]; // ***由上述Point类型的点构成的点集***
color: string; // 笔画颜色
thickness: number; // 笔画粗细
active: boolean; // 框是否被选中而激活
timeStamp: number; // 最小外接矩形框生成时的时间戳
}
因此,单个页面中的数据就是以Figure[]作为类型存放的。
4. 状态管理*
Vue和React都有着自己的状态管理工具,分别对应于Vuex和Redux。一般地,在单页面富应用SPA中,跨组件数据及请求到的数据一般都存储到上述状态管理工具中,并且利用浏览器开发工具插件,可以追踪状态的改变。鉴于对开发带来的便利,使用它进行状态管理毋庸置疑。但Vue和React框架自身又提供了一种跨组件数据共享的方式,那就是Vue中的依赖注入(provide, inject)以及React中的上下文context(本质上与组件内部的状态相似,只不过可以直接将其共享给子组件及深层组件,而无需通过逐层传递的方式共享)。因此,在社区中,关于到底选择哪种进行共享状态的管理成为了一大话题。我个人认为,这还是由具体的业务场景来决定的。对于手写签批的状态应如何管理这一问题,不妨来分析一下:
- 首先,为确保绘制出的曲线的流畅度 (注意这里从本质上限制了节流函数
throttle的使用),绘制必须是逐帧(frame-wise / tick-wise)绘制的,也就是大概以16ms的实时速率进行,这也就意味着每一次状态的改变相当频繁; - 其次,
Redux中存储的数据状态都是可追踪的,在调试bug时,某些UI可视状态等其他关键状态在何时、何处切换是排查的关键所在,但是上述频繁的状态改变,一旦存储到Redux中,显然需要看到的有用状态切换记录都将被overwhelm。并且,频繁的访问Redux本身就是不被建议的操作。
事实上,
Pinia(被视为Vuex下一代5.x的状态管理库) 在何种场景使用何种状态管理机制也有一段如下描述可作为参考:A store should contain data that can be accessed throughout your application. This includes data that is used in many places, e.g. User information that is displayed in the navbar, as well as data that needs to be preserved through pages, e.g. a very complicated multi-step form.
On the other hand, you should avoid including in the store local data that could be hosted in a component instead, e.g. the visibility of an element local to a page. (pinia.vuejs.org/getting-sta…)
因此,手写签批使用上下文context来共享数据:
/* 签批组件入口 */
interface ISignContext {
paintColor: string;
paintThickness: number;
figures: Figure[];
/* 实际需要存储的状态远不止上面这些 */
paintStyle: -1 as PaintStyle,
configPanelShown: false,
paintFontsize: 16,
paintShape: LineBBoxShape.RECT,
signNames: [],
signStamps: [],
signModalShown: false,
historyRecords: [],
historyStages: [],
}
export const SignContext = createContext({} as ISignContext)
export default memo(function PcSign(props: ISignProps) {
// ...
return useMemo(() => {
return (
<SignContext.Provider value={{ paintColor: xxx, paintThickness: xxx, ...... }}>
{/* JSX...... */}
</SignContext.Provider>
)
}, [...deps])
}
尽管上述使用context的过程确实做到了跨组件数据的共享,且消费者组件确实能够拿到响应式的数据。但管理的状态字段实在是太多,无法做到集中管理,且提供者组件提供的内容也是一大堆堆在一起,不利于后期的维护。
那么,如何既保留context的使用,又符合redux集中管理数据的理念呢?答案就是使用useReducer这一React提供的能够在组件内部使用的另一大额外hook。该hook需要传入一个reducer,reducer中定义了管理的状态由派发哪些action来进行改变,以及如何改变,改变是否还依赖于其他状态,且还需要传入状态的初始值,是否惰性初始化 (可选)。useReducer将返回状态的当前值,且用于mutate状态的派发器dispatcher。具体规则,请参照官方文档 [3]。
/* in reducer.ts */
/* 定义初始状态,是不是与redux中定义reducer完全一致呢? */
export const initState: ISignState = {
drawable: false, // 是否允许绘制的开关
origin: {} as Point, // 一次绘制过程中,点集的起始点
points: [] as Point[], // 一次绘制过程构成的点集
figureArr: [] as Array<{ points: Point[] }>, // 单张canvas上对应的笔画
/* 以下省略更复杂业务逻辑相对应的字段 */
// ......
};
/* 确保reducer必须是一个纯函数 [Pure Function] */
export default function reducer(state = initState, action: IAction): ISignState {
const { payload } = action; // 拿到派发的action携带的负荷
switch (action.type) {
case actionType.TOGGLE_DRAWABLE: // 各actionType名称都是提前定义好的常量
return { ...state, drawable: payload }; // 通过浅拷贝的方式触发react响应式(可优化点)
case actionType.CHANGE_ORIGIN:
return { ...state, origin: payload };
case actionType.ADD_POINT:
return { ...state, points: [...state.points, payload] };
case actionType.CLEAR_POINTS:
return { ...state, points: [] };
case actionType.PUSH_FIGURE:
return { ...state, figureArr: [...state.figureArr, payload] };
case actionType.CLEAR_FIGURE:
return { ...state, figureArr: [] };
default:
return state;
}
}
既然useReducer返回了集中管理的状态,且又把强大的改变状态的dispatcher拿到手,何不与context打个完美的配合,将它们两者作为组件的提供值传入呢?与上述传入大量字段相比,这种方式岂不是妙哉?再结合对象增强写法,简直简洁到爆炸!并且,因返回的状态本身是响应式的,又能随之改变context共享的整体对象,也就确保了消费者组件中拿到的值始终是具备响应式的。假如再把hooks的依赖项正确设置完毕,完全可以放心的用!大胆的用!
注意!React保证dispatcher是稳定的,且不会在组件重新渲染后发生(引用的)改变,这就是为什么可以在一些常用hooks的依赖列表中安全的将它们移除。
改进过后如下:
/* 提供者组件 */
interface ISignContext {
state: ISignState; // 集中管理的state
dispatcher: React.Dispatch<IAction>; // 改变state的dispatcher
}
export const SignContext = createContext({} as ISignContext)
export default memo(function PcSign(props: ISignProps) {
// ......
const [state, dispatcher] = useReducer(reducer, initState);
// ......
return useMemo(() => {
return (
<SignContext.Provider value={{ state, dispatcher }}>
{/* JSX...... */}
</SignContext.Provider>
)
}, [...deps])
})
/* 消费者组件(任意深的层次),取出共享的数据 */
const {state, dispatcher} = useContext(SignContext)
const {/*...*/} = state
5. 完整的一次绘制过程
既然事件监听的处理函数已添加完毕,数据结构已定义完毕,集中管理的状态及改变状态的方法已拿到手,那么接下来,我们来看看结合上述内容,完整的一次绘制过程是如何实现的。
5.1 鼠标按下 / 手指落下时需要做的事情
首先我们需要拿到相应的事件对象,这与点击事件的一般处理完全相同,这里就不再赘述了。通过该事件对象,我们可以获取很多有价值的信息:
const {clientX, clientY} = event // 相对于屏幕左上角角点的坐标值, 移动端需要拿到点按的那根手指,touch = event.touches[0]; const {clientX, clientY} = touch
const {timeStemp} = event // 事件发生时对应的时间戳,用于后续判断绘制的速度快慢
但是,如果仅仅基于clientX和clientY进行绘制,那就大错特错了。因为它们始终是相对于屏幕左上角角点的坐标值。最终真正绘制的坐标应该是相对于Canvas初始坐标轴原点的。好吧,如果你还不理解,那么直接上图吧!如下图1所示,以Y轴坐标为例,绘制的点的y值应当是clientY减去canvas元素距离屏幕顶部的距离offsetY(具体场景可能涉及更复杂的计算,这里只是个简单demo)。
图1. 绘制坐标求取示意图
最后,该动作对应的事件处理函数如下:
const {state, dispatcher} = useContext(SignContext)
const onMouseDown = useCallback(
(e: any) => {
const { offsetLeft, offsetTop } = initializePaint(e); // 这个封装的函数就是在做canvas元素距离屏幕顶部距离的相关计算
const origin = {
x: e.clientX - offsetLeft,
y: e.clientY - offsetTop,
t: e.timeStamp.toFixed(0),
} as Point; // 确定绘制过程的起点
dispatcher({ type: _actionType.TOGGLE_DRAWABLE, payload: true }); // 更改状态为可绘制状态
dispatcher({ type: _actionType.CHANGE_ORIGIN, payload: origin }); // 记录下绘制起点
dispatcher({ type: _actionType.ADD_POINT, payload: origin }); // 往缓存点集中添加绘点
},
[dispatcher, state],
);
当定义完上述集中管理的状态及其派发器后,需要改变何种状态直接派发即可,几行搞定,一切就是变得如此简单!
5.2 鼠标拖拽 / 手指滑动时需要做的事情
这一动作是整个绘制的核心。
const onMouseMove = useCallback( // 需要注意的是每移动1px,都会回调这里的函数
(e: any) => {
if (!paintState.drawable) return; // 如果为不可绘制状态,则直接退出
const { offsetLeft, offsetTop } = initializePaint(e); // 同样拿到canvas元素相对于屏幕左上角的距离
const endInTick = {
x: e.clientX - offsetLeft,
y: e.clientY - offsetTop,
t: e.timeStamp.toFixed(0),
} as Point; // 确定绘制过程中途经的各点,也就是每一帧结束时被记录下的点
/* 逐帧绘制核心 [tick-wise painting] */
const ctx = canvasRef.current.getContext('2d')!; // 拿到canvas元素对应的2D渲染上下文对象
paint(ctx, state.origin, endInTick); // 传入上一帧结束的点与当前帧结束的点,进行绘制
dispatcher({ type: _actionType.ADD_POINT, payload: endInTick }); // 向缓存点集中添加途经的点
dispatcher({ type: _actionType.CHANGE_ORIGIN, payload: endInTick }); // 将此帧结束的点作为下一帧(若有)的起始点,一种微分的思想
},
[dispatcher, state],
);
/* utils.ts 中 */
export function paint( // 注意!此函数是在帧与帧之间被调用的,调用频率极高
ctx: CanvasRenderingContext2D,
origin: Point,
end: Point,
lineWidth: number = 2,
color: string = 'black') {
// ...... 关键源码省略
ctx.beginPath(); // 路径开始
ctx.fillStyle = color; // 填充色
for (let i = 0; i <= curve.d; i++) {
let x = origin.x + (i * (end.x - origin.x)) / curve.d; // 计算下一步绘制点(圆心)
let y = origin.y + (i * (end.y - origin.y)) / curve.d;
ctx.moveTo(x, y); // 移动绘制起点
ctx.arc(x, y, curve.w, 0, 2 * Math.PI, false); // 以线宽为半径,绘制小圆点
}
ctx.closePath(); // 路径结束
ctx.fill(); // 填充路径
}
5.3 鼠标松开 / 手指抬起时需要做的事情
这一动作对应的事件处理函数中,主要做的是收尾工作。
const onMouseUp = useCallback(
(e: any) => {
dispatcher({
type: _actionType.PUSH_FIGURE,
payload: { points: state.points },
}); // 将缓存点集一并添加至笔画对象的点集中,构成一笔笔画
dispatcher({ type: _actionType.CHANGE_ORIGIN, payload: {} }); // 重置绘制起点
dispatcher({ type: _actionType.CLEAR_POINTS }); // 清除缓存点集
dispatcher({ type: _actionType.TOGGLE_DRAWABLE, payload: false }); // 更改状态为不可绘制状态
},
[dispatcher, state],
);
最终绘制效果如下图2所示:
图2. 签批绘制实际效果
6. 性能优化
如果耐心和细致的你看到了这里,我相信你肯定发现我在本博客中始终强调hooks(包括useMemo, useCallback)依赖项的正确填写,那么为什么组件内部基本所有的引用数据类型都需要外围包裹一层这样的hooks呢?答案就是要做到性能优化。当然,在GPU算力较强,即浏览器的重绘渲染能力较强的情形下,结合较新版本Chrome中V8引擎的加持,手写签批的绘制肯定是相当顺畅和丝滑的,这种情况下的确没有必要做过多的性能优化,费时又费力。但是,当用户使用带有较差核显的CPU且仅有CPU的PC,且使用较低版本的Chrome甚至是万恶的IE时,势必会出现绘制的卡顿。
在本博客第4节状态管理中,提到了频繁(逐帧)更新状态的问题。在React中,即便使用memo包裹函数式组件,也无法从根本上规避因浅层比较的不同而导致的非必要重新渲染问题,假如因频繁更新状态导致子组件或更深组件的重新渲染,这对于性能来说是相当致命的。然而,手动地为useMemo和useCallback这两个hooks添加依赖项,就可以做到由coder自己来指定哪些响应式对象的变化才会导致返回的对象引用的改变,从而按需的/确定性的触发子组件的重新渲染。
事实上,本博客介绍的手写签批还有很多地方可以做再优化。例如
reducer函数中,传入的initState必须通过浅拷贝这种更改方式来触发React的响应式,而目前前端社区中一些非常优秀的库例如immer[4] 和immutableJS[5] 都可以加速React对于不同对象的判定,从而避免对大量的或对较大复杂对象的浅拷贝所引起的性能损耗问题。
7. 小结
通过手写签批这一小小的案例,相信大家对于Canvas尤其是React或多或少都有了新的认识。是啊,React相较于Vue具有更高的灵活度,利用好JSX能够玩出很多花样。但任何事情都是一把双刃剑,高灵活度带来的代价就是更高的门槛,也需要更强的JS功底来更好的“驾驭”它。如果还需进一步剖析其底层原理,或将其运用到更复杂的业务场景,相信还有更漫长的路需要走 (这里小小的期待一下React 18)。最后,如果你对更多衍生产品感兴趣:传送门。博客中的文字除引用参考之外,全部是博主一个字一个字码出来,如有不足之处,还请各位大佬多多指教~
8. 参考链接
- [1] echarts.apache.org/zh/api.html…? | ECharts的初始化过程
- [2] styled-components.com/? | Styled-Component
- [3] reactjs.org/docs/hooks-…? | React-Hooks-useReducer
- [4] immutable-js.com/? | Immutable
- [5] immerjs.github.io/immer/? | Immer