普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月26日首页

一次“秒开”变成“转菊花”的线上事故

作者 前端微白
2025年7月25日 22:26

上周运营在群里炸锅:
“东南亚用户打开活动页要 7 秒,国内只要 1 秒,是不是 CDN 挂了?”
第一反应是 CDN 节点问题,结果 curl 一看,TLS 握手 30 ms,首包却 2.8 s——典型的 DNS 拖后腿。
最后发现是新买的 .app 域名没开 EDNS0-Client-Subnet,权威 DNS 把新加坡用户解析到美国去了。
要根治,得先把 DNS 协议从“输入网址”到“拿到 IP”整条链路拆个透。


解决方案:把一次域名解析拆成 5 张“车票”

🔍 关键决策点

  • 第 2 步没加 ECS,权威 DNS 只能按递归出口 IP(北京)返回最近节点,导致新加坡绕路。
  • 第 8 步 CNAME 链太长,每多一跳就加一次 RTT。

原理剖析:三层报文结构,一层比一层“黑盒”

层级 协议字段 作用 常见坑
传输层 UDP 53 / TCP 53 0-RTT vs 可靠传输 截断后 fallback TCP 多一次 RTT
应用层 QR、Opcode、RD、RA、RCODE 控制递归/权威行为 RCODE=2 SERVFAIL 时浏览器会重试 3 次
扩展层 EDNS0-Client-Subnet 把用户真实网段带给权威 长度 > 512 字节时部分老旧防火墙直接丢包

抓包片段(tcpdump -nn -s0 -vvv port 53)

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 0x1234
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION: EDNS: version: 0, flags:; udp: 4096
;; CLIENT-SUBNET: 116.12.34.0/24/24

看到 CLIENT-SUBNET 就说明 ECS 生效,权威 DNS 能按用户真实地理位置返回最优 A 记录。


应用扩展:一条“秒开”配置模板

我们在 CDN 控制台 + 权威 DNS 做了三件事,把东南亚首包降到 300 ms:

# 1. 权威 DNS 打开 ECS 支持(以 NS1 为例)
curl -X PUT https://api.nsone.net/v1/zones/xxx.com/api \
  -d '{"edns_client_subnet":true}'

# 2. 缩短 CNAME 链
api.xxx.com 60 IN A 1.2.3.4   # 直接 A 记录,不再 CNAME 到 CDN 二级域名

# 3. 运营商递归 DNS 预热
dig @8.8.8.8 api.xxx.com      # 上线前手动触发全球递归缓存

环境适配说明

  • 国内用户走 114.114.114.114,海外走 8.8.8.8,两边 TTL 设 60 s,方便灰度回滚。
  • 老旧安卓 4.x 不支持 ECS,需要单独在边缘节点做 GeoIP fallback。

对比主流“加速”方案

方案 原理 延迟收益 实施成本
ECS 把用户子网带给权威 100-300 ms 改权威 DNS 配置
HTTPDNS 绕过本地递归,直接 HTTP 拿结果 50-200 ms 客户端集成 SDK
DoH/DoT 加密防劫持,复用 HTTP/2 多路复用 0-50 ms 升级系统解析库

最终组合:

  • 国内 App 内嵌 HTTPDNS 兜底;
  • 海外浏览器走 DoH(Cloudflare 1.1.1.1);
  • 权威 DNS 全量开 ECS。

举一反三:三个变体场景思路

  1. 灰度发布
    在权威 DNS 把 10% 流量解析到新版本 IP,TTL 设 30 s,出问题 30 秒全局回滚。

  2. 多活容灾
    利用 DNS 的 weighted 记录,主站 100 权重,灾备 0 权重;故障时一键把灾备调到 100,无需改 CDN。

  3. 防劫持
    客户端预埋一份“可信 IP 列表”,如果 DNS 返回的 A 记录不在列表里,立即切换到 DoH 重查,并上报异常。


小结

  • DNS 不是“黑魔法”,而是一张 5 跳的车票,每一跳都能量化。
  • 打开 ECS、缩短 CNAME、预热递归缓存,是跨国业务的三板斧。
  • 把 DNS 指标(解析耗时、TTL、ECS 命中率)接进 Prometheus,比“用户说慢”提前 5 分钟发现异常。

一次诡异的登录失效

作者 前端微白
2025年7月25日 21:50

