01 · Dart 语言基础
目标:读完后能看懂
lib/下任意一个文件的语法,不再被?、!、..、switch表达式卡住。本文所有「项目实例」都标注了
文件:行号,可直接跳转核对。
开篇:Dart 与 TypeScript 的整体对照
Dart 和 TS 非常像——都是 C 系语法、都有类型推断、都支持 async/await。主要差异集中在空安全和模式匹配两块。
| 概念 | TypeScript | Dart |
|---|---|---|
| 变量声明 | const / let | final(一次赋值)/ var(可改)/const(编译时常量) |
| 类型推断 | const x = 1 | final x = 1(推荐显式写类型) |
| 可空类型 | string | null | String? |
| 非空断言 | x! | x! |
| 可选链 | a?.b | a?.b |
| 空值合并 | a ?? b | a ?? b |
| 接口 | interface Foo {} | abstract class Foo {} 或 @freezed class |
| 联合类型 | type R = A | B | sealed class R(配合模式匹配) |
| 异步 | Promise<T> | Future<T> |
| 异步等待 | await | await |
| 数组 | Array<T> / T[] | List<T> |
| 键值对 | Record<string, T> / Map | Map<String, T> |
| 解构 | const {a, b} = obj | final (a, b) = record(Dart 3 Record) |
| 展开运算符 | [...a, ...b] | [...a, ...b](一样) |
| 箭头函数 | () => {} | () {} 或 () => expr |
| 泛型 | Foo<T> | Foo<T>(一样) |
一、变量:final / var / const 的三选一
前端对照
| TS | Dart |
|---|---|
const x = 1(不可重新赋值) | final x = 1(运行时确定,赋值一次) |
let x = 1(可改) | var x = 1(可改) |
const enum / 字面量常量 | const x = 1(编译时常量) |
三条规则
- 绝大多数情况用
final—— 变量只赋值一次,运行时计算 - 只有需要重新赋值才用
var—— 但 Widget 里几乎不需要(见 03 章「为什么 build 里不用 var」) const是编译期常量 —— 它的值必须在编译时就确定
final now = DateTime.now(); // ✅ 运行时计算,final 可以
const now = DateTime.now(); // ❌ 编译错误!DateTime.now() 不是编译期常量
const pi = 3.14159; // ✅ 字面量,编译期确定
const list = [1, 2, 3]; // ✅ 常量字面量项目实例
lib/ui/core/models/paged_state.dart:28 —— freezed 类的私有构造用 const:
const PagedState._();lib/ui/core/notifiers/paged_notifier_mixin.dart:67 —— 状态赋值用 const:
state = const AsyncValue.loading();这里 AsyncValue.loading() 配 const 是性能关键:同一个常量对象在整个 App 里只创建一次。详见 03 章。
⚠️ 本项目最高频的坑:不要用 const 包 token
padding: const EdgeInsets.all(AppTokens.spacingMd) // ❌ 编译错误
padding: EdgeInsets.all(AppTokens.spacingMd) // ✅原因:AppTokens.spacingMd 是一个 getter(运行时从主题取),不是编译期常量。所有 AppTokens.* 都不能进 const 上下文。
练习 1.1
在 lib/main_playground.dart 里试:
// 依次取消注释,观察哪个报错、报什么错
const a = 1 + 2; // ?
const b = DateTime.now(); // ?
final c = DateTime.now(); // ?
var d = 1; // ?
d = 2; // ?
final e = 1;
e = 2; // ?自检
- [ ] 能说出
final与const的区别(运行时 vs 编译期) - [ ] 知道为什么
const EdgeInsets.all(AppTokens.spacingMd)会报错 - [ ] 在自己的代码里,默认用
final
二、空安全:? / ! / late / ?? / ?.
Dart 的健全空安全(sound null safety)是它比 TS 严格的地方:TS 的 strictNullChecks 只影响类型检查,Dart 的空安全是运行时也保证的。
前端对照
| TS | Dart | 含义 |
|---|---|---|
string | null | String? | 可空类型 |
x! | x! | 非空断言(我保证不为 null) |
x?.y | x?.y | 可空访问 |
x ?? default | x ?? default | 空值合并 |
if (x !== null) | if (x != null) | 判空后自动提升为非空 |
| — | late String x; | 延迟初始化(我保证用之前会赋值) |
| — | required String x | 命名参数必填 |
四个符号
String? name; // 可空:name 可能是 null
name!.length; // 断言非空:如果 name 是 null 会运行时抛错
name?.length; // 安全访问:name 为 null 时整个表达式是 null
name ?? '匿名'; // 空值合并:name 为 null 时用默认值「类型提升」——Dart 比 TS 更聪明的地方
void printLength(String? s) {
if (s != null) {
// 这里 s 已经被**提升**为 String(非空),不需要 s!
print(s.length); // ✅
}
}这在 TS 里也成立,但 Dart 的提升范围更广,包括 return 提前退出后:
String describe(String? s) {
if (s == null) return 'null';
return '长度 ${s.length}'; // ✅ s 已提升
}late:延迟初始化
late String _token; // 声明时不赋值,承诺用之前会赋值
void init() => _token = 'abc';
void use() => print(_token.length); // 如果没调 init 就 use → LateInitializationError本项目中的典型用法:StatefulWidget 里的 controller(见 03 章生命周期)。
什么时候不该用 late:如果一个值本来就可能没有,用 String? 而不是 late String。late 是「我保证有」的承诺,不是「可能是 null」的表达。
required:命名参数必填
// 定义
void login({required String username, required String password}) {}
// 调用
login(username: 'a', password: 'b'); // ✅
login(username: 'a'); // ❌ 编译错误项目实例
lib/ui/core/models/paged_state.dart:19-26 —— freezed 模型里可空与默认值的配合:
const factory PagedState({
@Default([]) List<T> items, // 默认空列表,非 null
@Default(1) int page,
@Default(true) bool hasMore,
Object? loadMoreError, // 可空:null 表示没有错误
int? totalCount, // 可空:后端可能不返回
}) = _PagedState<T>;注意这里的设计意图:loadMoreError 用 Object? 而不是 bool hasError,这样错误信息本身也被保留。
lib/ui/core/notifiers/paged_notifier_mixin.dart:98-99 —— 判空提升:
final current = state.valueOrNull;
if (current == null) return; // 提前返回
// 此后 current 是非空的 PagedState<TOrder>
state = AsyncValue.data(current.copyWith(isFetchingMore: true));练习 1.2
String? maybe;
void main() {
// 1. 不判空直接用 maybe.length,看报什么错(编译期还是运行期?)
// 2. 用 ?. / ?? / ! 分别改写,观察区别
// 3. 写一个函数 describe(List<String>? items),
// 要求:null 返回 '无',空列表返回 '空',否则返回 '共 N 项'
}自检
- [ ] 看到
String?立刻知道要判空或用?./?? - [ ] 知道
late用错会抛LateInitializationError - [ ] 能解释
PagedState里为什么loadMoreError是Object?而不是bool
三、异步:Future / async / await
前端对照
| TS | Dart |
|---|---|
Promise<T> | Future<T> |
async function | Future<T> func() async |
await | await |
Promise.all([a, b]) | Future.wait([a, b]) |
Promise.resolve(x) | Future.value(x) |
new Promise(r => setTimeout(r, 100)) | Future.delayed(Duration(milliseconds: 100)) |
try/catch | try/catch(一样) |
Observable / EventEmitter | Stream<T>(见 06 章) |
语法几乎一模一样,主要差异:
// Dart:返回类型要写 Future<T>
Future<String> fetchName() async {
await Future.delayed(const Duration(seconds: 1));
return 'Haier';
}
// 不需要 async 也可以返回 Future
Future<String> fetchNameSync() => Future.value('Haier');项目实例:Result 模式匹配
lib/ui/core/notifiers/paged_notifier_mixin.dart:125-149 是本项目异步 + 模式匹配的标准写法:
Future<PagedState<TOrder>> _fetchPage(int page, {CancelToken? cancelToken}) async {
final result = await fetchPage(page, keyword: _keyword, ...);
switch (result) {
case Ok(value: final pageDto):
_totalPages = pageDto.totalPages;
final items = content.map(mapDto).toList();
return PagedState(items: items, page: page, hasMore: hasMore, ...);
case ErrorResult(error: final e):
throw e; // 抛出后由 AsyncValue.guard 捕获成 error 态
}
}注意这个模式的三层:
Future<Result<T>> ← Repository 返回(不抛异常,把错误包进 Result)
↓ switch 模式匹配
Ok → 取 .value 用
Error → throw e(转成 Riverpod 的 AsyncValue.error,见 08 章)练习 1.3
Future<int> slowAdd(int a, int b) async {
await Future.delayed(const Duration(milliseconds: 500));
return a + b;
}
void main() async {
// 1. 顺序 await:总共耗时多少?
final x = await slowAdd(1, 2);
final y = await slowAdd(3, 4);
// 2. 并发:改成 Future.wait,耗时多少?
final results = await Future.wait([slowAdd(1, 2), slowAdd(3, 4)]);
}自检
- [ ] 能写出
Future<T> func() async的签名 - [ ] 知道
Future.wait是并发,顺序await是串行 - [ ] 能读懂
switch (result) { case Ok(...): case ErrorResult(...): }
四、集合操作:map / where / fold / expand
前端对照
Dart 的 Iterable API 和 JS 的 Array 几乎一一对应:
| JS | Dart |
|---|---|
arr.map(f) | list.map(f)(返回懒加载的 Iterable,要 .toList()) |
arr.filter(f) | list.where(f) |
arr.reduce(f, init) | list.fold(init, f) |
arr.flatMap(f) | list.expand(f) |
arr.find(f) | list.firstWhere(f, orElse: () => default) |
arr.some(f) | list.any(f) |
arr.every(f) | list.every(f) |
arr.forEach(f) | list.forEach(f) |
[...a, ...b] | [...a, ...b] |
arr.length | list.length |
⚠️ 差异一:.map() 返回的是 Iterable,不是 List
final list = [1, 2, 3];
final doubled = list.map((x) => x * 2); // Iterable<int>,懒加载
final doubledList = list.map((x) => x * 2).toList(); // List<int>忘了 .toList() 是新手高频错误。如果函数签名要 List<T>,传 Iterable<T> 会编译失败。
⚠️ 差异二:firstWhere 找不到会抛异常
// JS:arr.find() 找不到返回 undefined
// Dart:firstWhere 找不到会抛 StateError,除非给 orElse
final x = list.firstWhere((e) => e.id == 1, orElse: () => defaultItem);项目实例
lib/ui/core/notifiers/paged_notifier_mixin.dart:136-139(map + toList):
final content = pageDto.content;
final items = content.map(mapDto).toList();lib/ui/core/notifiers/paged_notifier_mixin.dart:105-108(展开符合并两页数据):
final merged = <TOrder>[
...current.items, // 已加载的
...next.items, // 新一页
];lib/ui/auth/models/auth_state.dart:172(any 判断权限):
return perms.any((p) => p == '*:*:*' || p == code);这行实现的是「超管通配 *:*:* 或精确匹配」。
练习 1.4
final users = [
{'name': '张三', 'age': 20, 'city': '青岛'},
{'name': '李四', 'age': 17, 'city': '青岛'},
{'name': '王五', 'age': 25, 'city': '北京'},
];
// 1. 找出所有青岛人的名字(List<String>)
// 2. 判断是否有人未成年(bool)
// 3. 求所有人年龄之和(int,用 fold)自检
- [ ] 记得
.map()后要.toList() - [ ]
firstWhere会写orElse - [ ] 会用
...展开符合并列表
五、级联操作符 .. 和 ?..
这是 JS 里没有的语法,也是 Dart 代码里非常常见的写法。
概念
.. 让你对同一个对象连续调用多个方法/设置多个属性,而不用重复写对象名。
// 不用级联
final p = Person();
p.name = '张三';
p.age = 20;
p.sayHello();
// 用级联
final p = Person()
..name = '张三'
..age = 20
..sayHello();关键:级联表达式的返回值是对象本身,不是最后一个方法的返回值。
final x = obj..method(); // x === obj(不是 method() 的返回值)前端类比
最接近的是 jQuery 链式调用,或 JS 的 Object.assign:
// JS
const config = Object.assign(new Config(), { a: 1, b: 2 });
// Dart
final config = Config()
..a = 1
..b = 2;?.. 可空级联
对象可能为 null 时用 ?..,为 null 时整条链跳过:
controller?..setText('x')..update(); // controller 为 null 时什么都不做项目实例
lib/main.dart:42-57 —— EasyLoading 配置,级联的经典用法:
EasyLoading.instance
..displayDuration = const Duration(milliseconds: 2000)
..animationDuration = const Duration(milliseconds: 200)
..maskType = EasyLoadingMaskType.none
..userInteractions = false
..dismissOnTap = false
..loadingStyle = EasyLoadingStyle.custom
..backgroundColor = theme.barrier
..textColor = Colors.white
..indicatorColor = Colors.white
..fontSize = AppTokens.bodyMdRegular.fontSize!
..radius = AppTokens.radiusLarge
..contentPadding = EdgeInsets.symmetric(
horizontal: AppTokens.spacingSm,
vertical: AppTokens.spacingXs,
);如果不用级联,这段代码要写 12 遍 EasyLoading.instance。
练习 1.5
用级联改写下面这段(不用级联的版本):
final sb = StringBuffer();
sb.write('Hello');
sb.write(' ');
sb.write('Flutter');
print(sb.toString());自检
- [ ] 看到
x..a..b..c知道是对同一个 x 连续操作 - [ ] 知道级联表达式返回对象本身
- [ ] 能读懂
main.dart里 EasyLoading 的配置
六、闭包与 typedef
前端对照
Dart 的闭包(匿名函数)和 JS 箭头函数几乎一样:
final f = (int x) => x * 2; // 类似 JS: (x) => x * 2
list.map((item) => item.name); // 类似 JS: list.map(item => item.name)差异:Dart 的函数必须声明参数类型(或在上下文可推断时省略)。
typedef:给函数类型起别名
typedef ItemBuilder<T> = Widget Function(BuildContext context, T item, int index);之后就可以用 ItemBuilder<T> 代替那一长串。
项目实例
lib/ui/core/notifiers/paged_notifier_mixin.dart:51 —— 抽象方法作为「回调」交给子类实现:
/// 子类提供:DTO → UI model 映射。
TOrder mapDto(TDto dto);这不是 typedef,但思想一致:把「怎么转换」当作参数传递。使用时:
final items = content.map(mapDto).toList(); // mapDto 作为函数传入 maplib/ui/core/widgets/paged_list_body.dart:43 —— 典型的 Widget builder 回调签名:
Widget itemBuilder(BuildContext context, T item, int index);练习 1.6
// 1. 写一个 typedef:typedef Transformer<T, R> = R Function(T input);
// 2. 写一个函数 applyAll<T,R>(List<T> items, Transformer<T,R> f) => items.map(f).toList();
// 3. 用它把 [1,2,3] 转成 ['1','2','3']自检
- [ ] 能写
(x) => expr形式的闭包 - [ ] 看到
typedef X = Y Function(Z)知道是函数类型别名 - [ ] 能读懂
itemBuilder(BuildContext, T, int)这种抽象方法的含义
七、Record 与模式匹配(Dart 3)
这是 Dart 3 最重要的新特性,也是本项目代码里大量使用的。
前端对照
| 概念 | TS | Dart 3 |
|---|---|---|
| 元组 | [string, number] | (String, int) —— Record |
| 具名字段元组 | { name: string; age: number } | ({String name, int age}) —— Record |
| 解构 | const [a, b] = t | final (a, b) = t |
| switch 表达式 | 无(switch 是语句) | switch 是表达式,可返回值 |
| 穷尽性检查 | 需手动 never 兜底 | sealed class 自动检查 |
Record:轻量多元组
(String, int) getUser() => ('张三', 20);
final (name, age) = getUser(); // 解构
print('$name 今年 $age 岁');
// 具名字段版本
({String name, int age}) getUser2() => (name: '张三', age: 20);
final u = getUser2();
print(u.name);什么时候用 Record 而不是 class:临时返回 2~3 个值、不值得建一个类的场合。本项目中较少使用(因为大量用 freezed 类),但第三方库和 Flutter 新 API 里很常见。
模式匹配:switch 表达式
Dart 3 的 switch 是表达式,可以返回值:
final desc = switch (statusCode) {
200 => '成功',
404 => '未找到',
_ => '未知', // _ 是通配符(类似 default)
};解构匹配——这是本项目用得最多的形式:
final result = await repo.fetch();
return switch (result) {
Ok(value: final data) => processData(data), // 匹配 Ok 并取出 value
ErrorResult(error: final e) => throw e, // 匹配 Error 并取出 error
};对比传统写法:
// 不用模式匹配(啰嗦且需要类型断言)
if (result is Ok) {
return processData((result as Ok).value);
} else if (result is ErrorResult) {
throw (result as ErrorResult).error;
}sealed class + 穷尽性检查
sealed 类的子类必须在同一个文件里,编译器因此知道所有可能的子类型,可以检查你的 switch 是否遗漏分支。
@freezed
sealed class Result<T> with _$Result<T> {
const factory Result.ok(T value) = Ok<T>;
const factory Result.error(Exception error) = ErrorResult<T>;
}因为没有 default 分支,如果以后有人给 Result 加了第三个变体,所有漏改的 switch 都会编译报错——这是非常强的重构保护。
项目实例
lib/ui/core/notifiers/paged_notifier_mixin.dart:135-148(已见):
switch (result) {
case Ok(value: final pageDto):
...
case ErrorResult(error: final e):
throw e;
}lib/main.dart:155-161 —— 对枚举的模式匹配:
final isDark = switch (mode) {
ThemeMode.dark => true,
ThemeMode.light => false,
ThemeMode.system =>
View.of(context).platformDispatcher.platformBrightness == Brightness.dark,
};lib/core/permission/permission_guard.dart:25-50 —— 对枚举穷举匹配(省略 default,靠编译器检查):
switch (permission) {
case AppPermission.location:
return PermissionRationale(...);
case AppPermission.camera:
return PermissionRationale(...);
case AppPermission.photos:
return PermissionRationale(...);
case AppPermission.notification:
return PermissionRationale(...);
}练习 1.7
sealed class Shape {}
class Circle extends Shape { final double r; Circle(this.r); }
class Rect extends Shape { final double w, h; Rect(this.w, this.h); }
// 1. 用 switch 表达式写 area(Shape s),不写 default 分支
// 2. 再加一个 Triangle 子类,观察编译器报什么错(体验穷尽性检查)自检
- [ ] 知道
switch在 Dart 3 里是表达式 - [ ] 能读懂
Ok(value: final data)这种解构匹配 - [ ] 理解
sealed class带来的编译期穷尽性保护
八、@freezed 数据类(先看概念,细节见 09 章)
项目里所有模型都用 freezed 代码生成。你只需要知道:
@freezed
class PagedState<T> with _$PagedState<T> {
const factory PagedState({
@Default([]) List<T> items,
@Default(1) int page,
Object? loadMoreError,
}) = _PagedState<T>;
}它自动生成:
copyWith()—— 复制并修改部分字段(不可变对象的关键)==/hashCode/toString()—— 值相等比较- 不可变性(所有字段
final)
前端对照
| 前端 | freezed |
|---|---|
TypeScript interface | 数据结构的定义 |
immer 的 produce | copyWith |
Object.freeze | 天然不可变 |
// 修改状态:不是改字段,而是产生新对象
final next = current.copyWith(isFetchingMore: true);为什么不可变很重要:Riverpod 通过比较对象引用(==)判断是否需要重建 UI。可变对象会让这个判断失效。详见 08 章。
生成命令
写完/改完 @freezed 或 @riverpod 的类后必须跑:
fvm dart run build_runner build --delete-conflicting-outputs生成的 *.freezed.dart / *.g.dart 要提交到 git。
自检
- [ ] 知道
copyWith是产生新对象,不是原地修改 - [ ] 记得改模型后要跑 build_runner
九、本章速查
| 想做的事 | 写法 |
|---|---|
| 声明不可重新赋值的变量 | final x = ... |
| 声明编译期常量 | const x = ... |
| 可空类型 | String? |
| 断言非空 | x! |
| 安全访问 | x?.y |
| 空值兜底 | x ?? default |
| 延迟初始化 | late String x; |
| 命名参数必填 | {required String x} |
| 异步函数 | Future<T> f() async {} |
| 并发等待 | Future.wait([a, b]) |
| 集合转换 | list.map(f).toList() |
| 条件过滤 | list.where(f).toList() |
| 聚合 | list.fold(init, (acc, e) => ...) |
| 合并列表 | [...a, ...b] |
| 连续操作同一对象 | obj..a = 1..b = 2 |
| switch 表达式 | final x = switch (v) { A => 1, _ => 2 }; |
| 解构匹配 | case Ok(value: final d): |
| 复制对象 | obj.copyWith(field: v) |