🦀 用五天时间把一个百星项目用 Rust 重构,性能真的提升了 25 倍!

1 前言

先说说背景。

我们公司有几个迭代了非常久的前端项目,项目体积非常庞大。最大的前端项目单次构建即需要 5 分钟,在 lint 环境进行全局 lint 也需要 5 分钟不止。

而在 lint 环境中,其中一个性能瓶颈来源是我们的团队规范中要求的「无循环依赖」(关于循环依赖的定义可以看到这篇文章),对此我们选择的检测工具是 dpdm,单次检测用时平均值是 76.67s。

诶,恰好的是,我在调研了社区之后,居然意味地发现类似的工具都尚未 rust 化!那我这不是正好!

所以本篇文章出现了,也如你所见,我不是标题党,速度真的提升了 25 倍!

如果你的项目对循环依赖检测有需求,欢迎换上dpdm-fast,享受超快的 Lint 体验

link_preview

2 要做什么

2.1 先干掉核心逻辑

将一个完整的项目 rust 化是非常艰难的,尤其是这个项目的作者并不是你,但是如果我们先跑通核心逻辑,再补充其他逻辑,那么整个过程就会简单许多。

那么来看到原始项目,易得入口文件 dpdm.ts

再看到 main 函数,可以找到核心逻辑是 parseDependencyTree 。再阅读后边的代码,后边的代码本质上都是在 tree 上读取数据进行数据分析,也就是说我们只要能保证我们的 rust 程序能生成出 99.99% 相似的 tree,那么就可以完成核心逻辑。

为了方便借助现有的 demo,我们直接在原项目的基础上进行修改。不过在正式开始核心逻辑之前,我们可以先把类型和常量转换过来。

这个过程中,借助 AI 的力量,甚至不需要我们自己思考。

于是,我们得到了一份基础类型和常量定义。

这个时候我们再看到核心函数------当然可以先屏蔽掉部分需要 options 的部分。

那么其实可以看见,核心函数这里做的事情就是读取入口文件------进行 parseTreeRecursive ------返回。

我们可以完成下边这样的框架。

rust 复制代码
pub async fn parse_dependency_tree(
    entries: &Vec<String>,
    base_options: &ParseOptions,
) -> DependencyTree {
    let options: ParseOptions = (*base_options).clone();

    let current_directory = fs::canonicalize(PathBuf::from(".")).unwrap();
    let root = current_directory.clone();

    let cm = Lrc::new(SourceMap::default());
    let output: Arc<Mutex<DependencyTree>> = Arc::new(Mutex::new(HashMap::new()));

    // 获取文件列表
    let mut tasks = vec![];
    for entry in entries {
        for entry_path in glob(&entry).expect("Failed to read glob pattern") {
            match entry_path {
                Ok(filename) => {
                    let path: PathBuf = current_directory.join(filename);
                    let output_clone = Arc::clone(&output);
                    let task = parse_tree_recursive(
                        current_directory.clone(),
                        path,
                        output_clone,
                        Arc::new(cm.clone()),
                        Arc::new(options.clone()),
                    );
                    tasks.push(task);
                }
                Err(e) => eprintln!("{:?}", e),
            }
        }
    }

    futures::future::join_all(tasks).await;

    let output_lock = output.lock().unwrap();
    shorten_tree(
        &current_directory.to_string_lossy().to_string(),
        &output_lock,
    )
}

2.1.1 Arc & Mutex 的应用

我来解释一下这份代码。关于用到了哪些库,实现了哪些能力。

rust 复制代码
let current_directory = fs::canonicalize(PathBuf::from(".")).unwrap();
let root = current_directory.clone();

第一部分,使用 fs::canonicalize 函数获取当前目录的规范路径,并将结果存储在 current_directory 变量中。这一步骤是对应 node 中的process.pwd()

再继续往下就有点难懂了。这一部分创建一个新的 Lrc 实例 cm,使用 SourceMap 进行初始化,而 output 则是我们要生成的 tree:

rust 复制代码
let cm = Lrc::new(SourceMap::default());
let output: Arc<Mutex<DependencyTree>> = Arc::new(Mutex::new(HashMap::new()));

我们用到了三个东西:LrcArc 以及 Mutex,其中 LrcSourceMap 来自于我们要使用的 swc_common 库,这一部分后边会讲到。

rust 复制代码
use std::sync::{Arc, Mutex};
use swc_common::{sync::Lrc, SourceMap};

主要要说的是 ArcMutex,对比 TS 的单线程,Rust 是完全可以多线程运行的,所以当我们声明一些偏向于共用的变量时,需要确保在各个线程间的访问和操作是原子性的。

这种时候,当我们需要读写一个变量时------比如我们的 HashMap,由于在后续的函数中可能存在多线程的操作,所以我们需要用 Mutex 作为互斥锁,用于确保在同一时间只有一个线程可以访问被保护的数据。

诶这个时候就有问题了,众所周知,Rust 用所有权来标识变量的生命周期,多线程环境下我们的变量所有权不是乱糟糟的?

