# brush **Repository Path**: ymssx/brush ## Basic Information - **Project Name**: brush - **Description**: Brush.js是一个绘制canvas的JavaScript框架。 - **Primary Language**: JavaScript - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 4 - **Forks**: 2 - **Created**: 2020-05-12 - **Last Updated**: 2022-06-01 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Brush.js -- 用Canvas开发复杂应用 Brush.js是一个绘制Canvas的JavaScript框架。它是一套Canvas复杂应用开发的最佳实践。 * **组件化** 它解决了Canvas组件化的难题,为复杂应用的组件资产沉淀提供了可能性。 * **响应式** 它是数据驱动的,当数据更新时,Brush会自动更新相关的部分组件。在设计好组件的绘图逻辑之后,你只需要关注于数据逻辑部分。 * **高性能** 它对绘图的细节做了大量优化,将多组件绘图的时间复杂度从O(n)降到了O(log(n)),并可以自动实现局部渲染,你可以放心地交给Brush。 * **易使用** 它很容易上手,对于没有Canvas优化经验的开发者,也能轻松实现高性能的复杂Canvas应用。
## 为什么使用Brush Canvas应用由于其难把握性,开发者往往难以开发出可靠的复杂应用,这让现代前端UI的多元性发展受到了一定的阻扰。Brush希望通过一套简洁易用的框架,帮助开发者负责那些难以把握的性能优化环节,同时组件化的开发模式能够让复杂应用变得更简单。总而言之,Brush不需要开发者关心任何技术细节,而是可以专注于业务逻辑,极大的提升了开发效率。
## 📦 安装 ### 使用 ` ```
## 🧲 使用 我们以制作一个俄罗斯方块游戏为例。首先你需要创建一个Brush实例,并且传入尺寸 w 和 h 以及绑定的元素root。 ```javascript const brush = new Brush({ w: 300, h: 600, root: document.getElementById('root') }) // 如果后面的设置一切就绪,使用render方法就可以绘制 // brush.render() ``` 为了书写简便,在Brush中的宽度width、高度height、左距离left、上距离top分别被简化成了w、h、x、y。
### 图层 在Brush中存在图层的概念。从本质上,一个图层就是一个独立的canvas元素,这是为了应对复杂的绘图场景,各个图层在绘制时保持互相不干扰,同时也可以使用webwoker多线程渲染进行优化。 总之,一个图层是一个绘制的基本单位。我们首先需要创建一个图层。 ```javascript const layer = brush.createLayer({ style: { backgroundColor: 'white', w: 300, h: 600, x: 0, // 默认是0 y: 0 // 默认是0 }, // 根级组件 el: new Container({ w: 300, h: 600, backgroundColor: '#ddd' }) }) ``` 你需要为图层指定style样式,如果不指定,那么图层将会默认占满整个Brush画板,背景默认为透明。 你可能注意到了,我们设置了一个el属性,同时指定了一个入口组件,图层将会以这个组件开始,逐渐渲染整个组件树。当然,你也可以指定多个入口组件,通过数组传入多个组件即可。
### 组件 在Brush中,**一切皆是组件**,组件是构成整个复杂图像的基本单位。在组件中,我们将数据层和视图层进行了分离,在设置好了绘制模板之后,你只需要专注于数据业务逻辑即可。同时,每个组件将维护一个属于自己的offscreenCanvas,用于将自己的视图缓存下来,这也是Brush高效的原因之一。 Brush组件和React组件长得非常相似,本框架吸收了许多React的思想。因此,对于React的使用者来说,你可能使用起来非常熟悉。 我们首先来创建一个方块组件,为后面的俄罗斯方块游戏打下基础。 ```javascript class Box extends BrushElement { constructor(props) { super(props); // 组件的默认属性 this.defaultProps = { w: 50, h: 50, border: 5 } } // 绘图的部分放在这里 paint() { this.ctx.fillStyle = this.props.bg; let border = this.props.border; /** * 以下四个属性的两种获取方法是等同的 * this.w === this.props.w * this.h === this.props.h * this.x === this.props.x * this.y === this.props.y * 这是个简单的语法糖,简化对常用属性的访问 * 另外,brush支持使用百分比进行布局 */ this.ctx.fillRect(border , border, '95%', this.h); } } ``` 如何在组件中引用其它的组件呢?我们以方块“田”为例。 ```javascript class Tian extends BrushElement { constructor(props) { super(props); this.defaultProps = { w: 100, h: 100 } } /** * 指定需要用到的子组件 * 一定要命名为elMap,不支持自定义 * 组件实例名可以自定义,访问方式为: this.el.box */ elMap = { box: new Box({ bg: 'black', border: 5 }) }; paint() { // 注意是el不是elMap // 可以对子组件传参 // 在之后可以使用rotate、scale等方法进行后处理 // 最后一定要调用done方法表示结束 this.el.box({ x: 0, y: 0 }).done(); this.el.box({ x: 50 }).done(); this.el.box({ x: 0, y: 50 }).done(); this.el.box({ x: 50 }).done(); } } ``` 现在我们有了一个“田”字组件,接下来创建一个容器吧! 我们通过一些数据让方块动起来。 ```javascript class Container extends BrushElement { constructor(props) { super(props); // 设置内部状态 this.state = { i: 0 } } elMap = { tian: new Tian({ x: 0, y: 0 }) }; // 生命周期钩子,在组件被初始化后执行函数 created() { // 每隔一秒将i自增 setInterval(() => { this.setState({ i: this.state.i + 1 }) }, 1000); } paint() { // 我们需要先清除一下画布 this.clear(); this.el.tian({ y: this.state.i * 10 }).done(); } } ``` 怎么样,是不是“有那味了”,一切都和react那么像,你只需要通过setState更新数据,Brush会自动对相关组件进行重绘,十分简单易用。 接下来,你需要按下启动键,组件树就开始绘制了。 ```javascript brush.render(); ```
## 📚 核心概念 ### 组件 组件是Brush中核心的概念,你只需要将目标细化分解成一个个组件,就能轻松的构建复杂的图像。 Brush的每一个组件都维护了一个私有的offscreenCanvas,用于保存自己的视图状态,只有在必要更新时,组件才会进行重绘。 组件允许嵌套其它子组件,你可以在绘制函数`paint`中指定子组件的使用时机,你也可以在组件外部进行指定。 ```javascript class Demo extends BrushElement { // 指定你需要的组件们 elMap = { box: new Box({ // ... 初始化参数 }) }; constructor(props) { super(props); } paint() { //... 绘制逻辑 this.el.box.paint({ // ... 传递参数 }).done(); } } ``` 注意,`this.el.name`获取的是一个控制器函数,而不是组件实例本身。其功能是传递新的参数并通知更新,在子组件绘制之后,链式调用`done`方法采集其canvas内容。而`this.elMap.name`才能直接获取到组件实例本身。 在paint之后你可以使用rotate、scale、translate、transform、opacity等方法对子组件进行后处理: ```javascript this.el.box.paint({ // ... }).rotate(45).translate(100, 100).scale(1.2, 0.8).done(); ``` 你可能需要一个现成的组件上进行补充,或者以一个组件为背景快速创建图形,你可以在外部指定子组件。 ```javascript class Demo extends BrushElement { elMap = { // ... box1: new Box(), box2: new Box() } // ... paint() { this.el.box({ // ... 传递参数 }).addChild([ this.elMap.box1, this.elMap.box2 ]).done(); } } ```
### 绘图扩展 (进行中)Brush对canvas绘制API进行了一系列的补充,其简化了绘制复杂度,扩增了一系列常用的绘图功能,同时你也拥有完全的原生canvas API。 **Brush在绘制时允许你使用百分比、vw、vh等实用的动态参数。** #### `ctx.rect(x, y, w, h)` 绘制矩形 #### `ctx.circle(x, y, r)` 绘制圆 #### `ctx.plot(X: number[], Y: number[])` (计划)快速绘制折线图 #### `ctx.smooth(X: number[], Y: number[])` (计划)通过三次样条插值快速绘制曲线 ... 待补充
### 动画 Brush的动画是数据驱动的,你只需要指定你的目标state和过渡时间(ms),我们会自动平滑地绘制过渡动画(仅支持数值)。 ```javascript BrushElement.smoothState(targetState, delay); ``` * **targetState** 需要渐变的目标值,会自动渐近地改变state中对应的数值部分。 * **delay** 动画过渡时间。 smoothState的返回值是一个Promise对象,你可以在之后链式调用其它动画。 让我们升级一下上述的容器,让它的移动更平滑! ```javascript class Container extends BrushElement { constructor(props) { super(props); // 设置内部状态 this.state = { i: 0 } } elMap = { tian: new Tian({ x: 0, y: 0 }) }; created() { // 每隔一秒将i自增 let i = 1; setInterval(() => { // 300ms的过渡动画 this.smoothState({ i: i++ }, 300); }, 1000) } paint() { this.clear(); this.el.tian({ y: this.state.i * 10 }) } } ``` 也许你需要数据**永不停息**地增长,你可以使用`infiniteState`方法,传入一个增长速度,我们会按照这个速度进行平滑的增加。 ```javascript BrushElement.infiniteState(stepState); ``` 函数返回一个控制器,你可以使用stop、start进行暂停和启动。 例如: ```javascript let control = this.infiniteState({ i: 10, // 每秒平滑地增加10 j: { k: 1 // 每秒平滑地增加1 } }); setTimeout(() => { control.stop(); }, 5000) ``` 或者你需要组件没有时间限制的运动,你可以使用stepState,我们会尽可能的在每次电话帧之前修改state。 ```javascript BrushElement.stepState(stepState); ``` 函数返回一个控制器,你可以使用stop、start进行暂停和启动。 例如: ```javascript let control = this.stepState({ i: 10, // 每秒平滑地增加10 j: { k: 1 // 每秒平滑地增加1 } }); setTimeout(() => { control.stop(); }, 5000) ``` 你也可以传入一个函数 ```javascript let control = this.stepState(state => { return { x: state.x + 1, } }); ``` 当然了,你可以自定义你自己的动画,在通常我们使用requestAnimationFrame来请求动画帧,在Brush中,请使用nextFrame方法。 ```javascript let animation = () => { this.state.i++; // 如果你希望递归调用动画,请在末尾加上nextFrame this.nextFrame(animation); } this.nextFrame(animation); ```
### 事件 Brush中的父子组件通信可以使用props进行。 ... ### 界面交互 Brush允许你轻松的创建界面交互效果,你只需要在组件中设置鼠标事件回调函数即可。 ```javascript class Demo extends BrushElement { constructor(props) { super(props); this.state = { i: 1 }; } created() { this.addEvent('click', () => { this.setState({ i: this.state.i + 1 }) }) } } ``` 支持的鼠标事件有 `click | over | in | out` 同时,你也可以随时更改鼠标样式 ```javascript this.changeCursor('pointer'); ``` Brush组件中的事件同样存在事件冒泡,在捕获到最终的目标组件之后,事件会反向向父级传播,直到传播到根级组件。 ### store ... ### 状态提升 ...
## 💡 深入原理 ### 组件树

