图片 image
通用组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
图片与回退内容始终同挂 DOM、靠 hidden 互斥显隐,换人时盒子不塌也不跳
<script setup lang="ts">
import { XhImageFallback, XhImageImage, XhImageRoot } from "@xihan-ui/vue";
// 内联的示例图,省得示例依赖外部资源
const cover
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2016%209%22%3E%3Crect%20width=%2216%22%20height=%229%22%20fill=%22%23475569%22/%3E%3Cpath%20d=%22M0%209%206%203%2016%209z%22%20fill=%22%2394a3b8%22/%3E%3C/svg%3E";
</script>
<template>
<!-- 尺寸由这两个自定义属性给:不给就是满宽、高度 auto -->
<XhImageRoot
:src="cover"
alt="示例封面图"
style="--xh-image-w: 220px; --xh-image-ratio: 16 / 9;"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
</template>回退与状态
地址写坏和压根没给 src 是同一个落点,status-change 把三态报出来,root 上的 data-status 也有一份

<script setup lang="ts">
import { reactive } from "vue";
import { XhImageFallback, XhImageImage, XhImageRoot } from "@xihan-ui/vue";
const cover
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2016%209%22%3E%3Crect%20width=%2216%22%20height=%229%22%20fill=%22%23475569%22/%3E%3Cpath%20d=%22M0%209%206%203%2016%209z%22%20fill=%22%2394a3b8%22/%3E%3C/svg%3E";
const status = reactive<Record<string, string>>({
ok: "idle",
broken: "idle",
none: "idle",
});
</script>
<template>
<XhImageRoot
:src="cover"
alt="正常加载的图"
style="--xh-image-w: 160px; --xh-image-ratio: 16 / 9;"
@status-change="(d: { status: string }) => (status.ok = d.status)"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<XhImageRoot
src="https://example.invalid/broken.png"
alt="地址写坏的图"
style="--xh-image-w: 160px; --xh-image-ratio: 16 / 9;"
@status-change="(d: { status: string }) => (status.broken = d.status)"
>
<XhImageImage />
<XhImageFallback>图挂了</XhImageFallback>
</XhImageRoot>
<XhImageRoot
style="--xh-image-w: 160px; --xh-image-ratio: 16 / 9;"
@status-change="(d: { status: string }) => (status.none = d.status)"
>
<XhImageImage />
<XhImageFallback>没有来源</XhImageFallback>
</XhImageRoot>
<span style="font-size: 13px;">
状态:正常 {{ status.ok }} · 坏地址 {{ status.broken }} · 无 src {{ status.none }}
</span>
</template>尺寸与裁切
同一个组件既当封面图也当缩略图:宽高比由 --xh-image-ratio 定,画面怎么填由 --xh-image-fit 定
<script setup lang="ts">
import { XhImageFallback, XhImageImage, XhImageRoot } from "@xihan-ui/vue";
// 竖幅素材,放进方形盒子里才看得出 cover 与 contain 的差别
const portrait
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%203%204%22%3E%3Crect%20width=%223%22%20height=%224%22%20fill=%22%230f766e%22/%3E%3Ccircle%20cx=%221.5%22%20cy=%221.4%22%20r=%220.7%22%20fill=%22%235eead4%22/%3E%3C/svg%3E";
</script>
<template>
<XhImageRoot
:src="portrait"
alt="裁掉多余部分"
style="--xh-image-w: 120px; --xh-image-ratio: 1; --xh-image-fit: cover;"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<XhImageRoot
:src="portrait"
alt="整幅装进去"
style="--xh-image-w: 120px; --xh-image-ratio: 1; --xh-image-fit: contain;"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<!-- 圆形缩略图:圆角也是一个变量,不必另建一个组件 -->
<XhImageRoot
:src="portrait"
alt="圆形缩略图"
style="--xh-image-w: 64px; --xh-image-ratio: 1; --xh-image-radius: 50%;"
>
<XhImageImage />
<XhImageFallback>无</XhImageFallback>
</XhImageRoot>
<span style="font-size: 13px;">cover(裁切)· contain(留边)· 圆形缩略图</span>
</template>回退延迟与原生属性
fallback-delay 决定回退内容多久才露面,Infinity 表示加载期间一直不露面、只有失败才显;写在 image 部件上的原生属性照常落到底层图片元素上

