yuxino / source

🌟 The source code analysis
3 stars 0 forks source link

React-scratch #4

Open yuxino opened 6 years ago

yuxino commented 6 years ago

程序员的三大浪漫,编译原理,图形学,操作系统。最近在弄图形学相关的东西,emmmm , 说不上图形学。。。其实也就是在弄canvas。吃饭的时候看到支付宝付款后有一个刮刮卡。于是就在想能不能做个类似的东西呢。

答案是当然可以啊。于是就弄了一个demo。弄完之后又在想可不可以弄一个react的刮刮卡组件呢。当然也可以。这篇文章将记录我怎么弄一个这样的组件。

弄组件之前。我们先看看刮刮卡效果的实现原理。demo的代码很短加上空格也不到60行。

先看第一段。创建一个Image标签。并且获取canvas元素。为之后要用到这两个东西作准备。

const img = new Image();
img.src = "http://7xqvgr.com1.z0.glb.clouddn.com/70ed7f087bf40ad17078e01b5d2c11dfabeccec4.gif";
const canvas = document.querySelector('canvas');

接下来就是监听图片的load事件。当load完成后开始做某些事情。

img.addEventListener('load', function(e) { // ...  });

除此以外就没有其他的代码了。后面的篇幅讲的都是load完之后做的事情。所以会省略掉上面那段代码。只贴里面的核心逻辑。

完成刮刮卡效果的最主要的核心是通过compositing operation来控制组合图形。详细的资料可以在这里查到。还不错的中文博客

在刮刮卡里面用到了compositing operation的destination-out属性。

destination-out

The existing content is kept where it doesn't overlap the new shape.

这是MDN对destination-out的描述。但是这段话翻译过来还是蛮抽象,蛮难理解的的。这里我用我们肉眼可见的效果。可观察的结果来说就是使用了destination-out的图形。每当新的图形如果叠加在这个图形上的话。会让这个图形和新的图形叠加的部分变透明。

这里我特意写了个demo展示了一下这个效果。如果你不理解我上面的文字描述可以看看。

实现擦除效果

如果知道了destination-out给我们带来的效果。那么实现一个擦除效果就很简单了。我们只需要简单的在一个图层上叠图形就好了。叠的图形的首选当然是圆形。因为圆形比较像人的手指。圆的大小可以自己决定。我们唯一要运算的东西只有一个,那就是x,y坐标。这两个坐标就是鼠标在画布里面的坐标。

事件上的处理

一般来说刮刮卡 当然是要有一种刮开的感觉。当手指(鼠标)按住然后滑动才开始刮。松开后就停止刮。要实现这个逻辑的话也是非常简单。我们只需要维护一个state就好了。在开始时(mousedown)的时候把state设置为true。在松开时(mouseup)的时候把state设置为false。移(划)动(mouseup)的时候根据state判断是否开始擦除。

目标总结

总结一下。我们要做的事情就是实现擦除效果。处理手机和PC的事件。codepen的例子只处理了PC。所以在react-scrath中会把他补全。

开始

我使用了nwb构建项目。我写过一篇怎么构建项目的文章。可以看这里了解一下。但是这一篇不会告诉你怎么使用nwb。

我的目标和需求

为了写一个可以给其他人使用的sratch组件。那么我们需要定义一些可以定制化的东西。

我定了一下react-sratch需要接收的一些prop和它们的默认值。其中width,height,ratio的大小都是px。

prop desc defaultValue
width canvas的width 300
height canvas的height 200
baseBg 刮开后的图(可以是一个图片链接。也可以是一个颜色) null
coverBg 覆盖在刮开后的图或者颜色之上的东东 #000
ratio 刮开的程度 0.8
ratioSize 刮开的大小 30
callback 到达刮开的程度后调用的回掉 empty func

代码上的实现

import React, {Component} from 'react'
import t from 'prop-types'

export default class ReactScratch extends Component {
  // ....
}

ReactScratch.propTypes = {
  width: t.oneOfType([t.string,t.number]),
  height: t.oneOfType([t.string,t.number]),
  baseImg: t.string,
  baseBg: t.string,
  coverBg: t.string,
  ratio: t.oneOfType([t.string,t.number]),
  ratioSize: t.number,
  callback: t.func
}

ReactScratch.defaultProps = {
  width: 300,
  height: 200,
  baseImg: null,
  baseBg: null,
  coverBg: '#000',
  ratio: .8,
  ratioSize: 30,
  callback: () => {}
}

接下来要做的事情

首先我们设置一下canvas的大小并且把底图加到canvas上面吧。这里为了获得canvas的ref。用到了新的api-createRef()。可以了解一下。

const setSize = (node,props) => {
  const { width, height } = props
  node.width = width
  node.height = height
}

const getBg = (props) => {
  const isLink = /^https?:\/\//i
  const { baseBg } = props
  const ret = isLink.test(baseBg) ? `url(${baseBg})` : baseBg
  return ret
}

export default class ReactScratch extends Component {

  constructor (props) {
    super(props)
    this.canvasRef = React.createRef()
  }

  componentDidMount () {
    const node = this.canvasRef.current
    setSize(node,this.props)
  }

  render () {
    return (
      <canvas style={{
                      border: '1px solid black',
                      background: getBg(this.props),
                      backgroundSize: 'cover'
                    }}
              ref={this.canvasRef} />   
    )
  }
}

我这里放弃思考图片要怎么样。一律都是cover。我也不管图片是什么结尾。反正是https或者http开头的我都当成是链接。

现在可以这样使用。传一个地址或者一个颜色(可以是rgb,hsl)。

const baseBg = "http://os33nc36m.bkt.clouddn.com/FiXqeVa9OaZHb7empMXZrETKte9F"
// ....
<ReactScratch baseBg={baseBg} />

现在看起来是这样子。这里用的图是博士和坂本。

image

设置coverBg

好啊。设置完了这个。那就加一层遮罩(覆盖在上面的东东)吧。也就是coverBg。因为增加了一些api。整个index.js看起来有一些臃肿。所以我把设置canvas的东西抽出来放在了utils.js里面。重用了一下isLink函数。

index.js

import React, {Component} from 'react'
import t from 'prop-types'
import { getBg, getContext2d, setSize, setCover } from './utils'

export default class ReactScratch extends Component {

  constructor (props) {
    super(props)
    this.canvasRef = React.createRef()
  }

  componentDidMount () {
    const node = this.canvasRef.current
    setSize(node,this.props)
    setCover(node,this.props)
  }

  render () {
    return (
      <canvas style={{
                      border: '1px solid black',
                      background: getBg(this.props),
                      backgroundSize: 'cover'
                    }}
              ref={this.canvasRef} />   
    )
  }
}

ReactScratch.propTypes = {
  width: t.oneOfType([t.string,t.number]),
  height: t.oneOfType([t.string,t.number]),
  baseImg: t.string,
  baseBg: t.string,
  coverBg: t.string,
  ratio: t.oneOfType([t.string,t.number]),
  ratioSize: t.number,
  callback: t.func
}

ReactScratch.defaultProps = {
  width: 300,
  height: 200,
  baseImg: null,
  baseBg: null,
  coverBg: '#000',
  ratio: .8,
  ratioSize: 30,
  callback: () => {}
}

utils.js

/**
 * @private check string is a link ?
 * @param {*} str 
 */
const isLink = str => /^https?:\/\//i.test(str)

/**
 * @public get canvas context2d
 * @param {*} ref 
 */
const getContext2d = (ref) => {
  const ctx = ref.getContext('2d')
  return ctx
}

/**
 * @public get base background of canvas
 * @param {*} props 
 */
const getBg = (props) => {
  const { baseBg } = props
  const ret = isLink(baseBg) ? `url(${baseBg})` : baseBg
  return ret
}

/**
 * @public set canvas size
 * @param {*} node 
 * @param {*} props 
 */
const setSize = (node, props) => {
  const { width, height } = props
  node.width = width
  node.height = height
}

/**
 * @private set cover img
 * @param {*} ref 
 * @param {*} props 
 */
const setCoverImg = (ref,props) => {
  const { width, height, coverBg } = props
  const ctx = getContext2d(ref,props)
  const img = new Image()
  img.src = coverBg
  ctx.drawImage(img, width, height)
  ctx.globalCompositeOperation = 'destination-out'
}

/**
 * @private set conver background color
 * @param {*} ref 
 * @param {*} props 
 */
const setCoverBgColor = (ref,props) => {
  const { coverBg, width, height } = props
  const ctx = getContext2d(ref,props)
  ctx.fillStyle = coverBg;
  ctx.fillRect(0, 0, width, height)
  ctx.globalCompositeOperation = 'destination-out'
}

/**
 * @public switch to set cover background color or set cover img
 * @param {*} ref 
 * @param {*} props 
 */
const setCover = (ref, props) => {
  const { coverBg } = props
  isLink(coverBg) ? setCoverImg(ref, props) 
                       : setCoverBgColor(ref, props)
}

export {
  getBg,
  getContext2d,
  setSize,
  setCover
}

好现在我们可以这样使用了。如果不加参数的话默认会是一个黑色的块。

我这里设置了深绿色。如果想设置图片也可以。只要传一个链接就好了。现在看起来会是这个样子。

<ReactScratch baseBg={baseBg} coverBg="#006060" />

image

增加drawCircle

drawCircle就是字面意思画一个圆。这个圆是我们要用到的擦除效果。接收x,y坐标和size。size的话我们用ratioSize就好啦。于是乎我在utils.js里面加了一段。如果你跟着我的笔记走的话。不要忘记在export上把drawCircle暴露出去了噢。

/**
 * @public draw a cirle in (x,y) postion
 * @param {*} ref 
 * @param {*} x 
 * @param {*} y 
 * @param {*} size 
 */
const drawCircle = (ref, x, y, size) => {
  const ctx = getContext2d(ref)
  ctx.beginPath();
  ctx.arc(x, y, size, 0, Math.PI * 2);
  ctx.fill()
}

为了测试这个功能是否如期运行。我们就直接在componentDidMount里面加一段吧。目标是看见博士的脸。

// ...
import { getBg, getContext2d, setSize, setCover, drawCircle } from './utils'
// ...
componentDidMount () {
    const node = this.canvasRef.current
    setSize(node,this.props)
    setCover(node,this.props)
    const { width, height, ratioSize } = this.props
    const size = 30
    const x = 255
    const y = 55
    drawCircle(node, x, y, ratioSize)
 }

看见了吗。

image

好看见了。说明一切如期运行着。那么我们把加进index.js的部分都删掉吧。 感觉我们现在就差一个事件监听了。那么接下来我们会开始创建我们的事件监听。

emmmm 我们要做的事件监听无非就是两个。

手机端会麻烦一点但是还好。

pressed state

一般来说刮刮卡 当然是要有一种刮开的感觉。当手指(鼠标)按住然后滑动才开始刮。松开后就停止刮。要实现这个逻辑的话也是非常简单。我们只需要维护一个state就好了。在开始时(mousedown)的时候把state设置为true。在松开时(mouseup)的时候把state设置为false。移(划)动(mouseup)的时候根据state判断是否开始擦除。

在最前面的最后一段我曾经说过这个。所以我们需要维护一个state。现在我把这个state弄出来了并把它叫做pressed。同时我添加了两个方法。一个是onPressedonLoosen,意思分别是按压和松开的时候.

export default class ReactScratch extends Component {

  constructor (props) {
    super(props)
    this.canvasRef = React.createRef()
    this.state     = { pressed: false }
    this.onPressed = this.onPressed.bind(this)
    this.onLoosen  = this.onLoosen.bind(this)
  }

  onPressed () {
    this.setState({ pressed: true })
  }

  onLoosen () {
    this.setState({ pressed: false })
  }

  // ....
}

以上就是现在代码看起来的样子。接下来我们要知道在画布的哪个位置画圆。

获得坐标位置

我们先把事件赋给canvas。并添加一个叫做onMove的事件。这个事件会在我们按压移动的时候触发并且打印出坐标。

export default class ReactScratch extends Component {

  constructor (props) {
    super(props)
    this.canvasRef = React.createRef()
    this.state     = { pressed: false }
    this.onPressed = this.onPressed.bind(this)
    this.onLoosen  = this.onLoosen.bind(this)
    this.onMove    = this.onMove.bind(this)
  }

  onPressed () {
    this.setState({ pressed: true })
  }

  onLoosen () {
    this.setState({ pressed: false })
  }

  onMove (e) {
    const { pressed } = this.state
    if (pressed) {
      const left = e.target.offsetLeft
      const top = e.target.offsetTop
      const x = e.pageX -left , y = e.pageY - top
      console.log(`x: ${x},  y: ${y}`)
    }
  }

  componentDidMount () {
    const node = this.canvasRef.current
    setSize(node,this.props)
    setCover(node,this.props)
  }

  render () {
    return (
      <canvas style={{
                      border: '1px solid black',
                      background: getBg(this.props),
                      backgroundSize: 'cover'
                    }}
              onMouseDown={this.onPressed}
              onMouseUp={this.onLoosen}
              onMouseMove={this.onMove}
              ref={this.canvasRef} />   
    )
  }
}

test

擦除

略微修改onMove。并让canvas绑定事件。

  onMove (e) {
    const { pressed } = this.state
    if (pressed) {
      const { ratioSize } = this.props,
            left = e.target.offsetLeft,
            top = e.target.offsetTop,
            pageX = e.pageX || e.targetTouches[0].pageX,
            pageY = e.pageY || e.targetTouches[0].pageY,
            x = pageX - left - ratioSize / 2, 
            y = pageY - top  - ratioSize / 2
      drawCircle(this.canvasRef.current, x, y, ratioSize)
    }
  }

  render () {
    return (
      <canvas style={{
                      border: '1px solid black',
                      background: getBg(this.props),
                      backgroundSize: 'cover'
                    }}
              onMouseDown={this.onPressed}
              onMouseUp={this.onLoosen}
              onMouseMove={this.onMove}
              onTouchStart={this.onPressed}
              onTouchEnd={this.onLoosen}
              onTouchMove={this.onMove}
              ref={this.canvasRef} />
    )
  }

现在的react-scratch已经可以实现擦除了。删掉border: '1px solid black'因为这个不是必要的只是我在开发的时候想让这个canvas显眼一点才添加的。

手机端

test

PC端

test

Ok,那么整个组件的开发就到这里结束了。

才怪呢。ratio的callback还没有做呢。那么问题来了怎么获取ratio...

获取ratio

说实话我不知道怎么获取。但我觉得应该是计算覆盖在底图上的α指数(透明程度)有多少吧。看了lucky-card的源码确认了这点。感谢lucky-card !! 这里贴一下lucky-card的实现。

function _forEach(items, callback) {
  return Array.prototype.forEach.call(items, function(item, idx) {
    callback(item, idx);
  });
}

function _calcArea(ctx, callback, ratio) {
  var pixels = ctx.getImageData(0, 0, this.cWidth, this.cHeight);
  var transPixels = [];
  _forEach(pixels.data, function(item, i) {
    var pixel = pixels.data[i + 3];
    if (pixel === 0) {
        transPixels.push(pixel);
    }
  });

  if (transPixels.length / pixels.data.length > ratio) {
    callback && typeof callback === 'function' && callback();
  }
}

无耻的修改复制到utils.js里面。

/**
 * @public compute the ratio in canvas
 * @param {*} ref 
 * @param {*} props 
 */
const computeRatio = (ref, props) => {
  const { width, height } = props,
        pixels = getContext2d(ref).getImageData(0, 0, width, height),
        transPixels = []
  pixels.data.forEach((item, i) => {
    const pixel = pixels.data[i + 3]
    if (pixel === 0) {
        transPixels.push(pixel)
    }
  })
  return transPixels.length / pixels.data.length
}

改动一下index.js里的onLoosen

onLoosen () {
    const node = this.canvasRef.current,
          { ratio, callback } = this.props,
          _ratio = computeRatio(node, this.props)
    if (_ratio >= ratio) { callback() }
    this.setState({ pressed: false })
  }

理论上来说这个回调函数执行的次数应该是一次。但是这并不应该交由我来处理。应该给用户处理到底要执行多少次。

更改一下用法。加一个callback。让到达ratio的时候输出hello react scratch

<ReactScratch baseBg={baseBg} 
              coverBg="#006060"
              callback={() => console.log('hello react scratch')}
 />

test

一切运行正常 。终于react-strach可以release v1.0.0了

后记

虽然是个很小的组件。但是我认为笔记的描述程度有点过于多了。往后的笔记会看情况精简。

这个组件没有做测试,覆盖率很低。未来可能会补上。但是现在就到此为止吧。还有别的事情要做。

仓库地址: react-scratch 。欢(qiu qiu)迎(ni)给个star。