在 AI 辅助编程的时代,我们如何利用 Claude Code 这样的智能工具来构建一个完整的 Web 应用?本文将详细记录使用 Claude Code 开发一个功能完整的离线记事本 PWA (Progressive Web App) 的全过程,展示 AI 编程助手如何帮助我们快速实现从需求到部署的完整开发流程。
这个项目的特别之处在于:它是一个零依赖框架 的纯 JavaScript 应用,支持 Markdown 渲染 和 JSON 可视化 ,并且可以完全离线工作 。更重要的是,整个开发过程展示了如何与 AI 编程助手高效协作。
项目概览 我们要构建的是一个具有以下特性的离线记事本应用:
✅ 完全离线支持 - 使用 Service Worker 实现离线缓存 ✅ PWA 可安装 - 可以像原生应用一样安装到桌面 ✅ Markdown 支持 - 实时预览 Markdown 渲染效果 ✅ JSON 可视化 - 智能识别 JSON 内容并提供可折叠的语法高亮显示 ✅ 实时搜索 - 按标题和内容过滤笔记 ✅ 响应式设计 - 适配移动端和桌面端 ✅ 零框架依赖 - 纯 vanilla JavaScript (除了 Markdown 解析库) 技术栈:
HTML5 + CSS3 Vanilla JavaScript (ES6+) LocalStorage API (数据持久化) Service Worker API (离线支持) Web App Manifest (PWA 配置) Marked.js (Markdown 解析) 第一步:与 Claude Code 明确需求 在开始编码之前,与 Claude Code 的第一次对话至关重要。清晰的需求描述能让 AI 更好地理解项目目标。
我的初始提示词:
1 2 3 4 5 6 7 8 9 10 11 12 13 我想创建一个离线记事本 PWA 应用,需要具备以下功能: 1. 创建、编辑、删除笔记 2. 支持 Markdown 格式 3. 能够离线使用 4. 可以安装到桌面 5. 数据保存在浏览器本地 6. 实时搜索功能 7. 能够识别和美化 JSON 内容 技术要求: - 不使用任何前端框架,用纯 JavaScript 实现 - 响应式设计,支持移动端 - 使用 Service Worker 实现离线功能
Claude Code 的响应策略:
Claude Code 首先会询问一些关键的设计决策:
数据存储方式 (LocalStorage vs IndexedDB) UI 风格偏好 (简约现代 vs 经典样式) Markdown 库选择 (Marked.js vs Markdown-it) 是否需要数据导出功能 这个交互过程帮助我们明确了技术选型和设计方向。
第二步:项目结构设计 基于需求分析,Claude Code 建议了一个简洁的项目结构:
1 2 3 4 5 6 7 notepad/ ├── index.html # 主页面结构 ├── styles.css # 样式表 (538 行) ├── app.js # 核心应用逻辑 (398 行) ├── sw.js # Service Worker (71 行) ├── manifest.json # PWA 配置文件 └── README.md # 项目文档
设计原则:
关注点分离 - HTML (结构)、CSS (样式)、JS (逻辑) 独立单一职责 - 每个文件有明确的功能边界最小化依赖 - 只引入必要的外部库 (Marked.js)第三步:构建核心数据模型 3.1 数据结构设计 我向 Claude Code 描述了笔记的基本属性需求,它建议了以下数据模型:
1 2 3 4 5 6 7 8 9 10 { id : "1675332000000" , title : "我的第一条笔记" , content : "支持 **Markdown** 和 JSON" , createdAt : "2025-02-02T10:00:00.000Z" , updatedAt : "2025-02-02T10:30:00.000Z" }
设计亮点:
使用时间戳生成唯一 ID,避免 UUID 库依赖 ISO 8601 格式的时间戳便于国际化 简单的扁平结构,易于序列化到 LocalStorage 3.2 应用核心类 Claude Code 建议使用 ES6 类来组织应用逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 class NotesApp { constructor ( ) { this .notes = []; this .currentNoteId = null ; this .isPreviewMode = false ; } init ( ) { this .loadNotes (); this .bindEvents (); this .registerServiceWorker (); this .showList (); } loadNotes ( ) { const stored = localStorage .getItem ('notes' ); this .notes = stored ? JSON .parse (stored) : []; } saveNotes ( ) { localStorage .setItem ('notes' , JSON .stringify (this .notes )); } bindEvents ( ) { document .getElementById ('newNoteBtn' ) .addEventListener ('click' , () => this .showEditor (null )); document .getElementById ('backBtn' ) .addEventListener ('click' , () => this .showList ()); document .getElementById ('saveBtn' ) .addEventListener ('click' , () => this .saveNote ()); document .getElementById ('deleteBtn' ) .addEventListener ('click' , () => this .deleteNote ()); document .getElementById ('searchInput' ) .addEventListener ('input' , (e ) => this .searchNotes (e.target .value )); document .getElementById ('previewBtn' ) .addEventListener ('click' , () => this .togglePreview ()); document .getElementById ('noteContent' ) .addEventListener ('input' , () => this .updatePreview ()); } showList ( ) { document .getElementById ('listView' ).style .display = 'block' ; document .getElementById ('editView' ).style .display = 'none' ; this .renderNotesList (this .notes ); } showEditor (noteId ) { this .currentNoteId = noteId; const note = noteId ? this .notes .find (n => n.id === noteId) : null ; document .getElementById ('noteTitle' ).value = note ? note.title : '' ; document .getElementById ('noteContent' ).value = note ? note.content : '' ; document .getElementById ('listView' ).style .display = 'none' ; document .getElementById ('editView' ).style .display = 'block' ; if (!noteId) { document .getElementById ('noteTitle' ).focus (); } this .updatePreview (); this .updateJsonIndicator (); } } const app = new NotesApp ();app.init ();
与 Claude Code 的协作要点:
迭代式开发 - 先实现基础的 CRUD 功能,再添加高级特性代码审查 - 每次生成代码后,我会要求 Claude Code 检查潜在的 bug 和性能问题XSS 防护 - Claude Code 主动建议添加 HTML 转义函数防止 XSS 攻击1 2 3 4 5 6 7 8 escapeHtml (text ) { const div = document .createElement ('div' ); div.textContent = text; return div.innerHTML ; }
第四步:实现 Markdown 支持 4.1 集成 Marked.js 我向 Claude Code 询问:“如何添加 Markdown 预览功能?”
它建议使用轻量级的 Marked.js 库,并提供了实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 renderMarkdownPreview ( ) { const content = document .getElementById ('noteContent' ).value ; const previewContainer = document .getElementById ('markdownPreview' ); if (this .isValidJson (content)) { this .renderJsonPreview (content, previewContainer); } else { previewContainer.innerHTML = marked.parse (content); previewContainer.className = 'markdown-preview' ; } } isValidJson (str ) { try { JSON .parse (str); return true ; } catch (e) { return false ; } }
4.2 Markdown 样式优化 Claude Code 还生成了完整的 Markdown 样式表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 .markdown-preview { padding : 20px ; background : white; border-radius : 8px ; line-height : 1.8 ; } .markdown-preview h1 { font-size : 2em ; border-bottom : 2px solid #667eea ; padding-bottom : 10px ; margin-bottom : 20px ; } .markdown-preview h2 { font-size : 1.5em ; color : #667eea ; margin-top : 30px ; } .markdown-preview code { background : #f5f5f5 ; padding : 2px 6px ; border-radius : 4px ; font-family : 'Courier New' , monospace; font-size : 0.9em ; } .markdown-preview pre { background : #2d2d2d ; color : #f8f8f2 ; padding : 15px ; border-radius : 6px ; overflow-x : auto; } .markdown-preview blockquote { border-left : 4px solid #667eea ; padding-left : 20px ; color : #666 ; margin : 20px 0 ; font-style : italic; }
第五步:创新的 JSON 可视化功能 这是整个项目最有趣的部分。当我向 Claude Code 提出:“能否自动识别 JSON 内容并提供更好的显示效果?“它提出了一个完整的 JSON 可视化方案。
5.1 JSON 检测和徽章显示 1 2 3 4 5 6 7 8 9 10 11 12 13 14 updateJsonIndicator ( ) { const content = document .getElementById ('noteContent' ).value ; const indicator = document .getElementById ('jsonIndicator' ); if (this .isValidJson (content)) { indicator.textContent = '📋 JSON' ; indicator.style .display = 'inline-block' ; } else { indicator.style .display = 'none' ; } }
5.2 JSON 语法高亮渲染器 Claude Code 生成了一个复杂但高效的 JSON 渲染函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 renderJsonPreview (jsonString, container ) { try { const data = JSON .parse (jsonString); container.className = 'json-viewer' ; container.innerHTML = this .renderJsonNode (data, 0 ); this .bindJsonToggleEvents (); } catch (e) { container.innerHTML = ` <div class="json-error"> <strong>JSON 解析错误:</strong> <pre>${this .escapeHtml(e.message)} </pre> </div> ` ; } } renderJsonNode (data, level ) { const indent = ' ' .repeat (level); if (data === null ) return '<span class="json-null">null</span>' ; if (data === undefined ) return '<span class="json-undefined">undefined</span>' ; const type = typeof data; if (type === 'boolean' ) { return `<span class="json-boolean">${data} </span>` ; } if (type === 'number' ) { return `<span class="json-number">${data} </span>` ; } if (type === 'string' ) { const urlRegex = /^https?:\/\/.+/ ; if (urlRegex.test (data)) { return `<a href="${data} " target="_blank" class="json-url">"${this .escapeHtml(data)} "</a>` ; } if (data.length > 100 ) { const preview = this .escapeHtml (data.substring (0 , 100 )); const full = this .escapeHtml (data); return ` <span class="json-string-long"> "<span class="json-string-preview">${preview} ...</span> <span class="json-string-full" style="display:none;">${full} </span> <button class="json-expand-btn">Show More</button>" </span> ` ; } return `<span class="json-string">"${this .escapeHtml(data)} "</span>` ; } if (Array .isArray (data)) { if (data.length === 0 ) return '[]' ; const items = data.map ((item, index ) => { return `${indent} ${this .renderJsonNode(item, level + 1 )} ${index < data.length - 1 ? ',' : '' } ` ; }).join ('\n' ); return ` <span class="json-bracket">[</span> <span class="json-array-count">${data.length} items</span> <button class="json-toggle" data-collapsed="false">−</button> <div class="json-content"> \n${items} \n${indent} </div> <span class="json-bracket">]</span> ` ; } if (type === 'object' ) { const keys = Object .keys (data); if (keys.length === 0 ) return '{}' ; const properties = keys.map ((key, index ) => { const value = this .renderJsonNode (data[key], level + 1 ); return `${indent} <span class="json-key">"${this .escapeHtml(key)} "</span>: ${value} ${index < keys.length - 1 ? ',' : '' } ` ; }).join ('\n' ); return ` <span class="json-bracket">{</span> <span class="json-object-count">${keys.length} fields</span> <button class="json-toggle" data-collapsed="false">−</button> <div class="json-content"> \n${properties} \n${indent} </div> <span class="json-bracket">}</span> ` ; } return String (data); }
5.3 JSON 折叠/展开交互 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 bindJsonToggleEvents ( ) { document .querySelectorAll ('.json-toggle' ).forEach (btn => { btn.addEventListener ('click' , (e ) => { e.stopPropagation (); const isCollapsed = btn.dataset .collapsed === 'true' ; const content = btn.nextElementSibling ; if (isCollapsed) { content.style .display = 'block' ; btn.textContent = '−' ; btn.dataset .collapsed = 'false' ; } else { content.style .display = 'none' ; btn.textContent = '+' ; btn.dataset .collapsed = 'true' ; } }); }); document .querySelectorAll ('.json-expand-btn' ).forEach (btn => { btn.addEventListener ('click' , (e ) => { e.stopPropagation (); const container = btn.closest ('.json-string-long' ); const preview = container.querySelector ('.json-string-preview' ); const full = container.querySelector ('.json-string-full' ); if (full.style .display === 'none' ) { preview.style .display = 'none' ; full.style .display = 'inline' ; btn.textContent = 'Show Less' ; } else { preview.style .display = 'inline' ; full.style .display = 'none' ; btn.textContent = 'Show More' ; } }); }); }
JSON 可视化效果展示:
1 2 3 4 5 6 7 8 9 10 11 12 13 { "user" : { "id" : 12345 , "name" : "张三" , "email" : "zhangsan@example.com" , "isActive" : true , "profile" : { "bio" : "这是一段很长的个人简介..." , "website" : "https://example.com" , "tags" : [ "developer" , "blogger" , "AI enthusiast" ] } } }
渲染后会显示:
语法高亮 (键、值、括号不同颜色) 可折叠的对象和数组 字段/项目计数 长文本自动截断并提供展开按钮 URL 自动转为可点击链接 第六步:实现 PWA 离线功能 6.1 Service Worker 缓存策略 我向 Claude Code 询问:“如何实现离线支持?“它解释了 Service Worker 的工作原理,并生成了完整的实现:
// sw.js - Service Worker
const CACHE_NAME = 'notepad-v1';
const URLS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.json',
'https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js'
];
// 安装事件 - 缓存核心资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('缓存已打开');
return cache.addAll(URLS_TO_CACHE);
})
);
});
// 激活事件 - 清理旧缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
console.log('删除旧缓存:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
// 拦截请求 - Cache First 策略
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// 缓存命中,返回缓存
if (response) {
return response;
}
// 缓存未命中,发起网络请求
return fetch(event.request).then(response => {
// 检查是否为有效响应
if (!response || response.status !== 200 || response.type === 'error') {
return response;
}
// 克隆响应并缓存
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
---
原文链接: [用 Claude Code 从零构建离线记事本 PWA 应用](https://hugozhu.site/post/2026/119-building-pwa-notepad-with-claude-code/)