阅读 3069

今天这个仇先记下来了(深入版)

引言

    之前看到这个掘金的文章今天这个仇先记下来了,觉得挺有趣的,但是他是基于别人已经封装好的框架html2canvas来实现的。本着学习的态度,就用react和canvas原生api重新撸了一遍,在其中遇到了不少坑,也让自己学习到了不少姿势。

在线地址项目地址

项目效果:

preview

实现

项目中使用到的框架

reactant-designstyled-components

初始化部分

class Face extends Component {
  constructor (props) {
    super(props)
    this.state = {
      text: '2018年5月21日 没人给我点赞,这个仇我先记下来了', // 控制文本框和输出图片的文案
      canvasToDataURL: '' //把输出图片转换成DataURL,用于新建一张图片覆盖在原来的canvas上面,使得移动端可以长按保存图片    
    }
    this.img = null // 保存输出部分的image dom实例,塞进canvas里
    this.canvas = null // 保存canvas dom实例
    this.ctx = null // canvas上下文  
  }
}
复制代码

主体部分

{/* 输入部分 */}
<div className='input-container'>
  <img id="img"
    src={ require('../../../assets/grudges.png') }
    alt='记仇'/>
  <TextArea type='textarea'
    value={this.state.text}
    onChange={(e) => {
      this.onChangeText(e)
    }}
    placeholder='请输入你的记仇咯~'>
  </TextArea>
</div>
{/* 输出部分 */}
<div className='display-container'>
  {/* 我们需要操作的canvas */}
  <canvas id='canvas' width={200} height={205}></canvas>
  {/* 覆盖在canvas上面 */}
  <img className='replace-img' src={this.state.canvasToDataURL} alt="from canvas"/>
</div>
复制代码

    输入部分由初始图片和输入框组成,输入框添加两个props,一个是value,一个是onChangevalue是组建的状态textonChange的时候会调用onChangeText这个方法根据新的文案去更新canvas里的文案。

    输出部分由canvas和img组成,canvas用来处理图像,img覆盖在这个canvas上面,主要用于解决移动端不能直接长按canvas保存为图片的问题。用新的img覆盖在canvas上面,移动端就可以长按保存为图片。

输入框改变值onChangeText

onChangeText (e) {
  this.fillText(e.target.value) // 根据新值渲染新的文案
  this.setState({
    text: e.target.value,
    canvasToDataURL: this.canvas.toDataURL('image/png') // 通过渲染文案后的canvas,通过toDataURL方法更新占位img的src
  })
}
复制代码

组件挂载componentDidMount