这个时候就需要看到 ArcArc 是原子引用计数类型,用于在多线程环境下安全地共享所有权。它通过原子性的引用计数来管理数据的生命周期。每当一个新的线程获取对数据的引用时,引用计数增加;当一个线程的引用被释放时,引用计数减少。只有当引用计数为零时,数据才会被真正释放

代码再往下走的话,会来到遍历这一部分,我们调用了 glob 的三方包,并同时将 output 克隆了一份传递到了 parse_tree_recursive 中 ------ 但是别担心,这一部分并不会有什么问题,因为我们仍然是引用类型,并非 clone 了 output 整个列表。

rust 复制代码
    let mut tasks = vec![];
    for entry in entries {
        for entry_path in glob(&entry).expect("Failed to read glob pattern") {
            match entry_path {
                Ok(filename) => {
                    let path: PathBuf = current_directory.join(filename);
                    let output_clone = Arc::clone(&output);
                    let task = parse_tree_recursive(
                        current_directory.clone(),
                        path,
                        output_clone,
                        Arc::new(cm.clone()),
                        Arc::new(options.clone()),
                    );
                    tasks.push(task);
                }
                Err(e) => eprintln!("{:?}", e),
            }
        }
    }

在这份代码中,我们虽然对 output 进行了 clone,但是是基于 Arc::clone 进行的,不会影响到原对象,可以放心使用。

而到后边我们再使用的时候,比如我们要操作 output 了,此时 output 的类型为 Arc<Mutex<DependencyTree>> ,我们基于锁来先锁定所有权,再进行操作:

rust 复制代码
    let id: String = match id {
        Some(id) => {
            let output_lock = output.lock().unwrap();
            if output_lock.contains_key(&id) {
                return Some(id);
            }
            id
        }
        None => {
            return None;
        }
    };
rust 复制代码
    // 检查缓存
    {
        let cache = CACHE.lock().unwrap();
        if let Some(cached_result) = cache.get(&id) {
            let mut output_lock = output.lock().unwrap();
            output_lock.insert(id.clone(), Arc::clone(cached_result));
            return Some(id.clone());
        }
    }

2.1.2 Promise.all → future::join_all

接着,我们用到了 futures 库,用到了其中的 join_all 函数,非常类似于 Promise.all,由此等待所有异步任务完成。

rust 复制代码
futures::future::join_all(tasks).await;

但是需要注意的是在一些重要的细节上两者并不一致:

  • 从直观的代码行为上:
    • Node.js (Promise.all) : 用于并发地等待一组 Promise 全部完成,返回一个包含所有结果的数组。如果任意一个 Promise 被拒绝(rejected),Promise.all 会立即返回被拒绝的 Promise,并停止其他任务的处理。
    • Rust (future::join_all) : 是一个类似功能的异步工具,用于并发执行多个 Future,并返回所有 Future 的结果。但 Rust 的 Future 是惰性计算的(lazy),并且如果其中一个 Future 失败了,join_all 不会自动停止其他任务的执行。
  • 从并发特性上:
    • Node.js : Promise.all 是非阻塞的,基于事件循环,它会并行等待所有异步任务完成。
    • Rust : future::join_all 也是并发地执行多个 Future,但需要在运行时(如 tokioasync-std)中执行。它不会像 JavaScript 那样基于单线程事件循环,而是可以利用多线程环境,提升并发性能。

最后,我们再将目前的 output 解锁,通过 shorten_tree 函数精简内容,并返回。

rust 复制代码
let output_lock = output.lock().unwrap();
shorten_tree(
    &current_directory.to_string_lossy().to_string(),
    &output_lock,
)

💡 我们在 Rust 会经常看到 unwrap,翻译过来就是「打开...的包装」。那么什么是包装?在 Rust 中,我们会看到 Option 类似的📦,其表示一个值可能存在(Some(T))或者不存在(None)。而对 Option 使用 unwrap 会尝试返回 T,但是并不代表 Option 就是 T,如果 Option 是 None,就会导致 panic。

2.2 深入到依赖的递归处理

先看到 ts 版本的源码,其主要功能是递归地解析给定上下文中的代码文件,识别出不同类型的模块依赖关系,并构建一个依赖树结构。

