把资料包往百度网盘一扔、丢个链接完事的日子,真的过去了。你发过去,对方迟迟不点;真点了,也只看一眼就关掉。就算下载了,下一秒也可能被删掉,你连个影儿都没留下。

换成在线预览,整个局面就变了。访客至少愿意把目录翻完,顺便扫几页关键内容——水印实时叠上去,微信名或者手机号浮在页面上,哪怕截图也带着来源。想看完整版?可以,扫码加微信。这条路径绕开了传统网盘那种跳来跳去的摩擦感,版权和销售线索都能攥在自己手里。

为什么非要用前端搞预览?后端转图片那套太慢了

很多团队第一反应还是上后端:先把文档转成图片或PDF再端到前端去展示。路径当然能走,但成本也跟着上去了:存储、带宽、转换队列、失败重试,哪一个环节都不是省油的灯。更要命的是延迟——用户等你转码,耐心早就耗尽。

纯前端路线把这一步直接砍掉。浏览器拿到原始文件就能画出来,响应快,架构也简单。我们这次用的是 Vue-Office 这套组件库,常见办公格式都能应付:Word(.docx)、Excel(.xlsx, .xls)、PDF,甚至 pptx 也能预览。接入方式很直接——给 src 参数指向文档地址,组件负责剩下的事情。

// 安装依赖
npm i vue-office @vue-office/pdf

// 基本用法:传入文档地址即可渲染
<template>
  <div class="demo-preview">
    <preview-document :src="docUrl" />
  </div>
</template>

目录结构怎么呈现?别指望 <ul> 能撑住复杂层级。更稳的做法是把目录抽成独立数据(TOC),按章节挂载到对应区块,滚动时高亮当前位置;再配合水印层,把用户名、时间戳这些动态信息渲染到覆盖层上。想下载完整版?触发按钮之前先弹二维码,加了微信再放下载链接。整套逻辑都在前端串起来,既减少后端依赖,也让体验更顺滑。

Vue-Office document preview integration

五分钟搭起预览页,但加载态才是真坑

项目初始化这块其实没啥好说的。如果你用的是 Vite + Vue 3(现在应该没人开新项目还用 Vue CLI 了吧?),直接 npm create vue@latest 一路回车就行。然后装依赖:

npm i vue-office @vue-office/pdf @vue-office/docx @vue-office/excel

注意 vue-office 是核心包,但子包得单独安装。我第一次用的时候,以为装个主包就够了,结果跑去翻 issue 才发现每个格式都有自己的包。文档里其实写了,但我那会儿没仔细看。

以 PDF 为例——在组件里直接 import 进来注册,然后模板里扔一个 <vue-office-pdf />src 属性指向文档地址就行。Word 和 Excel 同理,换组件名即可。

<template>
  <div class="preview-wrap">
    <vue-office-pdf :src="pdfUrl" />
  </div>
</template>

<script setup>
import VueOfficePdf from '@vue-office/pdf'

const pdfUrl = 'https://your-cdn.com/sample.pdf'
</script>

就是这么简单。没有复杂的配置项,没有转码中间层。你给一个 src,它负责渲染。底层用的是 PDF.js 那套,但封装得比较干净。Word 和 Excel 的用法几乎一模一样:

<vue-office-docx :src="docxUrl" />
<vue-office-excel :src="xlsxUrl" />

但别高兴太早——大文件和加载态才是坑。假设资料包里有本 300 页的 PDF,文件 50MB。直接丢进 src,浏览器就要开始慢慢吞吞地下载+解析。这期间用户看到的是白屏,不说以为你页面崩了。

所以关键操作是两件事:加 loading 状态,加错误兜底。vue-office 组件默认不暴露加载进度事件(至少目前版本没有),但你可以监听 @loaded@error

<vue-office-pdf 
  :src="pdfUrl" 
  @loaded="onLoaded" 
  @error="onError" 
/>

配合一个控制变量:

const loading = ref(true)
const errorMsg = ref('')

function onLoaded() {
  loading.value = false
}
function onError(e) {
  loading.value = false
  errorMsg.value = '文档加载失败,稍后再试'
}

