2 Star 4 Fork 2

Yami / brush

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
MIT

Brush.js -- 用Canvas开发复杂应用

Brush.js是一个绘制Canvas的JavaScript框架。它是一套Canvas复杂应用开发的最佳实践。

  • 组件化 它解决了Canvas组件化的难题,为复杂应用的组件资产沉淀提供了可能性。

  • 响应式 它是数据驱动的,当数据更新时,Brush会自动更新相关的部分组件。在设计好组件的绘图逻辑之后,你只需要关注于数据逻辑部分。

  • 高性能 它对绘图的细节做了大量优化,将多组件绘图的时间复杂度从O(n)降到了O(log(n)),并可以自动实现局部渲染,你可以放心地交给Brush。

  • 易使用 它很容易上手,对于没有Canvas优化经验的开发者,也能轻松实现高性能的复杂Canvas应用。


为什么使用Brush

Canvas应用由于其难把握性,开发者往往难以开发出可靠的复杂应用,这让现代前端UI的多元性发展受到了一定的阻扰。Brush希望通过一套简洁易用的框架,帮助开发者负责那些难以把握的性能优化环节,同时组件化的开发模式能够让复杂应用变得更简单。总而言之,Brush不需要开发者关心任何技术细节,而是可以专注于业务逻辑,极大的提升了开发效率。


📦 安装

使用 <script> 引用

<div id="root"></div>

<script src="xxx/brush.js"></script>

🧲 使用

我们以制作一个俄罗斯方块游戏为例。首先你需要创建一个Brush实例,并且传入尺寸 w 和 h 以及绑定的元素root。

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多线程渲染进行优化。

总之,一个图层是一个绘制的基本单位。我们首先需要创建一个图层。

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的使用者来说,你可能使用起来非常熟悉。

我们首先来创建一个方块组件,为后面的俄罗斯方块游戏打下基础。

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);
  }
}

如何在组件中引用其它的组件呢?我们以方块“田”为例。

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();
  }
}

现在我们有了一个“田”字组件,接下来创建一个容器吧! 我们通过一些数据让方块动起来。

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会自动对相关组件进行重绘,十分简单易用。

接下来,你需要按下启动键,组件树就开始绘制了。

brush.render();

📚 核心概念

组件

组件是Brush中核心的概念,你只需要将目标细化分解成一个个组件,就能轻松的构建复杂的图像。

Brush的每一个组件都维护了一个私有的offscreenCanvas,用于保存自己的视图状态,只有在必要更新时,组件才会进行重绘。

组件允许嵌套其它子组件,你可以在绘制函数paint中指定子组件的使用时机,你也可以在组件外部进行指定。

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等方法对子组件进行后处理:

this.el.box.paint({
    // ...
}).rotate(45).translate(100, 100).scale(1.2, 0.8).done();

你可能需要一个现成的组件上进行补充,或者以一个组件为背景快速创建图形,你可以在外部指定子组件。

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),我们会自动平滑地绘制过渡动画(仅支持数值)。

BrushElement.smoothState(targetState, delay);
  • targetState 需要渐变的目标值,会自动渐近地改变state中对应的数值部分。

  • delay 动画过渡时间。

smoothState的返回值是一个Promise对象,你可以在之后链式调用其它动画。

让我们升级一下上述的容器,让它的移动更平滑!

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方法,传入一个增长速度,我们会按照这个速度进行平滑的增加。

BrushElement.infiniteState(stepState);

函数返回一个控制器,你可以使用stop、start进行暂停和启动。

例如:

let control = this.infiniteState({
  i: 10, // 每秒平滑地增加10
  j: {
    k: 1 // 每秒平滑地增加1
  }
});

setTimeout(() => {
  control.stop();
}, 5000)

或者你需要组件没有时间限制的运动,你可以使用stepState,我们会尽可能的在每次电话帧之前修改state。

BrushElement.stepState(stepState);

函数返回一个控制器,你可以使用stop、start进行暂停和启动。

例如:

let control = this.stepState({
  i: 10, // 每秒平滑地增加10
  j: {
    k: 1 // 每秒平滑地增加1
  }
});

setTimeout(() => {
  control.stop();
}, 5000)

你也可以传入一个函数

let control = this.stepState(state => {
  return {
    x: state.x + 1,
  }
});

当然了,你可以自定义你自己的动画,在通常我们使用requestAnimationFrame来请求动画帧,在Brush中,请使用nextFrame方法。

let animation = () => {
  this.state.i++;
  // 如果你希望递归调用动画,请在末尾加上nextFrame
  this.nextFrame(animation);
}
this.nextFrame(animation);

事件

Brush中的父子组件通信可以使用props进行。 ...

界面交互

Brush允许你轻松的创建界面交互效果,你只需要在组件中设置鼠标事件回调函数即可。

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

同时,你也可以随时更改鼠标样式


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),多次收到更新将会取消异步任务,重新执行异步函数。


  1. 在下一个动画帧之前,图层分析所有发起更新请求的子组件,得到从根组件到请求子组件的更新链,并且将更新链上所有的组件标记为 过期

  2. 由根组件开始向下发起渲染请求,每个组件在绘制时向子组件索要最新的canvas内容。如果子组件的状态为未过期,且props未发生有效变化,那么直接向父组件交付自己的canvas,不进行重绘;如果子组件已过期,那么就会强制进行重绘,同时也向自己的子组件索要最新内容,对于每一个子组件都重复上述的内容。这样一来,最终所有的组件都变成了最新状态。

一次整体的更新是在一次主任务中执行的,从上至下向子组件索要更新,这能保证每个组件的渲染顺序都是正确的,且最多只被绘制了一次,直接跳过无需更新的组件,所有问题都迎刃而解。

MIT License Copyright (c) 2020 Yami Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

简介

Brush.js是一个绘制canvas的JavaScript框架。 展开 收起
JavaScript
MIT
取消

发行版

暂无发行版

贡献者

全部

近期动态

加载更多
不能加载更多了
JavaScript
1
https://gitee.com/ymssx/brush.git
git@gitee.com:ymssx/brush.git
ymssx
brush
brush
master

搜索帮助