在 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", // 时间戳作为唯一 ID
title: "我的第一条笔记",
content: "支持 **Markdown** 和 JSON",
createdAt: "2025-02-02T10:00:00.000Z",
updatedAt: "2025-02-02T10:30:00.000Z"
}

// generated by hugo's coding agent

设计亮点:

  • 使用时间戳生成唯一 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; // 当前编辑的笔记 ID
this.isPreviewMode = false; // 预览模式标志
}

// 初始化应用
init() {
this.loadNotes();
this.bindEvents();
this.registerServiceWorker();
this.showList();
}

// 从 LocalStorage 加载笔记
loadNotes() {
const stored = localStorage.getItem('notes');
this.notes = stored ? JSON.parse(stored) : [];
}

// 保存笔记到 LocalStorage
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();
}

// CRUD 操作省略...
}

// 初始化应用
const app = new NotesApp();
app.init();

// generated by hugo's coding agent

与 Claude Code 的协作要点:

  1. 迭代式开发 - 先实现基础的 CRUD 功能,再添加高级特性
  2. 代码审查 - 每次生成代码后,我会要求 Claude Code 检查潜在的 bug 和性能问题
  3. XSS 防护 - Claude Code 主动建议添加 HTML 转义函数防止 XSS 攻击
1
2
3
4
5
6
7
8
// Claude Code 建议的 XSS 防护函数
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

// generated by hugo's coding agent

第四步:实现 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
// 渲染 Markdown 预览
renderMarkdownPreview() {
const content = document.getElementById('noteContent').value;
const previewContainer = document.getElementById('markdownPreview');

// 检测是否为 JSON 格式
if (this.isValidJson(content)) {
this.renderJsonPreview(content, previewContainer);
} else {
// 使用 Marked.js 渲染 Markdown
previewContainer.innerHTML = marked.parse(content);
previewContainer.className = 'markdown-preview';
}
}

// 验证 JSON 格式
isValidJson(str) {
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
}

// generated by hugo's coding agent

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 渲染样式 */
.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;
}

/* generated by hugo's coding agent */

第五步:创新的 JSON 可视化功能

这是整个项目最有趣的部分。当我向 Claude Code 提出:“能否自动识别 JSON 内容并提供更好的显示效果?“它提出了一个完整的 JSON 可视化方案。

5.1 JSON 检测和徽章显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 更新 JSON 指示器
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';
}
}

// generated by hugo's coding agent

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>
`;
}
}

// 递归渲染 JSON 节点
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') {
// 检测 URL
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);
}

// generated by hugo's coding agent

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
// 绑定 JSON 折叠按钮事件
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';
}
});
});
}

// generated by hugo's coding agent

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/)