
AI 一直重寫你寫過的功能?「快照」幫你省下解釋專案的工夫
先講結論
如果能對專案產生一個「快照」,快照的資訊足以讓 AI 快速進入狀況,但又不會耗很多 token,在很多開發狀況下很好用,也能大幅減少 AI 出乎意料的行為
本篇文章結尾我會提供我的 snapshot.js
腳本程式碼與 prompt 模板,你可以依照你的需求調整使用
為什麼 AI 老是重寫你已經寫過的功能?
你是不是常遇到請 AI 幫忙改個功能,結果它不知道你已經寫過哪些工具函式,又再寫了一個一樣的?
不是 AI 蠢,是你沒讓它知道
AI 並不會「自己去看你的整個專案」,它只看你丟給它的內容。你如果沒提供,它自然不知道你有哪些函式、哪些工具已經寫好,他只是盡責的自作主張再寫一次
懶人的做法就是「整包都餵給 AI」搭配我之前介紹的 Gitingest,但這做法有可能 context window 爆掉(Gemini 表示 😅)
省 context 的做法就是「自己勤勞點整理文件」,平常有在維護文檔這時候就很好用,但這做法還是有問題:
- 會忘記同步更新整理文件
- 你要拉給 AI 的檔案也會漏東漏西
- AI 永遠不知道你漏了什麼
- 所以還是會亂寫
因此,我們需要一個「快速生成,一個頂好幾個」的專案摘要快照 snapshot
Snapshot 是什麼?
這要先感謝受到海總理的電子報的啟發
簡單說,用一個自動化腳本幫你:
- 掃描整個專案目錄(排除像
node_modules
、.git
這些無用資料夾) - 把每個檔案的結構與函式列出來(含註解)
- 整理出專案使用的套件依賴
- 輸出成一份
snapshot.md
接下來找 AI 幫忙時,就把這個檔案丟給他就好

