CKEditor5 集成 Firebase Storage 实现图片上传

1,709 阅读4分钟

写在前面的话

在 CKEditor5 中默认提供的上传图片方式是 Base64 upload adapter ,该方式将图片转码成 Base64 格式后会导致文本内容过大,也不便于检索图片资源。所以实际集成过程中,都会重新定义图片上传方式。

选择合适的图片上传方式

image.png

CKEditor5 原生提供以下几种上传方式:

  • CKBox / Easy Image / CKFinder 收费,不考虑
  • Base64 adapter 图片被编码后直接存入数据库,导致文本内容过大
  • Simple adapter 提供上传请求自行上传,适合大部分自定义场景

具体描述可参考 Official upload adapters 。

如果使用的是 Google Cloud 原生的对象存储上传服务,使用 Simple adapter 基本就够用了。但本文中使用的是 Google Firebase Storage 来实现上传,该服务的特点在于可以通过 Google 提供的集成插件快速实现上传、控制权限。不过上传请求是插件内置的,外部无法直接更改。

最终实际应该使用的上传方式是 Custom image upload adapter ,也就是完全重写 CKEditor5 的图片上传逻辑。

具体实现过程

Vue 如何集成 CKEditor5 本文不做赘述,重点讲解如何在集成好的 CKEditor5 中引入自定义的图片上传插件,组件的基础目录结构如下图: image.png

集成的步骤大致分两步,首先编写自定义的图片上传插件,之后在具体的上传逻辑处引入 Firebase 即可。

编写自定义的图片上传插件

编写 ImageUploadAdapter

创建 imageUploadAdapter.ts 文件,实现代码如下:

export class ImageUploadAdapter {
  loader: any
  // 指定图片存储的二级目录
  prefix: string

  constructor(loader: any, prefix: string) {
    this.loader = loader
    this.prefix = prefix
  }

  upload() {
    return this.loader.file.then((file: File) => {
        // 上传的文件,后续在此处编写实际上传操作
        console.log(file)
    })
  }

  abort() {
    this.loader.abort()
  }
}

在实际组件中引入插件

组件的基础实现使用的是 CKEditor5 官方提供的 vue 集成版本 @ckeditor/ckeditor5-vue ,该组件对外提供一个 @ready 事件,通过监听该事件可获取到组件的可用实例。

随后动态插入自行编写好的 ImageUploadAdapter 插件即可。

该实现方式比官方提供的代码略微简单,因为官方参考代码中使用的是原始的 ClassicEditor 对象,而本文使用的是 Vue 的快速集成版本。

<template>
  <ckeditor :editor="editor" v-model="value" @ready="initExtraPlugins"/>
</template>

<script lang="ts">
  import CKEditor from '@ckeditor/ckeditor5-vue'
  import ClassicEditor from '@/components/editor/ckeditor'

  import { defineComponent } from 'vue'
  import { ImageUploadAdapter } from '@/components/editor/plugin/imageUploadAdapter'

  export default defineComponent({
    components: {
      ckeditor: CKEditor.component
    },
    props: {
      modelValue: String,
      // 由外部传入,用于指定图片存储的二级目录
      imagePrefix: {
        type: String,
        default: 'common'
      }
    },
    emits: ['update:modelValue'],
    data() {
      return {
        editor: ClassicEditor,
        value: ''
      }
    },
    mounted() {
      this.value = this.modelValue || ''
    },
    watch: {
      // 此处省略了自定义实现双向监听的代码
    },
    methods: {
      initExtraPlugins(editor: any) {
        editor.plugins.get('FileRepository').createUploadAdapter = (loader: any) => {
          return new ImageUploadAdapter(loader, this.imagePrefix)
        }
      }
    }
  })
</script>

使用 Firebase 完成上传操作

关于 Firebase Storage 详细对接文档可参考 Cloud Storage 使用入门 (Web) 。

开始前的一些提示

Firebase Storage 和 Google Cloud Storage 并不是同一个产品,在使用 Firebase Storage 之前需要先前往 Firebase Dashboard 创建对应的项目并添加 Storage 实例。 image.png

准备好上述这些后,在项目根目录执行 yarn add firebase 即可为项目添加 Firebase 插件。

初始化配置

在 imageUploadAdapter.ts 中添加以下代码:

import { initializeApp } from 'firebase/app'
import { getStorage } from 'firebase/storage'
import { FIREBASE_STORAGE_BUCKET } from '@/assets/ts/constant'

const firebaseConfig = {
  storageBucket: FIREBASE_STORAGE_BUCKET
}

const app = initializeApp(firebaseConfig)
const storage = getStorage(app)

export class ImageUploadAdapter {
    // 已省略重复代码
}

storageBucket 的链接来自下图中红框位置: image.png

编写上传操作

具体代码解释请阅读注释。

import { initializeApp } from 'firebase/app'
import { getDownloadURL, getStorage, ref, uploadBytesResumable } from 'firebase/storage'
import dayjs from 'dayjs'
import { FIREBASE_STORAGE_BUCKET } from '@/assets/ts/constant'

const firebaseConfig = {
  storageBucket: FIREBASE_STORAGE_BUCKET
}

const app = initializeApp(firebaseConfig)
const storage = getStorage(app)

export class ImageUploadAdapter {
  loader: any
  prefix: string

  constructor(loader: any, prefix: string) {
    this.loader = loader
    this.prefix = prefix
  }

  upload() {
    return this.loader.file.then((file: File) => new Promise((resolve, reject) => {
      // 获取文件名称
      const filename = generateFilename(this.prefix, file)
      const storageRef = ref(storage, filename)

      // 使用能够监控上传进度的接口来进行上传操作
      const uploadTask = uploadBytesResumable(storageRef, file)

      // 监控上传进度
      uploadTask.on('state_changed', snapshot => {
        // 将获取到的上传进度回传给 CKEditor5
        this.loader.uploadedPercent = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
      }, 
      err => reject(err), 
      // 上传成功后,再通过指定接口来获取完整的图片地址,并返回给 CKEditor5
      () => getDownloadURL(storageRef).then(url => resolve({ default: url })))
    }))
  }

  abort() {
    this.loader.abort()
  }
}

const generateFilename = (prefix: string, file: File) => {
  const originalName = file.name
  // 获取文件扩展名
  const extName = originalName.substring(originalName.lastIndexOf('.'))

  // 通过外部传入的二级目录,再加上 dayjs 生成的毫秒值,以及文件扩展名,生成完整的文件名称
  return `${prefix}/${dayjs().valueOf()}${extName}`
}

最终上传效果

因为上传时有传入二级目录,所以在 Firebase 的控制台可以看到相应的目录结构。 image.png

可进行的扩展项

带项目上线之前,还应该配置一下 App Check ,用于避免任何网站拿到桶名称后都可以进行上传。具体可参考 将 App Check 与 reCAPTCHA v3 搭配使用 。

如果没有完成这部分操作,在 Firebase 控制台会看到如下提示: image.png