Vue 懒挂载组件设计:LazyMountComponent 核心逻辑解析

在长页面或复杂表单里,一次性渲染所有模块,容易带来首屏慢、滚动卡顿的问题。
LazyMountComponent 的作用,就是把真实内容的渲染时机延后到“用户快看到它的时候”。

它的核心目标有 3 个:

  1. 默认先不渲染真实内容
  2. 进入可视区后再挂载
  3. 卸载时保留高度,避免页面抖动

一、组件做了什么

模板很简单:

<div :id="compId" ref="containerRef" :style="containerStyle">
    <div v-if="isMounted" ref="contentRef">
        <slot></slot>
    </div>
    <div v-else class="placeholder">
        <el-skeleton :rows="5" animated />
    </div>
</div>

只有两种状态:
isMounted = true:显示真实内容
isMounted = false:显示骨架屏
所以整个组件的关键,就是 控制 isMounted 什么时候切换。

二、核心逻辑

  1. 用 IntersectionObserver 判断是否进入可视区
const initObserver = () => {
    observer = new IntersectionObserver(
        (entries) => {
            entries.forEach(handleIntersection);
        },
        {
            root: null,
            rootMargin: props.rootMargin,
            threshold: props.threshold,
        }
    );

    observer.observe(containerRef.value);
};

当组件容器进入视口时,触发挂载逻辑;离开视口时,触发卸载逻辑。
这是整个懒加载机制的入口。

2 . 进入可视区后,不立即挂载,而是延迟挂载

const scheduleMount = (entry: IntersectionObserverEntry) => {
    clearMountTimer();
    mountTimer = setTimeout(() => {
        if (!entry.isIntersecting) return;
        mountComponent();
    }, MOUNT_DELAY);
};

这里的设计重点是:
不是一进入视口就立刻渲染,而是延迟一段时间再挂载。
这样做的好处是,用户快速滚动时,不会把一闪而过的模块也渲染出来,能减少无效渲染。

3. 真正挂载时,只做必要操作

const mountComponent = () => {
    isMounted.value = true;
    clearMountTimer();
    stopObservingWhenNeeded();
};

这里最核心的是:
1.把 isMounted 改成 true
2.清理定时器
3.如果后续不需要卸载,就停止继续监听可视区
也就是说,这个组件既支持“懒加载一次”,也支持“可见时挂载,不可见时卸载”。

4. 用 ResizeObserver 缓存真实内容高度

const cacheContentHeight = () => {
    const currentHeight = contentRef.value?.offsetHeight ?? 0;
    if (currentHeight > 0) {
        containerMinHeight.value = currentHeight;
    }
};

这一步非常关键。因为内容挂载后高度不一定固定,可能会因为表格、异步数据、折叠面板展开而变化。所以组件会持续记录真实内容高度,并把它保存到容器的 min-height。
目的只有一个:后续即使内容被卸载,页面高度也不要突然塌掉。

5. 离开可视区时,可按配置卸载内容

const unmountComponent = () => {
    if (!props.unmountWhenInvisible || !isMounted.value) return;
    cacheContentHeight();
    unobserveContent();
    isMounted.value = false;
};

这里的顺序不能乱:
先缓存高度
再取消监听
最后卸载内容
如果先卸载,再取高度,就拿不到真实尺寸了,页面容易抖动。

三、总结

这个组件可以概括成一句话:
用 IntersectionObserver 决定什么时候渲染,用 ResizeObserver 记录真实高度,用占位高度保证页面不抖。
如果页面里有很多重模块,比如表格、折叠区、复杂表单,这类组件很适合拿来做首屏和滚动性能优化。

四、技术亮点

  1. 设计并实现了高性能可视区懒加载组件,支持组件级按需渲染
  2. 创新引入 1500ms 延迟挂载机制,优化快速滚动场景下的渲染抖动
  3. 设计了动态高度缓存 + ResizeObserver 联动方案,实现占位高度自适应
  4. 支持 unmountWhenInvisible 可配置策略,在滚动离开视口后自动卸载 DOM 并回收内存
  5. 封装了 immediate 即时渲染 + triggerMount 手动控制的双模式接口

五、完整代码

<template>
    <div :id="compId" ref="containerRef" class="lazy-container" :style="containerStyle">
        <div v-if="isMounted" ref="contentRef">
            <slot></slot>
        </div>
        <div v-else class="placeholder">
            <el-skeleton :rows="5" animated />
        </div>
    </div>
</template>

<script setup lang="ts">
interface Props {
    threshold?: number;
    rootMargin?: string;
    placeholderHeight?: number;
    immediate?: boolean;
    unmountWhenInvisible?: boolean;
    compId: string;
}

const DEFAULT_PLACEHOLDER_HEIGHT = 400;
const MOUNT_DELAY = 1500;

