跪拜 Guibai
← Back to the summary

Building a Reusable File Preview Platform in Vue 3

General Design and Implementation of File Preview in Vue 3

File preview often starts as a small requirement: a user clicks an attachment and views its content on the page.

Once implementation begins, problems multiply quickly. PDF, Word, Excel, images, logs, and archives do not share the same browser capabilities; files may come from object storage or from interfaces requiring authentication; some files can be rendered directly, while others can only be downloaded or handed off to third-party services; not to mention cross-origin issues, memory release, large files, and sensitive data.

Therefore, file preview should not remain stuck at the stage of "stuffing an iframe into some page." A better approach is to design it as an independent capability: business pages are only responsible for providing files, while the preview platform handles identification, loading, rendering, degradation, and cleanup.

This article uses Vue 3 + TypeScript as an example to break down a file preview solution that can be migrated into admin backends, content systems, collaboration tools, and knowledge bases.

1. First, Draw the Boundary: What the Frontend Preview Layer Is Not

Before coding, clarify the responsibilities of the frontend preview layer:

It should not independently assume the following responsibilities:

These problems require participation from file services, gateways, or backend conversion services. No matter how polished the frontend platform is, it cannot replace permission control by "hiding the download button."

2. A Reusable Architecture

Break the preview capability into five layers, so that subsequent format expansion or file service replacement does not affect business pages.

Business Page / iframe / Standalone Preview Page
             |
             v
        Preview Service
     open() / close() / retry()
             |
             v
        Preview State
  URL, Blob, FileName, Type, Status
             |
             v
        Type Resolver
   Dispatch based on explicit type, MIME, extension
             |
             v
        Renderers
 PDF / Office / Image / Text / Archive
             |
             v
      File Access Layer
 URL, Auth, Proxy, Timeout, Cancel Request

The most critical principle here is: Business code only passes files and is unaware of rendering details.

The caller does not need to know which library PDF uses, whether DOC requires a third-party service, or how text files are requested; it only needs to use a stable interface:

preview.open({
  url: fileUrl,
  fileName: 'design-spec.pdf',
})

For files that need to be authenticated through a business interface first, the same form is maintained:

const blob = await fetchProtectedFile(fileId)

preview.open({
  blob,
  fileName: 'design-spec.pdf',
})

Both URL and Blob ultimately enter the same preview state, so the rendering layer naturally does not need to distinguish the file source.

3. Step 1: Design Stable Input and State Models

The smaller the public API of the preview platform, the easier it is to reuse across multiple projects. Here is a set of sufficiently practical type definitions:

export type PreviewStatus =
  | 'idle'
  | 'loading'
  | 'ready'
  | 'error'
  | 'unsupported'

export type PreviewKind =
  | 'pdf'
  | 'docx'
  | 'spreadsheet'
  | 'image'
  | 'text'
  | 'archive'
  | 'office-online'
  | 'unknown'

export interface PreviewInput {
  url?: string
  blob?: Blob
  fileName?: string
  fileType?: string
}

export interface PreviewState {
  visible: boolean
  status: PreviewStatus
  url: string
  fileName: string
  fileType: string
  kind: PreviewKind
  errorMessage: string
  isBlob: boolean
}

Three details are worth noting:

  1. fileType and kind are not exactly the same. The former can be a raw type like xlsx or jpg, while the latter is the classification the rendering layer really cares about.
  2. status should not be represented merely by loading: boolean. error, unsupported, and ready are different user decision points.
  3. The input allows the caller to explicitly pass fileName and fileType; do not rely entirely on the URL. Many signed URLs have no extension or carry complex query parameters.

Two pure utility functions will be used in the text. They do not need to be placed inside a composable; keeping them as ordinary functions makes them easier to test and reuse:

export function getFileName(url: string): string {
  try {
    const { pathname } = new URL(url, window.location.origin)
    return decodeURIComponent(pathname.split('/').pop() || 'unnamed-file')
  }
  catch {
    return 'unnamed-file'
  }
}

export function getExtension(fileName: string): string {
  const normalized = fileName.trim()
  const index = normalized.lastIndexOf('.')

  return index > -1 ? normalized.slice(index + 1).toLowerCase() : ''
}

4. Step 2: Unify URL and Blob, and Properly Reclaim Resources

Blob is a common input method for protected file previews. The browser cannot directly hand a Blob to most rendering components; it needs to first create a temporary address via URL.createObjectURL().

