一周我把一个25kStar,200w行代码的ts项目转换为了dart

0 阅读8分钟

背景

由于个人兴趣,我需要一个dart语言的命令行补全建议库,当前最权威的就是 fig 的—— withfig/autocomplete , 但它是由 javascript 语言编写的。 想把它引入到 flutter App项中,正值春节大假,有时间盘一盘这个项目。

如果你还不知道这个项目是干啥的,可以看看下面的介绍

一句话

我把 github.com/withfig/aut… 这个 25k star 的开源ts代码,使用dart实现了,开源在 github.com/littlewrite… ,并应用到了 www.faiterm.com/ 终端App中。

意义

接下来的内容,你将了解到

  1. 一个核心问题:为何通用转换工具(如 ts2dart)在面临大型、复杂的 TypeScript 项目时会“失灵”。
  2. 一种混合策略:如何通过“观察规律 + 编写专用转换器 + AI 辅助修补”的三段式方法,系统性地处理、翻译海量代码。
  3. 一次实战优化:从一次加载 500MB 内存的 MVP,到通过延迟加载、缓存策略实现平滑集成的完整演进路径。
  4. 一类典型问题的解法:在静态类型语言(Dart)中,如何模拟动态语言(JavaScript)的特性(如动态导入),并平衡内存、性能与开发体验。

一期工程

先看看一共有多少代码。 这个项目我先肉眼预览了一遍,代码量还是比较大的,有1.4k +个文件,代码行数: 2百万+ 。

$ cd autocomplete/src
$  find . -name "*.ts" -type f | wc -l
  1484 # 个 ts 文件

$ find . -type f -name "*" -exec wc -l {} + | tail -1
 2281947 total # ts 代码行数

代码量还是挺大的。

ts2dart ?

用代码转换? 我尝试过 [ts2dart]](github.com/dart-archiv…) 这个开源项目来处理代码。处理结果是:

...
         ^^^^^
 clang.ts:4863:3: Property assignment expected.
 clang.ts:4864:10: ';' expected.
 clang.ts:4865:1: Expression expected.

处理简单的ts代码还行,稍微复杂点的就会报错,例如这个 clang.ts, 它包含一些 ts 特性,dart 又没有实现的就乱转了。 这个项目已归档不再更新。