const props = withDefaults(defineProps<Props>(), {
    threshold: 0.2,
    rootMargin: '0px',
    placeholderHeight: 0, //占位符高度
    immediate: false, //是否立即挂载组件
    unmountWhenInvisible: false, //是否在不可见时卸载组件(当组件滚出可视区后再次卸载组件)
    compId: '',
});

const containerRef = ref<HTMLElement>();
const contentRef = ref<HTMLElement>();
const isMounted = ref(props.immediate);
const containerMinHeight = ref(props.placeholderHeight || DEFAULT_PLACEHOLDER_HEIGHT);
const containerStyle = computed(() => ({
    minHeight: `${containerMinHeight.value}px`,
}));

let observer: IntersectionObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
let mountTimer: ReturnType<typeof setTimeout> | null = null;

// 清理延迟挂载定时器,避免重复触发或组件销毁后继续执行。
const clearMountTimer = () => {
    if (!mountTimer) return;
    clearTimeout(mountTimer);
    mountTimer = null;
};

// 记录当前内容高度,供骨架屏和卸载后的容器占位复用,避免页面抖动。
const cacheContentHeight = () => {
    const currentHeight = contentRef.value?.offsetHeight ?? 0;
    if (currentHeight > 0) {
        containerMinHeight.value = currentHeight;
    }
};

// 当内容挂载后不再需要反复监听可视区时,停止交叉观察。
const stopObservingWhenNeeded = () => {
    if (props.unmountWhenInvisible) return;
    observer?.disconnect();
};

// 取消对真实内容高度的监听,通常在内容卸载或组件销毁时调用。
const unobserveContent = () => {
    if (!resizeObserver || !contentRef.value) return;
    resizeObserver.unobserve(contentRef.value);
};

// 在真实内容完成渲染后开始监听高度变化,并立即缓存一次当前高度。
const observeContent = async () => {
    if (!resizeObserver || !isMounted.value) return;

    await nextTick();
    if (!contentRef.value) return;

    resizeObserver.observe(contentRef.value);
    cacheContentHeight();
};

// 初始化尺寸观察器,确保内容高度变化时同步更新容器占位高度。
const initResizeObserver = () => {
    if (resizeObserver) return;

    resizeObserver = new ResizeObserver(() => {
        if (!isMounted.value) return;
        cacheContentHeight();
    });
};

// 执行真实内容挂载,并在必要时停止继续监听可视区变化。
const mountComponent = () => {
    isMounted.value = true;
    clearMountTimer();
    stopObservingWhenNeeded();
};

// 在允许“离开视口即卸载”时,先缓存高度再卸载真实内容。
const unmountComponent = () => {
    if (!props.unmountWhenInvisible || !isMounted.value) return;
    cacheContentHeight();
    unobserveContent();
    isMounted.value = false;
};

// 进入可视区后延迟挂载,减少快速滚动场景下的无效渲染。
const scheduleMount = (entry: IntersectionObserverEntry) => {
    clearMountTimer();
    mountTimer = setTimeout(() => {
        if (!entry.isIntersecting) return;
        mountComponent();
    }, MOUNT_DELAY);
};

// 处理进入可视区事件:已挂载则补充监听高度,未挂载则安排延迟挂载。
const handleVisibleEntry = (entry: IntersectionObserverEntry) => {
    if (isMounted.value) {
        void observeContent();
        return;
    }

    scheduleMount(entry);
};

// 处理离开可视区事件:取消待执行挂载,并按配置决定是否卸载内容。
const handleHiddenEntry = () => {
    clearMountTimer();
    unmountComponent();
};

// 统一分发交叉观察结果,根据可见状态执行挂载或卸载流程。
const handleIntersection = (entry: IntersectionObserverEntry) => {
    if (entry.isIntersecting) {
        handleVisibleEntry(entry);
        return;
    }

    handleHiddenEntry();
};

// 初始化交叉观察器,用容器是否进入视口来驱动懒挂载逻辑。
const initObserver = () => {
    if (!containerRef.value) return;

    observer?.disconnect();
    observer = new IntersectionObserver(
        (entries) => {
            entries.forEach(handleIntersection);
        },
        {
            root: null,
            rootMargin: props.rootMargin,
            threshold: props.threshold,
        }
    );

    observer.observe(containerRef.value);
};

// 根据挂载状态补充或移除内容高度监听,保持占位高度准确。
watch(isMounted, (mounted) => {
    if (mounted) {
        void observeContent();
        return;
    }

    unobserveContent();
});

// 组件挂载后初始化观察器;若要求立即渲染,则直接挂载真实内容。
onMounted(() => {
    initResizeObserver();
    initObserver();

    if (props.immediate) {
        mountComponent();
    }
});

// 组件销毁前清理定时器和各类观察器,避免残留引用。
onUnmounted(() => {
    clearMountTimer();
    unobserveContent();
    observer?.disconnect();
    resizeObserver?.disconnect();
});

// 暴露给父组件的手动挂载入口,用于跳过滚动检测直接显示内容。
const triggerMount = () => {
    mountComponent();
};

defineExpose({
    triggerMount,
});
</script>

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