需求
实现一个工具函数库,可以通过 npm 包和 vscode 插件两种方式使用
工具函数库目录结构:
bash
star-tools
├── validators
│ └── index.ts 计划把验证函数都放到这一个文件中
├── 3d
│ ├── control1.ts 因为 3d 类似 control 这种代码会比较长,是一个类,所以单独用文件
│ ├── control2.ts
│ └── index.ts 引入 control1、control2 等然后统一导出
└── ...
├── ...
└── index.ts
期望的 npm 包使用方式:
js
import {control1} 'star-tools/3d'
control1 拿到的就是 ts 源码
期望通过 vscode 插件使用的方式
- 调出 vscode 命令面板
- 每个函数对应一个命令,比如输入 star-tools:3d/control1,找到这个命令
- 选中命令后 enter
- control1.ts 中的代码插入到当前工作区的 focus 处
项目目录制定
两种方式在一个项目中,共用一份源码,主要是为了不维护双份的工具函数
如果不考虑两种方式共用一个项目,
对于 npm 包引用的方式,目录结构:
bash
star-tools
├── validators、3d、... 目录不变
├── .gitignore
└── package.json
直接 publish 源码,install 的就是源码,可以 import {control1} 'star-tools/3d' 引用到
vscode 插件目录结构
bash
star-tools
├── src
│ └── extension.ts 插件的入口文件,主要是插件的逻辑代码
├── tools
│ └── 省略,就是把三个分类文件夹直接移动过来
├── .gitignore
├── package.json
└── tsconfig.json
结论
显而易见了,就直接用 vscode 插件的目录结构,这样直接 publish 源码仍然可以引用到工具函数,并且两种方式共用同一份 tools
目前来看,这样的目录结构虽然 ok,但是会有不合理的地方,比如要把只和 vscode 相关的部分也发布到了 npm,这个会在稍后解决。
前置知识恶补
整个开发过程其实很不顺利,开发之前要先做一些调研,有一些前置知识会少走弯路。但很矛盾的是,一开始根本没有头绪,都不知道要先掌握哪些基本的前置知识。希望我总结的这些前置知识可以让你少走弯路咯 ~
进一步明确需求
在这个项目中:
- 由于插件的逻辑是用 ts 编写的,所以这部分代码需要编译为 js,因为 vscode 插件的运行时需要时 commonjs 模块规则
- 作为 vscode 插件时:代码片段作为源码直接插入,不需要把 ts 编译为 js。
- 作为 npm 包引用时:~~ 同上 ~~~
一些有用的 API
- 同步读取文件用 fs.readFileSync
各种有用的配置文件
-
package.json : 配置主入口、依赖、scripts 等、开发和发布后都要用到
-
tsconfig.json : 负责把 ts 编译 为 js。vscode 插件运行时只能是 js,如果插件源码是 ts,tsconfig 是必要的(或者用一些其他的打包工具内部集成了 ts 编译为 js 的能力)
-
.vscode 目录下的配置文件是用来配置 vscode 调试功能的,插件发布后无关。
-
.npmignore 用来过滤发布到 npm 的文件。
-
.vscodeignore 用来过滤发布到 marketplace 的文件。
-
package.json 中的 scripts 脚本,& 是并行,&& 是串联进行
F5 调试插件时 干嘛了
调试的入口文件为 .vscode/launch.json,一般情况下,vscode 左侧 debugger 工具面板都会自动定位到 .vscode/launch.json 中的 第一个命令\
按下 f5 时,先经历了这样的过程,然后再把调试窗口启动\
原来 f5 并不神秘,就是 npm run dev 哇,实际执行的脚本在 star-tools 中是
bash
yarn run remove-out && tsc && yarn run move-src && node ./build/genVscdPkg.js
只看 tsc,它是负责把 ts 编译为 js 的,之后插件才能在 vscode 的环境中运行起来。编译之前会先读取 tsconfig.json 中的配置,然后开始编译并输出编译后的代码。
remove-out、move-src、node ./build/genVscdPkg.js 先不管,这些都属于对目录和文件内容的定制化处理。会在后面的优化中提到
- 一般情况下 rootDir 都等同于当前目录所以是 './'
- 对于 star-tools 来说工具函数是不需要编译的,因为 star-tools 不参与运行,充当代码片段的作用。和插件相关的逻辑代码才需要编译,逻辑代码都在 src 下。所以配置了 includes
- 编译后输出到 out 目录下,out 目录下的文件与 src 下的对应
- 配置 mapSource 为 true 方便调试,所以可以看到 out 目录下都有对应 .map.js
================== ok!上面编译阶段就完成了, ====================
这时 vscode 就会把编译窗口启动,开始运行代码,再次看 package.json。其中 main 选项配置的文件就是运行时的入口文件
json
"main": "./out/main.js",
运行时就是插件的逻辑了,取决于插件的功能啦,star-tools 最开始的逻辑就是把每个工具函数都注册为命令,然后巴拉巴拉...(后面讲具体功能再说)
vsce publish 干嘛了 ?
运行 vsce publish 命令后,经历了这样的过程:\
- 使用 yarn run esbuild-base 来编译,是因为 esbuild 在 tsc 的基础上有一些扩展功能比如压缩等,编译之前也读取了 tsconfig 的配置。我这里其实只是强迫症想合并到一个文件。
- 编译打包之后输出到 out 文件夹
============= 发布准备结束!下面就到发布阶段了 ==============
(其实上面的流程也是编译阶段,和开发调试时不一样在于配置不同输出不同)
- 读取 .vscodeignore 对文件进行过滤
- 发布到 marketplace
安装插件 又干嘛了 ?
点击安装 => 下载发布到 marketplace 的插件源码 => 根据 package.json 中的 dependencies(‼️ 注意 ,要考的)安装依赖\
什么时候运行呢?由 package.json 中的配置项 activationEvents 决定
- 不配置或者为 [] :当在命令面板中选择插件相关的命令时开始进入运行时
- 配置为 * 号 : vscode 启动就进入运行时
======================= 安装阶段 🔚 =========================
运行时入口文件是 package.json 中的 main,接下来同开发调试时啦
npm publish 比 vsce publish 简单多了
- 不需要 tsc 编译了,因为就是发布源码,直接引用。(至于 node ./build/genNpmPkg 先不管,也是属于对目录和文件内容的定制化处理)
npm install
过滤后安装的包结构:
bash
- star-tools
- tools
- ... 源码结构
- package.json
- readme.md
插件开发插件第一阶段 基本功能
功能点
从代码上来讲,图中三部分对应以下三段代码:
配置命令
- package.json
json
// ...
"contributes": {
"commands": [
{
"command": "star-tools.3d.DeviceOrientationControls",
"title": "star-tools: 3d相关/陀螺仪控制器"
}
//...
]
},
// ...
注册命令
- src/extension.ts
js
const registerCommand = (subName: string, methodFileName: string, method: fs.PathLike) => {
const methodName = methodFileName.split(".")[0];
const commandName = `star-tools.${subName}.${methodName}`;
const content = (method as string); //processSourceFile 把文件作为字符串读取,逻辑省略
return vscode.commands.registerCommand(commandName, () => whenCommand(methodName, content));
};
export async function activate(context: vscode.ExtensionContext) {
// ... 读取目录逻辑省略
// subName 为分类名称如 "3d"
// methodFileName 为函数名称如 "DeviceOrientationControls.ts"
// method 为文件对应路径
context.subscriptions.push( registerCommand(subName, methodFileName, method))
}
插入代码片段
- src/extension.js
js
const whenCommand = (methodName: string, content: string) => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("star-tools: 工作区打开文件后才能使用该功能");
return;
}
const { selections } = editor;
if (selections.length === 0) {
vscode.window.showWarningMessage("star-tools: 请先选择一个区域");
return;
}
const firstSelection = selections[0];
const { start, end } = firstSelection;
const range = new vscode.Range(start, end); //计算选区范围
editor.edit((editBuilder) => {
editBuilder.replace(range, content); //替换选区
});
vscode.window.showInformationMessage(`✅ 已插入函数: ${methodName}`);
};
插件开发插件第二阶段 功能优化
做了一些规范化和自动化的事
每个文件对应一个代码片段
- 符合封闭开放原则,对修改封闭,对增加开放,易于维护
- 利于 AST 解析(马上用到)
ts
import aaa from 'aaa' //也可以引用多个,或者没有依赖
// 主体代码部分 start
type TXxx {
}
function bbb (){
}
function xxx (){
bbb()
}
// 主体代码部分 end
export default xxx //每个工具函数都有一个独立的文件,导出都用 export default
代码片段分割
vscode 插件的形式使用时,插入时,应该剔除 export 语句,并且将 import 插入顶部
- 改写 processSourceFile,加入 AST 解析逻辑
ts
export function processSourceFile(filePath: string) {
const sourceFile = ts.createSourceFile(filePath, fs.readFileSync(filePath).toString(), ts.ScriptTarget.Latest, true);
let imports: ts.ImportDeclaration[] = [];
let exportDefault: ts.ExportAssignment | any = null;
let otherStatements: ts.Statement[] = [];
function findImportsAndExports(node: ts.Node) {
if (ts.isImportDeclaration(node)) {
imports.push(node);
} else if (ts.isExportAssignment(node)) {
exportDefault = node;
} else {
ts.isStatement(node) && otherStatements.push(node);
}
ts.forEachChild(node, findImportsAndExports);
}
ts.forEachChild(sourceFile, findImportsAndExports);
// 将import语句转为字符串
let importStr = imports.map((imp) => sourceFile.text.substring(imp.getStart(), imp.getEnd())).join("\n");
// 将除了export和import之外的语句转为字符串
let bodyStart = imports.length ? imports[imports.length - 1].getEnd() : 0;
let bodyEnd = exportDefault ? exportDefault.getStart() : sourceFile.getEnd();
let bodyStr = sourceFile.text.substring(bodyStart, bodyEnd);
// 将export语句转为字符串
let exportStr = exportDefault ? sourceFile.text.substring(exportDefault.getStart(), exportDefault.getEnd()) : "";
return { importStr, bodyStr, exportStr };
}
- 改写 whenCommand
js
const whenCommand = (methodName: string, content: FileContent) => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("star-tools: 工作区打开文件后才能使用该功能");
return;
}
const { selections } = editor;
if (selections.length === 0) {
vscode.window.showWarningMessage("star-tools: 请先选择一个区域");
return;
}
const firstSelection = selections[0];
const { start, end } = firstSelection;
const range = new vscode.Range(start, end);
editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), content.importStr + "\n"); //引用
editBuilder.replace(range, content.bodyStr); //主体
});
vscode.window.showInformationMessage(`✅ 已插入函数: ${methodName}`);
};