rust 复制代码
async function parseTreeRecursive(
  context: string,
  request: string,
  options: ParseOptions,
  output: DependencyTree,
  resolve: Resolver,
): Promise<string | null> {
  const id = await resolve(context, request, options.extensions);
  if (!id || output[id]) {
    return id;
  }
  if (!options.include.test(id) || options.exclude.test(id)) {
    output[id] = null;
    return id;
  }
  if (options.js.indexOf(path.extname(id)) === -1) {
    output[id] = [];
    return id;
  }
  options.onProgress('start', id);
  const dependencies: Dependency[] = (output[id] = []);
  const jobs: Promise<string | null>[] = [];
  const newContext = path.dirname(id);

  function nodeVisitor(node: ts.Node) {
    let newRequest: string;
    let kind: DependencyKind;
    if (ts.isImportDeclaration(node)) {
      newRequest = (node.moduleSpecifier as ts.StringLiteral).text;
      kind = DependencyKind.StaticImport;
    } else if (
      ts.isCallExpression(node) &&
      node.expression.kind === ts.SyntaxKind.ImportKeyword &&
      node.arguments.length === 1 &&
      ts.isStringLiteral(node.arguments[0]) &&
      !options.skipDynamicImports
    ) {
      newRequest = (node.arguments[0] as ts.StringLiteral).text;
      kind = DependencyKind.DynamicImport;
    } else if (
      ts.isCallExpression(node) &&
      ts.isIdentifier(node.expression) &&
      node.expression.escapedText === 'require' &&
      node.arguments.length === 1 &&
      ts.isStringLiteral(node.arguments[0])
    ) {
      newRequest = (node.arguments[0] as ts.StringLiteral).text;
      kind = DependencyKind.CommonJS;
    } else if (
      ts.isExportDeclaration(node) &&
      node.moduleSpecifier &&
      ts.isStringLiteral(node.moduleSpecifier)
    ) {
      newRequest = (node.moduleSpecifier as ts.StringLiteral).text;
      kind = DependencyKind.StaticExport;
    } else {
      ts.forEachChild(node, nodeVisitor);
      return;
    }
    dependencies.push({
      issuer: id!,
      request: newRequest,
      kind: kind,
      id: null,
    });
    jobs.push(
      parseTreeRecursive(newContext, newRequest, options, output, resolve),
    );
  }

  const code = await fs.readFile(id, 'utf8');
  const ext = path.extname(id);
  let source: ts.SourceFile | undefined;
  if (
    options.transform &&
    (ext === ts.Extension.Ts || ext === ts.Extension.Tsx)
  ) {
    ts.transpileModule(code, {
      compilerOptions: typescriptTransformOptions,
      transformers: {
        after: [() => (node) => (source = node)],
      },
    });
  } else {
    source = ts.createSourceFile(
      id,
      code,
      ts.ScriptTarget.Latest,
      true,
      ts.ScriptKind.TSX,
    );
  }
  ts.forEachChild(source!, nodeVisitor);
  options.onProgress('end', id);
  return Promise.all(jobs).then((deps) => {
    deps.forEach((id, index) => (dependencies[index].id = id));
    return id;
  });
}

我们从参数开始说:

  • context:表示当前文件的路径上下文,用于解析相对路径。
  • request:特定的请求字符串,可能是模块的名称或路径。
  • options:一个包含各种解析选项的对象,如includeexclude测试函数用于筛选要包含或排除的模块,onProgress用于报告解析进度等。
  • output:一个用于存储依赖树的对象。
  • resolve:一个解析函数,用于确定模块的唯一标识符。

这里会存在第一个问题,在 Js 中,函数是一等公民,想怎么传递就怎么传递,但是在 Rust 中不行。

实现动态函数传递...在这第一个版本中就完成太抽象了,所以我们的函数定义应该是:

rust 复制代码
pub async fn parse_tree_recursive(
    context: PathBuf,
    path: PathBuf,
    output: Arc<Mutex<DependencyTree>>,
    cm: Arc<Lrc<SourceMap>>,
    options: Arc<ParseOptions>,
    alias: Option<Arc<Alias>>,
) -> Option<String>

这几个参数,分别代表:

  • context:对应原本的 context,不同的是用 PathBuf 存储。
  • path:原本的 request,可能是模块的名称或路径。
  • output: 通过 Arc & Mutex 来存储依赖树
  • cm:cmSourceMap 的一个实例。SourceMapswc_common 库中的一个结构,用于管理源代码的映射关系。它的主要作用是帮助解析和生成代码时,保持源代码位置的正确性,特别是在进行代码转换和生成时,能够追踪原始代码的位置。
  • options:一些选项
  • alias:从一个已知者的角度,alias 的设计源于源文件中的 tsconfig paths 的作用。在原本的代码中,dpdm 会加载 tsconfig 来加载 ts 文件,此时会用到 tsconfig.json 中的 paths 属性来进行别名的识别。由于现有 rust 基建没有覆盖到这里,所以我自己完成了一版解析。

alias 结构体相关的代码,可以看到下边这里。这也是我们和原始的部分不一样的地方。

rust 复制代码
#[derive(Debug, Clone)]
pub struct Alias {
    pub root: PathBuf,
    pub paths: HashMap<String, Vec<String>>,
}

2.2.1 require.resolve 特性缺失与 path.join 表现不符预期

这就衍生出第一部分了,我们往下去走到 resolve 函数,也对应原本第一行的位置。

rust 复制代码
pub async fn simple_resolver(
    context: &str,
    request: &str,
    extensions: &Vec<String>,
    alias: Option<&Alias>,
) -> Result<Option<String>, Box<dyn std::error::Error>>

从原始的代码中,我们可以快速知道这一部分的作用是在尝试找到某个文件存在的依赖。其中的基本步骤是:

  1. 如传入的路径为绝对路径,则进入 appendSuffix 函数
  2. 如传入的为相对路径,则进入 appendSuffix 函数
  3. 通过 node 提供的 require.resolve 函数读取 package.json 的 module 或 main 字段以进入 appendSuffix 函数
  4. 上述失败时,直接进入 require.resolve

