记录一下vue3 渲染带组件html字符串的方法

3,224 阅读2分钟

显示需求和使用场景:

  1. 展示后台返回的富文本文章;
  2. 文章需要展示图片、播放视频,视频格式包括MP4及flv;
  3. 文章中的附件资源获取时需要带上当前用户的token;

目前考虑的解决方案:

  1. 开发图片组件,将文章图片附件同步获取请求方式改为异步请求,请求时就可以带上用户token了,然后再展示;
  2. 二次封装dplayer,开发自定义视频组件,实现多格式视频播放;
  3. 获取文章正文字符串后,使用正则匹配,去批量替换图片、视频的标签,在正文中插入自定义标签;
  4. 通过专门的渲染组件,将带组件标签的html字符串渲染展示出来;

具体开发:

  1. 封装图片组件
// custom-image.vue
<template>
  <el-image :src="imageUrl">
    <template #placeholder>
      <div v-if="!loading" class="image-slot">加载中<span class="dot">...</span></div>
    </template>
    <template #error>
      <div class="image-slot">
        <i class="el-icon-picture-outline"></i>
      </div>
    </template>
  </el-image>
</template>
<script lang="ts" setup>
// service 是二次封装的 axios ,异步请求带 token 在这里面做,对返回数据格式结构也做了统一处理
import service from '@/api/http'

const props = defineProps({
  src: {
    type: String,
    default: ''
  }
})
const imageUrl = ref<string>('')
const loading = ref<boolean>(false)

const handleGetImage = async () => {
  loading.value = true
  try {
    // 将图片地址以 blob 二进制流的方式异步获取
     const response = await service.get<Blob>(props.src, {}, { responseType: 'arraybuffer' })
    if (response.code === 200 && response.data) {
      imageUrl.value = URL.createObjectURL(response.data)
    } else {
      imageUrl.value = ''
    }
    loading.value = false
  } catch (error) {
    console.log(error)
    imageUrl.value = ''
    loading.value = false
  }
}
onMounted(() => {
  if (props.src) {
    handleGetImage()
  }
})
</script>

  1. 封装视频组件
// custom-video.vue
<template>
  <div ref="videoRef" class="player" :style="{ width: defaultOptions.width, height: defaultOptions.height }" />
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import flvJs from 'flv.js'
import Dplayer, { DPlayerVideo, Preload } from 'dplayer'
import service from '@/api/http'

type VideoOptions = {
  width: string
  height: string
  autoPlay: boolean
  loop: boolean
  lang: string
  hotkey: boolean
  preload: Preload
  volume: number
  playbackSpeed: number[]
  src?: string
}

const props = defineProps({
  options: {
    type: Object as PropType<VideoOptions>,
    default: () => ({
      width: '800px',
      height: '450px',
      autoPlay: false,
      loop: false,
      lang: 'zh-cn',
      hotkey: true,
      preload: 'auto',
      volume: 0.7,
      playbackSpeed: [0.5, 0.75, 1, 1.25, 1.5, 2]
    })
  },
  title: {
    type: String,
    default: ''
  },
  src: {
    type: String,
    default: ''
  }
})
const defaultOptions = computed<VideoOptions>(() => ({
  ...props.options,
  src: props.src || '',
  title: props.title
}))
const isFlv = computed(() => {
  if (defaultOptions.value.src) {
    const link = defaultOptions.value.src.split('?')
    const newLink = link[0].split('.')
    return newLink[newLink.length - 1].toLocaleLowerCase() === 'flv'
  }
  return false
})
const videoRef = ref()

const loading = ref<boolean>(false)

