从零搭建 Vue3 富文本编辑器:基于 Quill可扩展方案

在现代前端开发中,富文本编辑器是许多场景的核心组件 ------ 无论是博客平台的内容创作、社交应用的评论系统,还是企业 CMS 的编辑模块,都离不开一款功能完善、体验流畅的富文本工具。但市面上现成的编辑器要么体积庞大、要么定制化能力弱,很难完美适配业务需求。

今天分享一个我基于 Vue3 生态开发的轻量级富文本编辑器方案,整合了 Quill 的强大编辑能力,支持按需扩展,可直接集成到现有项目中。

一、组件结构设计:分层管理视图与交互

编辑器组件采用了「核心编辑区 + 工具栏 + 辅助功能区」的三层结构,通过 Vue3 的模板语法实现清晰的视图分层:

ts 复制代码
<template> 
    <div class="write-editor-wrapper"> 
    <!-- 1. 核心编辑区(Quill 实例挂载点) -->
    <div ref="editorContainer" class="quill-editor"></div>
    <!-- 2. 标签与字数统计区 -->
    <div class="custom-tags">...</div> 
    <!-- 3. 自定义工具栏(含格式控制与功能按钮) --> 
    <div class="custom-toolbar">...</div>
    <!-- 4. 功能弹窗(如插入链接对话框) -->
    <InsertLinkDialog ... /> </div>
</template>

二、Quill 初始化:自定义配置与事件监听

<script setup> 中,通过 onMounted 钩子完成 Quill 实例的初始化:

ts 复制代码
  onMounted(() => {
    quill = new Quill(editorContainer.value!, {
      theme: 'snow',
      modules: {
        toolbar: false,
        history: {
          delay: 2000,
          maxStack: 500,
          userOnly: true
        }
      },
      placeholder: props.placeholder || '请输入内容...'
    })
    if (props.modelValue) {
      quill.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
    }
    updateTextLength()
    quill.on('text-change', () => {
      emit('update:modelValue', quill.root.innerHTML)
      updateCurrentFormats()
      updateTextLength()
    })
    quill.on('selection-change', (range: any) => {
      updateCurrentFormats()
      isEditorFocused.value = !!range
    })
    quill.on('editor-change', (eventName: any) => {
      if (eventName === 'undo' || eventName === 'redo' || eventName === 'text-change') {
        updateRedoState()
      }
    })
    updateCurrentFormats()
    updateRedoState()
    tryInsertAtUser()
  })

关键设计点:

  1. 禁用 Quill 默认工具栏,完全自定义工具栏样式与交互,实现多端适配
  2. 通过 dangerouslyPasteHTML 支持初始内容渲染,配合 v-model 实现双向绑定
  3. 监听三大核心事件,分别处理内容更新、格式状态同步和历史记录管理

三、核心功能实现:从基础编辑到高级交互

1. 格式控制:基于 Quill API 的样式切换

通过 Quill 的 format 方法实现文本格式控制,配合状态管理实现按钮激活状态同步:

ts 复制代码
// 粗体
  function toggleFormat(name: string, value: any = true) {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    const isActive = !!currentFormats[name]
    quill.format(name, isActive ? false : value)
    updateCurrentFormats()
  }
// 标题
  function toggleHeader(level: number) {
    quill.focus()
    const isActive = currentFormats.header === level
    if (isActive) {
      quill.format('header', false)
    } else {
      quill.format('list', false)
      quill.format('blockquote', false)
      quill.format('header', level)
    }
  }
 // 列表
  function toggleList(type: 'ordered' | 'bullet') {
    quill.focus()
    const isActive = currentFormats.list === type
    if (isActive) {
      quill.format('list', false)
    } else {
      quill.format('header', false)
      quill.format('blockquote', false)
      quill.format('list', type)
    }
  }
 // 引用
  function toggleBlockquote() {
    quill.focus()
    const isActive = !!currentFormats.blockquote
    if (isActive) {
      quill.format('blockquote', false)
    } else {
      quill.format('header', false)
      quill.format('list', false)
      quill.format('blockquote', true)
    }
  }