It is also the place most prone to memory leaks.

The following composable unifies URL and Blob into a single state model and handles race conditions from close animations and repeated file openings:

import { reactive, readonly } from 'vue'

export function useFilePreview() {
  const state = reactive<PreviewState>({
    visible: false,
    status: 'idle',
    url: '',
    fileName: '',
    fileType: '',
    kind: 'unknown',
    errorMessage: '',
    isBlob: false,
  })

  let objectUrl: string | undefined
  let clearTimer: number | undefined

  function releaseObjectUrl() {
    if (objectUrl) {
      URL.revokeObjectURL(objectUrl)
      objectUrl = undefined
    }
  }

  function reset() {
    releaseObjectUrl()
    Object.assign(state, {
      visible: false,
      status: 'idle',
      url: '',
      fileName: '',
      fileType: '',
      kind: 'unknown',
      errorMessage: '',
      isBlob: false,
    })
  }

  function open(input: PreviewInput) {
    window.clearTimeout(clearTimer)
    reset()

    if (input.blob) {
      objectUrl = URL.createObjectURL(input.blob)
      state.url = objectUrl
      state.fileName = input.fileName || 'unnamed-file'
      state.fileType = input.fileType || input.blob.type
      state.isBlob = true
    }
    else if (input.url) {
      state.url = input.url
      state.fileName = input.fileName || getFileName(input.url)
      state.fileType = input.fileType || getExtension(state.fileName)
    }
    else {
      throw new Error('PreviewInput must contain either url or blob.')
    }

    state.kind = resolvePreviewKind(state.fileName, state.fileType)
    state.status = state.kind === 'unknown' ? 'unsupported' : 'loading'
    state.visible = true
  }

  function close() {
    state.visible = false

    // Allow the close animation to complete, avoiding content disappearing instantly.
    clearTimer = window.setTimeout(reset, 250)
  }

  function setReady() {
    state.status = 'ready'
  }

  function setError(error: unknown) {
    state.status = 'error'
    state.errorMessage = error instanceof Error ? error.message : 'File loading failed'
  }

  return {
    state: readonly(state),
    open,
    close,
    setReady,
    setError,
    dispose: reset,
  }
}

This code has two easily overlooked points.

First, close() cannot simply call revokeObjectURL() immediately. If the modal is executing a close animation, the browser may briefly display blank content. Delayed cleanup avoids flickering. Second, a new open() must cancel the old cleanup timer. Otherwise, if the user quickly closes and then opens a new file, the old timer might mistakenly delete the new file's state.

If the preview instance is bound within a component, dispose() should be called when the component unmounts; if it is a global singleton, the root component hosting the preview modal should be responsible for destruction.

5. Step 3: Format Identification Is Not a split('.')

The extension is the most convenient clue, but it is unreliable. A more reasonable priority is:

Explicit type specified by the caller
        >
MIME type returned by the server
        >
File name extension
        >
unknown

Common types can first be grouped into rendering types:

const imageExtensions = new Set([
  'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg',
])

const textExtensions = new Set([
  'txt', 'md', 'json', 'xml', 'csv', 'log', 'yaml', 'yml',
])

export function resolvePreviewKind(
  fileName: string,
  fileType = '',
): PreviewKind {
  const type = fileType.toLowerCase()
  const ext = getExtension(fileName).toLowerCase()
  const value = type || ext

  if (value === 'pdf' || value === 'application/pdf')
    return 'pdf'

  if (value === 'docx'
    || value.includes('wordprocessingml.document'))
    return 'docx'

  if (['xls', 'xlsx'].includes(value)
    || value.includes('spreadsheet'))
    return 'spreadsheet'

  if (value.startsWith('image/') || imageExtensions.has(value))
    return 'image'

  if (value.startsWith('text/')
    || ['application/json', 'application/xml'].includes(value)
    || textExtensions.has(value))
    return 'text'

  if (['zip', 'application/zip'].includes(value))
    return 'archive'

  return 'unknown'
}

The responsibility of this function is to decide "which renderer to hand off to," not to verify file security.

For example, an attacker could rename an HTML file to photo.jpg. True format validation should be done server-side, including response Content-Type, file magic numbers, file size, archive decompression size, and malicious content scanning.

6. Step 4: One Format, One Rendering Strategy

A mature preview platform should not stuff all formats into an iframe. Reasonable strategies differ for different formats.

