Rust + Neon + Node 开发 VSCode 插件(磨刀篇)

2,305 阅读3分钟

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

书接上回,在《英雄集结篇》中我们确定了这个缝合怪项目使用的技术栈,但是整体的开发流程还没有做到如丝般顺滑。

《磨刀篇》我们将对项目做一些改动,让开发更加顺手,更加智能。

代码构建

插件部分

VSCode 开发脚手架 yo 默认生成的插件开发项目(TypeScript),在 package.json 中提供了几个命令,用于构建项目调试、发布的代码。

"scripts": {
  "vscode:prepublish": "npm run compile",  // vsce publish 发布前执行构建
  "compile": "tsc -p ./", // 构建代码
  "watch": "tsc -watch -p ./", // 监听代码修改进行构建
  ...
},

目前的构建代码比较简单,直接使用了 tsc 命令将 .ts 文件都转换成 .js 输出到 out 目录下。当插件代码越来越复杂,引入的包更多后,这么做就不是很合理了。

幸运的是,市面上有很多能够解决这个问题的打包构建工具,比如 webpackesbuildrollup等等。下面我们将使用 webpack 来改造我们的项目。

首先我们在项目引入 webpack 相关的 npm

yarn add -D webpack webpack-cli node-loader

新建一个 webpack.config.js 文件,内容大致如下:

"use strict";

const path = require("path");

/**@type {import('webpack').Configuration}*/
const config = {
  target: "node", // vscode插件运行在Node.js环境中 📖 -> https://webpack.js.org/configuration/node/
  entry: "./src/extension.ts", // 插件的入口文件 📖 -> https://webpack.js.org/configuration/entry-context/
  output: {
    // 打包好的文件储存在'dist'文件夹中 (请参考package.json), 📖 -> https://webpack.js.org/configuration/output/
    path: path.resolve(__dirname, "dist"),
    filename: "extension.js",
    libraryTarget: "commonjs2",
    devtoolModuleFilenameTemplate: "../[resource-path]"
  },
  devtool: "source-map",
  externals: {
    vscode: "commonjs vscode" // vscode-module是热更新的临时目录,所以要排除掉。 在这里添加其他不应该被webpack打包的文件, 📖 -> https://webpack.js.org/configuration/externals/
  },
  resolve: {
    // 支持读取TypeScript和JavaScript文件, 📖 -> https://github.com/TypeStrong/ts-loader
    extensions: [".ts", ".js", ".node"]
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "ts-loader"
          }
        ]
      },
      {
        test: /\.node$/,
        exclude: /node_modules/,
        loader: "node-loader",
        options:{
          name: "native.node",
        }
      },
    ]
  }
};
module.exports = config;

将入口指定为 ./src/extension.ts ,输出目录为当前目录下的 dist 目录。

另外,我们还引入了一个 .node 文件的加载器,加载器会把引用的 .node 文件复制到 ./dist 目录下命名为 native.node 如果没有这个加载器,webpack 在解析代码时遇到 .node 文件会把它当成一个 .js 文件,读取其内容,遍历引用。很可惜,.node 文件是个二进制文件,这时候 webpack 就会报错,与我们想要的效果不符。

接着我将 package.json 中的 main 字段指定为 ./dist/extension.js,并且修改负责构建代码的几个命令:

"scripts": {
  "vscode:prepublish": "webpack --mode production", // 构建生产环境下的代码
  "compile": "webpack --mode none", // 构建调试、测试时的代码
  "watch": "webpack --mode none --watch", // 监听并构建
  ...
},

.vscode/tasks.json 也需要改动一下:

"problemMatcher": "$tsc-watch"
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
"problemMatcher": [
  "$ts-webpack-watch",
  "$tslint-webpack-watch"
]

至此我们已经完成了插件项目构建方面的改造,接下来看看 Rust & Neon 代码编译方面有哪些可以优化的地方吧~

Rust & Neon 部分

在插件开发上,Rust 提供的支援是以编译为 .node 模块的形式,编译的命令如下:

. env.sh && neon build

在每次修改 Rust 代码后,都需要重新执行编译,这样插件才能得到最新的 .node 文件,可是这样真的很机车nei。

能不能让我每次修改以后都自动编译?可以!强大的 VSCode ,没有什么不可以。

就算 VSCode 本身不支持,还有各路能人异士开发各种好用的插件满足你各种奇奇怪怪的需求。

这边我们使用的插件是 “Run on Save”,可以监听指定文件修改,支持通配符。

在工作区的配置文件 .vscode/settings.json 中加入以下代码:

"emeraldwalk.runonsave": {
  "commands": [
    {
      "match": "native/src/lib.rs", // Neon 的连接文件保存时触发
      "isAsync": true,
      "cmd": "tasks/rust-to-node.sh" // 执行编译脚本
    },
    {
      "match": "native/src/public/*", // public 目录下的所有文件保存时触发
      "isAsync": true,
      "cmd": "tasks/rust-to-node.sh" // 执行编译脚本
    },
  ]
}

这样在文件保存后,便会自动执行 tasks/rust-to-node.sh 编译出新的 .node 文件了,rust-to-node.sh 文件内容如下:

# 将 Neon 编译为 .node 文件
# 这时候直接执行 . env.sh && neon build 会失败
# 因为找不到 neon 这个命令,所以直接找命令的真实路径运行
. env.sh && node_modules/.bin/neon build

# 创建目录,防止移动文件因为没有文件夹失败
mkdir -p src/lib/native

# 将编译好的 .node 文件移动到插件项目下
mv native/index.node src/lib/native/index.node

是否能直接以快捷键的形式触发编译呢?答案也是可以的,请把 VSCode 牛皮打在公屏上,下面我们来进行配置。

.vscode/tasks.jsontasks 数组中新增以下任务配置:

// 执行编译任务
{
  "label": "compile",
  "type": "shell",
  "command":"tasks/rust-to-node.sh",
  "isBackground": true,
}

接着打开 设置键盘快捷方式 ,切换到编辑模式

新增以下按键绑定:

{
  "key": "cmd+r",
  "command": "workbench.action.tasks.runTask",
  "args": "compile"
}

这样在按下 Command + Rwindows下为 Ctrl + R) 快捷键的时候就会执行 labelcompile 的任务执行编译了。

疑难杂症

编译后的 .node 文件貌似不会触发 webpack 的文件修改监听,再重新运行调试时会导致异常报错,经测试,只要复制新的 .node 文件到 dist 目录下便可解决,所以在 rust-to-node.sh 文件中加上了复制新文件的命令:

# 复制最新 .node 文件到调试目录
rm -rf dist/native.node

# 必须先删除再复制,用强制覆盖都不行,果然计算机的尽头是玄学
cp -f src/lib/native/index.node dist/native.node

智能

前面说到想要让这个项目变得更智能,其实是想让 .node 文件在使用时有更好的体验,能够让开发者知道它里面都包含了哪些可用的方法。

TypeScript 中的 d.ts 文件可以描述一个对象的类型,并且让编辑器提供智能提示,所以我们可以为编译好的 .node 文件写一个 d.ts 文件,这样我们在使用的时候会方便不少。

但是我们每次改动都手动修改 d.ts 文件,非常繁琐,谈不上智能,智者花喵曾经说过:懒是第一生产力。

这种繁琐的事情就必须交给代码来完成,所以我写了个小工具,针对 Neon 的连接层暴露的函数来生成对应的 d.ts 文件。

tasks/build-type.js 文件内容:

const fs = require('fs');
const { nodeOutput, neonLibPath } = require('./config.json');

// 解析单个参数类型
function singleParamsParse(paramsItem) {
  paramsItem.match(/@param\s\{([a-zA-z_]*)\}\s([a-zA-z0-9_]*)\s-\s([\u4e00-\u9fa5a-zA-Z0-9_ -]*)$/gm);
  const type = RegExp.$1;
  const name = RegExp.$2;
  const description = RegExp.$3;
  return {
    name,
    type,
    description
  };
}