这里的核心逻辑是「状态同步」:通过 quill.getFormat() 获取当前选区格式,存入 currentFormats 响应式对象,再通过 :class="{ active: ... }" 绑定到按钮,实现 UI 与实际格式的一致。

2. 媒体插入:图片 / 视频上传与嵌入

通过隐藏的 <input type="file"> 实现文件选择,配合 Quill 的 insertEmbed 方法插入媒体:

ts 复制代码
 // 图片上传
  function onImageChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const reader = new FileReader()
      reader.onload = function (evt) {
        const url = evt.target?.result as string
        quill.focus()
        const range = quill.getSelection()
        if (range) {
          quill.insertEmbed(range.index, 'image', url, 'user')
          quill.setSelection(range.index + 1)
        }
      }
      reader.readAsDataURL(file)
      imageInput.value!.value = ''
    }
  }
  
 // 视频上传
  function onVideoChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const url = URL.createObjectURL(file)
      quill.focus()
      const range = quill.getSelection()
      if (range) {
        quill.insertEmbed(range.index, 'video', url, 'user')
        quill.setSelection(range.index + 1)
      }
      videoInput.value!.value = ''
    }
  }

实际项目中,这里通常需要扩展替换为后端上传(通过 axios 发送文件,拿到 URL 后再插入)

3. 自定义功能:@用户与链接嵌入

通过 Quill 的自定义 Blot 机制实现富文本中的特殊元素(如 @用户、链接)

ts 复制代码
// @用户插入逻辑
  function handleAtSelect(user: any) {
    if (!quill) return
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      // 插入自定义的 'at-user' 类型内容
      quill.insertEmbed(range.index, 'at-user', { id: user.id, name: user.name })
      quill.setSelection(range.index + user.name.length + 1)
    }
  }
// 链接嵌入
  function handleLinkSubmit(data: { url: string; text: string }) {
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'link-embed', { url: data.url, text: data.text })
      quill.setSelection(range.index + 1)
    }
  }

注意:自定义 Blot 需要提前定义(对应代码中的 import './editor-blots'),通过继承 Quill 的 Embed 类实现自定义元素的渲染与交互

4. 状态管理:结合 Pinia 处理跨组件数据

使用 Pinia 管理选中的话题标签,实现编辑器与话题选择页的数据共享:

ts 复制代码
// 引入 Pinia 仓库 
import { useArticleStore } from '@/store/article'
const articleStore = useArticleStore()
// 移除话题标签
function removeTopic(topic: any) { articleStore.removeTopic(topic) }
// 跳转到话题选择页
function onTopicClick() { router.push('/topic?from=article') }

四、样式设计:响应式布局

通过 SCSS 结合 CSS Modules 实现样式隔离与响应式设计:

scss 复制代码
// 核心编辑区样式
.quill-editor {
  flex: 1;
  min-height: 0;
  border: none;
  overflow-y: auto;
}

// 自定义工具栏样式(移动端优化)
.custom-toolbar .toolbar-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-top: 1px solid $border-color;
  border-bottom: 1px solid $border-color;
}

// 格式面板采用弹性布局,适配小屏设备
.custom-toolbar .set-style-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  padding: 12px;
}

// 标签区域横向滚动,避免溢出
.selected-tags {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scrollbar-width: none; // 隐藏滚动条
  -ms-overflow-style: none;
  white-space: nowrap;
}
  • 编辑区使用 flex: 1 实现高度自适应,配合 min-height: 0 解决滚动问题
  • 标签区域使用横向滚动,避免标签过多时换行影响布局

富文本编辑组件代码如下:

vue 复制代码
<template>
  <div class="write-editor-wrapper">
    <div ref="editorContainer" class="quill-editor"></div>
    <div class="custom-tags">
      <van-button
        v-if="hasTopic"
        style="
          background-color: #f5f5f5;
          border: none;
          color: #666;
          font-size: 13px;
          padding: 0 8px;
        "
        icon="plus"
        round
        size="mini"
        type="default"
        @click="onTopicClick"
        >话题
        <span class="topic-count" v-if="articleStore.selectedTopics.length"
          >{{ articleStore.selectedTopics.length }}/{{ articleStore.maxTopics }}</span
        ></van-button
      >
      <div v-if="articleStore.selectedTopics.length" class="selected-tags">
        <span v-for="topic in articleStore.selectedTopics" :key="topic.id" class="tag">
          #{{ topic.name }} <van-icon name="cross" @click="removeTopic(topic)" size="12" />
        </span>
      </div>
      <div class="text-length">{{ textLength }} · 草稿</div>
    </div>
    <div class="custom-toolbar">
      <div class="toolbar-row">
        <button @click="toggleSetStyle" :disabled="!isEditorFocused">A</button>
        <button @click="triggerImageInput" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_image.png')" alt="上传图片" />
        </button>
        <input
          ref="imageInput"
          type="file"
          accept="image/*"
          style="display: none"
          @change="onImageChange"
        />
        <button @click="triggerVideoInput" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_video.png')" alt="上传视频" />
        </button>
        <input
          ref="videoInput"
          type="file"
          accept="video/*"
          style="display: none"
          @change="onVideoChange"
        />
        <button @click="undo" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_undo.png')" alt="撤销" />
        </button>
        <button @click="redo" :disabled="!isEditorFocused || !canRedo">
          <img :src="getAssetUrl('icon_redo.png')" alt="重做" />
        </button>
        <button @click="toggleMore" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_more.png')" alt="更多" />
        </button>
      </div>
      <transition name="fade">
        <div class="set-style-row" v-if="showSetStyle">
          <button :class="{ active: currentFormats.header === 3 }" @click="toggleHeader(3)">
            <i class="iconfont icon-title">H</i>标题
          </button>
          <button :class="{ active: currentFormats.bold }" @click="toggleFormat('bold')">
            <i class="iconfont icon-bold">B</i> 粗体
          </button>
          <button :class="{ active: currentFormats.blockquote }" @click="toggleBlockquote()">
            <svg-icon name="blockquote"></svg-icon> 引用
          </button>
          <button @click="insertDivider"><svg-icon name="divider"></svg-icon> 分割线</button>
          <button
            :class="{ active: currentFormats.list === 'ordered' }"
            @click="toggleList('ordered')"
          >
            <svg-icon name="orderedList"></svg-icon> 有序列表
          </button>
          <button
            :class="{ active: currentFormats.list === 'bullet' }"
            @click="toggleList('bullet')"
          >
            <svg-icon name="bulletList"></svg-icon> 无序列表
          </button>
        </div>
      </transition>
      <transition name="fade">
        <div class="more-row" v-if="showMore">
          <button @click="insertLink"><svg-icon name="link"></svg-icon> 添加链接</button>
          <button @click="insertAttachment">
            <svg-icon name="attachment"></svg-icon> 添加附件
          </button>
          <button @click="goToAt"><i class="iconfont icon-at">@</i> 提到</button>
          <button @click="saveDraft"><svg-icon name="draft"></svg-icon> 草稿备份</button>
        </div>
      </transition>
    </div>

    <!-- 插入链接弹框 -->
    <InsertLinkDialog
      :show="showLinkDialog"
      @update:show="value => (showLinkDialog = value)"
      @submit="handleLinkSubmit"
      @cancel="handleLinkCancel"
    />
  </div>
</template>

