Image 图片

懒加载、撑住位置并在失败时回退的图片。

夏日午后的坡道

ATRI

科幻
<script setup lang="ts">
  import { Card, Image, Stack, Tag, Text } from '@hina-ui/vue'
</script>

<template>
  <Card class="w-full max-w-xs" :padded="false">
    <Image
      src="/sample.webp"
      alt="夏日午后的坡道"
      :ratio="4 / 3"
      :lazy="false"
      eager
      class="rounded-t-lg"
    />
    <Stack gap="xs" align="start" class="p-4">
      <Text class="font-medium">ATRI</Text>
      <Tag>科幻</Tag>
    </Stack>
  </Card>
</template>
vue

用法

import { Image } from '@hina-ui/vue'
ts

图片在接近视口时才开始加载,加载期间由骨架占位,图片就绪后骨架淡出。用类名给出尺寸,或者用 ratio 提前占好高度。

夏日午后的坡道
<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image src="/sample.webp" alt="夏日午后的坡道" class="h-40 w-full max-w-md rounded-md" />
</template>
vue

示例

宽高比

ratio 是宽除以高。图片还不存在时框就已经是这个形状,因此图片到位不会顶动页面。

1 / 1

夏日午后的坡道

4 / 3

夏日午后的坡道

16 / 9

夏日午后的坡道
<script setup lang="ts">
  import { Image, Inline, Stack, Text } from '@hina-ui/vue'

  const ratios = [
    { label: '1 / 1', value: 1 },
    { label: '4 / 3', value: 4 / 3 },
    { label: '16 / 9', value: 16 / 9 },
  ]
</script>

<template>
  <Inline align="start" class="gap-4">
    <Stack v-for="ratio in ratios" :key="ratio.label" gap="xs" class="w-40">
      <Text tone="muted" size="sm">{{ ratio.label }}</Text>
      <Image src="/sample.webp" alt="夏日午后的坡道" :ratio="ratio.value" class="rounded-md" />
    </Stack>
  </Inline>
</template>
vue

外框与图片样式

class、style 设置外框,imageClass、imageStyle 设置内部的 img。style 支持字符串、对象和数组;其中的 aspectRatio 会覆盖 ratio。

<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/sample.webp"
    alt="夏日午后的坡道"
    preview
    class="rounded-md"
    :style="{ width: '240px', height: '160px' }"
    :image-style="{ objectPosition: 'left center' }"
  />
</template>
vue

填充方式

fit 决定图片如何填满外框,默认为 cover。

cover

夏日午后的坡道

contain

夏日午后的坡道

fill

夏日午后的坡道
<script setup lang="ts">
  import { Image, Inline, Stack, Text } from '@hina-ui/vue'

  const fits = ['cover', 'contain', 'fill'] as const
</script>

<template>
  <Inline align="start" class="gap-4">
    <Stack v-for="fit in fits" :key="fit" gap="xs" class="w-32">
      <Text tone="muted" size="sm">{{ fit }}</Text>
      <Image
        src="/sample.webp"
        alt="夏日午后的坡道"
        :fit="fit"
        :ratio="1"
        class="bg-inset rounded-md"
      />
    </Stack>
  </Inline>
</template>
vue

首屏图片

lazy 默认开启,图片要等观察器放行才开始请求,服务端渲染阶段也不会带上地址。首屏图片应当关闭 lazy 并开启 eager:地址随首屏 HTML 一同送达,请求以 fetchpriority="high" 发出,解码方式也改为同步。

夏日午后的坡道
<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/sample.webp"
    alt="夏日午后的坡道"
    :lazy="false"
    eager
    :ratio="16 / 9"
    class="w-full max-w-md rounded-md"
  />
</template>
vue

模糊占位

thumbhash 接收图片的 ThumbHash,格式为 base64 字符串,标准与 URL 安全两种字母表均可。加载期间外框显示由它还原的模糊缩略图,代替骨架;图片解码后与缩略图交叉淡化,图片淡入的同时缩略图淡出,两者同时开始、同时结束。

ThumbHash 由 thumbhash 包的 rgbaToThumbHash 生成,输入是缩小到 100 像素以内的图片像素,一般在上传时生成并与图片地址一起保存。

夏日午后的坡道
<script setup lang="ts">
  import { shallowRef } from 'vue'
  import { Button, Image, Stack } from '@hina-ui/vue'

  const round = shallowRef(0)
</script>