这里会有两个地方是小 block:

  1. require.resolve 是 node 提供的运行时能力,rust 没有,我们需要寻求三方库或者自己实现。
  2. path.join 的能力是 node 提供的对路径进行处理的能力。在 rust 中虽然有类似的能力,但是 rust 中进行 path.join 时会判断生成的路径是否存在,同时拼接时还会形成类似:../src/./utils 的奇怪路径,我们需要自己实现。

关于这两点,对应的处理方式是:

  1. github.com/goto-bus-st...这个库,完全遵守了 Node.js module resolution algorithm ,可以直接使用。美中不足是,其运行速度大多为同步,且没有缓存机制,运行速度较慢。
  2. 自己实现一个字符串拼接函数,覆盖大部分场景。
rust 复制代码
use std::path::{Component, PathBuf};

pub fn join_paths<P: AsRef<std::path::Path>>(paths: &[P]) -> PathBuf {
    let mut result = PathBuf::new();
    let mut last_had_slash = false;

    for path in paths {
        let path_ref = path.as_ref();

        // 检查路径是否有末尾的斜杠
        if let Some(path_str) = path_ref.to_str() {
            last_had_slash = path_str.ends_with('/');
        }

        // 处理绝对路径,重置结果
        if path_ref.is_absolute() {
            result = PathBuf::new();
        }

        result.push(path_ref);
    }

    // 规范化路径,处理 `.` 和 `..` 并生成正确的路径
    let mut normalized = PathBuf::new();
    for component in result.components() {
        match component {
            Component::ParentDir => {
                if normalized.file_name().is_some() {
                    normalized.pop();
                }
            }
            Component::CurDir => {} // 忽略当前目录 `.`
            _ => normalized.push(component),
        }
    }

    // 如果最后有斜杠,需要手动添加
    if last_had_slash && !normalized.ends_with("/") {
        normalized.push(""); // 保留末尾的斜杠
    }

    normalized
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_basic_path_join() {
        let paths = ["/users", "john", "documents"];
        let full_path = join_paths(&paths);
        assert_eq!(full_path, PathBuf::from("/users/john/documents"));
    }

    #[test]
    fn test_relative_path() {
        let relative_paths = ["/users/john", "../documents"];
        let relative_path = join_paths(&relative_paths);
        assert_eq!(relative_path, PathBuf::from("/users/documents"));
    }

    #[test]
    fn test_single_dot_is_ignored() {
        let paths = ["/users/john", ".", "documents"];
        let full_path = join_paths(&paths);
        assert_eq!(full_path, PathBuf::from("/users/john/documents"));
    }

    #[test]
    fn test_empty_paths() {
        let paths: [&str; 0] = [];
        let full_path = join_paths(&paths);
        assert_eq!(full_path, PathBuf::from(""));
    }

    #[test]
    fn test_double_dots_resolved() {
        let paths = ["/users/john", "../..", "documents"];
        let full_path = join_paths(&paths);
        assert_eq!(full_path, PathBuf::from("/documents"));
    }
}

2.2.2 Rust 递归函数必备的 Box

接下来,再看到 append_suffix 函数,这份逻辑看似简单,但是对于 Rust 新手来说,要在 Rust 中完成必会踩坑:递归需要用 Box 包裹

typescript 复制代码
export async function appendSuffix(
  request: string,
  extensions: string[],
): Promise<string | null> {
  for (const ext of extensions) {
    try {
      const stat = await fs.stat(request + ext);
      if (stat.isFile()) {
        return request + ext;
      }
    } catch {}
  }
  try {
    const stat = await fs.stat(request);
    if (stat.isDirectory()) {
      return appendSuffix(path.join(request, 'index'), extensions);
    }
  } catch {}
  return null;
}

整理成 Rust 的话,这份代码如下。其中可以看到,我们用到了两个新特性,一个是 move,另一个则是 Box::pin

rust 复制代码
pub async fn append_suffix(
    request: &str,
    extensions: &[String],
) -> Result<Option<String>, Box<dyn std::error::Error>> {
    // 并行处理扩展名检查
    let futures = extensions.iter().map(|ext| {
        let path_with_ext = format!("{}{}", request, ext);
        async move {
            match metadata(&path_with_ext).await {
                Ok(metadata) => {
                    if metadata.is_file() {
                        return Some(path_with_ext);
                    }
                }
                Err(_) => {}
            }
            None
        }
    });
    let results = join_all(futures).await;
    for result in results {
        if let Some(path) = result {
            return Ok(Some(path));
        }
    }

    // 如果 request 是一个目录,则尝试添加 index 后缀,递归调用
    match metadata(request).await {
        Ok(metadata) => {
            if metadata.is_dir() {
                return append_suffix_boxed(&format!("{}/index", request), extensions).await;
            }
        }
        Err(_) => {}
    }

    Ok(None)
}

async fn append_suffix_boxed(
    request: &str,
    extensions: &[String],
) -> Result<Option<String>, Box<dyn std::error::Error>> {
    // 使用 Box::pin 来处理递归调用
    Box::pin(append_suffix(request, extensions)).await
}