线上告警:用户上午 10:00 登录成功,10:15 刷新页面却跳回登录页。
排查发现,你给 token Cookie 设置了 Max-Age=900,但运维在 Nginx 又加了一层 proxy_cookie_path,把过期时间改写成 Session
用户浏览器里同时出现了两条同名 Cookie,一条 15 min 一条 Session,结果请求带的是后者——登录状态瞬间蒸发。
要彻底堵这种坑,得先拆清楚 Cookie 到底由哪些“零件”组成。


解决方案:把 Cookie 拆成 7 个可配置字段

下面这段代码来自我们统一封装的 setCookie 工具,把每个字段都做成可选参数,避免手写字符串拼错。

// utils/cookie.ts
export const setCookie = (opts: {
  name: string;
  value: string;
  maxAge?: number;      // 秒
  expires?: Date;
  path?: string;
  domain?: string;
  secure?: boolean;
  sameSite?: 'Strict' | 'Lax' | 'None';
  httpOnly?: boolean;   // 🔥 仅服务端可写
}) => {
  const parts = [
    `${opts.name}=${encodeURIComponent(opts.value)}`,
    opts.maxAge   && `Max-Age=${opts.maxAge}`,
    opts.expires  && `Expires=${opts.expires.toUTCString()}`,
    opts.path     && `Path=${opts.path}`,
    opts.domain   && `Domain=${opts.domain}`,
    opts.secure   && 'Secure',
    opts.sameSite && `SameSite=${opts.sameSite}`,
    opts.httpOnly && 'HttpOnly',
  ].filter(Boolean);
  document.cookie = parts.join('; ');
};

🔍 关键决策点

  • encodeURIComponent 兜住中文或特殊符号。
  • Max-AgeExpires 二选一,现代浏览器优先 Max-Age
  • SameSite=None 必须同时带 Secure,否则 Chrome 直接拒绝。

原理剖析:从“键值对”到“安全策略”的三层视角

层级 字段 作用域 设计哲学
数据层 name=value 浏览器 ↔ 服务器 纯业务键值,必须 URL 编码
作用域层 Domain / Path 决定 Cookie 随哪些请求头走 最小权限原则,防跨域污染
安全层 Secure / HttpOnly / SameSite 决定谁能读、谁能写、何时发 纵深防御,层层收紧

时序文字图

  1. 浏览器收到 Set-Cookie: token=abc; Path=/api; Secure; SameSite=Lax
  2. 下次请求匹配 Path=/api 且为 HTTPS,才把 token=abc 塞进 Cookie: 头。
  3. 前端 JS 无法读取 HttpOnly,XSS 偷不走;SameSite=Lax 挡住 CSRF 的 POST 跨站。

应用扩展:一条 Cookie 的“生命周期”配置片段

生产环境我们拆成三档:

// 会话级:关闭标签即失效
setCookie({ name: 'ui-theme', value: 'dark', path: '/', sameSite: 'Lax' });

// 持久化:7 天免登录
setCookie({
  name: 'refreshToken',
  value: jwt,
  maxAge: 7 * 24 * 3600,
  path: '/auth',
  secure: true,
  sameSite: 'Strict',
});

// 服务端专用:前端不可见
setCookie({
  name: 'sessionId',
  value: uuid,
  httpOnly: true,
  secure: true,
  sameSite: 'None',
});

环境适配说明

  • 本地 localhost 开发时,Chrome 允许 Secure Cookie 通过 HTTP,但 Safari 不行,需加 start --ignore-certificate-errors 或直接用 HTTPS 自签证书。
  • 子域共享登录态时,Domain=.example.com 记得带点,否则只对当前子域生效。

举一反三:三个变体场景实现思路

  1. 多租户隔离
    Path 设成 /tenant/:id,前端切换租户时动态改写 Cookie,避免跨租户串号。

  2. A/B 实验灰度
    Max-Age=3600 的短期 Cookie 存分组 ID,服务端按 Cookie 值路由到不同版本,实验结束自然过期,无需清理脚本。

  3. 第三方埋点兼容
    埋点域名 analytics.xxx.com 需要回写 Cookie,但主站是 www.xxx.com,设置 Domain=xxx.com + SameSite=None; Secure,既跨子域又防 CSRF。


