前端使用选型对比(AI 视频下载总结器的极简实现)

前端选型的第一道坎,往往不是技术,而是对产品形态的共识。产品形态很明确:用户粘一个视频链接,页面提交任务,后台下载、抽字幕、让大模型总结,再把结果一段段流式刷到屏幕上。就这点交互,团队却为”用不用 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 流式连接,让大模型总结一段段往页面上刷。整个流程按四步走,顺序不能乱:

  1. 用户在表单粘贴视频链接并提交;
  2. 后端下载视频、提取字幕,返回 video id;
  3. 前端拿到 id 发起 SSE 流式请求,让大模型逐段生成;
  4. 全部完成后展示复制、导出和思维导图。

为什么不直接用 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 万或功能复杂时考虑。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部