<script setup lang="ts">
import { XhImageFallback, XhImageImage, XhImageRoot } from "@xihan-ui/vue";
const shot
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%204%203%22%3E%3Crect%20width=%224%22%20height=%223%22%20fill=%22%231e293b%22/%3E%3Crect%20x=%220.4%22%20y=%220.4%22%20width=%223.2%22%20height=%220.5%22%20fill=%22%2338bdf8%22/%3E%3Crect%20x=%220.4%22%20y=%221.2%22%20width=%222%22%20height=%220.4%22%20fill=%22%2364748b%22/%3E%3C/svg%3E";
// 加载期间一直不让回退内容露面
const untilFailed = Number.POSITIVE_INFINITY;
</script>
<template>
<!-- 缺省 0:加载还没完成的那一刻回退内容就顶上 -->
<XhImageRoot
:src="shot"
alt="立刻顶上回退内容"
style="--xh-image-w: 140px; --xh-image-ratio: 4 / 3;"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<!-- 600 毫秒内加载完就一次都不闪 -->
<XhImageRoot
:src="shot"
alt="慢过 600 毫秒才顶上回退内容"
:fallback-delay="600"
style="--xh-image-w: 140px; --xh-image-ratio: 4 / 3;"
>
<!-- loading 是原生图片属性,组件不拦,直接落到图片元素上 -->
<XhImageImage loading="lazy" />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<!-- Infinity:加载途中什么都不显,只有失败才换人 -->
<XhImageRoot
src="https://example.invalid/shot.png"
alt="只在失败时顶上回退内容"
:fallback-delay="untilFailed"
style="--xh-image-w: 140px; --xh-image-ratio: 4 / 3;"
>
<XhImageImage />
<XhImageFallback>图挂了</XhImageFallback>
</XhImageRoot>
</template>按状态分流的回退内容
根插槽把 status 交出来:加载中给占位、失败给提示与重试入口,两套内容共用同一个回退部件

