维刻 Vex
GitHub
维刻 Vex / 文档 / 核心原理与源码导读

核心原理与源码导读

不要从最大、最复杂的编辑器文件开始。先理解 Dart 对象、Flutter 重建、共享会话和异步保存四件事,再回到真实源码。

1. Dart 和 Flutter 各负责什么

Dart 是语言,Flutter 是使用这门语言的界面框架。 就像你用中文写说明,而 Markdown 决定标题、列表等表示形式;二者不是同一个层次。

dart
// 教学代码:只演示 Dart 语法,不是应用的完整文档模型。
class Note {
  Note({required this.title, this.path});

  String title;
  String? path;

  bool get hasFile => path != null;
}

void main() {
  final note = Note(title: '我的第一篇笔记');
  print(note.title);
  print(note.hasFile);
}
语法 含义
class Note 定义一类对象的结构与行为
Note(...) 创建对象时的构造方法
required 调用者必须提供这个命名参数
String? 允许为空的字符串;String 不允许空值
final note 变量只赋值一次;不代表对象内部永远不可改变
bool get hasFile 用属性形式调用的计算结果
=> 简写的返回表达式

真实 WorkspaceDocument.path 也可空:新建草稿尚未选保存位置,不该用一个虚假的文件名代替磁盘路径。

2. Widget 是界面描述,不是保存位置

下面也是教学组件,不是当前应用的界面复制:

dart
import 'package:flutter/material.dart';

class NoteTitle extends StatelessWidget {
  const NoteTitle({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(12),
      child: Text(title),
    );
  }
}

build 根据当前输入描述界面。Flutter 可以多次调用它,所以不能在里面随手“打开文件、写设置、发网络请求”。否则滚动、主题变化都可能触发重复副作用。

StatelessWidget 自己不保存可变内部状态,但可以因父组件传来新标题而重新构建。StatefulWidget 搭配 State 保存组件自身的短期状态,比如一个面板是否展开。有状态不等于所有业务状态都应该塞在这个 State 里。

三棵树,先有个印象就够了

  • Widget:这次想要什么界面。
  • Element:框架怎样把前后两次界面描述对应起来。
  • RenderObject:最终测量尺寸、布局和绘制。

因此“重新 build”不等于“把整扇窗口删掉重画所有像素”。真正是否耗时还要看布局、绘制、文本和派生数据计算。

3. 状态怎样让屏幕变化

Vex 的工作区 ViewModel 继承 ChangeNotifier,应用通过 provider 提供它:

text
用户点开关
    ↓
ViewModel 更新状态
    ↓
notifyListeners 通知监听者
    ↓
Selector / Consumer 读取关心的状态
    ↓
Flutter 更新相关界面

Selector 可限制某个界面只关心主题、语言等指定值。它不能自动阻止你在其他地方做昂贵的全文解析,也不能代替正确的状态设计。

控制器解决的是“组件怎么被操作”:例如获取编辑选区、撤销、定位某个章节。ViewModel 解决的是“用户意图怎么串起来”:例如保存哪个文档、用哪个服务、失败怎样提示。

4. 从 main 开始读一次真实启动

阅读顺序:apps/vex/lib/main.dart → app.dart → workspace_screen.dart。

  1. WidgetsFlutterBinding.ensureInitialized():先准备 Flutter 与平台通信基础。
  2. 注册字体许可,让应用许可信息包含所用字体。
  3. 检查是否为内置只读文档的独立进程入口;如果是,打开文档后返回。
  4. 初始化桌面窗口,macOS 等平台采用相应标题栏策略。
  5. 创建真实文件服务、设置仓库和恢复仓库,注入 WorkspaceViewModel。
  6. 等待 vm.initialize() 加载设置、恢复状态等。
  7. 尝试打开命令行传入的文件路径。
  8. runApp(VexApp(...)) 建立 Flutter 应用树。

app.dart 再负责主题、语言、本地化委托与工作区根界面。不要误以为 main.dart 必须包含所有菜单和编辑逻辑。

5. 为什么原文只能有一份权威状态

假设源码视图保存字符串 A,阅读视图保存字符串 B,导图又保存字符串 C。用户改了 A、切到 C、再撤销时,很难判断到底以哪份为准。

Vex 让 MarkdownSession 保存权威原文:

dart
// 可在 vex_markdown_core 包环境中理解和练习。
import 'package:vex_markdown_core/vex_markdown_core.dart';

void main() {
  final session = MarkdownSession('# 学习 Flutter\n');
  session.replace('# 学习 Flutter\n\n今天先认识目录。\n');
  print(session.isDirty); // true
  session.undo();
  print(session.isDirty); // false:回到了最初保存基线
}