<script setup lang="ts">
  import { ref, onMounted, reactive } from 'vue'
  import { getAssetUrl } from '@/utils/index'
  import { useRouter } from 'vue-router'
  import Quill from 'quill'
  import 'quill/dist/quill.snow.css'
  import InsertLinkDialog from './InsertLinkDialog.vue'
  import './editor-blots' // 导入Blot
  import { useArticleStore } from '@/store/article'

  const router = useRouter()
  const articleStore = useArticleStore()

  const props = defineProps({
    modelValue: { type: String, default: '' },
    placeholder: { type: String, default: '' },
    hasTopic: { type: Boolean, default: true }
  })
  const emit = defineEmits(['update:modelValue'])

  const editorContainer = ref<HTMLDivElement | null>(null)
  let quill: Quill

  // 插入链接弹框相关
  const showLinkDialog = ref(false)

  // quill初始化与相关更新
  const textLength = ref(0)
  const currentFormats = reactive<{ [key: string]: any }>({})
  const isEditorFocused = ref(false)
  const canRedo = ref(false)

  // 更新文本长度
  function updateTextLength() {
    if (!quill) {
      textLength.value = 0
    } else {
      textLength.value = getFullTextLength(quill)
    }
  }
  function getFullTextLength(quill: Quill) {
    const delta = quill.getContents()
    let length = 0
    delta.ops.forEach((op: any) => {
      if (typeof op.insert === 'string') {
        length += op.insert.replace(/\s/g, '').length // 只统计非空白
      } else if (op.insert['at-user']) {
        length += ('@' + op.insert['at-user'].name).length
      } else if (op.insert['link-embed']) {
        length += (op.insert['link-embed'].text || '').length
      }
      // 其他自定义Blot可继续扩展
    })
    return length
  }
  // 更新当前格式
  function updateCurrentFormats() {
    if (!quill) return
    const range = quill.getSelection()
    if (range) {
      const formats = quill.getFormat(range)
      Object.keys(currentFormats).forEach(key => delete currentFormats[key])
      Object.assign(currentFormats, formats)
    } else {
      Object.keys(currentFormats).forEach(key => delete currentFormats[key])
    }
  }

  // 更新重做状态
  function updateRedoState() {
    if (quill) {
      canRedo.value = quill.history.stack.redo.length > 0
    }
  }

  onMounted(() => {
    quill = new Quill(editorContainer.value!, {
      theme: 'snow',
      modules: {
        toolbar: false,
        history: {
          delay: 2000,
          maxStack: 500,
          userOnly: true
        }
      },
      placeholder: props.placeholder || '请输入内容...'
    })
    if (props.modelValue) {
      quill.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
    }
    updateTextLength()
    quill.on('text-change', () => {
      emit('update:modelValue', quill.root.innerHTML)
      updateCurrentFormats()
      updateTextLength()
    })
    quill.on('selection-change', (range: any) => {
      updateCurrentFormats()
      isEditorFocused.value = !!range
    })
    quill.on('editor-change', (eventName: any) => {
      if (eventName === 'undo' || eventName === 'redo' || eventName === 'text-change') {
        updateRedoState()
      }
    })
    updateCurrentFormats()
    updateRedoState()
    tryInsertAtUser()
  })

  // 暴露 setContent 方法
  function setContent(html: string) {
    if (quill) {
      // 清空编辑器内容
      quill.setText('')
      // 设置新内容
      quill.clipboard.dangerouslyPasteHTML(html || '', 'silent')
      // 触发内容更新
      emit('update:modelValue', quill.root.innerHTML)
      // 更新文本长度
      updateTextLength()
    }
  }
  defineExpose({ setContent })

  // 工具栏/格式相关
  const showSetStyle = ref(false)
  const showMore = ref(false)
  function toggleSetStyle() {
    showSetStyle.value = !showSetStyle.value
    showMore.value = false
  }
  function toggleMore() {
    showMore.value = !showMore.value
    showSetStyle.value = false
  }
  function toggleFormat(name: string, value: any = true) {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    const isActive = !!currentFormats[name]
    quill.format(name, isActive ? false : value)
    updateCurrentFormats()
  }
  function toggleHeader(level: number) {
    quill.focus()
    const isActive = currentFormats.header === level
    if (isActive) {
      quill.format('header', false)
    } else {
      quill.format('list', false)
      quill.format('blockquote', false)
      quill.format('header', level)
    }
  }
  function toggleList(type: 'ordered' | 'bullet') {
    quill.focus()
    const isActive = currentFormats.list === type
    if (isActive) {
      quill.format('list', false)
    } else {
      quill.format('header', false)
      quill.format('blockquote', false)
      quill.format('list', type)
    }
  }
  function toggleBlockquote() {
    quill.focus()
    const isActive = !!currentFormats.blockquote
    if (isActive) {
      quill.format('blockquote', false)
    } else {
      quill.format('header', false)
      quill.format('list', false)
      quill.format('blockquote', true)
    }
  }
  function undo() {
    quill.history.undo()
  }
  function redo() {
    if (!isEditorFocused.value || !canRedo.value) return
    quill.history.redo()
  }

  //  媒体插入相关
  const imageInput = ref<HTMLInputElement | null>(null)
  const videoInput = ref<HTMLInputElement | null>(null)
  function triggerImageInput() {
    imageInput.value?.click()
  }
  function triggerVideoInput() {
    videoInput.value?.click()
  }
  function onImageChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const reader = new FileReader()
      reader.onload = function (evt) {
        const url = evt.target?.result as string
        quill.focus()
        const range = quill.getSelection()
        if (range) {
          quill.insertEmbed(range.index, 'image', url, 'user')
          quill.setSelection(range.index + 1)
        }
      }
      reader.readAsDataURL(file)
      imageInput.value!.value = ''
    }
  }
  function onVideoChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const url = URL.createObjectURL(file)
      quill.focus()
      const range = quill.getSelection()
      if (range) {
        quill.insertEmbed(range.index, 'video', url, 'user')
        quill.setSelection(range.index + 1)
      }
      videoInput.value!.value = ''
    }
  }
  function insertDivider() {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    quill.insertEmbed(range.index, 'hr', true, 'user')
    //将光标定位到文档末尾
    const len = quill.getLength()
    quill.setSelection(len - 1, 0)
  }

  // 标签相关
  function removeTopic(topic: any) {
    articleStore.removeTopic(topic)
  }

  // 话题选择
  function onTopicClick() {
    router.push('/topic?from=article')
  }

  function tryInsertAtUser() {
    const atUserStr = sessionStorage.getItem('atUser')
    if (atUserStr) {
      const atUser = JSON.parse(atUserStr)
      handleAtSelect(atUser)
      sessionStorage.removeItem('atUser')
    }
  }
  function handleAtSelect(user: any) {
    if (!quill) return
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'at-user', { id: user.id, name: user.name })
      quill.setSelection(range.index + user.name.length + 1)
    }
  }

  function insertLink() {
    showLinkDialog.value = true
  }

  function handleLinkSubmit(data: { url: string; text: string }) {
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'link-embed', { url: data.url, text: data.text })
      quill.setSelection(range.index + 1)
    }
  }

  function handleLinkCancel() {
    // 取消插入链接,不做任何操作
  }

  // @用户功能
  function goToAt() {
    router.push('/at')
  }

  function insertAttachment() {
    quill.focus()
    alert('此处可集成附件上传逻辑')
  }
  function saveDraft() {
    quill.focus()
    alert('此处可集成草稿保存逻辑')
  }