使用體驗
AI 如果不知道你專案有哪些東西,它只能亂猜。
snapshot 就是給 AI 看的地圖:讓它知道你寫過哪些東西、用了哪些工具、怎麼接起來。
我自己使用經驗是:除非我只需要拉一個檔案,不然拉三個以上檔案參考,我還要想,那我還不如丟 snapshot,直接懶出新高度
snapshot 是一個夠好用的 context 內容,像萬金油一樣
他就是 Jimmy Butler,兼容性高,有他很難輸,但不是穩贏(這不是廢話)
Snapshot.js 範例程式碼
同步更新在 Github 上
以下是我的 snapshot 腳本:
/**
* snapshot.js
* 用途:掃描任意 JS/TS/Vue 專案,輸出專案結構、函式清單與依賴清單至 snapshot.md
* 使用方式:在專案根目錄執行 `node snapshot.js`
* 僅依賴 Node.js 內建模組:fs, path
*/
///////////////////////////////////////
// 1. 可編輯設定區
///////////////////////////////////////
/**
* 要排除掃描的檔案或資料夾(相對於專案根目錄)
* 使用者可自行新增或移除
*/
const EXCLUDES = ["node_modules", ".git", "dist", "build"];
/** 支援的檔案副檔名,可按需擴充 */
const FILE_EXTENSIONS = [".js", ".ts", ".vue"];
/** 目錄樹最大深度,0 或 null 表示不限 */
const MAX_DEPTH = 0;
/** 解析規則:Controller 物件模式與 Export 函式模式 */
const PARSERS = {
controller: {
test: /(?:module\\.exports\\s*=\\s*|\\bexport\\s+default\\s*)\\{/,
// 方法名稱與參數
regex: /(\\w+)\\s*:\\s*(?:async\\s*)?function\\s*(?:\\w*)\\s*\\(([^)]*)\\)/g,
comment: /\\/\\/\\s*(.*)/,
},
export: {
test: /export\\s+(?:async\\s+)?(?:function|const)/,
// 匹配 export function foo(...) 或 export const useXxx = (...) =>
regex:
/export\\s+(?:async\\s+)?function\\s+(\\w+)\\s*\\(([^)]*)\\)|export\\s+const\\s+(\\w+)\\s*=\\s*(?:async\\s*)?\\(?([^)]*)\\)?\\s*=>/g,
comment: /\\/\\/\\s*(.*)/,
},
};
///////////////////////////////////////
// 2. 引入 Node 內建模組
///////////////////////////////////////
const fs = require("fs");
const path = require("path");
///////////////////////////////////////
// 3. 判斷是否排除路徑
///////////////////////////////////////
function isExcluded(filePath) {
return EXCLUDES.some((ex) => filePath.includes(ex));
}
///////////////////////////////////////
// 4. 遞迴掃描檔案
///////////////////////////////////////
function scanFiles(dir, fileList = []) {
const entries = fs.readdirSync(dir);
for (const name of entries) {
const fullPath = path.join(dir, name);
if (isExcluded(fullPath)) continue;
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
scanFiles(fullPath, fileList);
} else if (FILE_EXTENSIONS.includes(path.extname(name))) {
fileList.push(fullPath);
}
}
return fileList;
}
///////////////////////////////////////
// 5. 解析檔案中函式/方法
///////////////////////////////////////
function parseFile(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
let parser = null;
if (PARSERS.controller.test.test(content)) {
parser = PARSERS.controller;
} else if (PARSERS.export.test.test(content)) {
parser = PARSERS.export;
} else {
return []; // 無匹配解析模式
}
const lines = content.split(/\\r?\\n/);
const results = [];
lines.forEach((line, idx) => {
parser.regex.lastIndex = 0;
let match;
while ((match = parser.regex.exec(line)) !== null) {
// 取出函式名稱與參數
const name = match[1] || match[3];
const params = match[2] || match[4] || "";
// 嘗試讀取上一行的單行註解
let comment = "";
if (idx > 0) {
const prev = lines[idx - 1].match(parser.comment);
if (prev) comment = prev[1].trim();
}
results.push({ name, params, comment });
}
});
return results;
}
///////////////////////////////////////
// 6. 生成 ASCII 目錄樹
///////////////////////////////////////
function buildTree(dir, prefix = "", depth = 1) {
if (MAX_DEPTH && depth > MAX_DEPTH) return "";
let tree = "";
const entries = fs
.readdirSync(dir)
.filter((name) => !isExcluded(path.join(dir, name)))
.sort();
entries.forEach((name, idx) => {
const fullPath = path.join(dir, name);
const isDir = fs.statSync(fullPath).isDirectory();
const connector = idx === entries.length - 1 ? "└── " : "├── ";
tree += `${prefix}${connector}${name}\\n`;
if (isDir) {
const newPrefix = prefix + (idx === entries.length - 1 ? " " : "│ ");
tree += buildTree(fullPath, newPrefix, depth + 1);
}
});
return tree;
}
///////////////////////////////////////
// 7. 搜尋各子專案的 package.json
///////////////////////////////////////
function findPackages(dir, list = []) {
const entries = fs.readdirSync(dir);
for (const name of entries) {
const fullPath = path.join(dir, name);
if (isExcluded(fullPath)) continue;
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
findPackages(fullPath, list);
} else if (name === "package.json") {
list.push(fullPath);
}
}
return list;
}
///////////////////////////////////////
// 8. 主流程:掃描、解析、組合 Markdown
///////////////////////////////////////
function main() {
const root = process.cwd();
// 目錄樹
const tree = buildTree(root);
// 掃描所有檔案並解析函式
const files = scanFiles(root);
const funcMap = {}; // filePath -> [ {name, params, comment}, ... ]
files.forEach((fp) => {
const rel = path.relative(root, fp);
const funcs = parseFile(fp);
if (funcs.length) funcMap[rel] = funcs;
});
// 收集依賴
const pkgFiles = findPackages(root);
const depsMap = {}; // projectName -> { dependencies, devDependencies }
pkgFiles.forEach((pf) => {
try {
const data = JSON.parse(fs.readFileSync(pf, "utf-8"));
const proj = data.name || path.basename(path.dirname(pf));
depsMap[proj] = {
dependencies: data.dependencies || {},
devDependencies: data.devDependencies || {},
};
} catch (err) {
console.warn(`解析 ${pf} 時發生錯誤:${err.message}`);
}
});
// 組合 Markdown
let md = "";
// 1. 專案目錄結構
md += "## 專案目錄結構\\n\\n";
md += "```text\\n" + tree + "```\\n\\n";
// 2. 函式清單
md += "## 函式清單\\n\\n";
for (const [file, funcs] of Object.entries(funcMap)) {
md += `### ${file}\\n`;
funcs.forEach((f) => {
md += `- **${f.name}(${f.params})**${
f.comment ? " - " + f.comment : ""
}\\n`;
});
md += "\\n";
}
// 3. 依賴清單
md += "## 依賴清單\\n\\n";
for (const [proj, info] of Object.entries(depsMap)) {
md += `## ${proj}\\n\\n`;
md += "### devDependencies\\n";
if (Object.keys(info.devDependencies).length) {
md +=
"```json\\n" + JSON.stringify(info.devDependencies, null, 2) + "\\n```\\n";
} else {
md += "無\\n";
}
md += "\\n### dependencies\\n";
if (Object.keys(info.dependencies).length) {
md +=
"```json\\n" + JSON.stringify(info.dependencies, null, 2) + "\\n```\\n";
} else {
md += "無\\n";
}
md += "\\n";
}
// 輸出至 snapshot.md
fs.writeFileSync(path.join(root, "snapshot.md"), md, "utf-8");
console.log("已生成 snapshot.md");
}
// 執行主流程
main();
你可以根據需求修改這支腳本,或是叫 AI 幫你改,你也可以重新生成這個腳本
修改這個 Prompt,生成你的 Snapshot 腳本
請幫我撰寫一支名為 `snapshot.js` 的 Node.js 單檔腳本,用於任意 JavaScript/TypeScript 專案,生成完整專案結構與程式函式快照,並匯出成 Markdown 文件 `snapshot.md`。請確保它具備以下通用功能,並且易於客製:
1. **排除清單設定**
- 腳本開頭定義一個可編輯的陣列 `EXCLUDES`,列出所有要忽略的檔案或目錄(相對專案根目錄的名稱或路徑片段)。使用者可自由新增或刪除。
2. **檔案掃描**
- 遞迴尋訪整個專案,搜尋所有副檔名為 `.js`、`.ts`、`.vue`(可依需求自行擴充)的檔案。
- 自動跳過在 `EXCLUDES` 裡設定的路徑或檔案。
3. **程式碼解析(可自訂)**
- 預設支援兩種解析模式,可根據檔案特性自訂:
- **Controller 物件模式**:若檔案匯出一個物件(如 `module.exports = { … }` 或 `export default { … }`),只擷取該物件內的方法名稱與簽名,並可抓取上方的單行註解。
- **Export 函式模式**:對於一般模組或 Vue `<script>`,擷取所有以 `export function`、`export async function` 或 `export const useXxx =` 等形式定義的函式/composable,並可抓取上方的單行註解。
- 使用者只要在程式中標註或調整對應的正則或檢測邏輯,即可快速替換成自訂解析規則。
4. **目錄樹生成**
- 以純文字 ASCII 樹狀圖形式顯示整個專案結構,深度與格式均可在程式中設定。
5. **多專案依賴收集**
- 自動搜尋所有子目錄下名為 `package.json` 的檔案,讀取其 `"name"` 欄位(或根據需要自訂映射),並擷取其中的 `dependencies` 與 `devDependencies`。
- 輸出格式為:
```md
## 專案名稱
### devDependencies
"套件A": "版本",
...
### dependencies
"套件X": "版本",
...
```
6. **Markdown 輸出**
- 統一將結果寫入 `snapshot.md`,結構清晰分段:
1. `## 專案目錄結構`(樹狀圖)
2. `## 函式清單`(按檔案分組列出函式與註解)
3. `## 依賴清單`(按專案分組列出套件版本)
7. **執行環境與相依**
- 僅使用 Node.js 內建模組(`fs`, `path`),不依賴第三方套件;
- 在腳本最上方加入 shebang (`#!/usr/bin/env node`);
- 支援同步與非同步皆可,自由選擇;
- 使用者只需放到專案根目錄,執行:
```bash
node snapshot.js
```
- 每個功能區塊都請加上清楚註解,方便使用者按需求開關或修改。
---
請依照上述通用規格,完整生成 `snapshot.js` 的程式碼,並在註解中標示每個區塊的功能用途。