第一部分并行处理是我们的优化,不再使用 for...await 的方式,而是通过 future::join_all 异步处理。在其中用到了 move 和闭包,这一部分后边讲。(如果不优化成 join_all 则不需要用到 move 和闭包)。

要讲的主要是 Box::pin 。我们知道,递归消耗的是栈上的空间,当递归过深时,栈空间可能被耗尽。而 Box 就是 Rust 提供的解决方案,Box 是一个智能指针类,用于在堆上分配内存。当在递归函数中使用Box时,可以将部分数据从栈上移动到堆上。

那么现在,我们完成了 resolve 函数,我们回到 parseTreeRecursive 函数。

2.2.3 用 swc 解析 TS/JS 代码

在上边,我们完成了函数定义,接下来我们来补充。

rust 复制代码
pub async fn parse_tree_recursive(
    context: PathBuf,
    path: PathBuf,
    output: Arc<Mutex<DependencyTree>>,
    cm: Arc<Lrc<SourceMap>>,
    options: Arc<ParseOptions>,
    alias: Option<Arc<Alias>>,
) -> Option<String>

在 TS 代码中,第一部分相对比较简单,我们可以跳过来看:

现在我们需要来进行 parser 操作,由于我们对 swc parser 的使用并不熟悉,最好的办法是看一下开源库们是怎么处理的,比如最近比较火的 rsbuild。

由此我们可以完成代码如下:

rust 复制代码
    let fm: Lrc<swc_common::SourceFile> =
        cm.new_source_file(FileName::Real(id_path.clone()).into(), file_content);
    let lexer = swc_ecma_parser::lexer::Lexer::new(
        Syntax::Typescript(TsSyntax {
            tsx: true,
            decorators: false,
            ..Default::default()
        }),
        EsVersion::EsNext,
        StringInput::from(&*fm),
        None,
    );

正常来说,我们可以进行 AST 节点访问了,但是由于原始代码中通过 transpileModule 特殊处理了 ts 文件,我们也需要模拟它的逻辑。

transpileModule 的作用在这里是将 TypeScript 中关于 type 的类型依赖忽略,那么我们在使用 swc 时,则可以通过 strip 函数来进行修剪。

rust 复制代码
    let program = match options.transform {
        true => match id.ends_with(".tsx") || id.ends_with(".ts") {
            true => {
                let program = GLOBALS.set(&Globals::new(), || {
                    let unresolved_mark = Mark::new();
                    let top_level_mark = Mark::new();

                    let program =
                        program.fold_with(&mut resolver(unresolved_mark, top_level_mark, true));
                    let program = program.fold_with(&mut strip(top_level_mark, unresolved_mark));
                    program
                });
                program
            }
            false => program,
        },
        false => program,
    };

接下来,我们需要进行 Visit 的过程,根据 SWC 提供的 API,我们只要实现它的 Visit 特质就行。在代码中,我们对不同类型的节点进行访问,进行依赖收集,这一部分我们只要能 cover 住所有的类型就行,反而是最简单的。

rust 复制代码
use super::consts::DependencyKind;
use super::types::Dependency;
use std::path::PathBuf;
use swc_ecma_ast::Callee;
use swc_ecma_visit::{Visit, VisitWith};

pub struct DependencyCollector {
    pub path: PathBuf,
    pub dependencies: Vec<Dependency>,
    pub id: String,
}

impl Visit for DependencyCollector {
    fn visit_import_decl(&mut self, import: &swc_ecma_ast::ImportDecl) {
        // 处理静态导入
        let request = import.src.value.to_string();
        let dependency = Dependency {
            issuer: self.path.to_string_lossy().to_string(),
            request,
            kind: DependencyKind::StaticImport,
            id: Some(self.id.clone()),
        };
        self.dependencies.push(dependency);
    }

    fn visit_call_expr(&mut self, expr: &swc_ecma_ast::CallExpr) {
        if let Callee::Import(_) = &expr.callee {
            if let Some(arg) = expr.args.get(0) {
                if let swc_ecma_ast::Expr::Lit(swc_ecma_ast::Lit::Str(ref s)) = *arg.expr {
                    let request = s.value.to_string();
                    let dependency = Dependency {
                        issuer: self.path.to_string_lossy().to_string(),
                        request,
                        kind: DependencyKind::DynamicImport,
                        id: Some(self.id.clone()),
                    };
                    self.dependencies.push(dependency);
                }
            }
        }

        if let swc_ecma_ast::Callee::Expr(ref callee_expr) = expr.callee {
            // 处理 CommonJS 导入
            if let swc_ecma_ast::Expr::Ident(ref ident) = &**callee_expr {
                if ident.sym == *"require" {
                    if let Some(arg) = expr.args.get(0) {
                        if let swc_ecma_ast::Expr::Lit(swc_ecma_ast::Lit::Str(ref s)) = *arg.expr {
                            let request = s.value.to_string();
                            let dependency = Dependency {
                                issuer: self.path.to_string_lossy().to_string(),
                                request,
                                kind: DependencyKind::CommonJS,
                                id: Some(self.id.clone()),
                            };
                            self.dependencies.push(dependency);
                        }
                    }
                }
            }
        }
        expr.visit_children_with(self);
    }