isDirty 不是“按过任何键就永远为真”,而是当前内容是否不同于保存基线。撤销回到基线可以变干净;再次编辑则又变脏。

“脏”在编程里只表示未保存差异,不是文件损坏。

6. 解析与源码范围有什么用

Markdown 原文:

markdown
# 学习计划

## Flutter

先认识 Widget。

核心解析后知道哪里是一级标题、哪里是二级标题,也知道它们在原始字符串中的位置。导图改名时,用标题文字范围替换,不用把全篇重新生成成某种“标准格式”。

当前替换后仍会重新解析文档,不是完全增量解析。保留源范围的意义是安全定位和尽量保留原文,不等于所有操作都是零成本。

UTF-16 为什么会影响选区

Dart 的字符串长度和 Flutter 文本选区偏移以 UTF-16 代码单元计数。大部分汉字长度为 1,某些补充字符长度为 2;用户眼里的一个字符还可能由多个码点组成。

dart
void main() {
  print('维'.length); // 1
  print('\u{1F680}'.length); // 2
  print('\u{1F680}'.runes.length); // 1 个 Unicode 码点
}

源码范围、选区、换行和可见文本投影必须保持同一坐标约定。否则“删除一个字符”可能删错范围。CRLF 由两个代码单元组成,也不能只在部分代码里当成一个计数。

7. Future、async、await 到底是什么

保存文件通常不是立刻完成。Future<T> 表示“将来得到一个 T 或一个错误”;await 等待结果,但允许事件循环处理其他输入。

dart
Future<String> explainAsync() async {
  await Future<void>.delayed(const Duration(milliseconds: 100));
  return '等待结束';
}

async 不自动等于开了另一个线程。 在异步函数里同步做很重的计算,仍然可能阻塞界面。要区分等待 I/O 与 CPU 计算。

等待期间,世界仍在变化

用户点保存时文本是 A;等待磁盘写入时,用户输入成 B。最后成功只能说明 A 已保存,不能把 B 也标记成功。

这个简化示例说明原则,真实代码还处理队列、冲突与路径:

dart
Future<void> saveSnapshot(
  MarkdownSession session,
  Future<void> Function(String) write,
) async {
  final snapshot = session.text;
  await write(snapshot);
  session.markSaved(savedText: snapshot);
}

配合 revision、目标对象检查和串行队列,才能避免旧操作回来覆盖新状态。仅仅“所有地方都加 await”并不能自动解决竞态。

8. 防抖和队列不是一回事

机制 解决的问题 类比
防抖 debounce 连续输入不必每键写一次恢复文件 等你短暂停笔再记录一次
串行队列 多次异步写入不能乱序 前一个写完,后一个再开始
快照 snapshot 明确这次操作处理哪一份内容 给当时的文本拍一个内容副本
版本 revision 等待结束后判断目标是否变了 看内容编号是否已经更新

这些机制互补,不能拿一个替代另一个。

9. 打字机和专注为什么分开

打字机模式主要控制滚动:当前光标行靠近编辑器可用视口的垂直中线,文档随着输入和移动滚动,需要额外留白支持首尾行居中。不是把屏幕上画一个固定光标,也不是点哪里都强行移动鼠标。

专注模式主要控制呈现:当前段落清楚,其余内容变淡。二者可以共存,但开启专注不应擅自更改打字机开关。

动画还应尊重减少动态效果设置,避免每次输入引发剧烈位移。不同输入法、软键盘和视口高度需要真实环境检验。

10. 源码导读路线

阶段 读哪些文件 只回答一个问题
第一遍 main.dart、app.dart 程序怎么启动?
第二遍 domain/workspace.dart 当前文档由什么组成?
第三遍 vex_markdown_core/lib/src/session.dart 一次修改怎样撤销?
第四遍 file_service.dart、保存相关 VM 方法 保存失败和继续输入怎样处理?
第五遍 settings_repository.dart、recovery_repository.dart 正式文件、偏好、恢复有什么区别?
第六遍 workspace_panes.dart、workspace_menu.dart 状态怎样变成布局和动作?
后续 编辑器选区、即时映射、导图布局、导出 逐个专题深入,不要一次读完

不要求一开始理解每个私有方法。能画出“用户动作 → 状态 → 会话 → 服务 → 结果”的路径,就是有效进展。

下一篇:开发调试与测试入门。

💬 评论暂未开启
有疑问或建议,欢迎到 GitHub 提交 Issue 参与讨论。
tems[selIndex] as HTMLAnchorElement).click(); } } });