这两周给极客微博加上了“发微博的时候,可以带上图片”的功能(作为一个“微博”app,怎么能没有发图片微博的功能呢?)。没想到,做这个小功能的折腾程度,超出了我的预期。
其实如果是纯Rails MVC,那么给微博加上图片功能,简直不要太简单。后端两行核心代码:
# tweet.rbhas_many_attached :images# tweet_controller.rbparams.require(:tweet).permit(:body, images: [])
然后前端相关的template 文件里面,相应的加上文件选择控件,和显示图片的相关代码就搞定了,毕竟ActiveStorage什么都帮你做好了。
可是极客微博前端用了Vue。发微博的时候,是通过前端发POST Ajax请求的方式发的,所以事情就变得复杂了。首先,你没有办法像文本字段一样,给json body加一个file字段,然后POST给后端。所以想要在前端用Ajax发请求上传图片,就剩下两种办法:
1. 在前端先把图片上传到图片存储服务(我用的是阿里云OSS),拿到上传后的图片url,然后把url传给后端。
2. 使用FormData。这是经过一些搜索,以及在微信群里面请教后,得到的方案。
我不是特别想使用第一种方式,原因有多个。其一,这种方式的实现成本不小,这个可以在Rails ActiveStorage的官方文档(https://edgeguides.rubyonrails.org/active_storage_overview.html#direct-uploads)里面了解到。其二,这种方式的用户体验也不是很好,因为上传需要一个过程,如果上传的图片比较大的话,用户等待的时间就会有点长。先压缩再上传?那又增加了一点工作量。最后,如果用户想换一张图片的话,那之前的上传和等待就都浪费了。出于这几点考虑,我决定使用第二种方式。
但是使用FormData,其实就相当于使用表单提交的方式发请求。出于安全考虑,为了防止CSRF攻击,Rails默认需要验证表单提交的请求的CSRF token。在使用 form_for, form_with这些rails view helper构造表单的时候,rails会自动生成csrf tag field,然后在表单提交的时候,自动带上这个field。然而现在,我们的表单是自己构造的(记住这里,后面要考),不是使用Rails的view helper生成的:
"multipart/form-data"> "new_tweet_body" ... ref= ... class=
我们不能在这个form里面自己随便生成一个csrf token field,然后带给后端,因为这样生成的token是无效的。token必须由后端生成,然后通过某种方式传给前端发Ajax请求的地方。这个怎么办呢?
Google一下,发现了这篇文章: https://gist.github.com/przbadu/084197ea821a98b0e177b266b41ba0a2。看了下,解决方法其实非常简单。那就是在layout文件(比如application.html.erb)的header区加一行:
这会生成一个
<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](https://github.com/ChrisZou/geekweibo)上面看到,这里把前端主要代码贴一下,供有类似需求的小伙伴参考。后端的代码跟前面提到的一样,只需要两行改动而已。
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,列出项目的所有功能。然后以后新增功能时,挨个检查一遍,看会不会影响到。