然后在模板里根据 loadingerrorMsg 显示骨架屏或错误提示。骨架屏没必要搞复杂,一段灰色渐变块就够了。如果你需要真正的加载进度提示——比如大文件显示“已加载 30%”——那就要自己控制请求。把文档先 fetch 成 Blob,用 axios 拿到进度,然后把 Blob URL 传给组件。这招我后来才加上,前面偷懒直接传 URL 结果用户等得骂娘。

资料包肯定不止一个文件,往往是多个文档的合集。我们的做法是在页面左侧或顶部放一个目录列表,点击切换右侧预览的 src。目录数据从 JSON 里读取,每个条目包含标题和文档地址,结构清晰。这个目录以后还会和滚动位置联动、配合高亮,那是后面要聊的事。但至少现在,用户能点开资料包,看到内容长什么样,而不是面对一个冷冰冰的下载按钮。

dynamic watermark overlay on document

给预览页铺一层动态水印,截图也不怕

预览跑起来了,截图也更方便了。我们得在最上层铺一层半透明水印:用户手机号后四位 + 当前时间,必要时再加个一次性随机串。不求完全防住,但至少让外泄能追溯。

水印不是 DRM,它更像可追踪的标识。我们会把水印铺满整个预览区域,文字半透明、斜着排,密度别太高以免挡内容。核心信息放手机号后四位和精确到分钟的时间戳;运营侧后台就能把泄露定位到人。

最直接的做法是单独建一个 <canvas>,生成后塞到绝对定位的 <div> 里, 保证点击可以穿透到下面的 vue-office 组件。绘制逻辑很简单:根据容器尺寸计算行列数,每行每列重复你传入的文本,顺时针旋转 15deg,字号固定, fillStylergba(0,0,0,0.15)

function createWatermarkLayer(text, container) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  const dpr = window.devicePixelRatio || 1;
  canvas.width = container.offsetWidth * dpr;
  canvas.height = container.offsetHeight * dpr;
  canvas.style.width = container.offsetWidth + 'px';
  canvas.style.height = container.offsetHeight + 'px';
  ctx.scale(dpr, dpr);
  ctx.font = '16px system-ui, -apple-system, sans-serif';
  ctx.fillStyle = 'rgba(0,0,0,0.15)';
  ctx.translate(container.offsetWidth / 2, container.offsetHeight / 2);
  ctx.rotate(Math.PI / 12);
  const lines = Math.ceil(container.offsetHeight / 40) + 1;
  const cols = Math.ceil(container.offsetWidth / 200) + 1;
  for (let i = -lines; i <= lines; i++) {
    for (let j = -cols; j <= cols; j++) {
      ctx.fillText(text, j * 180, i * 50);
    }
  }
  const wrapper = document.createElement('div');
  wrapper.style.cssText = `position:absolute;inset:0;pointer-events:none;z-index:100;overflow:hidden`;
  wrapper.appendChild(canvas);
  return { layer: wrapper, resize() { /* 见下一节 */ } };
}

如果你不想手写绘制逻辑,也可以用现成的库(例如 watermarkjs),但我们更喜欢把依赖压到最小,几行原生代码就搞定。预览区常常放在抽屉或弹层里,拖动会触发容器尺寸变化。用 盯住容器,尺寸一变就把 canvas 的宽高和绘制坐标系同步更新。别忘了 ,高清屏上如果不乘 dpr,水印会发虚。

function observeContainer(container, watermark) {
  const ro = new ResizeObserver((entries) => {
    for (const e of entries) {
      const cr = e.contentRect;
      const dpr = window.devicePixelRatio || 1;
      watermark.layer.querySelector('canvas').width = cr.width * dpr;
      watermark.layer.querySelector('canvas').height = cr.height * dpr;
      watermark.layer.querySelector('canvas').style.width = cr.width + 'px';
      watermark.layer.querySelector('canvas').style.height = cr.height + 'px';
      watermark.layer.querySelector('canvas').getContext('2d').scale(dpr, dpr);
      watermark.redraw(); // 重新执行 fillText 循环
    }
  });
  ro.observe(container);
  return ro;
}

另外,如果页面支持暗色模式,记得把 fillStyle 改成 rgba(255,255,255,0.12),否则夜里截图一片黑。

目录预览与完整版下载分离:如何设计“诱饵”页面?