</script>

<style lang="scss" scoped>
  .write-editor-wrapper {
    width: 100%;
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }

  .quill-editor {
    flex: 1;
    min-height: 0;
    border: none;
    overflow-y: auto;
  }

  :deep(.ql-editor) {
    line-height: 1.8;
  }

  :deep(.ql-editor.ql-blank::before) {
    color: #bfbfbf;
    font-size: 15px;
    font-style: normal;
  }

  .custom-toolbar .toolbar-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    border-top: 1px solid $border-color;
    border-bottom: 1px solid $border-color;
  }

  .custom-toolbar .toolbar-row button {
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    font-size: 20px;
    width: 44px;
    height: 44px;
    line-height: 44px;
    box-sizing: border-box;
    cursor: pointer;
  }

  .custom-toolbar .toolbar-row button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .custom-toolbar button img {
    width: 20px;
    height: 20px;
  }

  .custom-toolbar button i {
    font-size: 20px;
    font-style: normal;
  }

  .custom-toolbar .set-style-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 12px;
    padding: 12px;
  }

  .custom-toolbar .set-style-row button {
    width: calc(50% - 8px);
    height: 44px;
    line-height: 44px;
    background-color: #f5f5f5;
    border-radius: 4px;
    font-size: 14px;
    color: #333;
    cursor: pointer;
    padding: 0 24px;
    text-align: left;
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .custom-toolbar .set-style-row button.active {
    color: $primary-color;
    border: 1px solid $primary-color;
    background-color: rgba($primary-color, 0.1);
  }

  .custom-toolbar .more-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px;
  }

  .custom-toolbar .more-row button {
    width: calc(25% - 8px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-size: 14px;
  }

  .custom-toolbar .more-row button i {
    font-size: 24px;
    font-style: normal;
    width: 50px;
    height: 50px;
    line-height: 50px;
    background-color: #f5f5f5;
    border-radius: 4px;
  }

  .custom-toolbar .more-row button svg {
    width: 50px;
    height: 50px;
    line-height: 50px;
    background-color: #f5f5f5;
    border-radius: 4px;
    padding: 13px;
    box-sizing: border-box;
  }

  .custom-tags {
    display: flex;
    align-items: center;
    padding: 0 12px 12px;
    gap: 8px;
  }
  .custom-tags button {
    flex-shrink: 0;
  }
  .selected-tags {
    display: flex;
    flex-wrap: nowrap;
    gap: 8px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
    white-space: nowrap;
  }
  .selected-tags::-webkit-scrollbar {
    display: none;
  }
  .selected-tags .tag {
    display: flex;
    align-items: center;
    background: rgba($color: $primary-color, $alpha: 0.1);
    color: $primary-color;
    border-radius: 12px;
    height: 24px;
    padding: 0 10px;
    font-size: 13px;
    white-space: nowrap;
    flex-shrink: 0;
  }

  .selected-tags .tag i {
    cursor: pointer;
    margin-left: 4px;
  }

  .custom-tags .text-length {
    flex-shrink: 0;
    font-size: 13px;
    color: #666;
    margin-left: auto;
  }

  :deep(.at-user),
  :deep(.link-embed) {
    color: #1989fa;
    padding: 0 4px;
    cursor: pointer;
  }
</style>
相关推荐
李剑一4 小时前
vite框架下大屏适配方案
前端·vue.js·响应式设计
濑户川5 小时前
Vue3 计算属性与监听器:computed、watch、watchEffect 用法解析
前端·javascript·vue.js
前端九哥5 小时前
我删光了项目里的 try-catch,老板:6
前端·vue.js
顽疲5 小时前
SpringBoot + Vue 集成阿里云OSS直传最佳实践
vue.js·spring boot·阿里云
一 乐6 小时前
车辆管理|校园车辆信息|基于SprinBoot+vue的校园车辆管理系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·车辆管理
百锦再6 小时前
Python、Java与Go:AI大模型时代的语言抉择
java·前端·vue.js·人工智能·python·go·1024程序员节
菩提树下的凡夫6 小时前
前端vue的开发流程
前端·javascript·vue.js
Zz燕7 小时前
G6实战_手把手实现简单流程图
javascript·vue.js
極光未晚7 小时前
乾坤微前端项目:前端处理后台分批次返回的 Markdown 流式数据
前端·vue.js·面试