永中云服务平台基于永中软件股份有限公司十多年自主研发的Office核心技术,提供多种文档处理加工Saas服务,实现文档在线预览、文档在线编辑、文档格式转换等多种功能。
本博客主要介绍的是永中在线预览产品中基于React在在线预览文档上进行手写签批的基本实现原理。
首先我们来整体感受一下签批效果,如下图1所示,整个签批包括画笔、线框、文字、签字、签章五大功能,本博客以签字功能为例进行阐述,其他功能可前往官网自行体验,传送门:永中云服务。
图1. 手写签批整体效果展示
0. 一些感想
也许正在看这篇博客的你是一位前端大佬,那就当是对 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是需要签批的某一页的宽和高
)
}, [...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((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. 数据结构设计
设计良好的数据结构能够在最大程度上降低后续的维护成本,提升整体健壮性。
那么,对于前端 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. 状态管理*
React
有着自己的状态管理工具:Redux
。一般地,在单页面富应用SPA
中,跨组件数据及请求到的数据一般都存储到上述状态管理工具中,并且利用浏览器开发工具插件,可以追踪状态的改变。鉴于对开发带来的便利,使用它进行状态管理毋庸置疑。但React
框架自身又提供了一种跨组件数据共享的方式,那就是上下文context
(本质上与组件内部的状态相似,只不过可以直接将其共享给子组件及深层组件,而无需通过逐层传递的方式共享)。因此,在社区中,关于到底选择哪种进行共享状态的管理成为了一大话题。我个人认为,这还是由具体的业务场景来决定的。对于手写签批的状态应如何管理这一问题,不妨来分析一下:
- 首先,为确保绘制出的曲线的流畅度 (注意这里从本质上限制了节流函数
throttle
的使用),绘制必须是逐帧(frame-wise / tick-wise)
绘制的,也就是大概以16ms
的实时速率进行,这也就意味着每一次状态的改变相当频繁; - 其次,
Redux
中存储的数据状态都是可追踪的,在调试bug时,某些UI
可视状态等其他关键状态在何时、何处切换是排查的关键所在,但是上述频繁的状态改变,一旦存储到Redux
中,显然需要看到的有用状态切换记录都将被overwhelm
。并且,频繁的访问Redux
本身就是不被建议的操作。
因此,手写签批使用上下文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 (
{/* JSX...... */}
)
}, [...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
的依赖项正确设置完毕,完全可以放心的使用。
改进过后如下:
/* 提供者组件 */
interface ISignContext {
state: ISignState; // 集中管理的state
dispatcher: React.Dispatch; // 改变state的dispatcher
}
export const SignContext = createContext({} as ISignContext)
export default memo(function PcSign(props: ISignProps) {
// ......
const [state, dispatcher] = useReducer(reducer, initState);
// ......
return useMemo(() => {
return (
{/* JSX...... */}
)
}, [...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
初始坐标轴原点的。好吧,如果你还不理解,那么直接上图吧!如下图2所示,以Y轴坐标为例,绘制的点的y值应当是clientY
减去canvas
元素距离屏幕顶部的距离offsetY
(具体场景可能涉及更复杂的计算,这里只是个简单demo)。
图2. 绘制坐标求取示意图
最后,该动作对应的事件处理函数如下:
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],
);
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
功底来更好的“驾驭”它。最后,本博客如有不足之处,还请各位大佬多多指教~
8. 参考链接
- [1] ECharts初始化
- [2] Styled Components
- [3] React-Hooks-useReducer
- [4] Immer
- [5] ImmutableJS