猎豹Cheetah插件开发 - 核心篇
立项
本文将详细剖析这个核心模块的配置、开发、发布流程,让我们开始吧~
项目配置
整个项目的文件结构如下:
cheetah-core
├─ .eslintignore
├─ .eslintrc.js
├─ .gitignore
├─ CHANGE.md
├─ README.md
├─ declares 类型声明
│ ├─ ffi.d.ts
│ ├─ global.d.ts
│ └─ txikijs.d.ts
├─ package.json
├─ rollup.config.js rollup 配置文件
├─ src
│ ├─ index.ts
│ ├─ lib
│ │ ├─ application.ts
│ │ ├─ config.ts
│ │ ├─ constant.ts
│ │ ├─ core.ts
│ │ └─ system.ts
│ └─ test.ts 测试入口
├─ test
│ ├─ runtime
│ │ └─ txiki
│ └─ script
│ ├─ node.sh Node.js 测试命令
│ └─ txiki.sh Txiki.js 测试命令
├─ tsconfig.json
为了规范自己,并且在其他项目中使用模块时更便捷,模块使用 TypeScript
开发。构建工具选择的是 rollup.js
,更轻量且配置方便。
rollup.config.js
import typescript from 'rollup-plugin-typescript2';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
// import { uglify } from 'rollup-plugin-uglify';
const production = process.env.NODE_ENV === 'production';
// 测试入口方便分别在node或者txiki环境下测试相关逻辑
const devInput = {
index: 'src/index.ts',
test: 'src/test.ts',
};
const productionInput = {
index: 'src/index.ts',
};
export default {
input: production ? productionInput : devInput,
// 排除不需要打包的依赖
external: [
'fs',
'path',
],
plugins: [
resolve({
jsnext: true,
preferBuiltins: true,
}),
// 支持导入 commonjs 规范的依赖库
commonjs({ include: 'node_modules/**' }),
typescript({
verbosity: 2,
abortOnError: false,
clean: true,
useTsconfigDeclarationDir: true,
}),
json(),
// 这边取消了生产环境编译时压缩代码,因为 uTools 插件在提交时需要主文件明文可读,压缩后审核会不通过。
// production && uglify(),
],
output: [
{
format: 'cjs',
dir: 'dist/commonjs',
banner: '/* eslint-disable */',
exports: 'auto',
},
{
format: 'esm',
dir: 'dist/esm',
banner: '/* eslint-disable */',
exports: 'auto',
},
],
};
测试
为了方便在 Node.js
与 Txiki.js
下测试,在开发环境增加了一个 test
入口,指向的文件路径是 src/test.ts
,并且在 test/script
目录下创建了分别测试 Node.js
和 Txiki.js
的 shell 命令文件,运行后可以触发构建开发环境并且使用 node
或者 */txiki
运行构建结果中的 test.js
文件。
txiki.sh
命令配置如下:
# 返回根目录并且运行开发环境构建
cd $PWD && yarn dev
echo "txiki 测试开始"
# 使用 runtime 下的 txiki 可执行文件运行构建结果中的 test.js
# txiki 可执行文件可以通过开源项目编译,详情请查看 https://github.com/saghul/txiki.js
$PWD/test/runtime/txiki $PWD/dist/esm/test.js
echo "txiki 测试结束"
node.sh
命令配置如下:
# 返回根目录并且运行开发环境构建
cd $PWD && yarn dev
echo "node 测试开始"
# 使用系统安装的 node 运行构建结果中的 test.js
node $PWD/dist/commonjs/test.js
echo "node 测试结束"
⚠️ .sh
文件创建后需要手动添加权限,命令如下:
chmod +x **/*.sh
package.json
为方便 .sh
文件运行,在 package.json
中配置对应命令:
"scripts": {
"dev": "rm -rf ./dist && rollup -c", // 开发环境构建
"build": "rm -rf ./dist && cross-env NODE_ENV=production rollup -c", // 生产环境构建
"test": "jest --no-cache", // 尝试使用jest,但是发现这个奇葩项目不适用
"txiki-test": "./test/script/txiki.sh", // 运行 Txiki.js 测试
"node-test": "./test/script/node.sh" // 运行 Node.js 测试
},
添加以下入口及类型配置:
"main": "dist/commonjs/index.js",
"module": "dist/esm/index.js",
"jsnext:main": "dist/esm/index.js",
"types": "types/index.d.ts",
配置发布时包含的目录:
"files": [
"src",
"dist",
"types"
],
tsconfig.json
项目编译配置文件如下:
{
"compilerOptions": {
"target": "es6",
"module": "ES2020",
"noImplicitAny": true,
"sourceMap": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitThis": true,
"diagnostics": true,
"listFiles": true,
"pretty": true,
"moduleResolution": "node",
"noEmitOnError": false,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"strict": true,
"outDir": "./dist", // 指定输出目录
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "./types", 指定类型定义文件输出目录
"resolveJsonModule": true,
},
"include": [
"src/**/*", // 代码所在目录
"declares/**/*" // 自定义类型所在目录
],
"exclude": []
}
核心实现
下面进入正题,看看核心模块包含哪些内容吧~
错误处理
下面的代码示例中抛出了一些错误代码,先看看这些错误代码怎么使用,如果有更好的方法可以在评论区留言,感谢。
因为不同的平台错误输出的方式不同,所以没有直接在核心模块中处理和输出这些错误,选择将错误抛出,在调用库时再根据不同的错误代码,使用平台提供的方式输出不同的错误信息。
首先看看这些错误代码的含义以及通用报错信息:
// 错误代码对应的文字提示
export const ErrorCodeMessage: { [code: string]: string } = {
'100': '文件读取失败',
'101': '文件写入失败',
'102': '文件删除失败',
'103': '工作目录未配置',
'104': '缓存文件路径未配置',
'105': '系统平台未配置',
'106': '环境变量读取失败',
'107': '缓存文件写入失败',
'108': '读取文件夹失败',
'109': '未知的终端程序,降级为文件夹打开',
'110': '缓存内无此项目',
'111': '应用路径为空',
};
文件系统相关
因为 Node.js
和 Txiki.js
操作文件系统的 API
不同,需要封装一个函数,通过当前运行环境使用不同的 API
来抹平差异,保证相同的输入输出。
那么怎么判断当前运行环境是 Node.js
还是 Txiki.js
呢?
Txiki.js
会在 Global
上挂载一个 tjs
对象,通过 tjs
可以调用 Txiki.js
提供的 API
,我们只要判断 Global
上 tjs
不为空,即可确定当前环境为 Txiki.js
,反之则是 Node.js
。
export const isTxiki = !!global.tjs;
获取环境变量
此函数用于获取系统环境变量,在 Alfred Workflows
开发中环境变量是流程块间的参数传递最重要的方式。
当 isTxiki
为真时使用 tjs.getenv
方法获取环境变量,为假时直接在 process.env
中获取即可。
/**
* @description: 获取环境变量
* @param {string} envName 要获取的环境变量名称
* @param {any} defaultValue 默认值
* @return {string} 获取到的环境变量值
*/
export function getEnv(envName: string, defaultValue?: any): string {
try {
return isTxiki ? tjs.getenv(envName) : process.env[envName]!;
} catch (error) {
return defaultValue;
}
}
读取文件
读取文件也要区分不同的运行环境,在 Txiki.js
下,读取的内容为 buffer
数组,需要使用 Txiki.js
提供的 TextDecoder
解码后才能输出字符串内容。Node.js
下 readFileSync
调用时需要动态加载 fs
模块,如果在文件头部导入 fs
模块,在 Txiki.js
环境运行时会因为找不到 fs
模块而报错。
/**
* @description: 读取文件内容
* @param {string} filePath 文件路径
* @return {Promise<string>} 文件内容
*/
export async function readFile(filePath: string): Promise<string> {
let fileContent = '';
try {
if (isTxiki) {
const buffer = await tjs.readFile(filePath);
const decoder = new TextDecoder();
fileContent = decoder.decode(buffer);
} else {
const fs = require('fs');
fileContent = fs.readFileSync(filePath).toString();
}
return fileContent;
} catch (error) {
throw new Error('100');
}
}
写入文件
Txiki.js
下写入文件和读取文件相反,需要使用其提供的 TextEncoder
将字符串编码为 buffer
以后再调用已经使用 tjs.open
打开的文件对象。
在 Node.js
下也需要动态加载 fs
模块,并且多一步创建文件夹的操作,否则直接在目标路径写入文件可能报目录不存在的错误。
/**
* @description: 写入文件内容
* @param {string} filePath 文件路径
* @param {string} content 文件内容
* @return {Promise<void>}
*/
export async function writeFile(
filePath: string,
content: string,
): Promise<void> {
try {
if (isTxiki) {
const cacheFile = await tjs.open(filePath, 'rw', 0o666);
const encoder = new TextEncoder();
await cacheFile.write(encoder.encode(content));
} else {
const fs = require('fs');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
} catch (error) {
throw new Error('101');
}
}
删除文件
这个函数目前只用在清除缓存文件时,也要做平台差异的抹平。
/**
* @description: 删除文件
* @param {string} filePath 文件路径
* @return {Promise<void>}
*/
export async function unlink(filePath: string): Promise<void> {
try {
if (isTxiki) {
await tjs.unlink(filePath);
} else {
const fs = require('fs');
fs.unlinkSync(filePath);
}
} catch (error) {
throw new Error('102');
}
}
读取文件夹下内容
这个函数比较重要,在工作区下搜索项目时,根据子文件的类型判断是否需要进一步递归搜索,子文件列表中如包含名为 .git
的目录则判断其为项目,这个方法需要返回当前文件列表,列表中文件对象包含是否为文件夹、详细路径、是否为文件夹三个属性。
Txiki.js
在读取文件夹时直接给了子文件的类型,Node.js
则需要再对每个文件调用 fs.statSync
方法获取,可能会有些影响性能,有更好的方法可以告诉笔者,谢谢。
// 遍历文件夹时获取的文件信息
export interface ChildInfo {
name: string;
path: string;
isDir: boolean;
}
/**
* @description: 读取文件夹下所有内容
* @param {string} dirPath 文件夹路径
* @return {ChildInfo[]} 文件夹下文件对象集合
*/
export async function readDir(dirPath: string): Promise<ChildInfo[]> {
let dirIter;
try {
if (isTxiki) {
dirIter = await tjs.readdir(dirPath);
} else {
const fs = require('fs');
dirIter = fs.readdirSync(dirPath);
}
const files: ChildInfo[] = [];
for await (const item of dirIter) {
const name: string = isTxiki ? item.name : item;
const itemPath = path.join(dirPath, name);
let isDir = false;
if (isTxiki) {
isDir = item.isDirectory; // Txiki.js 判断是否文件夹
} else {
const fs = require('fs'); // 这块可能会有些影响性能,如果有更好的方法可以在评论区留言~
isDir = fs.statSync(itemPath).isDirectory(); // Node.js 判断是否文件夹
}
files.push({
name,
isDir,
path: itemPath,
});
}
return files;
} catch (error) {
throw new Error('108');
}
}
不同运行环境的文件系统操作差异我们已经解决,后续的逻辑实现就更方便快捷了。
查找 & 筛选项目
缓存
猎豹的缓存文件其实包含了配置的功能,根据在工作区中搜索到的项目列表生成一份 JSON
文档,包含项目列表,以及根据项目列表归类的项目类型,用于为类型指定应用,比如 andriod
类型的项目都使用 Android Studio
打开,javascript
、typescript
、vue
、react
等类型使用 VSCode
打开。
工具在搜索时没有需要刷新的参数标记时,默认从 cache 项目列表中匹配筛选项目,如果有刷新标记或者缓存中没有与关键词匹配的项目则重新在工作目录中查找项目匹配结果,并写入缓存文件。
JSON 结构如下:
{
"editor": {
"typescript": "Visual Studio Code",
"vue": "Visual Studio Code",
"android": "Android Studio",
"unknown": "",
"javascript": "Visual Studio Code",
"dart": "",
"nuxt": "",
"applescript": "",
"react": "Visual Studio Code",
"react_ts": "Visual Studio Code",
"vscode": "",
"rust": "",
"hexo": "",
...
},
"cache": [
{
"name": "api-to-model",
"path": "/Users/***/Documents/work/api-to-model",
"type": "typescript",
"hits": 1,
"idePath": "Sublime Text"
},
...
]
}
读取缓存
在缓存路径未配置时会抛出错误,文件内容为空或者文件不存在时会写入并返回一个 editor
和 cache
为空的对象。
/**
* @description: 读取缓存
* @return {Promise<Config>} 缓存内容
*/
export async function readCache(): Promise<Config> {
const { cachePath } = global;
if (!cachePath) {
throw new Error('104');
}
try {
const history = await readFile(cachePath);
return JSON.parse(history) ?? { editor: {}, cache: [] };
} catch (error: any) {
if (error.message === '100') {
// eslint-disable-next-line no-use-before-define
writeCache([]);
return { editor: {}, cache: [] };
}
return { editor: {}, cache: [] };
}
}
写入缓存
写入缓存时,需要提供一个项目对象列表,这个列表将和缓存中的 cache
数组合并,项目的新增、删除由传入的列表决定,这样能保证在项目变动后缓存及时更新,下次搜索不会出错,项目的点击量以及应用配置则取缓存文件中的内容,保证用户的使用习惯和配置不丢失。
editor
的内容啧先遍历传入的项目列表,获取类型以后再与缓存中的内容合并。
/**
* @description: 合并编辑器配置
* @param {Editors} editor 缓存中的编辑器配置
* @param {Project[]} cache 项目合集
* @return {Editors} 合并后的编辑器配置
*/
function combinedEditorList(editor: Editors, cache: Project[]): Editors {
const newEditor = { ...editor };
const currentEditor = Object.keys(newEditor);
cache.forEach(({ type }: Project) => {
if (!currentEditor.includes(type)) {
newEditor[type] = '';
}
});
return newEditor;
}
/**
* @description: 更新缓存时合并项目点击数
* @param {Project[]} newCache 最新的项目查找结果集合
* @return {Promise<Project[]>} 合并后的项目集合
*/
async function combinedCache(newCache: Project[]): Promise<Project[]> {
const { cache } = await readCache();
// 筛选有点击记录的项目
const needMergeList = {} as { [key: string]: Project };
cache
.filter((item: Project) => item.hits > 0 || item.idePath)
.forEach((item: Project) => {
needMergeList[item.path] = item;
});
// 合并点击数
newCache.forEach((item: Project) => {
const selfItem = item;
const cacheItem = needMergeList[selfItem.path] ?? {};
const { hits = 0, idePath = '' } = cacheItem;
selfItem.hits = selfItem.hits > hits ? selfItem.hits : hits;
selfItem.idePath = idePath;
});
return newCache;
}
/**
* @description: 写入缓存
* @param {Project} newCache 最新的项目查找结果集合
* @return {Promise<void>}
*/
export async function writeCache(newCache: Project[]): Promise<void> {
const { cachePath } = global;
if (!cachePath) {
throw new Error('104');
}
const { editor } = await readCache();
const newEditorList = combinedEditorList(editor, newCache);
const newConfig = { editor: newEditorList, cache: newCache };
const historyString = JSON.stringify(newConfig, null, 2);
await writeFile(cachePath, historyString);
}
项目信息更新
这边的项目信息是指插件在使用时搜索到的本地 git
项目信息,项目的定义如下:
// 项目信息
export interface Project {
name: string; // 项目名称,用于展示以及匹配工具输入的关键字
path: string; // 项目的系统绝对路径,选择项目后将用指定的应用打开此路径
type: string; // 项目的类型,如 vue、react、rust、android 等等
hits: number; // 此项目被选择的次数,影响此项目在搜索结果列表中的排序
idePath: string; // 项目指定的应用,在打开类型为编辑器时优先级最高,其次是缓存中的类型编辑器配置
}
更新点击量
在搜索结果列表中选择项目后,除了使用指定的应用打开项目以外,还会将项目的点击量增加 1
,这样使用频率越高的项目在后续使用时排序会越高,更方便选取。
首先读取缓存中所有项目列表,根据项目路径找到目标项目,为其 hits
增加 1
后重新写入缓存文件。
/**
* @description: 更新项目打开次数,用于排序
* @param {string} projectPath 被选择的项目路径
* @return {Promise<void>}
*/
export async function updateHits(projectPath: string): Promise<void> {
const { cache: cacheList = [] } = await readCache();
const targetProject = cacheList.find((item: Project) => item.path === projectPath);
if (!targetProject) {
throw new Error('110');
}
targetProject.hits += 1;
await writeCache(cacheList);
}
设置项目应用
为项目设置应用后,在命令为使用编辑器打开时,优先级最高,其次是缓存中 editor
下设置的类型应用,最后才是插件配置的默认编辑器应用。
与更新点击量逻辑类似,先查找目标项目,设置应用后写入缓存。
/**
* @description: 设置项目专属编辑器
* @param {string} projectPath 被选择的项目条目
* @param {string} appPath 应用路径
* @return {*}
*/
export async function setProjectApp(
projectPath: string,
appPath: string
): Promise<void> {
const { cache: cacheList = [] } = await readCache();
if (!appPath) {
throw new Error('111');
}
const targetProject = cacheList.find(
(item: Project) => item.path === projectPath
);
if (!targetProject) {
throw new Error('110');
}
// 更新项目编辑器
targetProject.idePath = appPath;
await writeCache(cacheList);
}
发布
上面提到的这些函数、类型、常量写在不同的文件内,在发布前需要在入口 index.ts
导出:
export * from './lib/application';
export * from './lib/config';
export * from './lib/constant';
export * from './lib/core';
export * from './lib/system';
export * from './lib/types';
这样在使用时直接导入会更加方便,不用指定详细目录。
相信很多读者朋友都有发布 npm
包的经验,这边再简单的讲一下流程~
- 首先需要注册 npm 账号。
- 在命令行使用
npm login
根据提示完成登录。(注意,如果设置了npm
镜像地址,需要先改回官网地址,否则会遇到403
的问题) - 执行项目构建。
- 执行
npm publish
即可完成发布。(建议先搜一搜是否有同名库)
这样就能在需要使用的项目中直接 npm install cheetah-core
啦~
小结
核心模块开发完后先在本地完成了御三家工具平台(Alfred
、uTools
、Raycast
)的插件开发,Alfred
和 uTools
之前就发布了插件,引入模块的改动很小,花费的时间不算多。
Raycast
的插件是全新开发的,在有核心模块的情况下,只需要处理插件平台的输入输出,项目的搜索,缓存操作只要调用核心模块导出的方法就行,整个开发时间非常短,目前已经提交插件市场审核,想要尝鲜的朋友可以下载源代码本地构建运行。
后续会陆续完成御三家插件的开发过程文章,敬请期待~
转载自:https://juejin.cn/post/7165142168921718821