小结

  • Cookie 不是“一串字符串”,而是 7 个独立旋钮的组合。
  • 把每个旋钮显式写成参数,就能在代码层面杜绝“拼错分号”这类低级 Bug。
  • 真正上线前,用 Chrome DevTools → Application → Cookies 面板再核对一次,比读 RFC 6265 更直观。
昨天 — 2025年7月25日首页

一张 8K 海报差点把首屏拖垮

作者 前端微白
2025年7月25日 21:13

你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。


解决方案:三条技术路线,你全踩了一遍

1. 最偷懒:原生 loading="lazy"

一行代码就能跑,浏览器帮你搞定。

<img
  src="https://cdn.xxx.com/poster1.jpg"
  loading="lazy"
  decoding="async"
  width="800" height="450"
/>

🔍 关键决策点

  • loading="lazy" 2020 年后现代浏览器全覆盖,IE 全军覆没。
  • 必须写死 width/height,否则 CLS 会抖成 PPT。

适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes(支持分段加载)。


2. 最稳妥:scroll 节流 + getBoundingClientRect

老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。

// utils/lazyLoad.js
const lazyImgs = [...document.querySelectorAll('[data-src]')];
let ticking = false;

const loadIfNeeded = () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    lazyImgs.forEach((img, idx) => {
      const { top } = img.getBoundingClientRect();
      if (top < window.innerHeight + 200) { // 提前 200px 预加载
        img.src = img.dataset.src;
        lazyImgs.splice(idx, 1); // 🔍 及时清理,防止重复计算
      }
    });
    ticking = false;
  });
};

window.addEventListener('scroll', loadIfNeeded, { passive: true });

🔍 关键决策点

  • requestAnimationFrame 把 30 ms 的节流降到 16 ms,肉眼不再掉帧。
  • 预加载阈值 200 px,实测 4G 网络滑动不白屏。

缺点:滚动密集时 CPU 占用仍高,列表越长越卡。


3. 最优雅:IntersectionObserver 精准观测

新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver 做统一调度。

// composables/useLazyLoad.ts
export const useLazyLoad = (selector = '.lazy') => {
  onMounted(() => {
    const imgs = document.querySelectorAll<HTMLImageElement>(selector);
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            const img = e.target as HTMLImageElement;
            img.src = img.dataset.src!;
            img.classList.add('fade-in'); // 🔍 加过渡动画
            io.unobserve(img);            // 观测完即销毁
          }
        });
      },
      { rootMargin: '100px', threshold: 0.01 } // 🔍 提前 100px 触发
    );
    imgs.forEach((img) => io.observe(img));
  });
};
  1. 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
  2. 主线程回调里只做一件事:把 data-src 搬到 src,然后 unobserve
  3. 整个滚动期间,零事件监听,CPU 占用 < 1%。

原理剖析:从「事件驱动」到「观测驱动」

维度 scroll + 节流 IntersectionObserver
触发时机 高频事件(~30 ms) 浏览器内部合成帧后回调
计算量 每帧遍历 N 个元素 仅通知交叉元素
线程占用 主线程 合成线程 → 主线程
兼容性 IE9+ Edge79+(可 polyfill)
代码体积 0.5 KB 0.3 KB(含 polyfill 2 KB)

一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。


应用扩展:把懒加载做成通用指令

在 Vue3 项目里,我们干脆封装成 v-lazy 指令,任何元素都能用。

// directives/lazy.ts
const lazyDirective = {
  mounted(el: HTMLImageElement, binding) {
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.src = binding.value; // 🔍 binding.value 就是 data-src
          io.disconnect();
        }
      },
      { rootMargin: '50px 0px' }
    );
    io.observe(el);
  },
};

app.directive('lazy', lazyDirective);

模板里直接写:

<img v-lazy="item.url" :alt="item.title" />

举一反三:三个变体场景思路

  1. 无限滚动列表
    IntersectionObserver 绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续 observe,形成递归观测链。

  2. 广告曝光统计
    广告位 50% 像素可见且持续 1 s 才算一次曝光。设置 threshold: 0.5 并在回调里用 setTimeout 延迟 1 s 上报,离开视口时 clearTimeout

  3. 背景图懒加载
    背景图没有 src,可以把真实地址塞在 style="--bg: url(...)",交叉时把 background-image 设成 var(--bg),同样零回流。


小结

  • 浏览器新特性能救命的,就别再卷节流函数了。
  • 写死尺寸、加过渡、及时 unobserve,是懒加载不翻车的三件套。
  • 把观测器做成指令/组合式函数,后续业务直接零成本接入。