File Type Recommended Strategy Issues to Note
PDF PDF rendering component or browser built-in preview CORS, pagination, zoom, large files
DOCX Frontend Office renderer or server-side conversion to PDF Complex layout compatibility
XLS/XLSX Spreadsheet renderer or server-side conversion Formulas, charts, very large workbooks
DOC/PPT Server-side conversion or controlled third-party preview Very weak native browser support
Image <img> or image viewer Large images, SVG security, rotation and zoom
Text/Log fetch() then use <pre> Character set, file size, search highlighting
ZIP Read directory and display file list Do not decompress and render all internal files by default

In Vue, the preview container only does dispatch, without mixing the loading logic for each format into one large component:

<script setup lang="ts">
const props = defineProps<{
  state: PreviewState
}>()

const emit = defineEmits<{
  ready: []
  error: [error: Error]
  download: []
}>()
</script>

<template>
  <PdfPreview
    v-if="props.state.kind === 'pdf'"
    :src="props.state.url"
    @ready="emit('ready')"
    @error="emit('error', $event)"
  />

  <DocxPreview
    v-else-if="props.state.kind === 'docx'"
    :src="props.state.url"
    @ready="emit('ready')"
    @error="emit('error', $event)"
  />

  <SpreadsheetPreview
    v-else-if="props.state.kind === 'spreadsheet'"
    :src="props.state.url"
    @ready="emit('ready')"
    @error="emit('error', $event)"
  />

  <ImagePreview
    v-else-if="props.state.kind === 'image'"
    :src="props.state.url"
    :alt="props.state.fileName"
    @ready="emit('ready')"
    @error="emit('error', $event)"
  />

  <TextPreview
    v-else-if="props.state.kind === 'text'"
    :src="props.state.url"
    @ready="emit('ready')"
    @error="emit('error', $event)"
  />

  <ArchivePreview
    v-else-if="props.state.kind === 'archive'"
    :src="props.state.url"
    @ready="emit('ready')"
    @error="emit('error', $event)"
  />

  <UnsupportedPreview
    v-else
    :file-name="props.state.fileName"
    @download="emit('download')"
  />
</template>

This split brings three benefits:

7. Text and Archives: Don't Forget to Cancel Stale Requests

Text and archives usually require actively fetching file content. When a user quickly switches between files in a list, an old request may return later than a new request, thus overwriting the current interface.

The simplest and most reliable solution is AbortController:

import { onBeforeUnmount, ref, watch } from 'vue'

export function useTextPreview(url: Ref<string>) {
  const content = ref('')
  const loading = ref(false)
  const error = ref('')

  let controller: AbortController | undefined

  watch(url, async (nextUrl, _, onCleanup) => {
    if (!nextUrl)
      return

    controller?.abort()
    const requestController = new AbortController()
    controller = requestController
    onCleanup(() => requestController.abort())

    loading.value = true
    error.value = ''
    content.value = ''

    try {
      const response = await fetch(nextUrl, {
        signal: requestController.signal,
        credentials: 'omit',
      })

      if (!response.ok)
        throw new Error(`HTTP ${response.status}`)

      content.value = await response.text()
    }
    catch (err) {
      if ((err as DOMException).name !== 'AbortError') {
        error.value = err instanceof Error ? err.message : 'Text loading failed'
      }
    }
    finally {
      // Only allow the current request to modify the interface state if it is still the latest request.
      if (controller === requestController) {
        loading.value = false
      }
    }
  }, { immediate: true })

  onBeforeUnmount(() => controller?.abort())

  return { content, loading, error }
}

For ZIP, it is recommended to only read directory information, such as file names, uncompressed sizes, and modification times. Do not automatically expand, decompress, and preview internal files without explicit user action. Formats like RAR, 7z, and TAR require different parsers; do not claim the same support capability just because they all fall under "archive."

8. Cross-Origin: Dev Proxy Is Not a Production Solution

Text, archives, and Office renderers usually need to use fetch() or XHR to read files. As long as the file domain is different, CORS restrictions apply.

Local development can be conveniently debugged via Vite proxy:

export default defineConfig({
  server: {
    proxy: {
      '/file-proxy': {
        target: 'https://files.example.com',
        changeOrigin: true,
        rewrite: path => path.replace(/^/file-proxy/, ''),
      },
    },
  },
})