    fn visit_export_named_specifier(&mut self, export: &swc_ecma_ast::ExportNamedSpecifier) {
        // 处理静态导出
        if let Some(src) = &export.exported {
            let request = match src {
                swc_ecma_ast::ModuleExportName::Ident(ident) => ident.sym.to_string(),
                swc_ecma_ast::ModuleExportName::Str(s) => s.value.to_string(),
            };

            let dependency = Dependency {
                issuer: self.path.to_string_lossy().to_string(),
                request,
                kind: DependencyKind::StaticExport,
                id: Some(self.id.clone()),
            };
            self.dependencies.push(dependency);
        }
    }

    fn visit_export_all(&mut self, node: &swc_ecma_ast::ExportAll) {
        let request = node.src.value.to_string();
        let dependency = Dependency {
            issuer: self.path.to_string_lossy().to_string(),
            request,
            kind: DependencyKind::StaticExport,
            id: Some(self.id.clone()),
        };
        self.dependencies.push(dependency);
    }
}

2.2.4 用 move 来执行疑似多线程的函数

继续接下来的部分,完成递归,我们的 parse_tree_recursive 函数就完成了。

rust 复制代码
   	program.visit_with(&mut collector);

    let mut deps: Vec<_> = Vec::new();
    for dep in &collector.dependencies {
        let path: PathBuf = PathBuf::from(dep.request.clone());
        let new_context: PathBuf = new_context.clone();
        let output_clone = Arc::clone(&output);
        let cm_clone = Arc::clone(&cm);
        let options_clone = Arc::clone(&options);
        let alias_clone = alias.clone();
        let dep_future = async move {
            Box::pin(parse_tree_recursive(
                new_context,
                path,
                output_clone,
                cm_clone,
                options_clone,
                alias_clone,
            ))
        };
        deps.push(dep_future);
    }

    let results = futures::future::join_all(deps).await;
    for (i, dep) in results.into_iter().enumerate() {
        collector.dependencies[i].id = dep.await;
    }
    
    Some(collector.id.clone())

这里边我们主要来看到 for 循环内部的内容,在这里我们进行了非常多的克隆工作,然后使用 move 关键字以及 Box::pin 递归调用了 parse_tree_recursive 函数。

由于我们期望在 join_all 中使用,这对 Rust 来说意味着函数可能在多个线程之间执行,所以为了确保多个线程间运行的结果符合预期,需要对将要递归的函数入参进行 clone 处理,如果不能 clone ,则需要用 ArcMutex 来保证原子性。

而在这其中,move 关键字的作用就是将克隆好的、要进行递归的变量的所有权显式地移动到函数中

那么到了这里,parse 的部分我们就完成了,我们已经成功收集了整个依赖树,剩下的环节则是对依赖树进行分析和进行输出。

在经历了上边的洗礼之后,我们再写起来也不是很麻烦了。这是最后的一个核心部分,分析存在循环依赖的代码:

rust 复制代码
pub fn parse_circular(tree: &mut DependencyTree, skip_dynamic_imports: bool) -> Vec<Vec<String>> {
    let mut circulars: Vec<Vec<String>> = Vec::new();

    fn visit(
        id: String,
        mut used: Vec<String>,
        tree: &mut DependencyTree,
        skip_dynamic_imports: bool,
        circulars: &mut Vec<Vec<String>>,
    ) {
        if let Some(index) = used.iter().position(|x| x == &id) {
            circulars.push(used[index..].to_vec());
        } else if let Some(deps) = tree.remove(&id) {
            used.push(id.clone());

            if let Some(deps) = deps.as_ref() {
                for dep in deps {
                    if !skip_dynamic_imports || dep.kind != DependencyKind::DynamicImport {
                        if let Some(id) = dep.id.as_deref() {
                            visit(id.to_string(), used.clone(), tree, skip_dynamic_imports, circulars);
                        }
                    }
                }
            }
        }
    }

    for id in tree.clone().keys() {
        visit(
            id.clone(),
            Vec::new(),
            tree,
            skip_dynamic_imports,
            &mut circulars,
        );
    }

    circulars
}

其中 visit 函数的逻辑是:

  1. 检查当前节点 ID 是否已经在 used 中出现过。如果出现过,说明找到了一个循环依赖,将该循环路径添加到 circulars 中。
  2. 如果没有出现过,尝试从 tree 中移除该节点的依赖列表。
  3. 将当前节点 ID 添加到 used 中。
  4. 遍历该节点的所有依赖:
    • 如果 skip_dynamic_imports 为 false 或者依赖的类型不是 DynamicImport,则递归调用 visit 函数继续检查依赖。

最终达成的效果就是,通过递归遍历每个节点的依赖关系,记录已经访问过的节点路径,一旦发现重复访问的节点,就记录下该循环路径。

3 优化开始

上边的实现,可以对应到 github.com/GrinZero/dp...,在小型项目或许看不出来,但是在我们公司的大型项目上,当我第一次运行时,发现带来的提升只有 0.5-7 倍左右。

