HarmonyOS知识库——笔记编辑器的双模式与标签管理
知识库 App 的 NoteEditor 承担了新建和编辑两种完全不同的职责——新建时所有字段为空,编辑时需要预填已有数据。这种"一个页面两种模式"的设计在工具类 App 里很常见,但要做到两种模式切换流畅、数据不串,需要在状态初始化和保存逻辑上下功夫。
完整效果

一、八个 @State 的职责划分
@State note: Note | undefined = undefined // 原始笔记数据(编辑模式)
@State isNew: boolean = true // 新建/编辑模式标记
@State title: string = '' // 标题输入
@State content: string = '' // 内容输入
@State selectedNb: number = 1 // 选中的笔记本 id
@State fav: boolean = false // 收藏状态
@State tagInput: string = '' // 标签输入框内容
@State tags: string[] = [] // 已添加的标签列表

八个 @State 可以分为三组:
| 分组 | 变量 | 作用 |
|---|---|---|
| 模式控制 | note, isNew | 区分新建和编辑 |
| 内容输入 | title, content, selectedNb, tags | 笔记的四个核心字段 |
| 交互状态 | fav, tagInput | 收藏切换和标签输入 |
note 存原始数据、title/content 存编辑副本——为什么要分开? 因为编辑过程中用户可能修改了但没保存。如果直接修改 note.title,用户取消编辑后原始数据也被改了。用独立的 title/content 存编辑副本,只有点"保存"才写回 note。
踩坑记录: 最初直接修改 this.note.title = v,用户改了标题但点返回没保存,首页列表里标题却变了——因为 note 是对象引用,直接修改会影响原始数据。改成独立的 title/content 变量后解决。
二、aboutToAppear 的模式判断
aboutToAppear(): void {
const p = router.getParams() as Record<string, Object>
if (p && p['noteId']) {
this.note = getNoteById(p['noteId'] as number)
if (this.note) {
this.isNew = false
this.title = this.note.title
this.content = this.note.content
this.selectedNb = this.note.notebookId
this.fav = this.note.favorited
const t: string[] = []
for (let i: number = 0; i < this.note.tags.length; i++) { t.push(this.note.tags[i]) }
this.tags = t
}
}
}

有没有 noteId 参数决定了是新建还是编辑。 从首页点"+"新建时不传 noteId,从笔记列表点某篇笔记进来时传 noteId。
编辑模式需要复制 tags 数组。 this.note.tags 是原始数据的引用,如果直接赋值 this.tags = this.note.tags,用户在编辑器里添加/删除标签会直接影响原始数据。用 for 循环创建副本保证原始数据安全。
为什么 tags 要复制而 title/content 不需要? 因为 tags 是数组——数组的引用复制会让两个变量指向同一内存,修改任何一个都会影响另一个。title/content 是基本类型(string),赋值时直接复制值,不存在引用问题。
两种模式的初始化对比
| 字段 | 新建模式 | 编辑模式 |
|---|---|---|
| isNew | true | false |
| title | ‘’(空) | note.title(预填) |
| content | ‘’(空) | note.content(预填) |
| selectedNb | 1(默认第一个笔记本) | note.notebookId(原笔记本) |
| fav | false | note.favorited(原状态) |
| tags | [](空数组) | note.tags 的副本 |
| 导航栏标题 | “新建笔记” | “编辑笔记” |
| 收藏/删除按钮 | 不显示 | 显示 |
三、笔记本选择器:Chip 样式的实现
Row() {
ForEach(NOTEBOOKS, (nb: Notebook) => {
Text(nb.icon + ' ' + nb.name).fontSize(12)
.fontWeight(this.selectedNb === nb.id ? FontWeight.Bold : FontWeight.Regular)
.fontColor(this.selectedNb === nb.id ? '#FFFFFF' : T2)
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(this.selectedNb === nb.id ? nb.color : '#F5F5FA')
.margin({ right: 6 })
.onClick(() => { this.selectedNb = nb.id })
})
}

