永中云服务平台基于永中软件股份有限公司十多年自主研发的 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 是须要签批的某一页的宽和高
<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. 数据结构设计
设计良好的数据结构可能在最大水平上升高后续的保护老本,晋升整体健壮性。
那么,对于前端 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 (<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
的依赖项正确设置结束,齐全能够释怀的应用。
改良过后如下:
/* 提供者组件 */
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
初始坐标轴原点的。好吧,如果你还不了解,那么间接上图吧!如下图 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