CKEditor5 集成 Firebase Storage 实现图片上传

996 阅读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