<script setup lang="ts">
import { ref } from "vue";
import { XhImageFallback, XhImageImage, XhImageRoot } from "@xihan-ui/vue";
const good
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%203%202%22%3E%3Crect%20width=%223%22%20height=%222%22%20fill=%22%23334155%22/%3E%3Ccircle%20cx=%222.2%22%20cy=%220.6%22%20r=%220.3%22%20fill=%22%23fde68a%22/%3E%3Cpath%20d=%22M0%202%201.2%200.8%202%201.5%202.6%201%203%201.4V2z%22%20fill=%22%2394a3b8%22/%3E%3C/svg%3E";
// 一开始给个取不到的地址,重试时换成能取到的
const src = ref("https://example.invalid/photo.png");
</script>
<template>
<XhImageRoot
:src="src"
alt="风景照"
style="--xh-image-w: 200px; --xh-image-ratio: 3 / 2;"
>
<template #default="{ status }">
<XhImageImage />
<XhImageFallback>
<!-- 失败与加载中是两回事,文案与可操作性都该不一样 -->
<span v-if="status === 'error'" style="display: inline-flex; gap: 8px; align-items: center;">
取不到这张图
<button type="button" @click="src = good">重试</button>
</span>
<span v-else>正在加载…</span>
</XhImageFallback>
</template>
</XhImageRoot>
</template>点开看大图
缩略图的点击与键盘由属性透传自己接,放大层是一个对话框,里面再放一份独立的图片实例
<script setup lang="ts">
import { ref } from "vue";
import {
XhDialogCloseTrigger,
XhDialogContent,
XhDialogRoot,
XhDialogTitle,
XhImageFallback,
XhImageImage,
XhImageRoot,
XhToolbarItem,
XhToolbarRoot,
} from "@xihan-ui/vue";
const photo
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%204%203%22%3E%3Crect%20width=%224%22%20height=%223%22%20fill=%22%230f172a%22/%3E%3Ccircle%20cx=%223.1%22%20cy=%220.8%22%20r=%220.35%22%20fill=%22%23fbbf24%22/%3E%3Cpath%20d=%22M0%203%201.4%201.4%202.4%202.3%203.1%201.6%204%202.4V3z%22%20fill=%22%2334d399%22/%3E%3C/svg%3E";
const open = ref(false);
const scale = ref(1);
const rotate = ref(0);
// 每次打开都从原始比例起看
function openPreview(): void {
scale.value = 1;
rotate.value = 0;
open.value = true;
}
function zoom(step: number): void {
scale.value = Math.min(3, Math.max(0.5, scale.value + step));
}
const itemStyle = {
padding: "4px 10px",
borderRadius: "6px",
border: "1px solid var(--xh-border-default)",
background: "var(--xh-bg-surface)",
};
</script>
<template>
<!-- 缩略图当触发器:角色、Tab 位与两个按键都写在根上,组件原样透传 -->
<XhImageRoot
:src="photo"
alt="山间日出"
role="button"
tabindex="0"
aria-label="放大查看 山间日出"
style="--xh-image-w: 160px; --xh-image-ratio: 4 / 3; cursor: zoom-in"
@click="openPreview"
@keydown.enter.prevent="openPreview"
@keydown.space.prevent="openPreview"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<!-- 遮罩、居中定位与焦点圈禁都由对话框给,Esc 与点遮罩就是关闭预览 -->
<XhDialogRoot v-model:open="open" size="lg" :translations="{ close: '关闭' }">
<XhDialogContent>
<XhDialogTitle>山间日出</XhDialogTitle>
<!-- 放大层里是另一份图片实例:它的 alt、裁切方式与缩略图那份互不相干 -->
<XhImageRoot
:src="photo"
alt="山间日出,放大查看"
style="--xh-image-w: 100%; --xh-image-ratio: 4 / 3; --xh-image-fit: contain"
>
<XhImageImage
:style="{
transform: `scale(${scale}) rotate(${rotate}deg)`,
transition: 'transform 120ms var(--xh-ease-standard)',
}"
/>
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<!-- 缩放与旋转是两个数值加一条 transform,工具条只负责把这几颗按钮串成一个 Tab 位 -->
<XhToolbarRoot>
<XhToolbarItem value="zoom-in" :style="itemStyle" @click="zoom(0.25)">
放大
</XhToolbarItem>
<XhToolbarItem value="zoom-out" :style="itemStyle" @click="zoom(-0.25)">
缩小
</XhToolbarItem>
<XhToolbarItem value="rotate" :style="itemStyle" @click="rotate += 90">
旋转
</XhToolbarItem>
<XhToolbarItem
value="reset"
:style="itemStyle"
@click="scale = 1; rotate = 0"
>
还原
</XhToolbarItem>
</XhToolbarRoot>
<XhDialogCloseTrigger>✕</XhDialogCloseTrigger>
</XhDialogContent>
</XhDialogRoot>
</template>一组图共用一个预览层
图与图之间不必互相认识:宿主拿着地址数组与当前下标,预览层里只放一份图片实例
<script setup lang="ts">
import { ref } from "vue";
import {
XhButton,
XhDialogCloseTrigger,
XhDialogContent,
XhDialogRoot,
XhDialogTitle,
XhImageFallback,
XhImageImage,
XhImageRoot,
} from "@xihan-ui/vue";
function tile(bg: string, mark: string): string {
return `data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%204%203%22%3E%3Crect%20width=%224%22%20height=%223%22%20fill=%22%23${bg}%22/%3E%3Ccircle%20cx=%222%22%20cy=%221.5%22%20r=%220.8%22%20fill=%22%23${mark}%22/%3E%3C/svg%3E`;
}
const shots = [
{ src: tile("1e3a8a", "93c5fd"), title: "海面" },
{ src: tile("065f46", "6ee7b7"), title: "林地" },
{ src: tile("7c2d12", "fdba74"), title: "岩壁" },
];
const open = ref(false);
const index = ref(0);
function preview(at: number): void {
index.value = at;
open.value = true;
}
// 翻页就是下标加减,走到头回绕
function step(delta: number): void {
index.value = (index.value + delta + shots.length) % shots.length;
}
</script>
<template>
<div style="display: flex; gap: 8px">
<XhImageRoot
v-for="(shot, at) in shots"
:key="shot.title"
:src="shot.src"
:alt="shot.title"
role="button"
tabindex="0"
:aria-label="`放大查看 ${shot.title}`"
style="--xh-image-w: 96px; --xh-image-ratio: 4 / 3; cursor: zoom-in"
@click="preview(at)"
@keydown.enter.prevent="preview(at)"
@keydown.space.prevent="preview(at)"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
</div>
<XhDialogRoot v-model:open="open" size="lg" :translations="{ close: '关闭' }">
<XhDialogContent>
<XhDialogTitle>{{ shots[index].title }}</XhDialogTitle>
<!-- 只有一份实例,src 换了机器就重走一遍加载,回退内容照常顶位 -->
<XhImageRoot
:src="shots[index].src"
:alt="shots[index].title"
style="--xh-image-w: 100%; --xh-image-ratio: 4 / 3; --xh-image-fit: contain"
>
<XhImageImage />
<XhImageFallback>加载中</XhImageFallback>
</XhImageRoot>
<div style="display: flex; align-items: center; gap: 12px">
<XhButton size="sm" variant="outline" @click="step(-1)">上一张</XhButton>
<span style="font-size: 13px">{{ index + 1 }} / {{ shots.length }}</span>
<XhButton size="sm" variant="outline" @click="step(1)">下一张</XhButton>
</div>
<XhDialogCloseTrigger>✕</XhDialogCloseTrigger>
</XhDialogContent>
</XhDialogRoot>
</template>自己决定何时取图
src 是响应式的:进入视口前不给地址,观察器命中再换上,机器立刻走一遍完整加载
往下滚,图片进视口才开始取。
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { XhImageFallback, XhImageImage, XhImageRoot } from "@xihan-ui/vue";
const remote
= "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%204%203%22%3E%3Crect%20width=%224%22%20height=%223%22%20fill=%22%23334155%22/%3E%3Crect%20x=%220.4%22%20y=%220.4%22%20width=%223.2%22%20height=%221%22%20fill=%22%2360a5fa%22/%3E%3Crect%20x=%220.4%22%20y=%221.8%22%20width=%222%22%20height=%220.8%22%20fill=%22%2394a3b8%22/%3E%3C/svg%3E";
const viewport = ref<HTMLElement | null>(null);
const host = ref<HTMLElement | null>(null);
const src = ref<string | undefined>(undefined);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!host.value)
return;
// 观察器的三个参数都在这里定:拿哪个盒子当视口、提前多远开始取、露出几成算数
observer = new IntersectionObserver(
(entries) => {
if (!entries.some(entry => entry.isIntersecting))
return;
src.value = remote;
observer?.disconnect();
},
{ root: viewport.value, rootMargin: "24px", threshold: 0.1 },
);
observer.observe(host.value);
});
onBeforeUnmount(() => observer?.disconnect());
</script>
<template>
<div
ref="viewport"
style="
inline-size: 260px;
block-size: 180px;
overflow-y: auto;
padding: 12px;
border: 1px solid var(--xh-border-default);
border-radius: 8px;
"
>
<p style="margin: 0 0 12px">往下滚,图片进视口才开始取。</p>
<div style="block-size: 200px" />
<div ref="host">
<!-- 还没给地址时落的是无来源那一态,回退部件正好当占位 -->
<XhImageRoot
:src="src"
alt="报表截图"
style="--xh-image-w: 100%; --xh-image-ratio: 4 / 3"
>
<XhImageImage />
<XhImageFallback>{{ src ? "加载中" : "还没开始取" }}</XhImageFallback>
</XhImageRoot>
</div>
</div>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-image> |
| Vue 组件 | XhImageFallback XhImageImage XhImageRoot |
| 组合式函数 | useImage |
| 状态机 | 无,connect 直接由 props 算属性 |
| 皮肤 | @xihan-ui/styles/image.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="image":root · image · fallback
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
src | string | ||
alt | string | ||
fallbackDelay | number | 加载超过这么久(毫秒)才让回退内容露面,默认 0(立刻露面)。 Infinity 表示加载期间永不显示回退内容,只有失败才显。 | |
onStatusChange | (details: ImageStatusChangeDetails) => void | 状态每次真正落位时通知一次;过渡态 idle 不通知。 |
状态机
事件:SRC.CHANGE · IMAGE.LOAD · IMAGE.ERROR · after.fallbackDelay
判据:hasSrc
connect API
useImage 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
status | ImageStatus | |
loaded | boolean | |
showFallback | boolean | 回退内容此刻是否该露面:加载失败恒为真,加载途中要看 fallbackDelay 是否已过。 |
getRootProps | () => T['element'] | |
getImageProps | () => T['img'] | |
getFallbackProps | () => T['element'] |
键盘
规格出处:W3C APG
无键盘交互(不接收焦点,或焦点行为完全由原生元素提供)。