选中和未选中的双重样式切换:
| 状态 | 背景色 | 文字颜色 | 字重 |
|---|---|---|---|
| 选中 | 笔记本对应颜色(如 #FF6B6B) | 白色 | Bold |
| 未选中 | #F5F5FA(浅灰) | T2(灰色) | Regular |
选中状态用笔记本自己的颜色——让用户一眼知道"这篇笔记属于哪个笔记本"。 4 个笔记本各有不同颜色(红/紫/绿/橙),选中时背景变成对应颜色,视觉上非常直观。
margin({ right: 6 }) 保证最后一个笔记本右侧也有间距。 如果用 Row({ space: 6 }),最后一个元素右侧没有间距。手动加 margin 更可控。
踩坑记录: 最初笔记本选择器没有 fontWeight 区分,选中和未选中只有颜色差异。在小屏手机上颜色差异不明显,用户反馈"不知道选了哪个"。加了 Bold 字重后视觉区分更清晰。
四、标题和内容的输入设计
标题:单行 TextInput
TextInput({ placeholder: '笔记标题', text: this.title })
.fontSize(20).fontWeight(FontWeight.Bold)
.backgroundColor('transparent').width('100%')
.padding({ left: 16, right: 16 })
.onChange((v: string) => { this.title = v })
标题用 TextInput(单行输入)——因为标题通常只有一行。 fontSize 20 + Bold 让标题在视觉上最突出。backgroundColor('transparent') 让输入框融入白色背景,用户感知不到"这是一个输入框",只看到文字。
内容:全高 TextArea
TextArea({ placeholder: '开始记录你的想法...', text: this.content })
.fontSize(15).backgroundColor('transparent')
.width('100%').padding({ left: 16, right: 16 }).layoutWeight(1)
.onChange((v: string) => { this.content = v })
内容用 TextArea(多行输入)——因为笔记内容可能很长。 layoutWeight(1) 让 TextArea 占据标题和标签之间的所有剩余空间,用户有最大的输入区域。
TextArea 和 TextInput 的选择标准:
| 组件 | 行数 | 适用场景 |
|---|---|---|
| TextInput | 单行 | 标题、搜索框、标签输入 |
| TextArea | 多行 | 笔记内容、长文本描述 |
分隔线用 Divider 而不是 Border。 Divider().strokeWidth(0.5).color('#F0EEF4') 在标题和内容之间画一条细线。比用 border-bottom 更简洁——不需要计算 padding 和宽度。
五、标签管理:添加与删除
标签展示区
Row({ space: 6 }) {
ForEach(this.tags, (t: string, idx: number) => {
Row() {
Text('#' + t).fontSize(11).fontColor(A)
Text(' ×').fontSize(11).fontColor(T3)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(A + '12').borderRadius(10)
.onClick(() => { this.removeTag(idx) })
})
}
标签用 # 前缀——像社交媒体的 hashtag。 紫色文字 + 浅紫背景,和主色保持一致。点击整个标签行触发删除——不需要单独的删除按钮。
idx 是 ForEach 的第二个参数——当前项的索引。 removeTag(idx) 按索引删除,不需要查找标签内容。
标签输入区
Row() {
TextInput({ placeholder: '添加标签...', text: this.tagInput })
.layoutWeight(1).height(32).fontSize(13)
.backgroundColor('#F5F5FA').borderRadius(16).padding({ left: 12 })
.onChange((v: string) => { this.tagInput = v })
.onSubmit(() => { this.addTag() })
Text('添加').fontSize(12).fontWeight(FontWeight.Bold).fontColor(A)
.margin({ left: 8 }).onClick(() => { this.addTag() })
}
两种添加方式: 点"添加"按钮,或者按键盘确认键。onSubmit 和 onClick 都调 addTag(),用户习惯哪种操作都能添加。
踩坑记录: 最初只写了 onClick 没写 onSubmit,用户在输入标签后习惯按回车确认,但按回车没反应。加了 onSubmit 后体验好很多。
addTag 的防御性检查
private addTag(): void {
if (this.tagInput.trim().length === 0) return
const t: string[] = []
for (let i: number = 0; i < this.tags.length; i++) { t.push(this.tags[i]) }
t.push(this.tagInput.trim())
this.tags = t
this.tagInput = ''
}
三个关键步骤:
- 空值检查 —
trim().length === 0防止添加空白标签 - 复制数组 — 创建新数组
t,避免直接修改this.tags引用 - 清空输入 —
this.tagInput = ''让输入框恢复空白
为什么 addTag 和 removeTag 都要复制数组? 因为 @State 要求替换引用才能触发刷新。直接 this.tags.push(...) 修改原数组不触发刷新。
removeTag 的实现
private removeTag(idx: number): void {
const t: string[] = []
for (let i: number = 0; i < this.tags.length; i++) {
if (i !== idx) t.push(this.tags[i])
}
this.tags = t
}
按索引过滤——遍历 tags,跳过指定索引,创建新数组。和菜谱 App 的 removeFromShop 逻辑完全一致。
六、保存逻辑:新建与编辑的分支处理
private save(): void {
if (this.title.trim().length === 0) return
if (this.isNew) {
addNote(this.title.trim(), this.content, this.selectedNb, this.tags)
} else if (this.note) {
this.note.title = this.title.trim()
this.note.content = this.content
this.note.notebookId = this.selectedNb
this.note.tags = this.tags
this.note.updatedAt = '2026-07-15'
}
router.back()
}
新建和编辑走不同的保存路径:
| 模式 | 保存方式 | 数据变化 |
|---|---|---|
| 新建 | addNote() 创建新对象插入 _notes 数组 |
新增一条笔记,noteCount+1 |
| 编辑 | 直接修改 this.note 的属性 |
更新已有笔记,updatedAt 改为当天 |
新建用 addNote 函数(数据层操作),编辑直接修改对象(引用操作)。 为什么编辑不用函数?因为编辑是修改已有对象的属性——this.note 是 _notes 数组里某个元素的引用,修改它就等于修改了 _notes 里的数据。
this.title.trim().length === 0 检查——标题不能为空。 如果用户没输入标题就点保存,save 直接 return,不执行任何操作。内容可以为空(用户可能只想记一个标题),但标题不能为空。
踩坑记录: 最初没检查标题为空,用户保存了一篇标题为空的笔记,首页列表里显示一个空标题卡片。加了检查后,空标题保存时静默失败——没有错误提示,但数据不会保存。更好的做法是弹一个 Toast 提示"标题不能为空"。
七、删除与收藏的交互
// 导航栏:编辑模式显示收藏和删除按钮
if (!this.isNew) {
Text(this.fav ? '❤️' : '🤍').fontSize(20)
.onClick(() => { this.toggleFav() }).margin({ right: 8 })
Text('🗑').fontSize(18)
.onClick(() => { if (this.note) { deleteNote(this.note.id); router.back() } })
.margin({ right: 8 })
}
// 收藏切换
private toggleFav(): void {
if (this.note) { this.fav = toggleFav(this.note.id) }
}
新建模式不显示收藏和删除按钮——因为还没保存,没有笔记可以收藏或删除。 if (!this.isNew) 控制按钮的显隐。
删除后直接 router.back()。 删除完不需要等用户确认——因为这是一个轻量级的知识库 App,不是重要数据管理工具。真实 App 应该加确认弹窗。
收藏切换的返回值设计。 toggleFav 返回 boolean,直接赋值 this.fav = toggleFav(...)。和菜谱 App 的收藏切换逻辑一致。
八、整体嵌套结构
Column 页面容器
├→ Row (导航栏) 返回 + 标题 + 操作按钮
└→ Column (白色内容区) 白色背景,包裹所有输入区
├→ Row (笔记本选择器) 横向排列 4 个 Chip
├→ TextInput (标题) 单行输入,20px 粗体
├→ Divider 分隔线
├→ TextArea (内容) 多行输入,layoutWeight(1) 全高
└→ Column (标签区) 白色背景 + 顶部边框
├→ Row (已有标签) 标签 Chip,点击删除
└→ Row (标签输入) TextInput + 添加按钮
标签区为什么在底部而不是标题旁边? 因为标签数量不确定——0 个标签时不占空间,5 个标签时占较多空间。放在底部避免了标签区的高度变化影响标题和内容的布局稳定性。
标签区的白色背景 + 顶部边框。 backgroundColor('#FFFFFF').border({ width: { top: 0.5 }, color: '#F0EEF4' }) 让标签区和上方的 TextArea 视觉上分开——TextArea 在白色背景上,标签区也在白色背景上,但标签区多了一条顶部细线作为分隔。
九、数据流向图
用户输入 title/content/tags
↓
@State 变量接收
↓
点击"保存"
↓
┌──────┴──────┐
新建模式 编辑模式
addNote() this.note.xxx = xxx
(插入新对象) (修改引用属性)
↓ ↓
_notes 数组变化
↓
router.back()
↓
首页 onPageShow → refresh()
↓
列表更新显示新笔记
新建和编辑的数据流差异: 新建走 addNote → 修改 _notes → 返回首页 → refresh → 列表更新。编辑直接修改 note 引用 → 返回首页 → 列表显示修改后的内容。两种方式最终都能让首页看到最新数据。
更多推荐

所有评论(0)