父子组件之间是一对多的关系,每个子组件同时拥有自己的子组件,最终形成了一个组件树。同时,每一个组件都自行维护了一个属于自己的offscreenCanvas,只有在有必要更新时,才会自行进行重绘,更新自己的canvas。 也就是说,当一个组件在重绘的时候,自己的每一个子组件都会根据情况(比如props是否有变动、变动属性是否被依赖、自身状态是否过期等)判断自己是否需要重绘,如果不需要则直接返回自己的canvas内容,而父级组件本身只需要拿到这些内容进行绘制。

这样一来,比起将所有的组件都无差别的进行重绘,Brush只需要对某一条路线进行重绘就可以了,时间复杂度从O(n)降低到了O(log(n)),这也就是为什么Brush高效的原因。
### 更新策略 Brush的更新策略非常值得一说。早期在设计Brush的架构的时候,Brush采用的是一种自下向上、反向传播通知的更新策略。 首先要明确的一点是,父组件完全依赖于子组件的内容,如果子组件的内容不是最新的,那么父级组件的绘制是完全无效的。所以Brush早期的策略是: 1. 每个组件都维护一个内部状态state,当使用setState更新数据时,Brush使用requestAnimationFrame将绘制函数放到异步队列,同时多次数据更新会取消之前的绘制任务,这样一来,可以只在下一个动画帧前仅执行一次绘制函数。 2. 当子组件更新时,子组件会进行自我重绘,在那之后反向通知父组件,父组件根据所有子组件当前的内容进行重绘,接着继续向上传播,直到根级组件被完全更新。 这样的策略看起来十分完美,但是实际上存在几个问题。 > 一个组件只有在其被更新完毕之后才能进行反向传播,因为父级组件提前进行更新是没有意义的。由于绘制任务是异步的,一个动画帧仅能执行一次,也就是说,只有等到一个动画帧绘制完毕之后才会通知父级进行更新,一帧仅能反向传播一个单位。 这会带来什么问题呢?我们假设在组件树的不同部位的两个子组件A、B同时(或者说在同一个主任务中)进行了数据更新,其最近的公共父组件为F。由于A、B在组件树里面的深度不一样,而反向传播的速度为 1层/帧,也就是说A、B到达父组件的顺序不一样!尽管A、B是同时更新的。最终父级组件F被分别更新了2次,这就带来了性能浪费。 父级组件非常依赖子组件的内容,所以渲染顺序十分重要,如果在子组件内容不是最新时,父组件进行绘制是完全无效的。由于子组件的反向传播时间取决于在树中的深度,所以绘制顺序难以控制。 同时,当一个组件树的路径非常长时,可能会导致需要传播许久才能达到根节点。 那么如果取消对绘制函数的异步处理呢?假设父子组件同时进行更新,父级组件会进行两次绘制,同时在子组件未更新时父组件的绘制是浪费的。所以我们需要转变思路,改为从正向传播更新,最终Brush采取了下面的策略。
#### Brush的策略

1. 每个子组件更新数据之后,直接向根级元素(图层)发送更新请求,图层收集来自各个组件的更新请求,并且把来自于同一个组件的请求进行去重,只保留一个。 2. 图层每次收到更新请求后,利用requestAnimationFrame执行一个异步函数(见3),多次收到更新将会取消异步任务,重新执行异步函数。

3. 在下一个动画帧之前,图层分析所有发起更新请求的子组件,得到从根组件到请求子组件的**更新链**,并且将更新链上所有的组件标记为 **过期**。 4. 由根组件开始向下发起渲染请求,每个组件在绘制时向子组件**索要**最新的canvas内容。如果子组件的状态为未过期,且props未发生有效变化,那么直接向父组件交付自己的canvas,不进行重绘;如果子组件已过期,那么就会强制进行重绘,同时也向自己的子组件索要最新内容,对于每一个子组件都重复上述的内容。这样一来,最终所有的组件都变成了最新状态。 一次整体的更新是在一次主任务中执行的,从上至下向子组件索要更新,这能保证每个组件的渲染顺序都是正确的,且最多只被绘制了一次,直接跳过无需更新的组件,所有问题都迎刃而解。