☰
Vue3+Vite手写仓库数据大盘:并发池、缓存与自绘图表
2026/10/28 17:08:50 网站建设 项目流程

简介:这份压缩包面向刚接触前端与JavaScript的开发者,内容围绕GitHub页面主题的静态站点练习展开,适合用来观察一个轻量网页项目如何组织页面、脚本与图片素材。包内共12个文件,以4个html页面、2个js脚本和2个png图片为主,另有.gitignore、.gitattributes等版本控制配置及系统缓存文件,压缩后约229KB,体量小巧,便于快速解压浏览。已有2084人学习下载,说明其作为入门参考具有一定关注度。读者可从中了解多页面HTML的结构拆分、JavaScript与页面元素的配合方式、静态图片资源的存放位置,以及Git仓库忽略规则和属性配置的基本写法;对准备仿写GitHub界面、练习基础交互或整理前端小项目目录的人而言,可作为对照样本和起步素材,也便于在本地逐页打开,观察页面之间的跳转关系与资源引用路径。

1. 一个下午翻了两百个仓库之后

去年做一次内部分享,我想统计自己维护的仓库在语言分布、最近推送时间上的情况。手动在代码托管平台上一个个点开、复制、粘进表格,弄到下午四点,Excel 拼了三张表还漏了两个仓库。当天晚上我就写了个 JavaScript 小面板,把开放接口的数据拉下来统一渲染:语言占比用环形图,活跃度用热力格,仓库卡片支持按语言和最近推送排序。这个项目适合两类人:一是想练前端请求层设计 + 手写数据可视化的开发者,二是在团队内部需要一块「仓库数据大盘」的人。它只做只读聚合,不带写操作,边界很清楚。

2. 骨架与技术选型:Vite + Vue 3 的目录怎么摆

先把技术栈定死,不然后面每一章都要重新解释一遍。

2.1 为什么是 Vue 3 + Vite,而不是更大的框架组合

这个面板一共两个视图:列表页和详情页。没有 SSR 需求,没有多团队协作,也不需要服务端渲染首屏。这种情况下上 Nuxt 属于给自己找活干,热更新慢半拍、构建链路多一层,调一个图表坐标还要翻框架文档。

Vue 3 的组合式 API 在这里刚好对症:数据获取、缓存、图表计算都是「一段有状态的异步逻辑」,抽成 composable 之后列表页和详情页能共用同一份,不用把状态抬到全局。组合式 API 的 shallowRef 还能避免把几百条仓库对象做成深度响应式,省一层 Proxy 开销。

不引状态管理库是我刻意的决定。整个应用真正跨组件共享的只有「当前 owner 的仓库列表」这一份数据,用模块级单例 ref 就够了,引 Pinia 只会多一个 store 文件和一层调试路径。状态什么时候该上库?我的判断线是:出现三份以上互相依赖、需要在不同路由间保留写入的状态,再考虑。

不引图表库也是同样的逻辑。语言占比环形图和提交热力图加起来不到 120 行代码,而一个完整图表库会带进来几百 KB 的运行时和一堆用不上的坐标系。自绘的好处是颜色完全跟着 CSS 变量走,深色模式切换零成本。

