前言

现代前端对速度的追求已经进入二进制工具时代,Rust 开发成为每个人的必修课。

一般我们将常见的前端 Rust 开发分为以下几类,难度由上至下递增:

  1. 开发 wasm 。

  2. 开发 swc 插件。

  3. 开发代码处理工具。

我们将默认读者具备最简单的 Rust 知识,进行快速入门介绍。

正文

开发 wasm

意义

开发 wasm 的意义在于利用浏览器运行 wasm 的优势,在 wasm 中进行大量复杂的计算、音视频、图像处理等,当你有此类需求,可以优先考虑使用 Rust 开发 wasm 分发至浏览器。

初始化

我们使用 wasm-pack 构建 wasm ,参考 wasm-pack > Quickstart 得到一个模板起始项目。

实战 case

使用 tsify 支持输出结构体的 TypeScript 类型,实现一个简单的加法运算:

# Cargo.toml 确保你含有这些依赖
[dependencies]
serde = { version = "1.0.163", features = ["derive"] }
tsify = "0.4.5"
use wasm_bindgen::prelude::*;
use serde::{Serialize, Deserialize};
use tsify::Tsify;

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Rect {
    pub width: u32,
    pub height: u32
}

#[wasm_bindgen]
pub fn plus(mut rect: Rect) -> Rect {
    rect.width += rect.height;
    rect
}

构建

  # dev
  wasm-pack build --verbose --out-dir pkg --out-name index --dev
  # release
  wasm-pack build --verbose --out-dir pkg --out-name index --release