这太不能让人接受了,我们需要优化!

💡 反思:因为此时的我已经是 10.09 的我了,在我又一次将同步改异步之后,发现速度进一步提升了......怎么说呢,后边出一期更完整地讲解 Rust async 与 JS async 之间的差别。

接下来,我会回退部分异步化的操作。

优化的话,磨刀不误砍柴工,我们需要一份火焰图工具,方便我们分析。我这里使用的是 Samply,非常简单好用的工具。

只要在我们的二进制工具运行前添加一行 samply record,就会自动生成性能报告,并且启动一个 Web 网页展示。

bash 复制代码
samply record ./target/debug/dpdm xxx

可以看到,主要花费的时间大多在 node_resolve `match_alias_pattern \swc_parser` 上,接下来,我们对前两者进行优化,最后者会在未来切换到 Oxc。

我们来看到未优化前的代码,分别进行优化。

3.1 正则瓶颈

match_alias_pattern 是我们自己编写的,用于兼容 tsconfig 别名能力的函数,其主要代码如下:

rust 复制代码
use lazy_static::lazy_static;
use regex::Regex;
use std::collections::HashMap;
use std::sync::Mutex;

use super::path::join_paths;

lazy_static! {
    static ref CACHE: Mutex<HashMap<String, Option<String>>> = Mutex::new(HashMap::new());
}

pub fn match_alias_pattern(source: &str, root: &str, alias: &str, path: &str) -> Option<String> {
    let cache_key = format!("{}|{}|{}|{}", source, root, alias, path);

    if let Some(cached_result) = CACHE.lock().unwrap().get(&cache_key) {
        return cached_result.clone();
    }

    // Step 1: 创建匹配别名的正则表达式,将别名中的 `*` 替换为正则表达式的 `.*`
    let alias_regex_str = regex::escape(alias).replace(r"\*", r"(.*)");
    let alias_regex = Regex::new(&format!("^{}$", alias_regex_str)).ok()?;

    // Step 2: 检查 source 是否匹配别名模式
    if let Some(captures) = alias_regex.captures(source) {
        // 如果匹配,获取通配符匹配到的部分
        let wildcard_part = captures.get(1).map_or("", |m| m.as_str());

        // Step 3: 将路径中的 `*` 替换为通配符部分
        let transformed_path = path.replace('*', wildcard_part);

        // Step 4: 使用 `join_paths` 将 root 和 transformed_path 组合成完整路径
        let full_path = join_paths(&[root, &transformed_path]);

        return Some(full_path.to_string_lossy().to_string());
    }

    None
}

而根据火焰图的指引,似乎其中问题最大的是 newcaptures ,非常让人惊讶的数据,没想到即便只是创建正则表达式对象的开销就非常大了,甚至在进行实际调用时的开销还比不上正则表达式的创建。

思考一下会发现,其实在我们的整个扫描过程中,paths 中正则表达式的创建都是基本固定的。对应的,我们可以将正则对象的对象缓存起来。

同时,我们目前通过 HashMap 来进行 Cache,为了支持多线程又使用了 ArcMutex 来原子化,这里我们直接切换到 DashMap ,替代 HashMap+Mutex 支持多线程操作。

根据这个优化思路,我们形成了下边这份代码:

rust 复制代码
use dashmap::DashMap;
use lazy_static::lazy_static;
use regex::Regex;
use std::sync::Arc;

use super::path::join_paths;

lazy_static! {
    static ref CACHE: Arc<DashMap<String, Option<String>>> = Arc::new(DashMap::new());
    static ref REGEX_CACHE: Arc<DashMap<String, Regex>> = Arc::new(DashMap::new());
}

pub fn match_alias_pattern(source: &str, root: &str, alias: &str, path: &str) -> Option<String> {
    let cache_key = format!("{}|{}|{}|{}", source, root, alias, path);

    if let Some(cached_result) = CACHE.get(&cache_key) {
        return cached_result.clone();
    }

    // Step 1: 创建匹配别名的正则表达式,将别名中的 `*` 替换为正则表达式的 `.*`
    let alias_regex = REGEX_CACHE.entry(alias.to_string()).or_insert_with(|| {
        let alias_regex_str = regex::escape(alias).replace(r"\*", r"(.*)");
        Regex::new(&format!("^{}$", alias_regex_str)).unwrap()
    });

    // Step 2: 检查 source 是否匹配别名模式
    if let Some(captures) = alias_regex.captures(source) {
        // 如果匹配,获取通配符匹配到的部分
        let wildcard_part = captures.get(1).map_or("", |m| m.as_str());

        // Step 3: 将路径中的 `*` 替换为通配符部分
        let transformed_path = path.replace('*', wildcard_part);

        // Step 4: 使用 `join_paths` 将 root 和 transformed_path 组合成完整路径
        let full_path = join_paths(&[root, &transformed_path]);

        let full_path_str = full_path.to_string_lossy().to_string();

        CACHE.insert(cache_key, Some(full_path_str.clone()));

        return Some(full_path_str);
    }
    None
}

此时再进行测试,火焰图中关于 regex 的部分一下子锐减,整体运行速度也从 5s 下降到了 996ms。

