Dart 3.13 的到底改了什么?为什么很重要?有什么坑?
感觉 Dart 3.13 对外可能只是多了一个新语法但是 Dart 的核心设计者甚至针对 Primary Constructors 写了一篇超长文章来聊这事因为如果只看 Demo 上的例子这个改动好像也没什么比如加了 Primary Constructors 之后是这样 class Point(final int x, final int y);然后以前我们是这样class Point { final int x; final int y; Point(this.x, this.y); }看起来就是少了几行代码好像 Dart 又从 Kotlin、Scala 借了一点语法糖设计的感觉但是实际对于 Dart 设计者来说这是 Dart 语法的一次重构过程。这次变动其实是 Dart 团队借 Primary Constructors 实现的过程重新整理了一遍 Dart 的「类、字段、参数、构造函数」之间的关系也是 Dart 一次重要的重构。所以 Dart 3.13 最终落地的东西可比一行class Point(...)大很多它包括declaring parametersprimary constructor body新的字段初始化作用域new()、factory()这套新的普通构造函数声明语法实际上从 Dart 3.13 开始构造函数这块的写法会导致整个 Dart 和 Flutter 出现一次比较明显的风格迁移。那为什么这么多年一直有人在说 Dart 不提供类似 Kotlin 的 Data Class然后现在反而做成了 Primary Constructor 确实 Dart language repository 这么多年来最热门的 feature request 一直是 Data Class类似 Kotlin 的data class User( val name: String, val age: Int )既可以自动声明字段和构造函数又会提供equals()、hashCode()、toString()、copy()等一系列 value semantics。但 Dart 团队整理了整个历史讨论之后发现其实很多 Dart 用户最想解决的其实是前半部分定义一个装数据的 class 太麻烦了比如传统 Dart 数据对象类似class Point { final int x; final int y; Point(int x, int y) : x x, y y; }这个一个x的概念在一个简单的 class 里可能出现四次所以后来 Dart 加了 initializing formalclass Point { final int x; final int y; Point(this.x, this.y); }这时候已经简化了一些但字段还是要声明一次构造参数再出现一次所以 Dart 团队随即把问题拆成两件事一个是怎样更简洁地声明「constructor parameter instance field」一个是怎样获得 value equality、hashCode、copy 等 Data Class 能力所以 Dart 3.13 的 Primary Constructors 目标就是先解决第一个问题在进行一次代码表达方式的压缩。实际上 Primary Constructor 的核心其实是 Declaring Parameter Primary Constructor 最简单的理解就是把构造函数挪到 class headerclass Point(int x, int y);不过这里其实有个比较容易忽略的问题这段代码里的int x和int y只是 constructor parameter它们不会自动变成字段真正关键的是 Dart 3.13 引入的 declaring parameterclass Point( final int x, final int y, );这里的final同时表达两层信息constructor parameter和final instance field也就是等价于class Point { final int x; final int y; Point(this.x, this.y); }如果字段需要可变那就用varclass Point( var int x, var int y, );这时候实际上等价于class Point { int x; int y; Point(this.x, this.y); }所以其实 Dart 团队实际上把 primary constructor parameter 分成了几种角色class Example( final int a, var int b, int c, );其中final int a parameter final fieldvar int b parameter mutable fieldint c 普通 parameter这点非常重要因为 Primary Constructor 没有规定「header 里的所有参数都是字段」参数还是可以只参与初始化计算比如class Rectangle( final double width, final double height, double scale, ) { final double area width * height * scale; }这里width和height会成为 instance fieldscale只存在于构造阶段。所以实际上可能理解上会比过去稍微复杂一点点官方 specification 甚至增加了一套新的 scope叫primary initializer scope让普通 primary constructor parameter 可以被字段 initializer 使用比如以前class DeltaPoint { final int x; final int y; DeltaPoint(this.x, int delta) : y x delta; }现在可以写class DeltaPoint( final int x, int delta, ) { final int y x delta; }这里delta没有成为字段但字段 initializer 可以直接读取。也就是很多以前只能放在 constructor initializer list 里的简单计算现在甚至可以直接回到字段声明旁边。那为什么 Dart 不直接照抄 Kotlin 就好呢这套东西明显就有 Kotlin 的影子啊比如 Kotlinclass Point( val x: Int, val y: Int )Dartclass Point( final int x, final int y, );实际上 Dart 团队在这个问题上确实纠结和挺久就是到底应该从「字段」推导 constructor还是从「constructor parameter」推导字段。比如 Swift 更倾向于另外一个方向「先声明 fields」 然后「compiler 生成 memberwise initializer」 。然后 Dart 最终选择了 「先声明 constructor API」 然后「部分 parameter 顺便生成 fields」 原因其实也很直接因为 Constructor 本身经常就是一个公开 API开发者可能需要更精确控制named / positional required / optional default value parameter order constructor name const这些东西都天然属于 constructor signature 如果从 fields 自动推导 constructor就会有更多问题需要处理比如字段顺序怎么算哪些字段应该 exposed哪些是 named parameter哪些 required默认值放在哪里所以 Dart 最后认为让开发者明确写 constructor signature再用var/final标记哪些参数同时产生字段组合起来更自然。这也是为什么 Dart 的 Primary Constructor 看起来比某些语言稍微“重”一点的原因它没有追求class Point(int x, int y)自动猜测x和y是字段你必须明确写class Point(final int x, final int y);或者class Point(var int x, var int y);字段的存在和可变性需要都直接暴露在源码里。当然实际上这里还有一个必须提的就是this {}Dart 团队说的是syntactic cliff语法悬崖。比如最开始有一个很简单的 classclass FormatterOptions({ final int indent 0, final int pageWidth 80, });但是后来需求慢慢增加变成了class FormatterOptions({ final int indent 0, final int pageWidth 80, final bool followLinks false, final bool setExitIfChanged false, });然后有一天你突然需要在 constructor 里打印一行 log如果 Primary Constructor 只支持「没有 body 的简单 class」你可能就需要把整个 class 改回class FormatterOptions { final int indent; final int pageWidth; final bool followLinks; final bool setExitIfChanged; FormatterOptions({ this.indent 0, this.pageWidth 80, this.followLinks false, this.setExitIfChanged false, }) { log.write(Created options.); } }语义只增加了一行log.write(...)但是实际上 Git diff 会突然变成十几二十行这就是 syntactic cliff一个很小的功能变化让代码突然从一种简洁语法掉进另一套非常冗长的语法。所以 Dart 给 Primary Constructor 又设计了一块 constructor bodyclass FormatterOptions({ final int indent 0, final int pageWidth 80, }) { this { log.write(Created options.); } }这里的this就是 Primary Constructor 的 body如果需要 initializer list同样可以写class Point( final int x, final int y, ) { this : assert(x 0); }甚至你可以写成class B( int x, int y, {required final String s2}, ) extends A { final String s1; this : s1 y.toString(), super.someName(x 1); }所以 Primary Constructor 没有被限制成「只能写 DTO」你同样可以处理parameters fields default values assert initializer list super constructor call constructor body这也是 Dart 团队为什么花了这么久设计它如果只是做class Point(final int x, final int y);其实没有那么复杂。不过 Primary Constructor 确实带来了一个问题有了 Primary Constructor 之后这个 constructor 在语义上真的是 primary官方 specification 有一条很重要的规则一个 class 如果拥有 primary constructor那么其他 generative constructor 必须最终 redirect 到这个 primary constructor。原因和前面提到的字段 initializer scope 有直接关系比如class C( int value, ) { final int result value * 2; }这里result value * 2成立的前提是每次创建C时一定都会执行这个 primary constructor如果同时允许C.other()完全绕过 primary constructor那么value从哪里来就没办法定义了。所以 Dart 强制所有 generative constructor 都经过 primary constructor这也是 Primary Constructor 和「普通 constructor 的另一种写法」之间真正存在的结构差异。如果一个 class 本来就有很多地位平等、初始化路径完全不同的 constructor那么继续使用传统 in-body constructor 反而更清楚。Dart 团队也明确说并没有打算让 Primary Constructors 取代所有 constructor对于 constructor 很多、class header 已经很复杂或者核心 constructor 是 private 的情况传统写法才更合理。当然其实 Dart 3.13 其实也改了普通 Constructor 的写法从 Dart 3.13 开始普通 constructor 其实可以不再重复 class name比如以前是class AnimatedFractionallySizedBox { AnimatedFractionallySizedBox(); AnimatedFractionallySizedBox.create(); factory AnimatedFractionallySizedBox.fromJson() { ... } }然后现在可以写class AnimatedFractionallySizedBox { new(); new create(); factory fromJson() { ... } }从对应关系可以看出来Old Dart 3.13 ClassName() new() ClassName.name() new name() const ClassName() const new() const ClassName.name() const new name() factory ClassName() factory() factory ClassName.name() factory name()这项变化其实反而很 Dart因为传统 C / Java / C# 风格 constructor 最大的问题就是class SomeExtremelyLongClassName { SomeExtremelyLongClassName(); }class name 在上下文里已经非常明确再写一遍基本没有提供额外信息而且这个问题在 Dart 将来准备做的 static extension members 里会更加麻烦比如class SomeClass {} typedef OtherName SomeClass; extension on OtherName { // constructor? }如果 extension 将来允许增加 constructor那么这里到底应该写OtherName()还是SomeClass()这就会出现 typedef identity、封装和名称解析问题所以 Dart 团队最后选择直接把这个历史包袱拆掉new()就是 generative constructor declarationfactory()就是 factory constructor declaration。这样 constructor 就不再需要知道 class 的文本名字当然比较搞笑的是它会产生const new();这种const new第一眼看确实有点违和哈。然后整套语法如果放到 Flutter 里以前典型 Widget 会是class UserCard extends StatelessWidget { const UserCard({ super.key, required this.name, required this.avatarUrl, this.showBadge false, }); final String name; final String avatarUrl; final bool showBadge; override Widget build(BuildContext context) { ... } }如果改成 Primary Constructor 风格可以写成类似class const UserCard({ super.key, required final String name, required final String avatarUrl, final bool showBadge false, }) extends StatelessWidget { override Widget build(BuildContext context) { ... } }这里super.key继续是 super parameter而三个finalparameter 直接产生 fields官方 specification 明确支持 super parametersclass A(final int a); class B(super.a) extends A;更有意思的是阅读的时候 class 的「输入 API 保存的状态」集中到了顶部class UserCard({ super.key, required final String name, required final String avatarUrl, final bool showBadge false, }) extends StatelessWidget {看到这一段基本就知道这个 Widget 保存了什么状态所以实际上这个语法糖的价值也不只是减少字符数而已它还能让代码更直接地暴露意图。同时 Dart 官方也明确这将是未来 Dart 的主流风格Dart 3.13 甚至开始通过 lint 推动这种新风格也就是从官方配套来看 Dart 官方显然没有把 Primary Constructors 当成一个「想用就用的边缘语法」状态这里 Dart 3.13 一次加入了六个相关 lintempty_container_bodies initialize_in_field_declaration unnecessary_const_in_enum_constructor unnecessary_primary_constructor_body unnecessary_type_name_in_constructor use_declaring_parameters这些 lint 都围绕一个方向让代码向更短的 rimary constructor 表达方式迁移IDE 还直接增加了Convert to primary constructor Convert to in-body constructor Convert to declaring parameter Move initialization to the field declaration特别值得注意的是unnecessary_type_name_in_constructor这个 lint 会认为class C { C(); C.name(); }应该改成class C { new(); new name(); }官方文档甚至直接把旧写法标成 BAD、新写法标成 GOOD也就是所以从长期趋势看new()constructor declaration 大概率会慢慢成为 Dart 官方推荐风格所以你不要觉得这个只是加了一个新语法实际上这很可能是一次断代的开始。另外还有两个升级到 Dart 3.13 时值得注意的小坑因为 Primary Constructors 虽然官方强调没有增加新的运行时 semantics但因为语法空间发生了变化还是产生了少量 source compatibility 问题。这里第一个是final parameter过去 Dart 可能有人会写void foo(final int value) { ... }这里会把final当作「不允许 parameter 被重新赋值」的语法而 Dart 3.13 以后final和var在 parameter 上会被赋予了 declaring parameter 的特殊含义所以普通 function parameter 上使用它们大概率会成为 compile-time error。这个坑是真的坑如果团队希望继续限制 parameter assignment官方建议使用parameter_assignmentslint。另一个比较冷门的情况是factory() {} 以前如果你恰好定义了一个没有显式 return type、名字就叫factory的 methodDart 3.13 的 parser 可能会把它理解成 factory constructor。当然普通 Flutter 项目里这两种情况一般都不算高频但是还是需要注意下。所以 Dart 3.13 实际上是另一次 null safety 变动的开始估计再有两个版本 Dart 可能就会有全新的断档了所以还是有必要关注下的。