水印挂上去了,但用户凭什么要加微信?你得给他一个“看了想拿又拿不全”的目录页。这不是套路,是所有资料包类产品的标准玩法——预览几页,吊住胃口,完整版放后面。

Vue-Office 的 page 属性就是干这个的。比如一份 30 页的 PDF,你只渲染前 3 页,用户滚动到底发现没了,这时候下载按钮才会亮起。代码量少到离谱:

<vue-office-docx
  :src="docUrl"
  :page="3"
  style="height: 600px; overflow-y: auto;"
/>

对 Word、Excel 也一样,page 对 Excel 是按 sheet 算的,如果你只想展示第一个 sheet,传 1 就行。但光限制页数不够——用户会直接跳转页面把 PDF 嗅探下来。所以下载按钮不能直接用 a 标签挂链接,得做成点击后弹浮层,浮层里是企业微信二维码。这里有个坑:二维码的生成最好用 qrcode@chenfengyuan/vue-qrcode 在前端实时算,别用静态图片,因为不同渠道的码要动态换参。

浮层的逻辑我拆成三步:

  • 点击下载 → 弹出二维码弹窗,并生成一个临时 token(前端用 uuid 或 nanoid 生成,存 sessionStorage)
  • 用户扫码 → 微信里跳转一个验证页面,后端把 token 和微信 openid 绑定
  • 用户回到浏览器 → 前端轮询后端接口(每秒一次,最多 30 秒),如果 token 状态变成 verified,则下发完整版下载链接

后端接口大概长这样:

GET /api/download/verify?token=xxx
// 返回 { status: 'pending' | 'verified', url?: string }

第一次写的时候我犯了个蠢:直接把完整 PDF 的 CDN 地址放在接口返回里。别人抓包一次就能绕过扫码。后来改成临时签名 URL,用阿里云 OSS 的 方法,有效期设 5 分钟,超过就过期。这样即使用户把链接分享给别人,5 分钟后也废了。还有个体验细节:弹窗里放个“我已扫码,查看下载”按钮,避免用户干等轮询。按钮点击后强制请求一次接口,如果还没验证就提示“再等等,好友确认需要时间”。后端那边,企业微信的 webhook 回调其实不太适合做实时验证,我们改用微信开放平台的扫码事件推送,比轮询快半秒左右。

整个“诱饵”页面的核心就两个词:限制预览 + 验证后放行。Vue-Office 的 page 帮你搞定前半段,后半段就是一个带超时机制的 token 状态机。别把完整版链接暴露在前端任何地方,包括 JS 的 source map——有人会翻的。最后提醒一句:page 属性在 Vue-Office 的 Excel 组件里坑比较多,如果你传 :page="1",它默认只显示第一个 sheet,但 sheet 名称栏还是会显示所有 sheet 标签。用户点一下标签就能看到第二个 sheet 的数据——这个目前没官方属性屏蔽,我的做法是在外层包一层 div 把标签栏裁掉,粗暴但有效。

扫码加微信才解锁:前后端联调“验证后放行”的完整闭环

上一章我们聊到预览与水印的落地,这一章把“扫码—加好友—自动下载”这条链路真正跑通。前后端的边界一旦划清楚,你会发现最难的不是加密,而是状态:谁在什么时候因为什么变成可下载。

用户点“下载资料包”,前端别急着发文件,先做三件事:用 nanoid 生成一个 32 位的 downloadToken,塞进 sessionStorage;把这个 token 作为参数渲染二维码;弹窗打开时启动一个 setInterval,每秒去 /api/download/verify?token=xxx 拉状态。qrcode 支持把完整 URL(例如 /wx-verify?token=xxx&from=docs)渲染成图,省得你手写 canvas。

import { qrcode } from 'qrcode'
const src = `/wx-verify?token=${nanoid(32)}&from=docs`
const imgSrc = await qrcode.toDataURL(src, { width: 160 })

别把完整版链接放到前端任何地方,哪怕是注释。这个规则后面会反复用到:前端只负责“问”,后端决定“给不给”。二维码跳的是微信开放平台的扫码事件回调或企业微信客服消息,两者都能提供“已添加/已确认”的事件。服务端收到事件后,把之前传过来的 token 与当前 openid 绑定,标记为 verified,再顺手往缓存里塞一个一次性签名:{ token, url: signedUrl, expiresAt }signedUrl 最好走 OSS 的 方案,有效期设 5 分钟,超时就废。