给大家看看转换结果:

 final List < Fig . Suggestion > stdCSuggestions = [ { "name" : [ "c89" , "c90" , "iso9899:1990" ] , "description" : "ISO C 1990" } , { "name" : "iso9899:199409" , "description" : "ISO C 1990 with amendment 1" } , { "name" : [ "gnu89" , "gnu90" ] , "description" : "ISO C 1990 with GNU extensions" } , { "name" : [ "c99" , "iso9899:1999" ] , "description" : "ISO C 1999" } , { "name" : "gnu99" , "description" : "ISO C 1999 with GNU extensions" } , { "name" : [ "c11" , "iso9899:2011" ] , "description" : "ISO C 2011" } , { "name" : "gnu11" , "description" : "ISO C 2011 with GNU extensions" } , { "name" : [ "c17" , "iso9899:2017" , "c18" , "iso9899:2018" ] , "description" : "ISO C 2017" } , { "name" : [ "gnu17" , "gnu18" ] , "description" : "ISO C 2017 with GNU extensions" } , { "name" : "c2x" , "description" : "Working Draft for ISO C2x" } , { "name" : "gnu2x" , "description" : "Working Draft for ISO C2x with GNU extensions" } ] ; final List < Fig . Suggestion > stdCPPSuggestions = [ { "name" : [ "c++98" , "c++03" ] , "description" : "ISO C++ 1998 with amendments" } , { "name" : [ "gnu++98" , "gnu++03" ] , "description" : "ISO C++ 1998 with amendments and GNU extensions" } , { "name" : "c++11" , "description" : "ISO C++ 2011 with amendments" } , { "name" : "gnu++11" , "description" : "ISO C++ 2011 with amendments and GNU extensions" } , { "name" : "c++14" , "description" : "ISO C++ 2014 with amendments" } , { "name" : "gnu++14" , 

没格式化,代码有错误也没法格式化,属于没法用。这个方案看来是行不通的。pass掉

All in AI ?

直接丢给AI?

我担心项目还没抱出来,钱包先爆掉。pass掉

vibe coding: ts 2 dart

我开始观察这个项目,看看有啥规则,规律。

这个项目的文件,代码量很多,但每个文件都有着类似的结构。 例如: ./src/@fig/publish-spec.ts

const completionSpec: Fig.Spec = {
  name: "@fig/publish-spec",
  description: "Publish a spec to fig teams",
  subcommands: [
    {
      name: "help",
      description: "Display help for command",
      priority: 49,
      args: { name: "command", isOptional: true },
    },
  ],
...

./src/adb.ts

const completionSpec: Fig.Spec = {
  name: "adb",
  description: "Android Debug Bridge",
  subcommands: [
    {
      name: "devices",
      description: "List connected devices",
      options: [
        {
          name: "-l",
          description: "Long output",
        },
      ],
    },
...

./src/cf.ts

const completionSpec: Fig.Spec = {
  name: "cf",
  description: "Cloudfoundry cli",
  subcommands: [
    {
      name: ["app", "a"],
      description: "Display health and status for app",
      args: {
        name: "APP_NAME",
        description: "The app you want to get health and status for",
        generators: generateAppNames,
      },
      options: [
        {
          name: "--guid",
          description:
            "Retrieve and display the given app's guid.  All other health and status output for the app is suppressed",
        },
      ],
    },
...

每个文件的入口文件都是如此,都是一个spec 对象,对象那包含很多对象数组,很少有函数逻辑(是的有自定义函数),大量的数组,对象定义代码,从ts转换为dart就是语法调整了一下,不容易出错。于是就让AI写了个 Fig.Spec 转换为 Dart FigSpec 的代码,先解决大部分工作。

输入需求, AI 就给出了一个 tools/converter-engine.cjs

怎么使唤AI的流程就不赘述了,直接给结果—— tools/ts-to-dart-converter.cjs 这个是转换代码。

代码转换结果: ./dart/lib/specs/arch.dart

// Auto-generated from TypeScript source: arch.ts
// Generated at: 2026-02-12
// WARNING: Manual changes may be overwritten!

import 'package:autocomplete/src/spec.dart';

/// Completion spec for `arch` CLI
final FigSpec archSpec = FigSpec(
  name: 'arch',
  description: 'Print architecture type or run select architecture',
  parserDirectives: ParserDirectives(
    flagsArePosixNoncompliant: true,
    optionsMustPrecedeArguments: true
  ),
  options: [

    Option(
      name: '-32',
      description: 'Add the native 32-bit architecture to the list of architectures'
    ),
    Option(
      name: '-64',
      description: 'Add the native 64-bit architecture to the list of architectures'
    ),
    Option(
      name: '-c',
      description: 'Clear the environment that will be passed to the command'
    ),
    Option(
      name: '-d',
      description: 'Delete the named environment variable from the command\'s environment',
      isRepeatable: true,
      args: [
        Arg(
        name: 'envname'
      )
      ]
    ),
    Option(
      name: '-e',
      description: 'Assign the given value to the variable in the command\'s environment',
      isRepeatable: true,
      args: [
        Arg(
        name: 'envname=value'
      )
      ]
    ),
    Option(
      name: '-h',
      description: 'Print a help message and exit'
    )
  ],
  args: [
    Arg(
    name: 'program',
    template: 'filepaths',
    isVariadic: true
  )
  ]
);

这样在写个 批量脚本就能完成整个项目的转换了,也就废了点写cjs的AI token,很划算,并且js的执行效率也比AI生成快非常快。

这样也只是转换了大部分代码,还有些代码用到了些特别的语法,以及动态函数,无法被转换,别跳过了。

先花 20%的时间,转换80%的代码,跑通MVP!

驱动代码

当前这个 autocomplete 项目中,ts 代码是命令的定义数据,还没法工作。例如,命令解析,load complete 这些功能是没有的。为了大家方便,就不单独拆一个 dart 项目存放了,直接搁一块了。

这里我借鉴了 -- microsoft/inshellisense 的代码,逻辑写到 ./dart/lib/src 目录下了。

ts 和 dart 语法特性问题

在处理 inshellisense 的代码时,发现这么一段代码:

const loadSpec = async (cmd: CommandToken[]): Promise<Fig.Spec | undefined> => {
...
  if (specSet[rootToken.token]) {
    const specPath = specSet[rootToken.token];
    const importPath = path.isAbsolute(specPath) ? pathToFileURL(specPath).href : specPath;
    const spec = (await import(importPath)).default;
    loadedSpecs[rootToken.token] = spec;
    return spec;
  }
};
const spec = (await import(importPath)).default;

这里的意思是,当我用到 git 命令时,才加载 git 相关的建议代码,当我用到 cd 代码是,才导入 cd 相关的建议。这个lazy load 是个非常棒的设计。

javascript 是一个动态语言,支持动态 import ,但 dart 不行,当前就只能全部import 了。

我就又让 AI 写了个加载所有 spec的 dart 生成代码——generate-all-specs.cjs

./dart/lib/spec/all_specs.dart

import '../src/registry.dart';
import '-.dart';
import '@capgo/cli.dart';
import 'act.dart';
import 'adb.dart';
import 'adr.dart';
import 'afplay.dart';

...

/// Register every spec. Called by [registerBuiltinSpecs] in autocomplete.dart.
void registerAllSpecs() {
  registerSpec(specSpec.name, () => specSpec);
  registerSpec(cliSpec.name, () => cliSpec);
  registerSpec(figPublishSpecSpec.name, () => figPublishSpecSpec);
  registerSpec(presetSpec.name, () => presetSpec);
  registerSpec(withfigAutocompleteToolsSpec.name, () => withfigAutocompleteToolsSpec);
  registerSpec(wordpressCreateBlockSpec.name, () => wordpressCreateBlockSpec);
  registerSpec(actSpec.name, () => actSpec);

...

好,完成了周边的命令解析代码,spec 驱动代码,写个example 试试—— dart/example/run_suggest.dart

void main(List<String> args) async {
  String? cmd;
  var shell = Shell.bash;

...

  registerBuiltinSpecs();
  final cwd = Directory.current.path;

  final blob = await getSuggestions(cmd, cwd, shell);
  if (blob == null) {
    print('(no spec or no suggestions)');
    exit(0);
  }

  if (blob.argumentDescription != null) {
    print('Argument: ${blob.argumentDescription}');
  }
  for (final s in blob.suggestions) {
    print('${s.name}\t${s.description ?? ""}');
  }
  print(
      '(${blob.suggestions.length} suggestions, charactersToDrop: ${blob.charactersToDrop})');
}
$ dart run ./example/run_suggest.dart "cut "
dart
example
docs
README.md
pubspec.yaml
lib
assets
-b	Byte positions as a comma or - separated list of numbers
-c	Column positions as a comma or - separated list of numbers
-f	Field positions as a comma or - separated list of numbers
-n	Do not split multi-byte characters
-d	Use delim as the field delimiter character instead of the tab character
-s	Suppress lines with no field delimiter characters.  unless specified, lines with no delimiters are passed through unmodified
-w	Use whitespace (spaces and tabs) as the delimiter.  Consecutive spaces and tabs count as one single field separator
(17 suggestions, charactersToDrop: 0)

好,一期工程完毕,完成了 MVP。

二期

当前项目还有这些问题:

  1. 大部分代码处理,都是一些简单易懂的代码,但是一些复杂的,动态代码是没处理的。
  2. 有些代码结构也简单,但它的结构有点变化,只是多了个变量、数组,并且也能找到规律,可以加入到通用转换逻辑。
  3. 需要接入实际的 flutter app 中,看看性能如何,cpu,内存占用如何。

解决方案

我是这样解决的

  1. 一些 ts2dart.cjs 没法转换的、简短的ts代码,直接丢给 AI,转换完了后,加上 complete 标记,避免 cjs 重复转换已处理好的 dart 代码。
  2. 对于一些 Fig.Option , 这些数组,这些代码,结构简单,但是往往有几百行,交给AI容易出错,可以写个ts代码来处理 。
  3. 对于一些常用的命令,自动转换失败的,很大的文件,人工去处理。例如:docker.ts, python.ts, npm.ts, git.ts . 这些命令的使用率很高,同时这些文件也很大,丢给AI,或者ts转dart都不能完全转换为正确的 dart 代码。

我人工review了每个ts文件,把一些简短的ts丢给AI重新生成,并标记 complete。再次 完善 ts2dart.cjs 。

在node中引入了多进程,来加快任务。之前发现了这个执行效率问题,加上了多线程,异步还是没把cpu跑满,想起来js的异步机制,就改为多进程进一步压榨cpu。

提交到仓库后—— littlewrite/autocomplete , 在Faiterm app 引入后使用了下,发现了新的问题,第一次引入时,程序直接卡了一下,然后内存蹦到了500MB。

通过打时间戳分析了一下,卡的那一帧是 all spaces.dart 代码里执行 registry all 导致的,这个函数就是初始化所有 command spec对象,一共几百个对象,注册完内存就上去了。

三期工程

待解决问题:

  1. 一次性注册所有命令内存,cpu 爆炸
  2. 不支持主命令推荐。
  3. 一些常用命令待人工review。

优化

问了下AI,dart 没有动态import,但有 延迟加载 (deferred)。

import 'package:autocomplete/specs/gt.dart' deferred as spec_gt; // 声明但并未实际导入

await spec_gt.loadLibrary(); // 运行时加载与使用

按照上面的技术方案,我调整了 generate-all-specs-v2.cjs .

之前的补全的建议是在明确了命令的情况给出的子命令建议推荐,现在新增命令推荐。原来输入 ”git “ ,”lsof “ 才能建议,现在新增功能,输入 ”g”, “d”, ”c” 时,提供命令推荐。

我先把所有的命令都集合到一个数组里,这样就能实现输入首字母然后推荐命令了。

./dart/lib/specs/all_specs_v2.dart

/// Command names available in this v2 bundle (for command-name completion without loading).
const List<String> v2SpecNames = ['-', '@commercelayer/cli', '@fig/publish-spec', '@forge/cli', '@withfig/autocomplete-tools', '@wordpress/create-block', 'Rscript', 'access-context-manager', 'accessanalyzer', 'account', 'acm-pca', 'acr', 'act', 'active-directory', 'ad', 'adb', ......

然而这个数组很大,通过首字母过滤推荐,我感觉有点慢,并且我似乎也用不到这么大的数组,我又想了个办法,把这些命令按照首字母归类。

// 按照首字母分类的命令映射
final Map<String, List<String>> categorizedCommands = {
  '@': [
    '@commercelayer/cli',
    '@fig/publish-spec', 
  ],
  'a': [
    'access-context-manager',
    'accessanalyzer',
    'account',
  ],
   'R': [
    'Rscript',
  ],

这样我就不用加载整个数组了。

...

等等,好像不太对劲,这不还是一个变量么,这个大Map。

我又优化了一下,改为每个字母为一个单独数组

const List<String> v2SpecNamesFirstChar_at = [
  '@commercelayer/cli',
  '@fig/publish-spec',
...

const List<String> v2SpecNamesFirstChar_a = [
  'act',
  'adb',
  'adr',
...

const List<String> v2SpecNamesFirstChar_b = [
  'babel',
  'banner',
  'barnard59',
...

const List<String> v2SpecNamesFirstChar_d = [
  'dart',
  'date',

四期

之前的我以为我上传成功了,其实并没有上传完整,它有这样的规则,哪怕你通过git add -f 把文件提交到了仓库,但它会参考.gitignore 中的设置,也就是 dart publish 的流水线会忽略这部分文件,这是个要注意的点!我是通过 pub.dev 的静态检查报错信息里面看到的,它报告找不到某某文件(这些文件虽然在git仓库,但命中了 .gitignore 的规则)

调整了 .gitignore 文件后,这些文件能提交了,再上传时又报错文件太大了。

│       ├── suggestion.dart (7 KB)
│       └── template.dart (2 KB)
└── pubspec.yaml (<1 KB)

Total compressed archive size: 7 MB.
Validating package... (43.5s)
...

Policy details are available at https://pub.dev/policy


Package has 2 warnings and 1 hint.. Do you want to publish autocomplete 0.1.2-dev to https://pub.dev (y/N)? y
Uploading... (9.5s)
Failed to scan tar archive. (Maximum total length reached: 104857600.)

这里展示的信息表示,上传到 pub.dev 过程中,项目压缩后右 7MB,但在服务器端解压后超过 100MB了,不符合 pub.dev 的规则,建议我这么多代码,拆成多个包发布。

再检查了下 dart 项目,有 104MB,算了,找几个不那么常用的命令删减一下吧,🤣

% du -hd 1 | sort -rh
104M	.
 44M	./aws
 25M	./az
 20M	./gcloud
436K	./heroku
328K	./fig
 96K	./shopify
 76K	./infracost
 60K	./dotnet
 44K	./task
 36K	./deno
 12K	./php
 12K	./example
 12K	./@capgo
8.0K	./@withfig
8.0K	./@usermn
4.0K	./python
4.0K	./@wordpress

这三大云的命令,都有很大啊,删一个云是删,那就三个一起删了。

这样就能上传到 pub.dev 了。

第五期

这一期改动都是几周之后了,这个项目完成后,我后续有空也看了看源码,发现当前是函数式的,中间发现解析spec是个一个深度检索,也就是你选择了git 命令后续的检索都是在这个git spec 上进行检索,也就是可以缓存可以避免多次解析 git spec 对象。

例如:你输入命令时,就是这样的 gitgit bgit br ,这会触发三次命令推荐函数,当第一次输入git 时,后续的推荐命令,都是在 git spec 内找,后续就可以服用 git spec 对象,避免了多次检索,如果是函数式,就需要一个全局缓存对象。

按照 dart/flutter 的设计思想,可以改为对象,这个缓存变量作为一个对象属性,和view一样有自己的生命周期管理,dispose 就回收资源。

这样之后,那个全局缓存对象就没有无限增长了,引入这个 autocomplete 后,app 内存,cpu 也没有突刺了。

结束

好的,autocomplete for dart 就算完成了,实装效果,大家下载 Faiterm.app 体验看看,作为这个终端的自动提示,使用起来还是非常棒的, 跨平台开发,Windows,Mac,Linux 的用户都可以使用。

回顾与经验分享

  1. 工具是手段,而非银弹:面对 200 万行代码,试图寻找一个全自动的转换工具一劳永逸是不现实的。正确的方法是快速验证工具的边界,然后立即转向能解决 80% 问题的、更定制化的方案。
  2. 让 AI 做它擅长的事:在这次迁移中,AI 的角色不是“翻译整个项目”,而是“撰写转换规则引擎”和“处理零散的、模式不统一的代码片段”。这极大地降低了成本并提高了准确性。
  3. 性能问题是设计出来的:初期 500MB 的内存占用是一个强烈的设计信号。它迫使我们将架构从“一次性全量加载”重新思考为“按需加载 + 智能缓存”,这反而是打磨出更健壮、更贴合 Dart/Flutter 生态设计模式(如对象生命周期)的契机。
  4. 发布即交付,细节定成败.gitignore规则和 pub.dev 的包大小限制,这两个看似微小的“发布流水线”细节,最终影响了交付物的内容。工程化项目必须尽早并频繁地走通整个构建、打包、发布流程