{ "name": "repo-deck", "private": true, "version": "0.3.0", "type": "module", "scripts": { "dev": "vite --open", "build": "vite build", "preview": "vite preview --port 4173", "analyze": "vite build --mode analyze" }, "dependencies": { "vue": "^3.4.0", "vue-router": "^4.3.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "vite": "^5.2.0", "rollup-plugin-visualizer": "^5.12.0" } }

依赖只有五个,analyze脚本是给第 5 章的包体积排查预留的入口。版本号写的是区间,实际装到哪个小版本以你本地锁文件为准,别照抄成硬编码。

运行环境只有一个要求:Node 18 以上。浏览器端请求用的是原生fetch,不需要 axios 这类封装库——信号取消、超时控制这些能力原生就有,多引一层反而容易把 abort 语义包丢。

2.2 目录结构:按「数据 / 视图 / 绘制」切成三层

目录不按文件类型堆,按职责分层。数据层只管拿数据和缓存,视图层只管渲染,绘制层是纯函数加一个薄组件。

repo-deck/ ├─ index.html ├─ vite.config.js ├─ .env.development ├─ public/ └─ src/ ├─ main.js # 挂路由、挂全局样式 ├─ App.vue ├─ router/index.js ├─ api/ │ ├─ queue.js # 并发池,控制同时在飞的请求数 │ ├─ client.js # fetch 封装:超时、重试、错误归一 │ ├─ cache.js # localStorage + TTL + 版本号 │ └─ adapters/index.js # 数据源适配器,换源只动这里 ├─ composables/ │ └─ useRepoList.js # 业务粘合层,组件只认它 ├─ components/ │ ├─ RepoCard.vue │ ├─ LangDonut.vue │ ├─ CommitHeatmap.vue │ └─ SkeletonCard.vue ├─ views/ │ ├─ HomeView.vue │ └─ RepoDetailView.vue ├─ utils/ │ ├─ color.js # 色阶映射、语言颜色兜底 │ └─ date.js └─ styles/tokens.css # 颜色、间距、热力色阶变量

api/里四个文件是有依赖顺序的:queue.js是最底层的调度器,client.js用它把请求排队,cache.js是旁路,adapters/在最上层做字段归一。这个顺序决定了改动方向——换数据源改最上层,调并发数改最下层,互不影响。

utils/color.js单独抽出来,是因为色阶映射是纯函数,可以在 node 里单独跑单元测试,不需要挂载组件。

2.3 从零跑起来:三条命令与两个必须改的配置

# 1. 装依赖 npm install # 2. 起开发服务器(默认 5273 端口,避开常用端口冲突) npm run dev # 3. 出生产包,产物在 dist/ npm run build

npm run dev起不来的头号原因是 Node 版本低于 18,Vite 5 会直接报 engine 不匹配。第二个原因是端口被占,改vite.config.js里的server.port即可。

配置文件里有两个地方是你拿到代码后必须改的:接口地址和部署路径。

// vite.config.js import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { // 部署到子目录时必须改成 '/repo-deck/',否则静态资源全 404 base: env.VITE_BASE || '/', resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, plugins: [ vue(), // 只在 npm run analyze 时挂载,平时不参与构建 mode === 'analyze' && visualizer({ open: true, gzipSize: true }) ].filter(Boolean), server: { port: 5273, proxy: { // 浏览器不直连第三方域,交给本地 node 转一层,绕开跨域 '/api': { target: env.VITE_API_ORIGIN, changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, '') } } }, build: { target: 'es2019', rollupOptions: { output: { // 框架代码单独分包,业务代码改动时不影响这部分的缓存 manualChunks: { vendor: ['vue', 'vue-router'] } } } } } })

base的默认值/只适合部署在域名根路径;放到/repo-deck/这类子目录下不改,打开页面就是一片白,控制台刷一堆 404。

server.proxy里rewrite那行要在第 5 章重点说,它是最容易漏的一行。target从.env.development读:

# .env.development VITE_API_ORIGIN=https://api.example-host.dev VITE_BASE=/ VITE_DATA_SOURCE=platform

VITE_前缀是 Vite 的硬规定,不带前缀的变量不会注入客户端。VITE_DATA_SOURCE是第 6 章换数据源的开关,先摆着。

3. 数据层拆解:把不受控的接口请求收进并发池

列表页一次要拿一百条仓库,详情页还要按仓库拉语言分布和近一年提交。如果全用Promise.all一把梭,浏览器会瞬间放出去几百个连接,第三方接口的限流阈值通常在几十个请求每秒,撞上去就是 403 或 429,而且你还没法优雅重试。

3.1 并发池:一次只放 4 个请求出去

调度器的原理很朴素:维护一个在飞计数和一个等待队列,每完成一个就补一个。

// src/api/queue.js export function createPool({ limit = 4 } = {}) { let active = 0 // 当前在飞的请求数 const waiting = [] // 还没轮到发出的任务 const next = () => { if (active >= limit || waiting.length === 0) return active += 1 const { task, resolve, reject } = waiting.shift() task() .then(resolve, reject) .finally(() => { active -= 1 next() // 空出一个槽位立刻补一个,保证并发数始终跑满 }) } return function enqueue(task) { return new Promise((resolve, reject) => { waiting.push({ task, resolve, reject }) next() }) } }

limit是唯一参数,默认 4。为什么不是 10?实测下来第三方接口在 4~6 并发时最稳,再高就开始间歇性返回 429,重试反而拖慢整体时间。task必须是一个返回 Promise 的函数而不是已经发出的 Promise——传 Promise 进队列就已经发出去了,排队毫无意义,这个细节翻车过好几次。

返回的enqueue保持 Promise 语义,调用方await的时候完全感知不到池子的存在。

3.2 请求封装:超时、重试、统一错误出口

池子只负责排队,真正发请求的是这一层。

// src/api/client.js import { createPool } from './queue' const enqueue = createPool({ limit: 4 }) const TIMEOUT = 8000 export class HttpError extends Error { constructor(status, body) { super(`HTTP ${status}`) this.status = status this.body = body } } async function request(path, { signal, retry = 2, ...init } = {}) { const url = path.startsWith('http') ? path : `/api${path}` const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), TIMEOUT) // 组件卸载时外部传进来的 signal 要能传导到底层,否则请求泄漏 signal?.addEventListener('abort', () => ctrl.abort(), { once: true }) try { const res = await fetch(url, { ...init, signal: ctrl.signal, headers: { Accept: 'application/json', ...init.headers } }) // 只对限流和服务端错误重试,4xx 业务错误重试没有意义 if ((res.status === 429 || res.status >= 500) && retry > 0) { clearTimeout(timer) await new Promise((r) => setTimeout(r, (3 - retry) * 800)) return request(path, { ...init, retry: retry - 1 }) } if (!res.ok) throw new HttpError(res.status, await res.text()) return await res.json() } catch (err) { if (err.name === 'AbortError') throw new Error(`请求超时或已取消: ${url}`) throw err } finally { clearTimeout(timer) } } export const api = { get: (path, opt) => enqueue(() => request(path, opt)) }

几个参数值得掰开说。TIMEOUT定在 8 秒,是因为列表接口返回数据量在 200KB 上下,弱网下 8 秒还没回来基本可以判定网络异常了。retry默认 2,配合(3 - retry) * 800的退避序列,实际等待是 800ms、1600ms,两次都失败才向上抛。

重试范围要卡死:只重试 429 和 5xx。401、404、422 重试多少次都是同一个结果,白白拉长用户等待时间。

finally里必须清 timer,否则请求成功后定时器还在跑,8 秒后对着一个已经完成的 controller 调 abort,虽然无害但会污染错误日志。

3.3 缓存层:localStorage 加 TTL 加版本号

没有缓存的面板体验极差:从详情页返回列表页,所有卡片重新闪一遍骨架屏。缓存 key 设计有两个坑,一个是不带业务参数,另一个是不带版本号。

// src/api/cache.js const PREFIX = 'repo-deck' const VERSION = 'v3' // 返回字段结构一变就改这里,旧缓存自动失联 const keyOf = (key) => `${PREFIX}:${VERSION}:${key}` export function readCache(key, ttl = 10 * 60 * 1000) { try { const raw = localStorage.getItem(keyOf(key)) if (!raw) return null const { at, data } = JSON.parse(raw) if (Date.now() - at > ttl) { localStorage.removeItem(keyOf(key)) return null } return data } catch { return null // 无痕模式或配额写满,直接当没缓存,不阻断主流程 } } export function writeCache(key, data) { try { localStorage.setItem(keyOf(key), JSON.stringify({ at: Date.now(), data })) } catch { // 配额满就放弃缓存,绝不能让缓存失败影响渲染 } } export function clearAll() { Object.keys(localStorage) .filter((k) => k.startsWith(`${PREFIX}:`)) .forEach((k) => localStorage.removeItem(k)) }

VERSION是后悔药。接口字段从stargazers_count改成stars之后,老缓存里的对象没有新字段,页面会渲染出一片undefined。改一次版本号,所有旧缓存自动失效,比让用户手动清浏览器缓存靠谱得多。

ttl默认 10 分钟,列表页用默认值即可。详情页的提交热力图建议传 30 分钟,因为这数据变化频率很低。

3.4 业务组合式函数:把三件事粘起来

组件不该知道缓存和池子的存在,它只调load()。

// src/composables/useRepoList.js import { ref, shallowRef } from 'vue' import { api } from '@/api/client' import { readCache, writeCache } from '@/api/cache' import { adapters } from '@/api/adapters' // 模块级单例:列表页和详情页拿到的是同一份数据 const state = shallowRef([]) const loading = ref(false) const error = ref(null) const adapter = adapters[import.meta.env.VITE_DATA_SOURCE || 'platform'] export function useRepoList() { async function load(owner, { force = false } = {}) { const cacheKey = `repos:${owner}` if (!force) { const cached = readCache(cacheKey) if (cached) { state.value = cached; return } } loading.value = true error.value = null try { const raw = await adapter.listRepos(owner) const slim = raw.map(adapter.normalize) // 只保留渲染需要的字段 state.value = slim writeCache(cacheKey, slim) } catch (e) { error.value = e } finally { loading.value = false } } return { state, loading, error, load } }

shallowRef而不是ref:仓库对象有七八个字段,一百条就是七八百个响应式代理,而列表页只整体替换、不逐字段修改,shallowRef语义完全够用,还省掉一层递归代理。

force参数是留给「刷新」按钮的,load(owner, { force: true })会跳过缓存直连接口。没有这个开关,用户点刷新没反应,会以为按钮坏了。

raw.map(adapter.normalize)那行把原始字段收窄成渲染层要用的五个,缓存体积能砍掉一半以上。

注意:state定义在模块作用域而不是函数内部。放函数里每次调用都返回新 ref,两个组件各拿一份,详情页改完列表页不更新。

4. 视图层拆解:环形图与热力图都不用图表库

图表这块最容易被「上库」绑架。两个图都不复杂,自绘反而可控。

4.1 语言占比环形图:用 stroke-dasharray 把比例画出来

圆环上画弧不需要贝塞尔路径,用一个circle加虚线就能拼出任意比例。

<script setup> import { computed } from 'vue' const props = defineProps({ data: { type: Array, default: () => [] }, // [{ language, count, color }] size: { type: Number, default: 180 }, // 画布边长 thickness: { type: Number, default: 18 } // 环的宽度 }) const R = computed(() => (props.size - props.thickness) / 2) const C = computed(() => 2 * Math.PI * R.value) // 整圆周长 const total = computed(() => props.data.reduce((s, d) => s + d.count, 0) || 1) // 每一段的实线长度就是占比,剩下的是透明间隙 const arcs = computed(() => { let offset = 0 return props.data.map((d) => { const len = (d.count / total.value) * C.value const item = { ...d, dash: `${len} ${C.value - len}`, offset, percent: len / C.value } offset += len // 下一段的起点接着上一段的终点 return item }) }) </script> <template> <svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`" role="img" aria-label="语言占比"> <g :transform="`rotate(-90 ${size / 2} ${size / 2})`"> <circle :cx="size / 2" :cy="size / 2" :r="R" fill="none" stroke="var(--track)" :stroke-width="thickness" /> <circle v-for="a in arcs" :key="a.language" :cx="size / 2" :cy="size / 2" :r="R" fill="none" :stroke="a.color" :stroke-width="thickness" :stroke-dasharray="a.dash" :stroke-dashoffset="-a.offset" /> </g> </svg> </template>

参数关系是这套画法的基础:R决定圆半径,C是整圆周长,也就是「100% 对应的虚线段长度」。stroke-dasharray="len C-len"表示画len长度的实线、剩下留空,占满一圈。

stroke-dashoffset用负值,作用是把这一段的起点往前推offset个长度单位,正好接上一段的尾巴。如果写成正值,所有弧会往反方向叠在一起,最后只看到最大那一段。

外层的rotate(-90)让起点从十二点方向开始,不然默认三点方向起笔,视觉上像被切了一刀。

total里的|| 1是防御性写法:空数据时避免除零得到 NaN,SVG 属性收到 NaN 会整块不渲染,排查起来特别费劲。

4.2 提交热力图:一个色阶函数加 CSS Grid

热力图本质是二维网格上色,用 Grid 布局比 Canvas 好维护,每个格子还能带title做悬浮提示。

// src/utils/color.js // 把 0~max 的提交数压到 5 档,避免逐格做插值运算 export function levelOf(count, max) { if (count <= 0) return 0 const ratio = count / Math.max(max, 1) if (ratio > 0.75) return 4 if (ratio > 0.5) return 3 if (ratio > 0.25) return 2 return 1 }

分档而不是连续插值,一是渲染时 CSS 只需要 5 个类,二是人对色阶的辨识本身就分不出几十档,连续渐变反而看不清差异。

组件侧把色阶变量写进 CSS:

<script setup> import { computed } from 'vue' import { levelOf } from '@/utils/color' const props = defineProps({ days: { type: Array, default: () => [] } }) // [{ date, count }] const max = computed(() => props.days.reduce((m, d) => Math.max(m, d.count), 0)) const cells = computed(() => props.days.map((d) => ({ ...d, level: levelOf(d.count, max.value) }))) </script> <template> <div class="heatmap"> <span v-for="c in cells" :key="c.date" class="cell" :data-level="c.level" :title="`${c.date} · ${c.count} 次`" /> </div> </template> <style scoped> .heatmap { display: grid; grid-template-columns: repeat(53, 1fr); /* 一年约 53 周 */ gap: 3px; } .cell { aspect-ratio: 1; border-radius: 2px; background: var(--heat-0); } .cell[data-level='1'] { background: var(--heat-1); } .cell[data-level='2'] { background: var(--heat-2); } .cell[data-level='3'] { background: var(--heat-3); } .cell[data-level='4'] { background: var(--heat-4); } </style>

--heat-0到--heat-4定义在styles/tokens.css里,深色模式只换这五个变量:

/* src/styles/tokens.css */ :root { --heat-0: #ebedf0; --heat-1: #c6e48b; --heat-2: #7bc96f; --heat-3: #239a3b; --heat-4: #196127; --track: #e5e7eb; } :root[data-theme='dark'] { --heat-0: #21262d; --heat-1: #0e4429; --heat-2: #006d32; --heat-3: #26a641; --heat-4: #39d353; --track: #30363d; }

aspect-ratio: 1保证格子始终是正方形,比写死宽高更容易适配容器宽度。repeat(53, 1fr)是列数,一年 52~53 周,多出来的空位用level 0补齐,不要让它自动折行,否则整个图形状就散了。

组件里没做的是时间轴对齐——真实场景需要把提交日期按周补齐,缺的那天填count: 0,否则格子会挤在一起,视觉上完全对不上月份。

4.3 卡片列表与骨架屏:首屏不闪的细节

数据没回来之前如果什么都不渲染,页面高度是 0,数据一到内容撑开,整个页面会跳一下,滚动位置也跟着丢。骨架屏的作用就是提前占住高度。

<!-- components/SkeletonCard.vue --> <template> <div class="card skeleton" aria-hidden="true"> <div class="line w60" /> <div class="line w90" /> <div class="line w40" /> </div> </template> <style scoped> .card { height: 104px; padding: 16px; border-radius: 10px; border: 1px solid var(--track); } .line { height: 12px; margin-bottom: 10px; border-radius: 6px; background: var(--track); } .w60 { width: 60%; } .w90 { width: 90%; } .w40 { width: 40%; } </style>

卡片高度写死104px,和真实卡片保持一致,这样骨架屏切到真实内容时不会产生布局偏移。aria-hidden="true"让屏幕阅读器跳过这段装饰性内容,避免读一串空 div。

渲染列表时用v-for的 key 取仓库 id,不要用索引。用索引的话刷新数据后 Vue 会复用错误的 DOM 节点,卡片上的语言颜色和名字会错位——这个 bug 看着像数据错了,其实是 key 的问题。

5. 避坑与排查:五个真机翻车的现场

下面五条都是我自己撞过的,按现象、原因、解决三段记。

5.1 列表只出来 30 个仓库

现象:账号下明明有一百多个仓库,页面上永远只有 30 张卡片。

原因:托管平台的列表接口默认分页 30 条,不传分页参数就只给第一页。我一开始以为是数组被哪里截断了,在后端日志里找了半天。

解决:请求时显式带上per_page=100&sort=pushed,超过 100 条的账号要做翻页循环,逐页拉完再合并。同时注意分页请求也要走并发池,不能一把Promise.all全放出去。

5.2 点了刷新按钮,数据纹丝不动

现象:别人刚推了代码,页面上的推送时间还是昨天的,刷新按钮连点三次没变化。

原因:缓存 key 只用了repos,没带 owner 和业务参数,force开关也没接到按钮上。更隐蔽的一层是VERSION没随字段结构调整,老缓存里缺字段,新代码读出来是undefined。

解决:缓存 key 拼上 owner,例如repos:${owner};刷新按钮显式传force: true;返回结构一改就升VERSION。排查时直接在控制台看 localStorage 里带前缀的 key 列表,比翻代码快。

5.3 深色模式下热力图整块变成黑的

现象:切到深色主题,热力图格子全部消失,只剩一片背景色。

原因:颜色写死成了浅色十六进制值,或者用了rgba(0,0,0,0.05)这类在半透明叠加后趋近于黑的写法。深色背景下浅色格子和背景几乎同色,看着就像没渲染。

解决:所有图表颜色从 CSS 变量读,:root[data-theme='dark']只覆盖变量,组件不动。切主题后要重新计算一次——如果绘制层做过颜色快照(比如把 CSS 变量读到 JS 里缓存),得监听主题变化清掉缓存。

5.4 打包体积从 180KB 涨到 900KB

现象:npm run build出来的 vendor 包突然涨了四倍,首屏加载白屏时间肉眼可见变长。

原因:为了画一个环形图引入了完整图表库,或者从图标库做了默认导入(import { } from 'xxx-icons'全部打进包)。Tree-shaking 对这类带副作用注册的库基本无能为力。

解决:跑npm run analyze看可视化报告,找出占比最大的模块。能换自绘就自绘,确实要引库就用按需注册的调用方式,别用全量导入。manualChunks把框架代码和业务代码分开,至少让业务改动不破坏框架那部分的缓存。

5.5 本地开发接口一直 404 或跨域报错

现象:npm run dev后列表页一片红,控制台 CORS 报错,或者请求打到/api/users/xxx返回 404。

原因:本地是localhost:5273,接口在另一个域,浏览器直连必然跨域。配了 proxy 但rewrite那行漏了,请求带着/api前缀原样转给上游,上游没有这个路由,就是 404。

解决:.env.development里配好VITE_API_ORIGIN,proxy 的rewrite: (p) => p.replace(/^\/api/, '')必须写。排查时看 Vite 终端有没有打出代理转发日志,有日志说明转发了、是路径问题;没日志说明请求根本没走代理,检查fetch里拼的 URL 是不是漏了/api。

6. 二次开发:换数据源、加模块、验证一次改动

拿到代码后最常见的三个改动是:接内部仓库服务、加一个统计模块、改完不知道对不对。

换数据源这件事,我在项目里留了一层适配器。src/api/adapters/index.js里每个数据源实现两个方法:listRepos(owner)负责发请求,normalize(raw)负责把字段掰成统一结构。接内部服务时只需要补一个对象:

// src/api/adapters/index.js export const adapters = { // 默认源:开放接口 platform: { listRepos: (owner) => api.get(`/users/${owner}/repos?per_page=100&sort=pushed`), normalize: (raw) => ({ id: raw.id, name: raw.name, language: raw.language || 'Other', stars: raw.stargazers_count, pushedAt: raw.pushed_at }) }, // 内部仓库服务:字段名完全不一样,只补这一个对象就能接上 internal: { listRepos: (owner) => api.get(`/internal/repos?owner=${owner}&size=100`), normalize: (raw) => ({ id: raw.repoId, name: raw.repoName, language: raw.lang || 'Other', stars: raw.starCount, pushedAt: raw.lastPush }) } }

改完把.env.development里的VITE_DATA_SOURCE切成internal,刷新即可。视图层和 composable 一行不用动——这就是把归一逻辑收在上层的好处。要新增数据源,就按同样的两个方法再加一个对象,normalize里字段缺什么补什么,别在组件里做raw.xxx || raw.yyy这种兜底判断,散在模板里以后没人找得到。

加模块的改动面也可以算清楚。比如要加一块「语言趋势」区域,最小改动是:utils/color.js里补一个趋势色阶函数、components/下新建一个纯展示组件、HomeView.vue里挂载、useRepoList.js里如果要复用列表数据就不用改。整条链上只有视图层是必须动的,其余都是可选。

改完的验证顺序我固定走三步,别跳:先用VITE_DATA_SOURCE=platform跑一遍列表页确认原有功能没坏;再切到新源跑同一页,开 Network 面板看请求数量是否还在 4 个以内、有没有 429;最后关掉网络刷新一次,确认缓存命中、骨架屏正常退出。

改动点验证方式失败时先看哪里
新增数据源切换VITE_DATA_SOURCE对比两次渲染结果normalize输出字段是否齐全
调整并发数Network 面板看同时在飞的请求数createPool的limit是否生效
改图表配色深浅主题各切一次CSS 变量是否被硬编码覆盖
升版本号清空前后的 localStorage 对比旧 key 是否真的被读到

吃过一次亏:我改完适配器只测了新源,老源的字段映射顺手改错了一个字母,上线后列表页全空。从那以后我每次动adapters目录,都强制把两个源的切换各跑一遍,再断网跑一次缓存路径,才允许提合并。这个习惯救回来的时间比我写测试花的多得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询