第一次做容易犯的错是:/verify 直接返回 CDN 直链。别人抓一次包就能复用。后来我们把 /verify 改成 { status: 'verified', downloadUrl: signUrl },并且在网关层对 signUrl 做一次鉴权白名单,只允许来自 /verify 的请求放行。并发不高就轮询,简单可靠;并发一上来,建议切 WebSocket。我们在本地压测过,1k 并发连接下,Redis 订阅发布比 HTTP 轮询少了一半延迟。无论哪种方式,记得两件事:超时与兜底。

  • 超时:最多 30 秒,超过提示“请刷新重试或联系客服”。
  • 兜底:提供一个“我已添加,立即查询”按钮,强制触发一次 /verify,避免用户干等。

拿到 downloadUrl 后,前端用 a 标签 + Blob 的方式拉回完整包,顺便把文件名带上 download 属性。如果浏览器不支持某 MIME,就 fallback 到 fetch + blob 构造 URL。

const res = await axios.get(url, { responseType: 'blob' })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(res.data)
link.download = filename || '资料包.pdf'
link.click()

整条链路最舒服的地方,就是用户不用在微信和浏览器之间来回跳转——扫码、加好友、切回页面,下载自己就开始了。但有个坑得提醒你:千万别把完整下载链接裸奔在 source map 或者网络面板里,现在真有人会翻这些。最后记得把日志埋细致点,4xx 和 5xx 分别打点,后面出问题排查起来能省不少功夫。

避坑指南与性能优化:让预览页在低端机上也流畅

功能做完丢测试机,直接卡成幻灯片。突然想起来,用户那台手机可能比我开发机老了整整三代。预览页要是滑不动,水印画得再精致都没用——人早关页面走了。

Vue-Office 默认会把整个文档解析进 DOM,Word 三十页、PDF 上百页,低端机直接崩。后来翻文档发现组件自带 lazy 属性,配合 pageSize 只渲染可视区域那几页。写法很简单:

<vue-office-docx
  :src="docUrl"
  :lazy="true"
  :pageSize="5"
  @rendered="onRendered"
/>

lazy 打开后组件内部用 判断哪些页面在视口内,超出范围的 DOM 节点直接卸载。实测一个 15MB 的 PDF,渲染首屏内存占用下降 40%。别在 created 里就传完整 url,等用户滚动到对应页码再按需拉分片——后端配合返回 Range 头就行。

动态水印如果每帧都在主线程画,低端机页面滚动时会出现明显掉帧。解决方案是离屏 Canvas:提前在 里把水印文字、旋转角度、透明度算好,生成一张 base64 图片,然后用 CSS 铺在预览区上层。两步走:

const offscreen = new OffscreenCanvas(400, 400);
const ctx = offscreen.getContext('2d');
ctx.font = '16px sans-serif';
ctx.fillStyle = 'rgba(0,0,0,0.08)';
ctx.translate(200, 200);
ctx.rotate(-0.4);
ctx.fillText('仅供预览 扫码下载', 0, 0);
const dataUrl = offscreen.convertToBlob().then(blob => URL.createObjectURL(blob));

然后把 dataUrl 设到 .preview-container::before 上, 保证点击穿透。滚动时只移动背景层,GPU 合成,CPU 几乎无开销。唯一坑点是 在部分 WebView 里不支持,得降级到常规 Canvas 加 硬扛。

二维码有效期设 5 分钟,signUrl 里带上时间戳和 md5 签名,后端校验 Math.floor(Date.now() / 1000) - timestamp < 300。IP 限频用 Nginx 的 就行:

limit_req_zone $binary_remote_addr zone=download:10m rate=1r/s;

同一 IP 每秒最多 1 次请求,超限直接返回 429。再加一层:同一个 openid 15 分钟内只能领取一次下载链接,Redis 里用 setnx 做分布式锁。否则有人用脚本刷接口,你的 OSS 流量会跑得飞快。这些坑踩一遍疼一遍,早点埋好不至于上线后半夜被报警叫醒。