现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。

为什么浏览器那条“假进度”救不了我们?

作者 前端微白
2025年7月25日 20:53

你在做「企业级低代码平台」时,客户把 200+ 微应用一次性嵌进门户首页。
浏览器自带的进度条只认主文档,微应用懒加载的 JS/CSS/图片它一概不管,用户盯着 100 % 的进度条却白屏 3 秒,投诉直接拉满。
于是,你撸了一条 完全受控的自定义加载进度条,从 0 % 到 100 % 与真实资源一一对应,投诉率当天掉到 0。


方案:15 分钟搭一条“真·进度条”

1. 骨架 HTML(30 秒)

<!-- index.html -->
<div id="progress-bar">
  <div class="fill"></div>
  <span class="text">0%</span>
</div>

2. 核心 JS(5 分钟)

// progress.js
class Loader {
  #total = 0
  #loaded = 0
  #fill = document.querySelector('.fill')
  #text = document.querySelector('.text')

  add(url) {
    this.#total++
    fetch(url).then(() => {
      this.#loaded++
      this.#render()
    })
  }

  #render() {
    const p = Math.round((this.#loaded / this.#total) * 100)
    this.#fill.style.transform = `scaleX(${p / 100})`
    this.#text.textContent = `${p}%`
    if (p === 100) this.#fill.parentElement.remove()
  }
}

window.loader = new Loader()

逐行解析

  • add 把每一个资源注册进来,用原生 fetch 自带 Promise 跟踪完成。
  • scaleX 做宽度动画,GPU 加速不掉帧。
  • 100 % 时自毁 DOM,避免污染全局。

3. 使用姿势(1 分钟)

<script type="module">
  import './progress.js'
  // 🔍 业务代码里显式注册资源
  loader.add('/static/chunk-a.js')
  loader.add('/static/theme.css')
  loader.add('/static/logo.png')
</script>

原理深挖:三层视角看“真进度”

层级 表面用法 底层机制 设计哲学
资源层 fetch 监听 HTTP 缓存协商 + TCP 多路复用 用浏览器能力,不重复造轮子
渲染层 scaleX + requestAnimationFrame 合成层动画,60 FPS 视觉反馈优先,主线程不阻塞
生命周期 100 % 自毁 垃圾回收自动清理 用完即走,零副作用

扩展:3 个真实业务变体

1. Vue3 组合式封装(微应用场景)

// useProgress.ts
import { ref, computed } from 'vue'
export function useProgress() {
  const total = ref(0), loaded = ref(0)
  const percent = computed(() => (loaded.value / total.value) * 100 || 0)
  const add = (url: string) => {
    total.value++
    fetch(url).finally(() => loaded.value++)
  }
  return { percent, add }
}

<Suspense>@pending 里调用 add,进度与组件懒加载天然同步。

2. 大文件切片上传(断点续传)

// 伪代码
chunks.forEach((chunk, i) => {
  loader.add(`/upload?index=${i}`) // 每个切片算 1 个进度单位
})

3. Service Worker 离线缓存

// sw.js
self.addEventListener('install', e => {
  const urls = [...] // 预缓存列表
  e.waitUntil(
    caches.open('v1').then(cache =>
      Promise.all(
        urls.map(u => fetch(u).then(r => cache.put(u, r)))
      )
    )
  )
  // 向主线程 postMessage 更新进度
})

一键复用片段

/* 进度条样式,直接拷 */
#progress-bar {
  position: fixed; top: 0; left: 0; right: 0; height: 3px;
  background: rgba(0,0,0,.1); z-index: 9999;
}
.fill {
  height: 100%; background: #0076ff;
  transform-origin: left; transition: transform .3s ease;
}
.text {
  position: absolute; top: 4px; right: 8px;
  font-size: 12px; color: #0076ff;
}

小结

浏览器那条“假进度”只能骗自己,自定义进度条才是用户信任的起点。
把上面的 30 行代码丢进任何项目,3 分钟就能让白屏时间变成“可控的等待”。
下次老板再说“体验优化”,你就可以把这篇文章甩给他,然后安心下班。

老板突然要看“代码当量 KPI”

作者 前端微白
2025年7月25日 20:44