<template>
  <Stack align="start" class="w-full max-w-md">
    <Image
      :key="round"
      :src="`/sample.webp?round=${round}`"
      thumbhash="YMcJJIIhukWZenmwmHWb9Pc5nw=="
      alt="夏日午后的坡道"
      :ratio="16 / 9"
      class="rounded-md"
    />
    <Button size="sm" variant="soft" tone="neutral" @click="round++">重新加载</Button>
  </Stack>
</template>
vue

空态与失败

没有 src 时渲染 empty 插槽。图片加载失败且没有可用的回退地址时,渲染 error 插槽。

加载完成

夏日午后的坡道

没有图片

加载失败

<script setup lang="ts">
  import { ImageOff } from '@lucide/vue'
  import { Center, Image, Inline, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="start" class="gap-4">
    <Stack gap="xs" class="w-40">
      <Text tone="muted" size="sm">加载完成</Text>
      <Image src="/sample.webp" alt="夏日午后的坡道" :ratio="1" class="rounded-md" />
    </Stack>
    <Stack gap="xs" class="w-40">
      <Text tone="muted" size="sm">没有图片</Text>
      <Image :ratio="1" class="bg-inset rounded-md">
        <template #empty>
          <Center class="text-muted size-full">
            <ImageOff class="size-6" />
          </Center>
        </template>
      </Image>
    </Stack>
    <Stack gap="xs" class="w-40">
      <Text tone="muted" size="sm">加载失败</Text>
      <Image src="/missing.webp" alt="" :ratio="1" class="bg-inset rounded-md">
        <template #error>
          <Center class="text-muted size-full">
            <Text size="sm">图片不可用</Text>
          </Center>
        </template>
      </Image>
    </Stack>
  </Inline>
</template>
vue

回退

src 加载失败后改用 fallback。回退地址同样失败时交给 error 插槽,并触发 error 事件。

夏日午后的坡道
<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/missing.webp"
    fallback="/sample.webp"
    alt="夏日午后的坡道"
    :ratio="16 / 9"
    class="w-full max-w-md rounded-md"
  />
</template>
vue

解析地址

组件只接收 src,不关心它是完整地址还是对象存储中的键。provideImageResolver 注入一个函数,由它把 src 变成最终地址;处理参数之类的细节由这个函数自行掌握,不必经过组件。没有注入解析器时,src 按原样使用。函数还会收到第二个参数说明用途:页面上的图片为 'image',预览时放大查看的大图为 'preview'。

夏日午后的坡道

这里的解析器为地址补充查询串,真实应用会将地址指向 CDN 与图片处理服务。

<script setup lang="ts">
  import { Image, Stack, Text, provideImageResolver } from '@hina-ui/vue'

  provideImageResolver(src => `${src}?preset=banner&quality=82`)
</script>

<template>
  <Stack class="w-full max-w-md">
    <Image src="/sample.webp" alt="夏日午后的坡道" :ratio="16 / 9" class="rounded-md" />
    <Text tone="muted" size="sm">
      这里的解析器为地址补充查询串,真实应用会将地址指向 CDN 与图片处理服务。
    </Text>
  </Stack>
</template>
vue

预览

设置 preview 后,图片可以点击放大查看:图片从页面上的原位放大至屏幕中央,关闭时缩回原位。预览支持缩放、拖动、旋转与下载,向下拖动图片也可以关闭。

小图和大图可以是两种规格:解析器按用途给出各自的地址,或者直接把大图地址传给 preview。打开时先显示页面上的小图,大图就绪后替换。

<script setup lang="ts">
  import { Image, provideImageResolver } from '@hina-ui/vue'

  provideImageResolver(
    (src, variant) =>
      `https://imagesp.yurari.moe/${src}?w=${variant === 'preview' ? 2000 : 600}&f=webp&fit=scale-down&q=85`,
  )
</script>

<template>
  <Image
    src="images/1ec4bca1-0674-4da6-b1a2-7433f1d79df5.webp"
    alt="《街角魔族》第一卷的封面"
    preview
    class="size-48 rounded-md"
  />
</template>
vue

需要独立控制预览时,可以直接使用 Lightbox。

预览原始尺寸

通过 previewSize 提供最终预览图片的原始像素宽高。存在独立的 preview 地址时,尺寸对应解析后的大图;否则对应当前图片。组件据此提前计算初始适配尺寸和缩放上限,打开时一次展开到位,大图加载完成后只替换画质。

宽高必须同时是有限正数。有效的显式尺寸始终优先于加载结果;未传入或无效时,继续自动获取尺寸。previewSize 只影响预览,页面外框仍由 class、style 和 ratio 控制。

下例对比自动获取尺寸与预先提供尺寸,两张图片使用同一份 320 × 180 缩略图和 1200 × 675 大图。

自动获取尺寸

预先提供尺寸

<script setup lang="ts">
  import { Image, Inline, Stack, Text } from '@hina-ui/vue'

  const image = {
    thumbnail: '/sample-thumbnail.webp',
    original: '/sample.webp',
    width: 1200,
    height: 675,
  }
</script>

<template>
  <Inline align="start" class="gap-6">
    <Stack gap="sm" class="w-64">
      <Text size="sm" tone="muted">自动获取尺寸</Text>
      <Image
        :src="image.thumbnail"
        :preview="image.original"
        alt="夏日午后的坡道"
        :ratio="16 / 9"
        class="rounded-xl"
      />
    </Stack>
    <Stack gap="sm" class="w-64">
      <Text size="sm" tone="muted">预先提供尺寸</Text>
      <Image
        :src="image.thumbnail"
        :preview="image.original"
        :preview-size="{ width: image.width, height: image.height }"
        alt="夏日午后的坡道"
        :ratio="16 / 9"
        class="rounded-xl"
      />
    </Stack>
  </Inline>
</template>
vue

分组

把多张图片放进 ImageGroup,点击任意一张后可以在整组之间切换,顺序与页面上的顺序一致。开启 loop 后翻页首尾相接。

<script setup lang="ts">
  import { Image, ImageGroup, Inline, provideImageResolver } from '@hina-ui/vue'

  provideImageResolver(
    (src, variant) =>
      `https://imagesp.yurari.moe/${src}?w=${variant === 'preview' ? 2000 : 600}&f=webp&fit=scale-down&q=85`,
  )

  const pictures = [
    { src: 'galgame/10509/y71oty0w_2.jpg', alt: '《宿星的女友 3》的截图', class: 'w-48' },
    {
      src: 'galgame/10003/orhsttk4_2.jpg',
      alt: '《抬头看看吧,看那天上的繁星》的封面',
      class: 'w-20',
    },
    { src: 'galgame/10012/co4a4kyg_2.png', alt: '《ONE.》的主视觉', class: 'w-20' },
    {
      src: 'images/1ec4bca1-0674-4da6-b1a2-7433f1d79df5.webp',
      alt: '《街角魔族》第一卷的封面',
      class: 'w-20',
    },
    {
      src: 'images/74b0eddb-819f-4ee8-b406-fa096943a5b9.webp',
      alt: '《抬头看看吧,看那天上的繁星 FINE DAYS》的封面',
      class: 'w-40',
    },
  ]
</script>

<template>
  <ImageGroup loop>
    <Inline>
      <Image
        v-for="picture in pictures"
        :key="picture.src"
        :src="picture.src"
        :alt="picture.alt"
        preview
        :class="`h-28 rounded-md ${picture.class}`"
      />
    </Inline>
  </ImageGroup>
</template>
vue

行为

  • 预览的开关动画保留图片及外层裁剪容器的圆角;多图布局按每张图片实际接触的裁剪边界分别计算四角。
  • 外框默认撑满容器宽度,开启或关闭 preview 时一致;显式宽度类名可以覆盖默认值。
  • 默认懒加载:交叉观察器观察外框,图片距视口不足 rootMargin 时才带上地址开始请求,该值默认为 200 像素。
  • 服务端渲染阶段观察器尚未介入,懒加载的图片先不带地址。首屏图片应当关闭 lazy 并开启 eager。
  • 骨架铺满外框,fit 为 contain 时留出的空白同样被覆盖。图片解码完成后,在骨架上层以 300ms 淡入,骨架同时以 200ms 淡出,两者均使用 ease-out 曲线。
  • 懒加载的图片在淡入开始前保持透明;关闭骨架后,图片仍然淡入。
  • 关闭 lazy 的图片位于骨架上层,浏览器完成绘制即可见,无需等待脚本;其下方的骨架直接移除,不做淡出。
  • 设置 thumbhash 后,加载期间显示还原出的缩略图而不是骨架,skeleton 与自定义骨架都不起作用。值无法解析时按未设置处理。
  • 缩略图与图片摆放方式相同:读取图片计算后的 object-fit、object-position、内边距与边框,经 imageClass 或 imageStyle 修改这些属性时缩略图随之调整。cover 与 none 铺满,contain 与 scale-down 完整显示,fill 拉伸到外框大小。服务端渲染的 HTML 按 fit 铺放,脚本运行后改为跟随图片。
  • ThumbHash 只记录近似的宽高比。contain 与 scale-down 时,缩略图在交叉淡化过程中缩放到图片的实际范围,淡化结束时两者重合。
  • 缩略图随服务端渲染的 HTML 一同输出,脚本加载之前即可显示。
  • 懒加载的图片解码后与缩略图以 450ms 交叉淡化,两者使用同一条 ease-in-out 曲线,任一时刻不透明度之和为 1:过渡中不透出外框背景,透明图片周围露出的缩略图随图片淡入同步消失。
  • 交叉淡化只在图片与缩略图之间进行,外框的背景色不参与混合。
  • 关闭 lazy 的图片直接盖在缩略图上,加载完成后缩略图以 450ms 淡出。
  • 已显示的图片更换 src 时,旧图片以 450ms 淡出,下方直接换成新图片的缩略图。
  • 加载失败时缩略图随图片一同撤走。
  • 浏览器不支持交叉观察器时,组件挂载后立即加载,不会使图片始终无法显示。
  • 组件未声明的其余特性会落到 img 上,因此 sizes、srcset 之类照常可用。
  • 更换 src 会重置回退状态,新图片从自己的地址开始加载,而不是沿用上一张的回退地址。
  • 预览打开期间页面不能滚动,焦点留在预览层内;关闭后焦点回到图片。
  • 向下拖动时图片随指针下移并缩小,背景逐渐透出页面;松开时拖动距离不足则弹回原处。
  • 双指捏合以两指中点为中心缩放,滚轮以指针所指的点为中心缩放。初次打开不放大小图,双击目标由图片尺寸决定,手动放大上限为原图的 2 倍;具体规则见 Lightbox 的尺寸与缩放。捏合超出上下限时阻力逐渐增大,松开后弹回限值。
  • 放大之后才能拖动查看,拖到边缘有阻力,松开后弹回;快速滑动后画面继续滑行并逐渐停下,到达边界时回弹。放大状态下向下拖动只平移画面,不会关闭。
  • 切换图片时上一张的缩放、位置与旋转复位;组内图片增删后,预览中的图片随之更新。只有一张图片,或者未开启 loop 且已经到达两端时,继续拖动有阻力并回弹。
  • 只为当前图片请求大图,切走后放弃请求,不会一次请求整组的大图。
  • 关闭后再次打开时,缩放与位置都恢复初始状态。
  • 系统开启减弱动态效果后,预览的打开、关闭、缩放、翻页与滑行都不再有过渡,直接切换到结果。

无障碍

  • alt 直接落到 img 上。装饰性图片留空,屏幕阅读器会跳过它。
  • 骨架带 aria-hidden,加载状态由拥有这张图片的区域负责播报。
  • 设置 preview 的图片渲染为按钮,alt 是它的可访问名称,也是预览层的标题;alt 为空时开发环境会输出警告。
  • 预览打开期间可用 ← → 键切换图片。缩略图栏中的每个按钮以图片的 alt 命名,当前项带 aria-current。

API

属性
类型
默认值
说明
src
string
—
图片地址,会经过解析器
alt
string
''
替代文本
fallback
string
—
src 失败后改用的地址
thumbhash
string
—
图片的 ThumbHash,加载期间显示为模糊缩略图
fit
'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
'cover'
图片如何填满外框
ratio
number
—
宽除以高,提前占位
lazy
boolean
true
是否等接近视口再加载
rootMargin
string
'200px'
提前多少距离开始加载
skeleton
boolean
true
加载期间是否显示骨架
eager
boolean
false
高优先级请求并同步解码
preview
boolean | string
false
是否可以点击放大查看,传入字符串时作为大图地址
previewSize
{ width: number; height: number }
—
最终预览图片的原始像素尺寸,宽高须为有限正数
draggable
boolean
—
图片是否可拖拽
class
string
—
追加至外框的类名
style
StyleValue
—
外框的内联样式
imageClass
string
—
追加至 img 的类名
imageStyle
StyleValue
—
img 的内联样式
事件
参数
说明
load
size: { width, height }
图片加载完成
error
—
所有地址都已尝试过
插槽
说明
skeleton
替换内置的加载骨架
empty
没有 src 时渲染
error
加载失败时渲染