// 解析单个函数类型
function singleFunctionTypeParse(origin) {
  origin.match(/@name\s([a-zA-z0-9_]*)[\S\s]*@returns\s\{([a-zA-Z_ -<>]*)\}/gm);
  const name = RegExp.$1;
  const returns = RegExp.$2;

  const paramsMatch = origin.match(/@param\s\{([a-zA-z0-9_]*)\}\s([a-zA-z0-9_]*)\s-\s([\u4e00-\u9fa5a-zA-Z0-9_ -]*)$/gm) ?? [];
  const params = paramsMatch.map(singleParamsParse);
  return {
    origin,
    name,
    params,
    returns
  };
}

// 将解析出来的函数类型转换为ts类型
function convertType(functionInfo) {
  const { origin, name, returns, params } = functionInfo;
  const paramsString = params.map(item => `${item.name}: ${item.type}`).join(', ');
  return `${origin}
export function ${name}(${paramsString}): ${returns};`;
}

async function main() {
  const neonLibContent = fs.readFileSync(neonLibPath, 'utf8').toString();
  const functionMatch = neonLibContent.match(/\/\*{1,2}[*\n\r\sa-zA-Z0-9@\u4e00-\u9fa5-_{}<>]*\*\//gm);

  const resultContent = functionMatch.map(singleFunctionTypeParse).map(convertType).join('\n\n');

  fs.writeFileSync(`${nodeOutput}/index.d.ts`, resultContent, 'utf8');
}

main();

Neon 连接层中的读取文件函数为例

/**
* @name read_file
* @description 读取文件
* @param {string} path - 文件路径
* @returns {string}
*/
pub fn read_file(mut cx: FunctionContext) -> JsResult<JsString> {
    let path = cx.argument::<JsString>(0)?.value();
    Ok(cx.string(utils::public_read_file(path.as_str())))
}

上面的 build-type.js 工具将匹配注释内容,获取函数名称入参返回值 ,生成以下内容写入 src/lib/native/index.d.ts 文件内

/**
* @name read_file
* @description 读取文件
* @param {string} path - 文件路径
* @returns {string}
*/
export function read_file(path: string): string;

build-type.js 工具的执行也加入 rust-to-node.sh

# 为 .node 文件生成 .d.ts 文件
node tasks/build-type.js

这样每次编译 .node 文件都会自动生成 d.ts 文件了,注意按格式写注释哦。

TypeScript 项目中使用 .node 文件时需要如下引入方式:

import * as native from './lib/native';

这样使用时就能够列出有哪些函数啦~

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9dcba57846724d4a82ea8e81897bec5f~tplv-k3u1fbpfcp-zoom-1.image

少传参数也会有对应的提示

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1d456038803045d4ad9b3a0fffff8a13~tplv-k3u1fbpfcp-zoom-1.image

变量赋值类型的检查也没有问题

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1d8118d36bc34cdca2d960fdf53abb70~tplv-k3u1fbpfcp-zoom-1.image

因为鄙人正则造诣不够,可能注释的格式稍微有些不正确就会导致匹配失败,所以这边提供了一个代码片段,让注释书写更方便准确,嘻嘻

"neon function annotation": {
  "scope": "rust",
  "prefix": "na",
  "body": [
    "/**",
    "* @name $1",
    "* @description $2",
    "* @param {${5:string}} $3 - $4",
    "* @returns {${6:string}}",
    "*/"
  ],
  "description": "Neon 函数注释模板"
}

这个代码片段可以配置在工作区下,也能配置在全局下,看个人喜好了。

小结

《磨刀篇》到这就结束了,完成以上的配置,已经能比较舒适地开发这个缝合怪项目了,在这过程中,其实学到了很多工(tou)程(lan)化的技巧,受益匪浅。

如果有什么优化建议,可以在评论区告诉我,十分感谢。

好了朋友们,今天的分享就到这里,潮水马上要涨上来了。

原文地址:www.fmcat.top/posts/16366…