你刚把 Vue-lite 交付给私有化客户,领导转头就在群里甩了一句:“下周评审,把各模块代码行数统计出来,软著申请要用。”
用 cloc?当然可以,但客户环境没外网,装不了二进制。于是,你 15 分钟搓了一个 零依赖 的 Node CLI,直接 npx 就能跑,还能按扩展名过滤,结果输出成 JSON 方便后续自动化。


方案:三步做出 count-lines 命令

1. 初始化项目(1 分钟)

mkdir count-lines && cd count-lines
npm init -y
# 🔍 把 bin 字段挂到全局命令
npm pkg set bin.count-lines=./bin/index.js

2. 核心脚本(10 分钟)

bin/index.js

#!/usr/bin/env node
import { readdir, readFile, stat } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { createWriteStream } from 'node:fs'

const [, , targetDir = '.', ...extArgs] = process.argv
const exts = extArgs.length ? extArgs : ['.js', '.ts', '.vue', '.css', '.html']

async function* walk(dir) {
  for (const name of await readdir(dir)) {
    const full = join(dir, name)
    if ((await stat(full)).isDirectory()) yield* walk(full)
    else yield full
  }
}

async function main() {
  const result = { total: 0, files: {} }
  for await (const file of walk(resolve(targetDir))) {
    if (!exts.includes(extname(file))) continue
    const lines = (await readFile(file, 'utf8')).split('\n').length
    result.total += lines
    result.files[file] = lines
  }
  const out = createWriteStream('count.output.json')
  out.write(JSON.stringify(result, null, 2))
  console.log(`✅ 统计完成,共 ${result.total} 行,详情见 count.output.json`)
}

main()

逐行拆解

  • walk 是一个异步生成器,递归遍历目录,避免一次性读爆内存。
  • exts 支持命令行传参,比如 npx count-lines ./src .vue .ts 只统计 Vue 和 TS。
  • 结果写本地 JSON,方便后续脚本直接 require('./count.output.json')

package.json 关键字段

{
  "type": "module",
  "bin": { "count-lines": "./bin/index.js" }
}

3. 本地测试 & 发布(4 分钟)

chmod +x bin/index.js
npm link          # 本地全局可用
count-lines ./src .vue .ts

原理深挖:从“读文件”到“流式输出”

表面用法 底层机制 设计哲学
readFile 读文本 Node 线程池异步 IO,不阻塞事件循环 单线程也能高并发
split('\n') 计数 利用 V8 内建字符串分割,比正则 /\r?\n/ 快 20% 能省一次正则就省
结果写 JSON 文件 流式写入 createWriteStream,内存占用 < 5 MB 大仓库也吃得下

扩展:3 个变体场景

场景 改动点 思路
软著申请 输出 Word 模板 officegen 把 JSON 渲染成 .docx,自动插入目录
CI 门禁 行数增量报警 在 GitHub Action 里对比 count.output.json 两次 commit 差异
多语言仓库 支持 .py .go exts 换成 Map,key 为扩展名,value 为注释正则,过滤空行和注释

一键复用片段

# 全局安装(私有 npm 源)
npm i -g @yourscope/count-lines
# 在 CI 里
- run: count-lines . .ts .vue
- run: node scripts/check-diff.js

把脚本丢进公司私有仓库,下次谁再要 KPI,一行命令搞定。

为什么我们要亲手“捏”一个 Vue 项目?

作者 前端微白
2025年7月25日 20:33

你在一家做 B 端 SaaS 的公司,产品迭代节奏极快。某天,老板突然甩来一句:“客户要私有化部署,包体必须 < 500 KB,脚手架里那些没用到的依赖全给我砍掉!”
那一刻,你深刻体会到:脚手架是“通用解”,而私有化场景需要“定制解”。于是,你决定从零手搓一个 Vue 应用,既能极致瘦身,又能随时插拔功能。


解决方案:30 分钟搭出可交付的“裸奔” Vue3 项目

1. 环境准备(2 分钟)

# 🔍 用 volta 锁 Node 版本,避免“在我电脑能跑”
volta install node@20
mkdir vue-lite && cd vue-lite
npm init -y

2. 最小依赖安装(3 分钟)

# 只装运行时 + 编译时刚需
npm i vue@next
npm i -D vite @vitejs/plugin-vue

