Vite + Vue 3 技术博客平台搭建实践:从选型到部署
打算认真维护一个技术博客的时候我第一个纠结的问题不是写什么选题而是这个博客程序本身用什么搭。手头那个旧博客是 Webpack 4 配 Vue 2每次改构建配置都像在维护一堆互相打架的 loader 和 plugin项目里的 Markdown 文件、组件、样式一多热更新就开始变钝改一个类名都要等两三秒。Vite 出来之后我几乎是第一时间把新博客的全部前端方案锁死在 Vite Vue 3 上。今天这篇文章就是完整复盘这个基于 Vite 的 Vue 技术博客平台是怎么从零做出来的为什么选型、脚手架和目录怎么规划、Markdown 怎么变成页面、路由和主题系统怎么组织、构建阶段的 mode 和分包有哪些坑、部署上线又要注意什么。所有内容都来自我真实跑过的项目不是概念介绍里面贴的配置和代码都是可以直接抄走再改的。1. 我为什么拿 Vite 来搭技术博客而不是继续用 Webpack1.1 内容型站点的开发体验核心竞争力是反馈速度博客网站本质上是一个内容驱动的前端应用页面骨架固定真正每天在变的是 Markdown 文章、代码块样式、目录交互和排版细节。我写一篇技术文章的时候经常要反复开关目录、检查代码高亮配色、调节标题间距这种高频的小改动对本地开发服务器的响应速度极其敏感。Webpack 4 时代的热更新走的是重新打包局部模块再通知浏览器的路线。项目小的时候没问题但 Markdown 文件一多、highlight 相关库一接入每次热更新都要重新走一遍模块图分析体感延迟非常明显。Vite 的思路完全不同开发阶段它不打包而是直接利用浏览器原生 ES Module把源码按需发给浏览器再用 esbuild 预构建 node_modules 里的依赖。冷启动基本是几百毫秒级别改一行代码浏览器几乎立即刷新这个差距在写一篇文章、调一天样式的场景里体验差异特别大。还有一个我比较看重的点Vite 对 Vue 单文件组件的支持是开箱即用的不需要像 Webpack 那样去拼vue-loader和vue-template-compiler的版本组合。vitejs/plugin-vue装好、注册好.vue文件就能直接编译。对我这种更喜欢把精力花在内容而不是工具链上的人来说这个省心程度非常重要。1.2 生产构建用 Rollup为什么并没有变成劣势有人会担心Vite 开发模式跑得爽生产构建是不是就不行了。实际上 Vite 的生产构建底层是 Rollup经过这么多轮版本迭代Rollup 在代码分割、Tree Shaking、插件生态上都已经非常成熟。对一个博客站来说依赖总量本身不大核心就是 Vue、Vue Router、几个 Markdown 相关的库最终产物也就几百 KB 级别Rollup 完全可以处理得很干净。我更在意的是 Vite 的配置心智负担比 Webpack 小很多。Webpack 里一个简单的alias、一个devServer配置项都有自己的一套历史包袱Vite 的resolve.alias、server、build这些配置键语义清晰查文档基本不费劲。对于个人维护的技术博客项目降低长期维护成本比极端追求构建性能更重要。2. 脚手架选型与工程化配置create-vue 里的每个选项都得想明白2.1 交互式选项不是随便点的每个选择都会影响后面几个月Vite 官方推荐的create-vue脚手架是交互式的会问你要不要 TypeScript、JSX、Vue Router、Pinia、Vitest、ESLint、Prettier 等一堆选项。我第一次创建项目时几乎全选 Yes结果生成了不少用不到的依赖项目看着很“全”实际很冗余。第二个项目我重新整理了一版每个选项都对应到博客的真实需求脚手架选项是否选择我的理由TypeScript是文章的 frontmatter 字段、路由 meta、主题配置都能定义类型写起来不会东错西错JSX否博客页面用 SFC 足够不需要 JSX 语法支持Vue Router是多页面导航、文章详情页、标签页都依赖路由必选Pinia否博客的全局状态其实很少一个主题 composable 就能解决不引入额外依赖Vitest否前期没有必须用单测覆盖的核心逻辑真要补测试再单独加ESLint Prettier是长期写代码和 Markdown 模板格式统一能少很多无效 diff这里特别想说一下 Pinia。很多 Vue 3 项目一上来就装 Pinia但技术博客唯一需要跨页面共享的状态通常只有主题模式和阅读进度这两样用一个带本地存储的 composable 就够了。少一个 store 依赖构建体积更小类型也更好写。等将来真的有了用户登录、评论状态这类复杂共享数据再加 Pinia 也不迟。2.2 目录结构设计把内容和代码分开比想象中重要项目创建完之后我按下面的结构重新整理了目录blog-platform/ ├── index.html ├── vite.config.ts ├── tsconfig.json ├── src/ │ ├── main.ts │ ├── App.vue │ ├── posts/ # 所有 Markdown 文章 │ │ ├── vite-vs-webpack.md │ │ ├── vite-build-mode.md │ │ └── ... │ ├── router/ │ │ └── index.ts │ ├── composables/ │ │ ├── useTheme.ts │ │ └── usePosts.ts │ ├── layouts/ │ │ ├── DefaultLayout.vue │ │ └── PostLayout.vue │ ├── components/ │ │ ├── PostCard.vue │ │ ├── PostList.vue │ │ ├── TocSidebar.vue │ │ └── ThemeToggle.vue │ └── styles/ │ ├── variables.css │ └── global.css最核心的设计思路是posts目录只放.md文件代码和内容完全隔离。这样我平时写文章时根本不需要打开任何 Vue 组件专心写 Markdown 就行。文章之间如果有图片我习惯放在src/posts/assets/下Vite 会自动处理引用路径并输出带 hash 的资源文件部署后能走 CDN 缓存。还有一个容易忽略的细节tsconfig.json里的types和include要明确一点避免 Vite 客户端类型和 Node 类型混在一起。如果要在vite.config.ts里使用node:path、node:url记得装types/node否则import.meta.url和fileURLToPath的类型会报错。3. Markdown 管道把 .md 文件变成可导航的文章页面3.1 选型vite-plugin-md 与 unplugin-vue-markdown 怎么选博客平台内容管道的核心任务是把.md文件编译成 Vue 组件同时把 Markdown 头部 YAML 里的标题、日期、标签等信息暴露出来。Vite 生态里有两个比较主流的插件一个是vite-plugin-md一个是unplugin-vue-markdown。我实际用的是vite-plugin-md因为它的使用方式非常直观在 Vite 配置里加一个Markdown()插件并在vitejs/plugin-vue的include里把.md也加进去然后就可以像导入 Vue 组件一样导入 Markdown 文件。需要说明的是unplugin-vue-markdown以及vite-plugin-md在能力上其实是同一个方向用哪个更多取决于你对 markdown-it 还是对 MarkdownIt 生态的熟悉程度。vite-plugin-md默认走 markdown-it我想要自定义 markdown-it 插件时只要在markdownItUses里直接传入数组非常灵活。如果你是从 Nuxt 生态过来、习惯用 MDC 语法或更高级的组件嵌入那unplugin-vue-markdown可能更合适。博客场景里我建议先选vite-plugin-md它简单直接、文档清晰。3.2 Frontmatter 与文章列表页的联动每篇文章的头部 YAML 是我统一约定的格式--- title: 基于 Vite 构建的现代化 Vue 技术博客平台 date: 2025-01-18 tags: [Vite, Vue, 前端工程化] summary: 从脚手架、Markdown 管道到路由主题和构建优化复盘一个 Vite Vue 3 博客平台的完整实现。 ---插件会把这段 frontmatter 解析出来作为一个具名导出挂在模块上。我在src/composables/usePosts.ts里用import.meta.glob一次性读取src/posts/**/*.md把所有文章的标题、日期、标签、摘要提取出来并按日期倒序排列。import { computed } from vue import type { PostMeta } from ../types const modules import.meta.glob(../posts/**/*.md, { eager: true }) export function usePosts() { const posts computedArrayPostMeta { path: string }(() Object.entries(modules) .map(([path, mod]) ({ path, ...(mod as any).frontmatter })) .sort((a, b) new Date(b.date).getTime() - new Date(a.date).getTime()) ) return { posts } }这里有个实践细节import.meta.glob的eager: true会在构建时把所有 Markdown 文件都打进同一个 chunk 或者对应的异步 chunk 里。文章数量不多几十篇的时候完全没问题但我后面会提到如果文章达到几百篇最好改成懒加载配合异步路由否则首屏构建产物会被文章内容撑大。3.3 代码高亮不要无脑全量引入 highlight.js技术博客逃不开代码高亮。我一开始图省事直接在 main.ts 里引入了完整版 highlight.js所有语言全部注册结果打包产物肉眼可见地变大。后来改为手动按需注册只保留博客里真正用到的语言typescript、javascript、bash、css、html、nginx、python、json。在vite.config.ts里配合 markdown-it 做高亮封装import hljs from highlight.js import Markdown from vite-plugin-md const highlight (str: string, lang: string) { if (lang hljs.getLanguage(lang)) { try { return pre classhljscode${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}/code/pre } catch (error) { console.error(error) } } return pre classhljscode${hljs.highlightAuto(str).value}/code/pre } export default defineConfig({ plugins: [ vue({ include: [/\.vue$/, /\.md$/] }), Markdown({ markdownItOptions: { html: true, linkify: true, typographer: true, highlight } }) ] })这样写的好处是构建时 Markdown 里的代码块就已经被渲染成带hljs类名的 HTML浏览器端只需要引入高亮主题的 CSS不再需要执行高亮 JS减少了客户端的计算量。主题切换的时候我也只需要给.hljs配上深浅两套配色变量即可。如果你更喜欢 Shiki 那种精确的 Token 级高亮方案也是可行的但要注意 Shiki 的 WASM 和语言包体积问题。我的经验是纯前端博客用 highlight.js 按需注册语言性价比最高追求极致美观可以后续再切换到 Shiki但要在构建插件里做预渲染不能把 Shiki 直接打进浏览器 bundle。4. 路由与目录体系一篇博客在站内是怎么被找到的4.1 用 import.meta.glob 生成路由表博客的路由结构不复杂但手工一个个写路由绝对不可取。我之前维护 Webpack 老博客时就是每写一篇新文章就往路由数组里加一行文章一多很容易漏。换到 Vite 项目后我用全量导入文章模块的方式自动生成路由新增 Markdown 文件后刷新浏览器就自动有对应页面。import { createRouter, createWebHistory, type RouteRecordRaw } from vue-router import DefaultLayout from ../layouts/DefaultLayout.vue const modules import.meta.glob(../posts/**/*.md) const postRoutes: RouteRecordRaw[] Object.entries(modules).map(([path, loader]) { const matched path.match(/\.\.\/posts\/(.)\.md$/) const slug matched ? matched[1] : path return { path: /posts/${slug}, component: () loader(), meta: { layout: post } } }) const routes: RouteRecordRaw[] [ { path: /, component: DefaultLayout, children: [ { path: , name: home, component: () import(../views/HomeView.vue) }, { path: posts/:slug, name: post, component: () import(../views/PostView.vue) }, ...postRoutes ] } ] const router createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition return { top: 0 } } })上面这段是我在早期版本里的简化写法。实际项目里为了防止 slug 冲突我用的是文章前 8 位日期加短横线命名法例如2025-01-18-vite-blog.md路由就是/posts/2025-01-18-vite-blog。这样即使有同名标题路径也不会撞车。4.2 路由 meta、标题守卫与目录导航文章页需要根据 frontmatter 里的标题动态修改浏览器的document.title这个我放在全局前置守卫里做router.beforeEach((to, from, next) { const title to.meta.title document.title title ? ${title} - 前端笔记 : 前端笔记 next() })这里要注意to.meta.title不一定存在。比如首页、关于页这种静态路由我在定义时给 meta 里补充了title字段文章页的 title 则需要在动态路由的beforeEnter或守卫里从模块的 frontmatter 读取。我最终采用的是在PostView.vue的setup里拿到路由参数后从usePosts的列表数据里查对应文章然后调用useHead类似的逻辑简单可靠。目录跳转TOC是技术博客必须有的交互。我处理的方式是在 Markdown 渲染后从文章 DOM 里抓取h2、h3的idmarkdown-it-anchor 会自动生成生成一个目录树点击目录项时用scrollIntoView({ behavior: smooth })平滑滚动。为了高亮当前阅读章节我会在scroll事件里用getBoundingClientRect判断各标题距离视口顶部的位置性能上完全够用不需要上 IntersectionObserver 或复杂计算。还有一个细节是上一篇和下一篇的切换。这个直接从usePosts里已经按日期排好序的数据里取前一篇和后一篇即可比在路由表里上下找要稳得多。排序时必须注意同一月份的文章日期格式保持一致最好统一YYYY-MM-DD否则字符串排序会出现错位。5. 主题系统深浅色切换从样式层面该怎么设计5.1 CSS 变量驱动的主题架构我见过不少博客把深浅色主题做成两套类名、两套 SCSS 变量文件切换时给 body 替换类名。这种方案能跑但维护成本偏高每新增一个组件样式都要想一遍它在深色和浅色下分别是什么值很容易漏。我更推荐用 CSS 变量做主题架构。在styles/variables.css里定义基础变量:root { --color-bg: #ffffff; --color-text: #1a1a1a; --color-border: #e5e7eb; --color-code-bg: #f6f8fa; --color-link: #2563eb; --color-muted: #6b7280; } [data-themedark] { --color-bg: #111827; --color-text: #f3f4f6; --color-border: #374151; --color-code-bg: #1f2937; --color-link: #60a5fa; --color-muted: #9ca3af; }组件里全部使用var(--color-bg)这类引用切换主题时只需要改html上的>script (function () { var stored localStorage.getItem(blog-theme) var theme stored light || stored dark ? stored : (window.matchMedia((prefers-color-scheme: dark)).matches ? dark : light) document.documentElement.setAttribute(data-theme, theme) })() /script这段内联脚本必须在任何 CSS 加载之前执行否则还是会闪。它还能顺带处理用户没有手动设置过主题的情况跟随系统偏好。后续用户点击顶部切换按钮时composable 会更新localStorage和>import { ref } from vue const THEME_KEY blog-theme const theme reflight | dark( (localStorage.getItem(THEME_KEY) as light | dark) || light ) export function useTheme() { function setTheme(next: light | dark) { theme.value next document.documentElement.setAttribute(data-theme, next) localStorage.setItem(THEME_KEY, next) } return { theme, setTheme } }这里我特意没有用 Pinia原因上文说过一个模块级ref加上localStorage已经能满足所有页面共享主题状态的需求。如果将来要在多个组件里调用只需要确保useTheme在模块顶层初始化一次不要在服务端渲染环境里执行否则会因为在初始化时访问localStorage而报错。博客是纯 SPA所以没有这个问题。6. 构建阶段的分包策略与 mode 环境区分6.1 手动分包把 Vue、Markdown、高亮拆成独立 chunk博客项目虽然总体不大但把所有第三方库打进同一个 bundle 依然不健康。任何一个依赖升级都会导致整个包缓存失效用户重新访问时要下载全部代码。我做的第一轮优化是用manualChunks手动分包。build: { rollupOptions: { output: { manualChunks(id) { if (id.includes(node_modules/vue) || id.includes(node_modules/vue-router)) { return vue-vendor } if (id.includes(node_modules/markdown-it) || id.includes(node_modules/highlight.js)) { return markdown-vendor } if (id.includes(node_modules/highlight.js)) { return highlight } } } } }注意manualChunks是一个函数时每个模块 id 会被依次调用所以判断顺序很重要。上面把highlight.js的判断放在最后让它在没有命中前面条件时才会进入独立 chunk。实际配置里我提前建立了所有语言注册的模块只会产生一个highlightchunk大小在 80KB 左右gzip 后约 20KB可以接受。另一个优化点是build.target。如果不需要兼容特别老的浏览器我会把build.target设为es2020或chrome100这样 Rollup 不会为了兼容旧语法生成大量 polyfill 辅助代码。博客读者大概率使用现代浏览器没必要为 IE 时代买单。6.2 vite build --mode test 到底改变了什么网上很多人问vite build --mode test到底干了什么我实际踩过这个坑这里把机制讲透。Vite 的构建命令vite build默认以production为模式会加载.env.production。当你加上--mode test后模式会变成testVite 会加载.env.test里的环境变量。注意这里说的是模式变了但vite build命令本身仍然会把NODE_ENV设置为production也就是说代码压缩、Tree Shaking 这些生产构建行为不会改变。在配置里可以通过defineConfig(({ command, mode }) ...)拿到当前模式再用loadEnv手动读取对应环境文件。我的vite.config.ts里有这样一段import { defineConfig, loadEnv } from vite export default defineConfig(({ mode }) { const env loadEnv(mode, process.cwd(), ) console.log(build mode:, mode, API:, env.VITE_API_BASE) return { // ... } }).env.test里的定义只有以VITE_开头的变量会被覆盖到import.meta.env上。默认情况下Vite还会给你注入MODE、DEV、PROD、SSR这些内置字段。所以如果你想区分测试环境的预发布构建和正式生产构建用--mode test是标准做法但一定要清楚它并不会改变NODE_ENV别指望它能跳过压缩或关闭生产优化。6.3 大构建内存溢出NODE_OPTIONS 在不同终端下的正确写法我在做一次大批量文章构建时遇到过 Node 堆内存不足报错信息是JavaScript heap out of memory。很多文章推荐用环境变量NODE_OPTIONS--max-old-space-size4096来扩容但这句话在 Windows 下经常失效原因是指令语法不同。我整理了一下三种终端的正确写法# bash / Linux / macOS NODE_OPTIONS--max-old-space-size4096 vite build # Windows PowerShell $env:NODE_OPTIONS--max-old-space-size4096; vite build # Windows CMD注意引号 set NODE_OPTIONS--max-old-space-size4096 vite build最常见的问题是拿着 bash 的语法直接到 CMD 里跑系统会提示NODE_OPTIONS 不是内部或外部命令。如果你用的是 npm 脚本可以在package.json里写一个跨平台的脚本或者干脆安装cross-env。不过我的最终建议是博客项目文章不到几百篇时默认的 Node 堆内存完全够用真遇到溢出先检查是不是有某个 Markdown 文件包含了超大的内联图片或超长代码块而不是一上来就堆内存。7. 部署上线时的几个隐藏雷区7.1 base 路径配置错了整站样式全挂部署到服务器时如果博客不是部署在域名根路径而是在子路径如https://example.com/blog/必须给 Vite 的base配上对应路径否则 JS、CSS 资源的加载路径会从域名根开始找返回 404页面白屏。export default defineConfig({ base: /blog/ })配置了base之后import.meta.env.BASE_URL也会变成/blog/在动态拼接资源地址时要用它而不是写死。跑本地开发时我习惯用环境变量控制const base process.env.DEPLOY_BASE || / export default defineConfig({ base })这样本地起服务默认根路径部署脚本里传DEPLOY_BASE/blog/就可以覆盖。这个细节看似简单我第一次部署时就是因为漏了base在服务器上白屏了很久后来看请求日志才发现所有资源都在 404。7.2 History 路由在 Nginx 下的回退规则Vue Router 用createWebHistory时刷新/posts/xxx这样的路径Nginx 会去磁盘上找posts/xxx对应的文件找不到就 404。必须在 Nginx 配置里加try_files回退到index.htmlserver { listen 80; server_name blog.example.com; root /var/www/blog/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ { expires 30d; add_header Cache-Control public, immutable; } }这里还有一层要注意静态资源带 hash 的文件适合做长时间缓存但index.html本身必须no-cache否则发布新版本后用户可能一直访问旧的 HTML 入口导致页面内容无法更新。我会加一段location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate; }如果你打算把前端构建产物丢进 Spring Boot 这类后端项目的static目录一起部署就要额外小心。Spring Boot 默认对/**的处理和后端 Controller 路由容易冲突History 路由刷新经常会 404。我的经验是需要单独配置一层资源映射或者干脆前端独立部署到 CDN / 静态服务器再用反向代理指到后端 API这样两端职责清晰出问题也好排查。7.3 环境变量的注入边界构建时替换 vs 运行时读取最后说一个容易被忽略的问题import.meta.env.VITE_*是在构建时被 Vite 静态替换的它不是浏览器运行时动态读取的环境变量。这意味着如果你用同一份构建产物部署到多个环境想通过修改服务器环境变量来切换 API 地址是行不通的。必须有一个构建步骤每个环境各自构建一次或者使用运行时配置方案在index.html里注入一个全局配置对象前端启动时读取它。博客项目如果没有任何后端接口这个问题就不存在。但很多人的博客会挂评论服务、搜索服务或者访问统计这些第三方服务的 key 往往分环境。我在项目里的做法是本地开发用.env.development线上构建用.env.production测试预发布用.env.test搭配vite build --mode test明确一次构建对应一个环境不搞跨环境复用产物。这样虽然构建次数多了一点但变量关系清楚不会出现测试环境把统计服务写进线上页面的低级事故。实际部署到服务器后我还会用 curl 检查首页 HTML 里有没有泄露非VITE_前缀的敏感配置。Vite 只会暴露VITE_开头的变量所以真正敏感的密钥千万不要以VITE_开头命名否则会被打包进前端代码。记住一个原则前端代码里任何东西都是可以被用户看到的后端密钥和 secret 必须留在服务端。最后分享一个我实测后的数据和你可能用得到的习惯。这套博客在启用分包之后首屏加载的 JS 大约 110KB gzip构建时间在两秒左右开发模式冷启动不到半秒。我个人的体会是Vite 带来的最大收益不是某个具体的配置项而是它让写文章 调界面这件事重新变得轻快不会因为改一次配置就产生畏难情绪。如果你正在搭自己的博客建议先把基础版本跑通再逐步加入自动生成目录、标签聚合、站内搜索这些功能每一步都能建立在现成的代码上。希望这篇复盘能让你少踩几个我已经踩过的坑。