vue实现全动态内容的按需渲染

276 阅读3分钟

有这样一个场景:接口返回一段文本,文本中有多个 [[]] 格式的内容,前端展示时,需要对[[]] 中的内容使用自定义组件渲染,其余内容当作字符串展示。

由于文本内容是接口动态返回的,所以,无法在 template 中定义展示的模板,而是需要编程式地动态生成虚拟 DOM 并渲染。

相关的 Vue 特性

component 标签和动态组件

component 标签有几种用法:

<component :is="xxx"></component>

is 可以是一个组件名的 string(前提是该组件已引入)

import Foo from "./Foo.vue";

export default {
  components: { Foo },
  data() {
    return {
      view: "Foo",
    };
  },
};
<component :is="view" />

也可以是一个 component 实例

import Foo from "./Foo.vue";

export default {
  components: {
    Foo,
  },
  data() {
    return {
      Foo,
    };
  },
<component :is="Foo"></component>

还可以是一个 jsx 实例

getJSX() {
      return <el-button>get jsx</el-button>;
    },
<component :is="getJSX()"></component>

方案设计

  1. 编写一个函数,传入接口返回的字符串,返回 jsx
  2. jsx 传给 component 标签的 is 参数

函数体的处理逻辑

由于[[]]内的内容需要替换为自定义组件,所以可以对字符串根据[[ 和]]进行分割。对于每个[[和]]外的内容,返回一个文本标签;对于[[和]]内的内容,返回一个组件的 jsx

如何根据 [[xxx]] 返回 jsx?

这属于业务逻辑,可以使用 js 的 String API 对 [[xxx]] 进行处理,得到业务需要的内容,如: 使用 match 进行字符串内容提取

使用 replace 进行字符串内容替换

或者另外调接口获取其他数据,然后一起展示

实现

根据上面的方案,写出第一版代码

import { ref, onMounted } from "vue";
import MyContent from "./MyContent.vue"; // 渲染[[]]的自定义组件

const dynamicContent = ref();
const initFn = async () => {
  const str = await getContent(); // 调接口获取文本
  const regex = /(\[\[)(.*?)(\]\])/g;
  const array = splitFn(str); // 对文本内容按照 [[ ]] 进行切割

  const result = array.map((item) => {
    if (item.match(regex)) {
      const current = item.replace(regex, (match, p1, p2) => {
        return p2;
      });
      return <MyContent content={current}></MyContent>;
    } else {
      return <span>{item}</span>;
    }
  });
  const newResult = repeatFn(result, 100);

  dynamicContent.value = <div>{newResult}</div>;
};
onMounted(initFn);
<component :is="dynamicContent"></component>

存在的不足

上述代码可以实现动态组件的渲染,但是,有以下缺点:

  1. 一次性渲染出所有动态组件 动态组件内部可能需要单独调接口,如果一次性渲染出所有组件,就会并发调用很多接口,阻塞页面渲染
  2. 无法按需渲染 如果组件内部 DOM 比较复杂,渲染计算量过大,而且存在大量这样的组件,我们希望等到这个组件要出现在视口时,再进行渲染,以避免页面卡顿。

有什么优化的方案呢?

为了避免大量组件一次性渲染造成的接口并发量过大,我们可以在组件内部控制 DOM 展示,或者接口调用的时机:在组件将要出现在视口时,才展示 DOM 或者调用接口。 可以通过 IntersectionObserver 来实现。

API 文档见:developer.mozilla.org/zh-CN/docs/…

IntersectionObserver 有 2 个参数,callbackoptions

callback 参数如下

参数名作用类型
entriesArray< IntersectionObserverEntry >
observerIntersectionObserver 实例IntersectionObserver

IntersectionObserverEntry 的所有属性详见:developer.mozilla.org/zh-CN/docs/…

下面列举几个常用的属性

属性名作用类型
isIntersecting目标元素和区域是否相交Boolean
intersectionRatio交叉比例Number

options 参数如下

参数名作用类型
root视口的 dom 元素DOM element
threshold阈值,默认为 0

threshold 的详细用法见:developer.mozilla.org/zh-CN/docs/…

如何使用 IntersectionObserver 来实现按需渲染呢?

在组件 mounted 时创建 IntersectionObserver 对象

io = new window.IntersectionObserver(intersectionHandler, {
  rootMargin: "0px",
  root: props.viewport,
  threshold: [0, Number.MIN_VALUE, 0.01],
});

使用 observe 函数监听目标 DOM 元素 observe 函数的详细用法见:developer.mozilla.org/zh-CN/docs/…

io.observe(componentRef.value);

组件销毁时,取消监听

if (io) {
  io.unobserve(componentRef.value);
}

优化后的代码如下

<div ref="componentRef">
  <component :is="displayContent"></component>
</div>
let io = null;

const intersectionHandler = (entries) => {
  if (
    // 正在交叉
    entries[0].isIntersecting || // 交叉率大于0
    entries[0].intersectionRatio
  ) {
    initFn(); // 业务逻辑
    io.unobserve(componentRef.value);
  }
};
const initIntersectionObserver = () => {
  io = new window.IntersectionObserver(intersectionHandler, {
    rootMargin: "0px",
    root: props.viewport,
    threshold: [0, Number.MIN_VALUE, 0.01],
  });
  io.observe(componentRef.value);
};

onMounted(initIntersectionObserver);
onBeforeUnmount(() => {
  if (io) {
    io.unobserve(componentRef.value);
  }
});

优化前的运行效果

20241103_142838_image.png

优化后的运行效果

20241103_142914_image.png