为什么选 Vite?
对比 Webpack5(webpage 3 的做法),Vite 在 dev 阶段用 esbuild 做预构建,冷启动 < 300 ms,正适合我们“边改边看”的私有化调试场景。

3. 目录结构(5 分钟)

vue-lite
├─ public
│  └─ favicon.ico
├─ src
│  ├─ main.ts          # 应用入口
│  ├─ App.vue
│  └─ components
│     └─ Hello.vue
├─ index.html          # Vite 的“钩子”
└─ vite.config.ts

4. 核心文件代码(10 分钟)

index.html

<!doctype html>
<html>
  <head>
    <title>Vue-Lite</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: 'src/main.ts',
      name: 'VueLite',
      fileName: 'vue-lite',
    },
    rollupOptions: {
      external: ['vue'], // 🔍 把 vue 打成 external,私有化时再外链 CDN
    },
  },
})

src/main.ts

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

src/App.vue

<template>
  <Hello />
</template>

<script setup lang="ts">
import Hello from './components/Hello.vue'
</script>

src/components/Hello.vue

<template>
  <h1>{{ msg }}</h1>
</template>

<script setup lang="ts">
const msg = 'Hello, 私有化客户!'
</script>

原理剖析:从“裸文件”到“可交付产物”的三层视角

层级 表面用法 底层机制 设计哲学
Dev 阶段 npm run dev 秒开浏览器 Vite 把 .vue 文件实时编译成 ESModule,浏览器直接 import 浏览器原生能力优先,工具链只做“翻译”
Build 阶段 npm run build 生成 dist/vue-lite.umd.js Rollup 做 tree-shaking + 代码分割,external vue 减少 80 KB 私有化场景下,业务代码与框架解耦
Runtime 阶段 客户页面 <script src="vue-lite.umd.js"></script> UMD 格式自动判断宿主环境(CommonJS / AMD / 全局) 不侵入客户构建体系,即插即用

应用扩展:把“裸奔”项目武装到牙齿

1. 插拔式路由(不打包路由库)

// src/router/index.ts
import { ref, computed } from 'vue'

const routes = {
  '/': () => import('../pages/Home.vue'),
  '/about': () => import('../pages/About.vue'),
}

export const path = ref(location.pathname)
window.addEventListener('popstate', () => (path.value = location.pathname))

export const currentView = computed(() => routes[path.value] || routes['/'])

原理:利用浏览器原生 popstate + Vue3 的响应式,实现 0 依赖路由。
场景:后台管理系统只有 3-4 个页面,无需整包 vue-router。

2. 按需引入 UI 组件(以 ElementPlus 为例)

// vite.config.ts 新增
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'

plugins: [
  vue(),
  Components({ resolvers: [ElementPlusResolver()] }),
]

结果:按钮、表格用到多少就打包多少,未使用的组件 0 成本。

3. 私有化环境变量注入

// src/env.ts
export const API_BASE = import.meta.env.VITE_API_BASE || 'https://saas.example.com'

客户部署时只需在 Nginx 加一行:

location / {
  sub_filter 'https://saas.example.com' 'https://customer.internal';
}

举一反三:3 个变体场景实现思路

场景 关键差异 实现思路
微前端子应用 需要生命周期钩子 main.ts 导出 bootstrap / mount / unmount,用 Vite 的 library 模式打包成 systemjs 格式
低代码平台渲染器 动态组件量巨大 defineAsyncComponent + import.meta.glob 做运行时加载,配合 CDN 缓存
Chrome 插件 popup 包体 < 100 KB 关闭 Vite 的代码分割,用 build.minify='terser' + pure_funcs=['console.log'] 删除所有日志

可复用配置片段

// vite.config.ts(私有化专用)
export default defineConfig(({ command }) => ({
  base: command === 'build' ? '/static/vue-lite/' : '/', // 🔍 适配客户子路径
  plugins: [vue()],
  build: {
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: { vue: 'Vue' }, // 告诉 UMD 外部依赖叫 Vue
      },
    },
  },
}))

小结

不用脚手架,不是“为了酷”,而是“为了活”。当你面对包体、合规、私有化这些真实约束时,手搓项目就像给自己开了一条逃生通道:

  • 想砍依赖?直接删 package.json 一行。
  • 想换构建?Vite 换 Rollup 换 esbuild,5 分钟搞定。

下次老板再提“极限瘦身”,你就可以淡定地打开这篇文章,30 分钟交差。

❌
❌