这将在当前目录的 pkg/* 下生成 wasm 产物与 index.js 等胶水代码,导入 index.js 便即开即用,非常方便。

运行 wasm

为了支持直接导入 .wasm 文件,我们需要 webpack 5 的 asyncWebAssembly 特性支持,此处以在 Umi 4 项目中调试为例,创建一个 Umi 4 Simple 模板项目:

  pnpm create umi wasm-demo

参考 FAQ > 怎么用 WebAssembly 配置开启 wasm 支持:

// .umirc.ts
 
export default {
  chainWebpack(config) {
    config.set('experiments', {
      ...config.get('experiments'),
      asyncWebAssembly: true
    })
 
    const REG = /\.wasm$/
 
    config.module.rule('asset').exclude.add(REG).end();
 
    config.module
      .rule('wasm')
      .test(REG)
      .exclude.add(/node_modules/)
      .end()
      .type('webassembly/async')
      .end()
  },
}

之后便可在项目中直接导入刚刚打包好,在 pkg/* 的 wasm 即开即用:

import * as wasm from './path/to/pkg'
const ret = wasm.plus({
  width: 1,
  height: 2,
})
// { width: 3, height: 2 }
console.log('ret: ', ret);

注:

  1. 由于 wasm 文件可能较大,当你需要优化时,可将使用 .wasm 的组件手动 React.lazy(() => import('./Component')) 拆包,之后在 useEffect 中懒加载 await import('./path/to/pkg')

  2. 对于非 Umi 4 的 webpack 5 项目,请自行开启 experiments.asyncWebAssembly 即可一键支持 wasm 导入。

缺点

由于当下浏览器和 PC 设备性能已足够强大,更多场合下,运行 wasm 进行数据计算,传递数据花费的时间将 远远超出使用 JavaScript 进行同逻辑计算的时间

所以除音视频场景外,你很可能不需要 wasm ,而是优先考虑使用 Worker 等优化策略。

开发 swc 插件

意义

现代前端高效构建往往将 babel 替代为 swc 化,为了替代 babel 插件实现代码转换,开发 swc 插件成为了一门必修课。

初始化

参考 swc > Create a project ,我们用 swc 脚手架初始化得到一个插件的模板起始项目。

实战 case

我们编写一个最简单的功能,将所有的 react 导入转换为 preact

# Cargo.toml 确保你含有这些依赖
[dependencies]
serde = "1.0.163"
serde_json = "1.0.96"
swc_core = { version = "0.76.39", features = ["ecma_plugin_transform"] }
use swc_core::ecma::{
    ast::{Program, ImportDecl, ImportSpecifier},
    visit::{as_folder, FoldWith, VisitMut, VisitMutWith},
};
use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata};
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
pub struct TransformPluginConfig {
    from: String,
    to: String,
}

pub struct TransformVisitor {
    config: TransformPluginConfig,
}

impl VisitMut for TransformVisitor {
    fn visit_mut_import_decl(&mut self, n: &mut ImportDecl) {
        n.visit_mut_children_with(self);

        if n.specifiers.len() == 1 {
            if let ImportSpecifier::Default(_) = n.specifiers[0] {
                if n.src.value == self.config.from {
                    n.src = Box::new(self.config.to.clone().into());
                }
            }
        }
    }
}

#[plugin_transform]
pub fn process_transform(program: Program, metadata: TransformPluginProgramMetadata) -> Program {
    let config = serde_json::from_str(
        &metadata
            .get_transform_plugin_config()
            .unwrap()
    )
    .expect("invalid config");

    program.fold_with(&mut as_folder(TransformVisitor { config }))
}

在编写过程中,以下文档可供参考:

构建

  # dev
  cargo build --target wasm32-wasi
  # release
  cargo build --target wasm32-wasi --release

通过构建,你可以在当前目录下得到 wasm 形式的 swc 插件产物。

运行 swc 插件

import { transformSync } from '@swc/core'

const transform = async () => {
  const { code } = transformSync(
    `
import React from 'react'
  `,
    {
      jsc: {
        experimental: {
          plugins: [
            [
              require.resolve(
                './target/wasm32-wasi/debug/my_first_plugin.wasm'
              ),
              {
                from: 'react',
                to: 'preact',
              },
            ],
          ],
        },
        parser: {
          syntax: 'typescript',
          dynamicImport: true,
          tsx: true,
        },
        target: 'es2015',
        minify: {
          compress: false,
          mangle: false,
        },
        transform: {
          react: {
            runtime: 'automatic',
            throwIfNamespace: true,
            development: true,
            useBuiltins: true,
          },
        },
      },
      module: {
        type: 'es6',
        ignoreDynamic: true,
      },
      minify: false,
      isModule: true,
      sourceMaps: false,
      filename: 'index.tsx',
    }
  )
  // import React from 'preact'
  console.log('code: ', code)
}

transform()

缺点

为了避免多平台差异,我们分发了 wasm32-wasi 目标的 wasm 包,好处是只需构建一次即可全平台通用,缺点是产物较大,同时 wasm 运行速度不如 .node ;但现代前端已无需担心只在本地编译阶段使用的包大小,如 Nextjs 单包依赖已达 40 M 以上,TypeScript 20 M ,你可以无需关心产物体积问题。

至此,我们介绍了如何借助 swc 插件实现 babel 插件的替代,在下文中,我们将继续深入,真正构建多平台分发的二进制包,同时不会做过多细节介绍,推荐只学习到此处为止

开发代码处理工具

意义

目前最主流的前端 Rust 开发即是借助 Swc 来解析 JavaScript 、TypeScript 代码,从而实现代码信息提取、转换、编译等,我们会将 Rust 编译为 Node addon .node 文件,以获得远比 wasm 更快的运行速度。

初始化

使用 napi-rs 构建 ,参考 napi > Create project 得到一个模板起始项目。

实战 case

此处同样我们实现一个:将所有 react 导入转换为 preact 的需求,所需要的依赖与模板代码如下:

# Cargo.toml 确保你含有这些依赖
[dependencies]
napi = { version = "2.12.2", default-features = false, features = ["napi4", "error_anyhow"] }
napi-derive = "2.12.2"
swc_common = { version = "0.31.12", features = ["sourcemap"] }
swc_ecmascript = { version = "0.228.27", features = ["parser", "visit", "codegen"] }
#[macro_use]
extern crate napi_derive;

use std::path::{Path, PathBuf};
use std::str;

use swc_common::comments::SingleThreadedComments;
use swc_common::{sync::Lrc, FileName, Globals, SourceMap};
use swc_ecmascript::ast;
use swc_ecmascript::codegen::text_writer::JsWriter;
use swc_ecmascript::parser::lexer::Lexer;
use swc_ecmascript::parser::{EsConfig, Parser, StringInput, Syntax, TsConfig};
use swc_ecmascript::visit::{VisitMut, VisitMutWith};


#[napi]
pub fn transform(code: String, options: ImportChange) -> String {
  let is_jsx = true;
  let is_typescript = true;
  let filename_path_buf = PathBuf::from("filename.tsx");

  let syntax = if is_typescript {
    Syntax::Typescript(TsConfig {
      tsx: is_jsx,
      decorators: true,
      ..Default::default()
    })
  } else {
    Syntax::Es(EsConfig {
      jsx: is_jsx,
      export_default_from: true,
      ..Default::default()
    })
  };

  let source_map = Lrc::new(SourceMap::default());
  let source_file = source_map.new_source_file(
    FileName::Real(filename_path_buf.clone()),
    code.clone().into(),
  );
  let comments = SingleThreadedComments::default();

  let lexer = Lexer::new(
    syntax,
    Default::default(),
    StringInput::from(&*source_file),
    Some(&comments),
  );

  let mut parser = Parser::new_from(lexer);
  let mut module = parser.parse_module().expect("failed to parse module");

  swc_common::GLOBALS.set(&Globals::new(), || {
    let mut visitor = options;
    module.visit_mut_with(&mut visitor);
  });

  let (code, _map) = emit_source_code(
    Lrc::clone(&source_map),
    Some(comments),
    &module,
    None,
    false,
  )
  .unwrap();

  code
}

#[napi(object)]
pub struct ImportChange {
  pub from: String,
  pub to: String,
}

impl ImportChange {
  pub fn new(from: String, to: String) -> Self {
    Self { from, to }
  }
}

impl VisitMut for ImportChange {
  fn visit_mut_module_decl(&mut self, decl: &mut ast::ModuleDecl) {
    if let ast::ModuleDecl::Import(import_decl) = decl {
      if import_decl.src.value == self.from {
        import_decl.src = Box::new(self.to.clone().into());
      }
    }
  }
}

pub fn emit_source_code(
  source_map: Lrc<SourceMap>,
  comments: Option<SingleThreadedComments>,
  program: &ast::Module,
  root_dir: Option<&Path>,
  source_maps: bool,
) -> Result<(String, Option<String>), napi::Error> {
  let mut src_map_buf = Vec::new();
  let mut buf = Vec::new();
  {
    let writer = Box::new(JsWriter::new(
      Lrc::clone(&source_map),
      "\n",
      &mut buf,
      if source_maps {
        Some(&mut src_map_buf)
      } else {
        None
      },
    ));
    let config = swc_ecmascript::codegen::Config {
      minify: false,
      target: ast::EsVersion::latest(),
      ascii_only: false,
      omit_last_semi: false,
    };
    let mut emitter = swc_ecmascript::codegen::Emitter {
      cfg: config,
      comments: Some(&comments),
      cm: Lrc::clone(&source_map),
      wr: writer,
    };
    emitter.emit_module(program)?;
  }

  let mut map_buf = vec![];
  let emit_source_maps = if source_maps {
    let mut s = source_map.build_source_map(&src_map_buf);
    if let Some(root_dir) = root_dir {
      s.set_source_root(Some(root_dir.to_str().unwrap()));
    }
    s.to_writer(&mut map_buf).is_ok()
  } else {
    false
  };
  if emit_source_maps {
    Ok((
      unsafe { str::from_utf8_unchecked(&buf).to_string() },
      unsafe { Some(str::from_utf8_unchecked(&map_buf).to_string()) },
    ))
  } else {
    Ok((unsafe { str::from_utf8_unchecked(&buf).to_string() }, None))
  }
}

构建

  # dev
  napi build --platform
  # release
  napi build --release

一般情况下,我们通常会分发至以下 9 个平台:

  "napi": {
    "triples": {
      "defaults": false,
      "additional": [
        "x86_64-apple-darwin",
        "aarch64-apple-darwin",
        "x86_64-pc-windows-msvc",
        "aarch64-pc-windows-msvc",
        "x86_64-unknown-linux-gnu",
        "aarch64-unknown-linux-gnu",
        "x86_64-unknown-linux-musl",
        "aarch64-unknown-linux-musl",
        "armv7-unknown-linux-gnueabihf"
      ]
    }
  }

通常本地只能编译自己平台的 .node 二进制文件,所以需要依赖 Github Actions CI 等云环境进行多平台的构建,并且在 CICD 中构造好 npm 包使用 Npm Token 发布,此部分内容往往是大量的模板代码与调试过程,请自行研究学习。

运行二进制包

import { transform } from './index'

console.log(
  // import React from 'preact'
  transform(
    `import React from 'react'`,
    { from: 'react', to: 'preact' }
  )
)

直接导入 napi 生成的 index.js 胶水代码即可使用 .node 二进制包。

缺点

  1. 通过构建 .node 分发至不同平台是目前最高运行效率、最小下载体积的方法,但相对应需要手动管理所有 Rust 代码,且多平台构建也强依赖云环境,这提出了一些较高的要求。

  2. 随着对 napi / Rust 异步、并发编程 / Swc 的理解精进,你可以写出更高运行效率的代码,得到更快的执行速度。但最简单的代码依然够用,因为现代计算机性能已经足够快,1s 还是 10s 的争论没有意义。

  3. 在开发过程中,你可能会遇到各种 Rust 构建相关的问题,请自行研究并解决。

总结

本文对 Rust 浅尝辄止,如希望更有所作为,你可以通过不断精进 Rust ,组织出更优雅的代码结构,实现更高的执行效率。

前端 AST 人尽皆知,如同开发 Babel 插件一样,开发 Rust Swc 插件已然成为现代前端的必修课,文本推荐只入门至 Swc 插件为止,已经能应对绝大多数场景。

另外,对于性能上无需过多追求,由于计算机的性能已经过剩,不管是 wasm 还是 .node 速度都是很快的,秒级之争没有意义。

以上。

06-04 10:17