这两周给自己的开源项目极客微博加上了发微博带图片的功能(作为一个“微博”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 发请求上传图片,就剩下两种办法:
我不是特别想使用第一种方式,原因有多个。其一,这种方式的实现成本不小,这个可以在 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" @user1="onFileChange" />
...
<button class="button" @user2="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)
})
},