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

chriszou · 2020年11月19日 · 最后由 tinyfeng 回复于 2020年11月24日 · 1330 次阅读

这两周给自己的开源项目极客微博加上了发微博带图片的功能(作为一个“微博”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" @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)
    })
},

其实跟 vue 没啥关系,前端实现都共通。

一直用的 direct uploads,没觉得有什么不妥,特别是你里面提到用户等待时间较长这点,用 form 上传,同样也是需要时间啊,direct uploads 也是可以控制的,可以在一个命令之后才开始上传,实现也很简单,不用话什么力气就可以和 element ui 等等的上传控件进行整合了

Rei 回复

嗯嗯,之前不知道怎么跟 vue 结合的地方在于 CSRF token 的传递。不过事后看实现方案,其实其他的前端场景也会有这样的问题(CSRF token 传递),然后也可以用这样的方式去处理。

pzgz 回复

“direct uploads 也是可以控制的,可以在一个命令之后才开始上传”,你的意思是,可以在比如说用户点击提交按钮之后再开始上传?这点之前到没注意。不过这样的话,是不是需要等前端上传完了之后,再发 post 请求给 server?这样控制起来会不会复杂点?不过可以预见的好处是,这样可以不用占用服务器端的流量。

chriszou 回复

ActiveStorage 有个 DirectUpload 的类,通过手动 create 可以控制它上传的时间,但是要看看源码才理解它怎么用,文档不够详细。

chriszou 回复

应该怪我描述不到位,activestorege 提供的 DirectUpload 目的是帮你解决从前端 js 直接上传到后端 ruby 文件服务的问题,而是否用户选中文件以后就直接开始上传,这是咱们自己可以控制的事情,就拿 element ui 来说,它的 upload 组件就有一个auto-upload属性可以用来控制是否自动上传,不自动上传的话,自己 submit 就可以,submit 的时候调用 DirectUpload 的代码就可以了,而一般做好以后,切换自动上传和手动上传就是设置上传组件的事情,和 DirectUpload 无关,DirectUpload 只接管上传组件和 Ruby 文件服务之间的事情。

在我的项目里面,我写了一个 vue 的 mixin,代码如下,在需要的地方,使用这个 mixin,然后设置 el-upload 的http-requestuploadByDirectUpload,设置 element-ui 上传组件的action为 rails 端的rails_direct_uploads_url,然后处理 upload 组件的事件就可以取得上传后的文件信息了,rails 的话,可以通过传回 signed_id 之类的信息,来做后续的文件绑定的操作。代码仅供参考,文件上传之类的事情就是这样,看着很烦,但一次做好以后,所有的地方都可以用了,用 form 有 form 的好处,但我其实后来感觉对于混合前后端来说,form 还可以,对于越来越多的前端完全分离来说,我更喜欢剥离文件和 rails form 之间的关系了。

import { DirectUpload } from "@rails/activestorage";

export default {
  data() {
    return {};
  },

  methods: {
    // 设置`el-upload`组件的`:http-request`为`uploadByDirectUpload`来实现选中文件就上传到服务器,上传成功的文件在`file-list`中会
    // 有`status`为`success`的状态,和之前已有的文件不同的是,新上传的文件会有一个`response`节点,里面包含有从服务器传回的信息,对于
    // rails而言,传回的信息为`blob`对象,里面包括`id`, `signed_id`等,其中`signed_id`可以作为key来赋值给对应的字段完成上传内容的赋值
    // TODO: 增加对`abort`的支持
    uploadByDirectUpload(option) {
      const directUploadDelegator = {
        // callbacks from active storage
        directUploadWillStoreFileWithXHR(xhr) {
          xhr.upload.addEventListener("progress", (event) => {
            const e = { percent: 0, ...event };
            if (event.total > 0) {
              e.percent = (event.loaded / event.total) * 100;
            }
            // console.log("Uploading process at: ", e.percent);
            option.onProgress(e);
          });
        },
      };
      const upload = new DirectUpload(
        option.file,
        option.action,
        directUploadDelegator
      );

      upload.create((error, blob) => {
        if (error) {
          // TODO
          console.error("Error occurred when upload file to backend: ", error);
          option.onError(error);
        } else {
          // console.log("File has been uploaded to backend: ", blob);
          option.onSuccess(blob);
        }
      });
      return upload;
    },

    // 检查el-upload的file-list对应的model字段,返回需要新attach上的内容, `isMultiple`表示该字段是否支持多文件
    //  - 对于支持多文件上传,返回非空数组包含有新上传blog的signed_id,返回空表示没有新上传的文件需要附加
    //  - 对于仅支持单文件上传的情况,返回当前新上传的blog信息,返回false表示文件保持不变,返回null表示清空单文件字段
    newAttachesFromFileList(fileList, isMultiple = false) {
      if (fileList == null || fileList.length <= 0) {
        return null;
      }
      if (isMultiple) {
        const newUploads = [];
        fileList.forEach((element) => {
          if (element.response && element.response.signed_id) {
            newUploads.push(element.response.signed_id);
          }
        });
        return newUploads;
      }
      if (
        fileList[0]
        && fileList[0].response
        && fileList[0].response.signed_id
      ) {
        return fileList[0].response.signed_id;
      }
      return false;
    },
  },
};
pzgz 回复

太感谢了!我看下,我以为 DirectUpload 是 upload 到 CDN[捂脸]

chriszou 回复

DirectUpload 是上传到储存后端,根据配置可以是本地文件储存或者 S3 一类的云储存,本地文件储存是由 Rails 提供了一个 API。

印象中 DirectUpload 是这样一个流程:

  1. 跟 Rails 后端通信,获得上传 token。
  2. 根据上传 token 上传文件到云储存。
  3. 上传完毕后返回一个字符串,这个字符串可以被 Model 的 attached 字段解析,提交后保存关联对象和储存信息。

我也用的 vue + element,解决方式是自己构造 formData,把 object 传进去

append(formData, obj, prefix) {
  if (Array.isArray(obj)) {
    for (const item of obj) {
      this.append(formData, item, `${prefix}[]`);
    }
  } else if (typeof obj === 'object') {
    var isObject = false
    for(var prop in obj) {
      if (obj.hasOwnProperty(prop)) {
        isObject = true
      }
    }
    if (isObject) {
      for (const key in obj) {
        var value = obj[key];
        if (value === null || value === undefined) {
          continue;
        }
        this.append(formData, value, `${prefix}[${key}]`);
      }
    } else {
      formData.append(prefix, obj);
    }
  } else {
    formData.append(prefix, obj);
  }
},

用的时候

var formData = new FormData();        
this.append(formData, this.obj, "obj");
this.fileList.map(item => {
  if (item.status === "ready") {
    formData.append("obj[your_attach_association][]", item.raw);
  }
});
需要 登录 后方可回复, 如果你还没有账号请 注册新账号