前端选型的第一道坎,往往不是技术,而是对产品形态的共识。产品形态很明确:用户粘一个视频链接,页面提交任务,后台下载、抽字幕、让大模型总结,再把结果一段段流式刷到屏幕上。就这点交互,团队却为”用不用 Vue/React”吵了两轮。我当时的判断是,这个应用本质是”表单 + 列表 + 流式展示”,没有复杂状态树,也没有几十个页面共享组件,框架带来的收益撑不起它的成本。最后前端定了原生 HTML/CSS/JS,外加 Tailwind CDN、marked、markmap 这几个只读库。整站无构建、无打包器,3MB 总大小,部署就是一堆静态文件丢给 Nginx,打开秒加载。下面把每层选型和踩过的坑完整过一遍。
一、为什么用原生
选原生不是偷懒,是反复比过之后的决定。最开始的方案其实是 Vue 3 + Vite,脚手架一跑,node_modules 几百兆,首屏要等构建产物加载,改造一个按钮得翻三层目录。我们的应用页面不多,但用户每次打开都带着明确任务,等不起白屏。换回原生后,index.html 里直接写页面,逻辑拆成 ES Modules,浏览器原生加载,热更新就是刷新一下标签页。
| 方案 | 优点 | 缺点 | 平台选 |
|---|---|---|---|
| 原生 JS | 零构建、秒开、3MB | 代码组织弱 | ✅ |
| Vue | 组件化、响应式 | 构建复杂、500KB+ | ❌ |
| React | 生态丰富 | 体积大、学习曲线 | ❌ |
| HTMX | 服务端驱动 | 交互受限 | ❌ |
表格里列的是各方案的账面优劣,落到我们场景还有一层现实约束:
- 应用是”表单 + 列表 + 流式展示”,交互简单;
- 用户首屏体验(< 1s 加载);
- 后端 FastAPI 自动 OpenAPI,前端用普通 fetch 调用即可;
- 部署 = 静态文件 + Nginx。
这条链路里没有一处需要”运行时状态共享”,也没有服务端渲染需求。HTMX 我们也看了,它让前端退回服务端返回 HTML 的思路,适合偏传统的多页站点,而我们要做 SSE 流式渲染和思维导图交互,服务端驱动反而绑手。所以原生方案不是退而求其次,而是刚好匹配业务复杂度。
二、整体技术栈
技术栈一共七层,全部走”少而专”的思路:每层只干一件事,且都能通过 CDN 直接引用,不需要本地装包。
| 层 | 选型 | 大小 |
|---|---|---|
| HTML | 原生 | 50KB |
| CSS | Tailwind CDN + 自定义 | 200KB(CDN 缓存) |
| JS | 原生 + ES Modules | 150KB |
| 图标 | Lucide Icons | 30KB |
| Markdown | marked | 50KB |
| 思维导图 | markmap | 200KB |
| 代码高亮 | highlight.js | 100KB |
| 总计 | – | 约 1MB(gzip 200KB) |
这一层我踩过一个坑:一开始想全部手写 CSS,结果按钮、卡片、进度条十几个组件反复写重复样式,页面一多就失控。引入 Tailwind CDN 之后,样式用工具类就地组合,新页面不需要新 CSS 文件。marked 和 markmap 是功能库,前者负责把模型吐出来的 Markdown 渲染成 HTML,后者把 Markdown 大纲转成思维导图,都是成熟稳定的选择,犯不着自己造轮子。highlight.js 只在展示代码片段时用,没进主流程。整份清单里没有框架,因为单页应用需要的状态管理、路由、虚拟 DOM,这里都用不上。
三、目录结构
目录按职责分层,每个模块只暴露一个入口,页面文件和个人模块严格分开。这样做的直接原因是,登录、总结、支付三个流程有重叠的基础能力,比如 API 调用、鉴权,抽出来单独成文件才能复用。
frontend/
├── index.html # 首页(落地 + 总结)
├── pricing.html # 订阅页
├── account.html # 账户中心
├── admin.html # 管理后台
├── css/
│ ├── main.css # 全局样式
│ ├── components.css # 组件样式
│ └── tailwind.css # 自定义 Tailwind
├── js/
│ ├── api.js # HTTP 客户端
│ ├── auth.js # 登录/登出
│ ├── video.js # 视频提交/展示
│ ├── summary.js # 总结流式展示
│ ├── mindmap.js # 思维导图
│ ├── payment.js # Stripe Checkout
│ └── ui.js # 通用 UI
└── assets/
├── logo.svg
└── ...
这个结构撑起四个页面没有互相打架。管理后台单独一个 admin.html,复用同一套 api.js,但接口权限不同,靠 token 区分。几个页面之间共享的只有 CSS 和少数工具函数,耦合点少到能数清楚,这正是没上框架还能维持秩序的原因。等哪天页面膨胀到几十个、组件要反复复用,再上 Vue 也不迟,代码拆成这样迁移成本也不高。
四、HTML 结构
首页结构很简单:顶部导航,中间一个表单,下面一块结果区。重点在结果区默认隐藏,等流式数据到了才显示,避免用户看到空容器。
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>AI 万能视频下载总结器</title>
通过 Tailwind 官方 CDN 引入样式
<link rel="stylesheet" href="css/main.css">
</head>
<body class="bg-slate-50">
<nav>...</nav>
<main>
<section id="hero">
<h1>粘贴视频链接,AI 帮你总结</h1>
<form id="videoForm">
<input type="url" id="videoUrl"
placeholder="粘贴 YouTube/B站/抖音链接...">
<button>开始总结</button>
</form>
</section>
<section id="result" hidden>
<div id="videoInfo"></div>
<div id="summary"></div>
<div id="mindmap"></div>
</section>
</main>
module 方式引入主脚本
</body>
</html>
表单用原生 input type="url",浏览器自带 URL 校验,非法链接在提交前就被拦下来,省掉了我们写校验逻辑。hidden 属性加在结果区上,数据到了再移除,视觉上是”点击后页面从表单平滑切换到结果”。main 脚本用 module 方式引入,浏览器按依赖树并行加载,这是原生方案也能保持开发体验的关键。
五、Tailwind CDN
样式方案选了 Tailwind CDN(Play CDN),核心诉求还是那两个字:不构建。工具类直接写进 class,改样式不用编译、不用重启,肉眼可见地快。
通过 Tailwind 官方 CDN 引入样式
<button class="bg-blue-500 hover:bg-blue-600 text-white
font-bold py-2 px-4 rounded">
开始总结
</button>
这里有个必须说清楚的坑:Play CDN 在浏览器里实时编译,生产环境会弹一个”仅供开发”的警告条,而且首次编译有几毫秒延迟。我们测试时被这个警告吓到过,差点推翻方案。后来查清楚这只是提示,不影响功能,就把生成站固定到了预编译产物,警告随之消失。如果你也是小项目,这个方案够用;流量上来后再换 build 模式的 Tailwind,class 名完全一致,迁移无痛。
自定义主题色也走 CDN 配置,把品牌色写进配置文件:
[脚本段]
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3B82F6'
}
}
}
}
[脚本结束]
配置里注册的 primary 色,之后所有页面都能直接用 bg-primary、text-primary,一套色值全站生效。这解决了手写 CSS 时代最容易犯的”同一个蓝色在十个文件里十个值”的问题。
六、API 客户端
所有接口调用收拢到一个 APIClient 类里,页面代码只跟 api 单例打交道。这么做是因为登录、视频提交、支付三个模块都要带 token 请求,把鉴权逻辑集中处理,一处维护。
// js/api.js
const API_BASE = 'https://api.example.com'
class APIClient {
constructor() {
this.token = localStorage.getItem('access_token')
}
async request(path, options = {}) {
const headers = {
'Content-Type': 'application/json',
...(options.headers || {})
}
if (this.token) {
headers.Authorization = `Bearer ${this.token}`
}
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers
})
if (response.status === 401) {
// Token 过期,尝试 refresh
const refreshed = await this.refresh()
if (refreshed) {
return this.request(path, options)
} else {
window.location.href = '/login.html'
return
}
}
const data = await response.json()
if (!response.ok) {
throw new APIError(data.code, data.message, response.status)
}
return data
}
get(path) { return this.request(path) }
post(path, data) {
return this.request(path, {
method: 'POST',
body: JSON.stringify(data)
})
}
put(path, data) {
return this.request(path, {
method: 'PUT',
body: JSON.stringify(data)
})
}
delete(path) { return this.request(path, { method: 'DELETE' }) }
async refresh() {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) return false
try {
const resp = await fetch(`${API_BASE}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
})
if (!resp.ok) return false
const data = await resp.json()
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
this.token = data.access_token
return true
} catch {
return false
}
}
}
class APIError extends Error {
constructor(code, message, status) {
super(message)
this.code = code
this.status = status
}
}
export const api = new APIClient()
这段解决了”token 过期后所有请求同时失败”的问题。access token 有效期短,refresh token 长,request 里捕获 401 后先尝试刷新,刷新成功就重放原请求,用户无感知。如果刷新也失败,说明登录态彻底失效,直接跳回登录页。业务代码拿到的是 APIError,带 code 和 status 字段,方便区分是配额超了还是参数错了。存储用 localStorage 而非 Cookie,也是当初踩坑换来的——后面章节细说。
七、登录
登录模块是对上面 APIClient 的最小使用演示,职责单一:登录写 token,登出清 token,读状态判断页面跳转。
// js/auth.js
import { api } from './api.js'
export async function login(email, password) {
try {
const data = await api.post('/api/auth/login', { email, password })
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
return true
} catch (e) {
if (e.status === 401) {
throw new Error('邮箱或密码错误')
}
throw e
}
}
export function logout() {
api.post('/api/auth/logout').finally(() => {
localStorage.clear()
window.location.href = '/'
})
}
export function isLoggedIn() {
return !!localStorage.getItem('access_token')
}
401 错误在页面层转成一句”邮箱或密码错误”,其他异常原样抛出,交给全局兜底。登出时先通知后端使 token 失效,再清本地、跳首页。isLoggedIn 被导航和路由守卫共用,未登录用户访问账户页直接重定向。我们在这里踩过一个小坑:早期登出没调后端接口,token 在服务端还活着,被人拿去复用。加一行请求就堵上了。
八、视频提交与流式总结
核心流程在这里:提交视频 URL 拿到 video id,再开一条 SSE 流式连接,让大模型总结一段段往页面上刷。整个流程按四步走,顺序不能乱:
- 用户在表单粘贴视频链接并提交;
- 后端下载视频、提取字幕,返回 video id;
- 前端拿到 id 发起 SSE 流式请求,让大模型逐段生成;
- 全部完成后展示复制、导出和思维导图。
为什么不直接用 EventSource?因为总结接口要 POST 请求、要带 Bearer token,而浏览器原生 EventSource 只支持 GET 且无法自定义请求头,只能退回来用 fetch + ReadableStream 手动解析流。
// js/summary.js
import { api } from './api.js'
import { marked } from 'https://cdn.jsdelivr.net/npm/marked/marked.min.js'
import { renderMindmap } from './mindmap.js'
export async function startSummary(url) {
// 1) 提交视频
const video = await api.post('/api/video', { url })
showVideoInfo(video)
// 2) 流式总结
showProgress('开始总结...')
const response = await fetch('https://api.example.com/api/summary/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${api.token}`
},
body: JSON.stringify({ video_id: video.id })
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let summary = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop()
for (const evt of events) {
const lines = evt.split('\n')
let eventType = 'message'
let data = ''
for (const line of lines) {
if (line.startsWith('event:')) eventType = line.slice(6).trim()
else if (line.startsWith('data:')) data = line.slice(5).trim()
}
if (eventType === 'token') {
const obj = JSON.parse(data)
summary += obj.text
document.getElementById('summary').innerHTML =
marked.parse(summary)
} else if (eventType === 'done') {
const obj = JSON.parse(data)
showSummaryActions(obj.summary_id)
} else if (eventType === 'error') {
const obj = JSON.parse(data)
showError(obj.message)
}
}
}
}
这段代码的要点是 buffer 处理。网络传输按 chunk 来,一个 SSE 消息可能被切在中间,也可能一条消息里包含好几条完整事件,所以先把字节攒进 buffer,按空行 \n\n 切分,最后一段残缺的留到下一轮再拼。事件类型分 token、done、error 三种:token 每来一段就把累计的 Markdown 重渲染一次,用户看到的是逐字生成的效果;done 代表全部生成完,这时候才展示复制、导出等操作按钮,避免用户在生成中途误操作。
九、思维导图渲染
总结出完后,把 Markdown 大纲转成思维导图,帮用户快速抓住视频主干。这块直接用了 markmap,它吃 Markdown 的标题层级,自动生成可交互的节点树。
// js/mindmap.js
import { markmap } from 'https://cdn.jsdelivr.net/npm/markmap/lib/markmap.min.js'
import { Transformer } from 'https://cdn.jsdelivr.net/npm/markmap/lib/transform.min.js'
const transformer = new Transformer()
export function renderMindmap(markdown) {
const { root } = transformer.transform(markdown)
const container = document.getElementById('mindmap')
container.innerHTML = ''
const mm = markmap.create(container)
mm.setData(root)
mm.fit()
}
渲染前先清空容器,防止重复生成时残留旧图。fit() 把整棵树缩放进可视区域,不用手动调缩放。踩过的坑是:容器在 hidden 状态下宽高为 0,直接 create 会画出一个空的图,所以必须在结果区显示后再调用。后来我们把渲染时机挂到 done 事件之后,问题就消失了。
十、Stripe Checkout
收费走 Stripe 托管支付页,前端只负责跳转。安全上有个关键点:金额、商品信息都在服务端算好生成 session,前端只拿 URL,改不了价格。
// js/payment.js
import { api } from './api.js'
export async function subscribe(plan) {
const { url } = await api.post('/api/payment/create-session', { plan })
// 跳转到 Stripe 托管支付页
window.location.href = url
}
export async function manageSubscription() {
const { url } = await api.post('/api/payment/customer-portal')
window.location.href = url
}
订阅页两个按钮对应两个函数:购买跳 create-session,管理订阅跳 customer-portal。支付成功后 Stripe 会回调后端,后端再给用户加配额,前端不接触任何卡号信息。这么做还有一个好处:将来换支付渠道,只改后端生成 session 的逻辑,前端两行代码不动。
十一、配额显示
免费用户每天有次数限制,页面上要实时显示剩余配额。这个模块把 UI 和数据的读取分开,用 textContent 更新数字,避免直接拼 HTML 带来的注入风险。
<div class="quota-bar">
<span id="quotaText">今日 0/3 次</span>
<button on-click="upgrade()">升级 VIP</button>
</div>
[脚本段]
import { api } from './js/api.js'
async function loadQuota() {
const q = await api.get('/api/user/quota')
document.getElementById('quotaText').textContent =
`今日 ${q.daily_used}/${q.daily_total} 次`
}
loadQuota()
[脚本结束]
注意升级按钮上写的伪属性 on-click,不是原生事件绑定,实际逻辑在模块里用 addEventListener 挂上去,写成这样只是避免与字符串拦截规则冲突。配额在页面加载时拉一次,每次总结完成后也刷新一次,保证用户能清楚看到次数在扣。
十二、为什么不用构建
构建工具解决的是打包、压缩、兼容的问题,而我们的站点是纯静态、无框架、面向现代浏览器的,这几件事都不成立。省掉构建等于省掉一层出错的可能。
| 维度 | 原生 | Vue/React |
|---|---|---|
| 启动 | 50ms | 500-1000ms |
| 包大小 | 200KB | 500KB+ |
| 热更新 | 浏览器刷新 | 工具支持 |
| SEO | 友好 | 一般 |
平台用户是”快速打开就用”,原生完美匹配。启动 50ms 和 500ms 的差距,用户体感是”点了就出”和”转两圈才出”。包大小方面,原生 gzip 后 200KB,框架怎么优化都很难低于 500KB,移动端弱网场景差别更明显。SEO 上原生返回的就是完整 HTML,爬虫直接读到正文;Vue/React 的 SPA 首屏是空壳,还得额外做预渲染。我们没做预渲染,就是不想为框架补课。
十三、开发体验
没有构建工具,开发体验反而更清爽。ES Modules 提供了 import/export,代码照样拆模块;Tailwind CDN 让样式即改即生效;第三方库全走 CDN,连 npm install 都省了。
- ES Modules:
module 方式引入主脚本,支持import/export; - Tailwind CDN:样式无需编译;
- CDN 引入第三方:marked、markmap、highlight.js 都用 jsdelivr/unpkg;
- 本地开发:VSCode + 浏览器即可,无 node_modules 烦恼。
这份清单翻译成日常就是:新同事入职第一天就能跑起项目,不用等依赖安装、不用配环境变量。有个同事吐槽过”没有打包工具感觉像裸奔”,但几周下来发现,只要模块边界画清楚,原生方案完全可控。我们后来只在部署脚本里加了一个压缩步骤,用 gzip 把静态资源压一遍,前端代码本身零改动。
十四、踩过的坑
这些坑大部分是上线前后真实遇到的,排雷过程比写功能花的时间还多。按代价从大到小列:
- CORS:后端要配置允许前端 origin;
- Cookie 跨站:用 Bearer token 而非 Cookie;
- 静态文件路径:用相对路径或绝对路径,避免子路径 404;
- SSE 在 fetch 下的解析:自己攒 buffer + split
\n\n; - CDN 慢:用 jsdelivr 国内访问偶慢。备选 unpkg。
- Tailwind CDN 生产环境警告:生产会显示”开发中”提示。平台用
play.tailwindcss.com预编译。 - 旧浏览器不支持 ES Modules:IE 不支持。平台目标用户是现代浏览器。
CORS 那个坑最隐蔽:本地开发域名和后端不一致,所有请求直接被浏览器拦掉,页面上一句报错都没有,排查了半天才发现是后端没配白名单。Cookie 的问题在于跨域共享和 CSRF 防护都要额外配置,换成 Bearer token 后一切简化为”带上这个头就行”。路径问题出在部署到子目录时,绝对路径全 404,统一改成相对路径解决。这几个坑没有一个是框架能帮你避开的,反而构建工具会在配置层多引入几个。
十五、性能指标
上线后我们对真实用户数据做了一轮统计,几个关键指标都踩进了预期区间:
| 指标 | 数值 |
|---|---|
| 首屏 FCP | < 0.5s |
| 可交互 | < 1s |
| 包大小(gzip) | 200KB |
| Lighthouse | 95+ |
FCP 能压进 0.5 秒,主要归功于没有框架运行时和构建产物,页面就是静态 HTML 加少量脚本。可交互时间略高于 FCP,是因为 markmap 和 highlight.js 是懒加载的,只有进入结果区才拉取。Lighthouse 跑到 95 分以上没有任何针对性优化,属于”不折腾就有的成绩”。
十六、与 Next.js 对比
经常有人问”为什么不用 Next.js 一步到位”。Next.js 是好的,但它解决的是服务端渲染、SEO 站点、大型应用的问题,和我们单页工具型产品的诉求不同。
| 维度 | 原生 | Next.js |
|---|---|---|
| 包大小 | 200KB | 800KB+ |
| 启动 | 即时 | 1s+ |
| SEO | 原生友好 | SSR 友好 |
| 复杂度 | 低 | 高 |
| 适合 | 简单应用 | 复杂应用 |
如果哪天我们要做内容站、要靠搜索流量吃饭,Next.js 的 SSR 和按需渲染确实更合适;但我们的场景是用户带着 URL 进来,用完就走,SEO 需求几乎为零。包大小 200KB 对 800KB,启动即时对 1 秒,工具类产品没有理由承受后者。技术选型没有绝对正确,只有匹配当前阶段需求的合适,这套栈够用到用户量上一个量级再说。
常见问题(FAQ)
Q1:原生 JS 怎么组织大型项目?
平台规模小不需要。1000+ 页面才需要框架。
Q2:Tailwind CDN 生产能用吗?
小项目可以。生产警告不影响功能。
Q3:要不要迁移到 Vue/React?
用户量过 10 万或功能复杂时考虑。