使用Vue前端、Rails后端实现图片上传的功能

这两周给自己的开源项目极客微博加上了发微博带图片的功能(作为一个“微博”app,怎么能没有发图片微博的功能呢?)。没想到,做这个小功能的折腾程度,超出了我的预期。

其实如果是纯Rails MVC,那么给微博加上图片功能,简直不要太简单。后端两行核心代码:

# tweet.rb
has_many_attached :images
​
# tweet_controller.rb
params.require(:tweet).permit(:body, images: [])

然后前端相关的template 文件里面,相应的加上文件选择控件,和显示图片的相关代码就搞定了,毕竟ActiveStorage什么都帮你做好了。

可是极客微博前端用了Vue。发微博的时候,是通过前端发POST Ajax请求的方式发的,所以事情就变得复杂了。首先,你没有办法像文本字段一样,给json body加一个file字段,然后POST给后端。所以想要在前端用Ajax发请求上传图片,就剩下两种办法:

  1. 在前端先把图片上传到图片存储服务(我用的是阿里云OSS),拿到上传后的图片url,然后把url传给后端。
  2. 使用FormData。这是经过一些搜索,以及在微信群里面请教后,得到的方案。

我不是特别想使用第一种方式,原因有多个。其一,这种方式的实现成本不小,这个可以在Rails ActiveStorage的官方文档里面了解到。其二,这种方式的用户体验也不是很好,因为上传需要一个过程,如果上传的图片比较大的话,用户等待的时间就会有点长。先压缩再上传?那又增加了一点工作量。最后,如果用户想换一张图片的话,那之前的上传和等待就都浪费了。出于这几点考虑,我决定使用第二种方式。

但是使用FormData,其实就相当于使用表单提交的方式发请求。出于安全考虑,为了防止CSRF攻击,Rails默认需要验证表单提交的请求的CSRF token。在使用 form_for, form_with这些rails view helper构造表单的时候,rails会自动生成csrf tag field,然后在表单提交的时候,自动带上这个field。然而现在,我们的表单是自己构造的(记住这里,后面要考),不是使用Rails的view helper生成的:

<form enctype="multipart/form-data">
  <textarea v-model="new_tweet_body"
  ...
  <input ref="image_picker" type="file" @change="onFileChange" />
  ...
  <button class="button" @click.stop="postTweet">发布</button>
</form>

我们不能在这个form里面自己随便生成一个csrf token field,然后带给后端,因为这样生成的token是无效的。token必须由后端生成,然后通过某种方式传给前端发Ajax请求的地方。这个怎么办呢?
Google一下,发现了这篇文章。看了下,解决方法其实非常简单。那就是在layout文件(比如application.html.erb)的header区加一行:

<%= csrf_meta_tags %>

这会生成一个

<meta name="csrf-token" content="the-secret-token-value">

这样的tag。里面的content 就是我们想要的token。然后在发Ajax请求的地方,直接用JS获得这个值,加到Key为'X-CSRF-Token'的header里面去就好了:

const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
fetch('/tweets', {
      method: 'POST',
      headers: { 'X-CSRF-Token': csrfToken },
      body: formData,
  })...

这样,CSRF Token的问题就解决了。

上面提到的过程,其实还有一个坑。那就是我们构造了一个form,然后把发微博相关的输入控件(textarea/file input/button等)都放在这个form里面。这样一来,每次点击发布按钮,页面就会刷新一次,然后网络请求的callback也不会得到调用。这一方面导致用户体验不好(页面刷新),另外一方面也导致我在success callback里面做的一些清理工作没办法得到执行。
刚开始我以为是这个页面刷新是后端的返回导致的,折腾了半天,才发现不是,而是由于前端存在form的原因。其实这个form完全是没必要的,去掉以后,页面刷新的问题就解决了,callback也能正确的到回调。

这个功能完整的代码可以在github上面看到,这里把前端主要代码贴一下,供有类似需求的小伙伴参考。后端的代码跟前面提到的一样,只需要两行改动而已。

postTweet() {
  //...

  let formData = new FormData()
  formData.append('tweet[body]', this.new_tweet)
  // this.imageFile是通过 file input上传的那个文件。
  // 注意key后面的"[]"因为images是个数组。
  if (this.imageFile) formData.append('tweet[images][]', this.imageFile) 

  const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
  fetch('/tweets', {
    method: 'POST',
    headers: { 'X-CSRF-Token': csrfToken },
    body: formData,
  })
    .then(res => res.json())
    .then(data => {
       //clear local cache
    })
    .catch(e => {
      console.log(e)
    })
},

此外,还有个技术无关的坑,也导致我在功能发布上线之后,又做了几次紧急发布。那就是,微博支持带图片之后,在微博详情页,微博分享卡片页面并没有相应的显示图片。这个没办法,业余项目,没有专门的测试同学帮忙,很多地方的处理就容易漏掉。目前想到的一个办法是,做一个mindmap,列出项目的所有功能。然后以后新增功能是,挨个检查一遍,看会不会影响到。

Leave a Reply