async componentDidMount () {
  this.img = document.querySelector('#img')
  this.canvas = document.querySelector('#canvas')
  this.ctx = this.canvas.getContext('2d')
  await this.initCanvas() // 初始化canvas,把img塞进canvas里
  this.fillText(this.state.text) // 绘制文案
  this.setState({
    canvasToDataURL: this.canvas.toDataURL('image/png')
  }) // 根据canvas里面的图像内容,生成DataURL,并更新给输出部分的img的src,这样输出部分的img得到了更新}
复制代码

初始化canvas

async initCanvas () {
  this.ctx.clearRect(0, 0, this.width, this.height) // 清空canvas
  this.ctx.fillStyle = 'white'
  this.ctx.fillRect(0, 0, this.width, this.height)
  const img = await this.loadImage('https://cdn.b1anker.com/grudges.png') // 创建一个新的img
  this.drawImage(img)// 把img写进canvas里  
  return Promise.resolve()
}
复制代码

画图片drawImage

    在这里就会遇到一个坑,就是我的图片等资源是放到自己的cdn上,当使用canvas的getImageData之类的方法的时候会发生跨域的问题。

    解决办法就是给img加个crossOrigin属性,值为anonymous,并且相应的资源的响应头必须返回access-control-allow-origin: *

    具体可以看跨域解决方案

loadImage (url, crossOrigin = true) {
  // 使用canvas以更好地处理回调地狱
  return new Promise ((resolve, reject) => {
    const img = document.createElement('img')
    if (crossOrigin) {
      img.crossOrigin = 'anonymous' // 添加crossOrigin以解决canvas跨域问题
    }
    img.onload = () => {
      resolve(img)
    }
    img.onerror = (err) => {
      reject(err)
    }
    img.src = url
  })
}
复制代码

绘制文案fillText

    这里又遇到另外一个坑,就是文字在canvas中的换行,不是那么好操作。

    首先要通过canvas的measureText方法,测量text每个字符的长度,然后累加,当长度超过文案最大的宽度时,就换行,然后重新累加。

    然而,事情并没有那么简单,因为输入的字符可能是中文,英文,数字等,所以换行的时候,会有误差,没有达到理想的换行效果。

误差

    可以看到,这个字,稍微有点往外边飘了,具体的原因就是在字符1之前累加的长度并没有超过文案最大的宽度,但这个时候已经非常接近文案最大宽度了,而在加上下一个字符后,因为日字是中文字符所以比数字英文字符之类的要长一点,最终究造成了这个字有点超出边界。所以在累加的时候,还要计算下一个字符,再判断加上下一个字符是否超过最大宽度减去4的差,如果超过则换行。至于为什么要减去4,这个是考虑到了各种情况啦(当前长度比较接近与最大宽度但为超过最大宽度,但是下一个字符是数字的情况)。

// 绘制文案
fillText (text) {
  if (!text) {
    this.initCanvas()
  return
  }
  // 清除图片以下的画布空间,以重新绘制文案
  this.ctx.clearRect(0, 155, 200, this.height - 155)
  this.ctx.fillStyle = 'white'
  this.ctx.fillRect(0, 155, 200, this.height - 155)
  this.ctx.font = '14px sans-serif'
  this.ctx.fillStyle = 'black'
  const boundary = this.width - 20 // 最大文案宽度
  let initHeight = 175 // 当前文案绘制距离canvas顶部的距离
  let lastIndex = 0
  let j = 0 // 记录换行深度,如果深度大于canvas能展示的范围,则对canvas进行resize(我们这里设置了从第三行开始扩展canvas)
  for (let i = 0; i < text.length; i++) {
    // 当前长度
    const currTextWidth = this.ctx.measureText(text.substring(lastIndex, i)).width
    // 加上下一次长度
    const nextTextWidth = this.ctx.measureText(text.substring(lastIndex, i + 1)).width
    if ( currTextWidth > boundary) {
      // 绘制文案
      this.ctx.fillText(text.substring(lastIndex, i), 8, initHeight)
      // 增加绘制文案高度,为换行做准备
      initHeight += 20
      lastIndex = i
      j++
      this.resize(j)
    }
    if (i === text.length - 1) { //绘制剩余部分
      this.ctx.fillText(text.substring(lastIndex, i + 1), 8, initHeight)
    }
  }
  this.resize(j)
}
复制代码

    因为canvas简单的改变宽高度会使内容发生变形等意向不到的情况,所以对此要做一些处理

// 重置canvas大小
resize (deep) {
  if (deep > 1) {
    // 存储当前canvas的图片信息
    const store = this.ctx.getImageData(0, 0, this.width, this.height)
    this.ctx.clearRect(0, 0, this.width, this.height)
    // 增加高度
    this.canvas.setAttribute('height', 200 + (deep - 1) * 20)
    this.ctx.fillStyle = 'white'
    this.ctx.fillRect(0, 155, 200, this.height - 155)
    this.ctx.font = '14px sans-serif'
    this.ctx.fillStyle = 'black'
    // 绘制之前存储的图片信息回canvas
    this.ctx.putImageData(store, 0, 0)
  }
}
复制代码

部署的坑

    等到部署在服务器上的时候,本以为万事大吉了,没想到还是出问题了。初次访问的时候是ok的,但是刷新之后,就出问题了。有些浏览器没问题,有些浏览器有问题。其中有问题的是iphone的safari,这个时候就用iphone连上mac进行调试。可以发现浏览器报错,竟然是说图片跨域了。可是我们刚刚已经采取了相应的跨域解决方案,为什么还是会跨域呢。通过右键复制为curl到终端访问,明明是可以的,但是safari偏偏不行。估计是不同浏览器对于service worker的实现有一定的差异。

    后来查阅了大量资料后,大概知道了原因。可能是因为service worker不支持动态的请求跨域资源,也就是跨域请求时service worker的request的属性modecors但是属性credentialssame-origin,就会报错了。所以要对动态请求的跨域资源做一些修正处理:

self.toolbox.router.get("/(.*)", function (request, values, options) {
  let newRequest = null
  if (request.mode === 'cors') {
    /* 当脚本发起的动态跨域请求时,request.mode的值是'cors'
     * 这个时候需要重新实例化一个Request
     * 并设置credenti为'include'
     * 用新的Request去代替原来的
     */
    const newRequest = new Request(request, {
      credentials: 'include'
    })
  }
  return self.toolbox.cacheFirst.apply(this, [newRequest || request, values, options])
}, {
  origin: /cdn\.b1anker\.com/,
  cache: {
    name: staticAssetsCacheName,
    maxEntries: maxEntries
  }
})
复制代码
  • 这里用到了sw-toolbox.js
  • service worker中cors的介绍: origin is optional, and it's used to determine whether or not the response that's returned is opaque. If you leave this out, the response will be opaque, and the client will have limited access to the response's body and headers. If the request was made with mode: 'cors', then returning an opaque response will be treated as an error. However, if you specify a string value equal to the origin of the remote client (which can be obtained via event.origin), you're explicitly opting in to provides a CORS-enabled response to the client.
  • credentials介绍:credentials 是Request接口的只读属性,用于表示用户代理是否应该在跨域请求的情况下从其他域发送cookies.

总结

    这次编码之旅花费了不少时间,但是也让自己学到了很多东西。canvas的相关操作,cdn如何使用,图片跨域,service work跨域怎么解决等等...

    第一个版本做的功能有些简单,之后会考虑加入替换本地图片,默认图片库等功能

关注下面的标签,发现更多相似文章
评论

查看更多 >