const handleInitPlayer = async () => {
  loading.value = true
  try {
    const response = await await service.get<Blob>(props.src, {}, { responseType: 'arraybuffer' })
    if (response.code === 200 && response.data) {
      let video: DPlayerVideo = {
        url: URL.createObjectURL(response.data) // 指定视频播放链接
      }
      if (isFlv.value) {
        video = {
          url: URL.createObjectURL(response.data),
          type: 'customFlv',
          customType: {
            customFlv(videoDom: any) {
              const flvPlayer = flvJs.createPlayer({
                type: 'flv',
                url: videoDom.src
              })
              flvPlayer.attachMediaElement(videoDom)
              flvPlayer.load()
            }
          }
        }
      }
      const dp = new Dplayer({
        // 初始化视频对象
        container: videoRef.value, // 指定视频容器节点
        autoplay: defaultOptions.value.autoPlay,
        loop: defaultOptions.value.loop,
        lang: defaultOptions.value.lang,
        hotkey: defaultOptions.value.hotkey,
        preload: defaultOptions.value.preload,
        volume: defaultOptions.value.volume,
        playbackSpeed: defaultOptions.value.playbackSpeed,
        video
      })
    }
    loading.value = false
  } catch (error) {
    loading.value = false
  }
}
onMounted(() => {
  nextTick(() => {
    handleInitPlayer()
  })
})
</script>
  1. 封装自定义html字符串渲染组件

渲染关键就是关键就是 compile 函数,不过我这种写法目前只支持展示,click 等事件只能在组件内部实现,不能通过子组件将事件传递给父组件,定义在组件字符串标签上的事件是不能加载的,会报事件未定义错误;
经过测试,想通过字符串的方式渲染的组件,也需要提前注册,并且挂载到 vue 实例上,也就是 全局注册;如果想通过事件做操作,可以考虑使用 pinia 状态管理去触发事件。

// custom-render.vue
<script lang="ts">
import { compile, VNode } from 'vue'

export default defineComponent({
  props: {
    html: { type: String, required: true }
  },
  computed: {
    template(): string {
      return this.html
    }
  },
  render(): VNode {
    return h(compile(this.template), { ...this.$attrs })
  }
})
</script>

使用

// main.ts 注册全局
...
import CustomVideo from '@/components/custom-video.vue'
import CustomImage from '@/components/custom-image.vue'
...
const app = createApp(App)
...
app.component('CustomVideo', CustomVideo)
app.component('CustomImage', CustomImage)

app.mount('#app')
// article.vue
<template>
  <custom-render :html="content"></custom-render>
</template>
<script setup lang="ts">
const article = `
  <p>我是文章</p>
  <p>巴拉巴拉</p>
  <p>我是图片:</p>
  <p><img src="http://xxx.com/200x200/abc.jpg"/></p>
  <p>我是MP4视频:</p>
  <p><video src="http://xxx.com/video/123.mp4"></video></p>
  <p>我是FLV视频:</p>
  <p><video src="http://xxx.com/video/123.flv"></video></p>
`
const content = computed(() => {
  let str = article.replaceAll('\n', '')

  const regImage = /<img.*?(?:img>|\/img>)/gi
  const images = str.match(regImage)
  images?.forEach(item => {
    str = str.replace(item, item.replace('<img ', '<custom-image ').replace(' img>', ' custom-image>'))
  })

  const regVideo = /<video.*?(?:video>|\/video>)/gi
  const videos = str.match(regVideo)
  videos?.forEach(item => {
    str = str.replace(item, item.replace('<video', '<custom-video ').replace(' video>', ' custom-video>'))
  })
  return `<div>${str}</div>`
})
</script>

ps:
本文的思路是在项目里面验证实现过的,但本文代码是脱敏后手打的,不保证照搬能运行起来,思路肯定没问题,照着这个思路写是可以实现文章头部的需求,如果你也有这个需求,代码参考下就行。

请无视代码中的很多写法,我水平真的一般,正则也写的垃圾,欢迎大佬帮忙优化,感激不尽;

目前确实没有能力实现在字符串组件上绑定事件功能,欢迎探讨,如果有解决方案,也请大佬共享下,确实有这个需求。

感谢您的阅读