But this is not a production solution. Production environments typically have two more reliable paths:

  1. The file service directly configures precise CORS response headers and uses short-lived signed URLs.
  2. The business backend or gateway provides a file proxy, unifying authentication, auditing, response headers, and domain whitelisting.

The second solution especially needs to guard against SSRF. The proxy interface must not accept an arbitrary url and then directly request it; at minimum, it should restrict protocol, port, target domain, redirect count, and file size.

The frontend access layer can be abstracted so that renderers do not care about proxy rules:

export async function fetchPreviewFile(
  url: string,
  signal?: AbortSignal,
) {
  const previewUrl = getPreviewUrl(url)
  const response = await fetch(previewUrl, {
    signal,
    credentials: 'omit',
    headers: { Accept: '*/*' },
  })

  if (!response.ok) {
    throw new Error(`File request failed: ${response.status}`)
  }

  return response
}

getPreviewUrl() can be replaced in different projects with "directly return signed URL," "map to gateway address," or "development environment proxy address," without the preview component needing to change.

9. The Correct Way to Use Third-Party Office Preview

Older formats like DOC and PPT are difficult to parse stably on the browser side, and third-party Office preview services can indeed lower the barrier to entry.

But there is an unavoidable prerequisite: the third-party service must be able to access the original file URL. That is, the file usually needs to be publicly reachable, and its URL may be exposed to external services.

If you decide to use it, do at least two things:

export function createOfficePreviewUrl(fileUrl: string) {
  const params = new URLSearchParams({
    src: fileUrl,
  })

  return `https://view.officeapps.live.com/op/embed.aspx?${params}`
}

For sensitive files such as contracts, personal information, and financial data, a more appropriate path is server-side authentication followed by conversion to PDF or images, then providing preview through your own domain.

10. State Design: Failure Is Not an Edge Case

The preview states perceivable by the user at least include:

idle -> loading -> ready
             |
             +-> error -> retry -> loading
             |
             +-> unsupported -> download

Centralize the state at the container layer, with all renderers only reporting ready or error:

<template>
  <div class="preview-shell">
    <LoadingState v-if="state.status === 'loading'" />

    <ErrorState
      v-else-if="state.status === 'error'"
      :message="state.errorMessage"
      @retry="reload"
      @download="download"
    />

    <UnsupportedState
      v-else-if="state.status === 'unsupported'"
      :file-name="state.fileName"
      @download="download"
    />

    <PreviewRenderer
      v-else-if="state.visible"
      :state="state"
      @ready="setReady"
      @error="setError"
      @download="download"
    />
  </div>
</template>

The value of doing this is not just UI uniformity. When integrating analytics later, you can also directly record "file type, file size, load duration, failure stage, and renderer name" to quickly determine whether the problem comes from the network, permissions, file format, or third-party service.

11. Standalone Preview Page: Let Other Systems Reuse It Too

In addition to the modal component, a standalone preview page can be provided for embedding via iframe, new window, or other frontend applications:

/file-preview
  ?url=https%3A%2F%2Ffiles.example.com%2Fguide.pdf
  &fileName=guide.pdf
  &fileType=pdf
  &title=Document Preview
  &hideDownload=1

It is recommended to expose only a few parameters:

Parameter Purpose
url File address, required
fileName Display name and type identification basis
fileType Explicitly specify format
title Page title
hideDownload Only controls whether the download button is shown in the interaction layer
mode Optional, e.g., inline, dialog, office

Again, it must be emphasized: hideDownload=1 is not permission control. As long as the browser has obtained a valid URL, the user may still directly request the file. True download permissions must be verified by the file service.

12. Pre-Launch Checklist

Before integrating the preview capability into more projects, it is recommended to check item by item:

Conclusion

The value of a file preview platform does not lie in listing how many "supported extensions" it has, but in whether it can organize file sources, format differences, loading states, and security boundaries into a stable structure.

A solution worth reusing typically has these characteristics:

  1. Use a unified state to accept URL and Blob.
  2. Use format dispatch instead of a universal iframe.
  3. Separate rendering, file fetching, and business permissions.
  4. Treat errors, downloads, and unsupported formats as part of the main flow.
  5. Treat cross-origin, authentication, large files, and third-party services as architectural problems, not component details.

When the preview capability grows from an attachment button on a single page into a foundational component shared by multiple products, doing a bit more layering upfront makes adding new formats, replacing storage services, and adjusting authentication rules much easier later on.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

王三岁_

Great article, thanks for sharing.