再看剩下的部分,就是 node_resolve 的调用了。

3.2 node_resolve 三方库速度不佳

node_resolve 是一个第三方库,由于作者没有太多时间来维护,且库代码不多,我们 copy 一份自己进行优化。

细看 node_resolve 会发现,其中主要存在较长运行时间的是 serde_json 相关的代码,从火焰图中,我们会发现消耗时间较长的其实是 serde_json 的部分。

对应得,我们看到源代码。显然,我们找到了性能瓶颈,这是一个 IO & 计算问题:

  1. 存在 File::open 的动作,存在 IO 瓶颈
  2. serde_json::from_readerjson 序列化,存在性能瓶颈。

这两个问题,我们分别用下边的方式进行优化。

3.2.1 IO 瓶颈

其实我考虑了多种方案,包括了「通过 mmap 进行内存映射」、「异步化 IO 操作」、「缓冲区减少频率」,但是最终考虑下来,只是对计算结果加了缓存。

原因如下:

  1. 「通过 mmap 进行内存映射」:更适合读取大文件和频繁读取同一文件场景,不适用我们频繁读取不同文件。
  2. 「异步化 IO 操作」:其一是实践证明,在 low cpu 上,异步的开销还要更大。其二是,和 JS 一样,Rust 中的 asyncawait 也存在污染性,这样修改成本较高。
  3. 「缓冲区减少频率」:根据实践,效果不佳。

对应的代码和我们在 Regex 中做的缓存方式基本相似。

3.2.2 serde_json 的坑

在之前,我们使用 serde_json::from_reader 来进行序列化,本意是想使得读取更快。但是没想到适得其反。

rust 复制代码
 	let pkg_dir = pkg_path.parent().unwrap_or_else(|| Path::new(ROOT));
        let file = File::open(pkg_path).map_err(Error::IOError)?;
        let reader = std::io::BufReader::new(file);
        let pkg: Value = serde_json::from_reader(reader).map_err(Error::JSONError)?;
        if !pkg.is_object() {
            self.cache.insert(pkg_path.to_path_buf(), None);
            return Err(RecoverableError::NonObjectPackageJson.into());
        }

从注释中可以看见,我们使用流输入效率甚至可能不如直接整个读取文件作为字符串再进行解析......

现在我们修改成 from_str 实现:

rust 复制代码
        let mut file_str = String::new();

        File::open(pkg_path)
            .map_err(Error::IOError)?
            .read_to_string(&mut file_str)
            .map_err(Error::IOError)?;
        let pkg: Value = serde_json::from_str(&file_str).map_err(Error::JSONError)?;

此时再进行一次性能分析。最终可以看见,优化后速度从 996ms 再次降低,现在是 672 ms。

4 性能测试

很好,我们现在终于完成了完成品的 dpdm-fast,在已内部项目接入测试一下。

注:

  1. 以下数据均在 CI 环境测试
  2. BApp、BWeb 为历时较久的项目,体积庞大。CApp 采用的技术栈较新、项目规模更小,故体积更小。
  • 原始数据

那么最终可以整理出优化数据为:

sth. \项目名 BApp BWeb CApp
dpdm 原始版本 46.33s 76.67s 15.3s
dpdm-fast 3.28s 4.23s 0.61s
优化效果 14.125 倍 18.125倍 25.08倍

5 总结

看到这里,希望诸位的脑子里还能记住本篇做了什么......记不住也没关系。在这里进行小结,学习本篇,你已经学习到了:

  • 一些标准库和三方库的使用
    • 怎么使用 swc 库来解析 TypeScript/JavaScripit 代码并访问 AST 节点
    • 怎么使用 Arc 和 Mutex 来确保多线程环境下的安全性
    • 学习 Rust 中的 「Promise.all」⇒ future::join_all
    • 在 Rust 中怎么实现递归,怎么用 Box 保证递归安全
  • 怎么性能优化
    • 了解到性能优化的观测工具 sampy
    • 在 Rust 中常见的性能瓶颈
      • 正则对象的创建
      • IO 瓶颈
      • JSON 序列化的坑
    • 面对性能瓶颈一个非常好用的方案:缓存

最后,如果你对「循环依赖检测」有需求,请一定考虑选择 dpdm-fast 🌟🌟🌟!诶嘿,似乎是社区中最快的?

相关推荐
come112346 分钟前
Vue 响应式数据传递:ref、reactive 与 Provide/Inject 完全指南
前端·javascript·vue.js
musk12121 小时前
electron 打包太大 试试 tauri , tauri 安装打包demo
前端·electron·tauri
国科安芯1 小时前
【AS32系列MCU调试教程】SPI调试的常见问题解析
单片机·嵌入式硬件·性能优化·硬件架构·硬件工程
桦说编程1 小时前
深入解析CompletableFuture源码实现
java·性能优化·源码
万少2 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL2 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl022 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang2 小时前
前端如何实现电子签名
前端·javascript·html5
今天又在摸鱼2 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js
蓝婷儿2 小时前
每天一个前端小知识 Day 21 - 浏览器兼容性与 Polyfill 策略
前端