本文为稀土技术社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!

立项

在《概念篇》中介绍了几个提效东西渠道以及插件开发的大致流程,而且提出了一个想法,就是将完成插件的中心逻辑抽离成一个模块,能够便利地适配各个东西渠道。

本文将具体剖析这个中心模块的装备、开发、发布流程,让咱们开始吧~

项目装备

整个项目的文件结构如下:

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.jsTxiki.js 下测验,在开发环境增加了一个 test 进口,指向的文件途径是 src/test.ts,而且在 test/script 目录下创立了别离测验 Node.jsTxiki.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.jsTxiki.js 操作文件体系的 API 不同,需求封装一个函数,经过当时运转环境运用不同的 API 来抹平差异,确保相同的输入输出。

那么怎样判断当时运转环境是 Node.js 仍是 Txiki.js 呢?
Txiki.js 会在 Global 上挂载一个 tjs 方针,经过 tjs 能够调用 Txiki.js 供给的 API,咱们只需判断 Globaltjs 不为空,即可确认当时环境为 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.jsreadFileSync 调用时需求动态加载 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');
  }
}

不同运转环境的文件体系操作差异咱们现已处理,后续的逻辑完成就更便利快捷了。

查找 & 挑选项目

查找项目的中心其实在之前笔者发布在团队账号中的《提效80%的Git 项目启动东西开发思路》文章中有具体说明,除了读取文件夹的函数替换成上面封装的函数外,其他别无二致,这边就不再赘述了。

缓存

猎豹的缓存文件其实包括了装备的功用,依据在作业区中查找到的项目列表生成一份 JSON 文档,包括项目列表,以及依据项目列表归类的项目类型,用于为类型指定运用,比方 andriod 类型的项目都运用 Android Studio 翻开,javascripttypescriptvuereact 等类型运用 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"
    },
    ...
  ]
}

读取缓存

在缓存途径未装备时会抛出过错,文件内容为空或许文件不存在时会写入并回来一个 editorcache 为空的方针。

/**
 * @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 包的经历,这边再简略的讲一下流程~

  1. 首要需求注册 npm 账号。
  2. 在指令行运用 npm login 依据提示完成登录。(留意,如果设置了 npm 镜像地址,需求先改回官网地址,不然会遇到 403 的问题)
  3. 履行项目构建。
  4. 履行 npm publish 即可完成发布。(主张先搜一搜是否有同名库)

这样就能在需求运用的项目中直接 npm install cheetah-core 啦~

小结

中心模块开发完后先在本地完成了御三家东西渠道(AlfreduToolsRaycast)的插件开发,AlfreduTools 之前就发布了插件,引进模块的改动很小,花费的时刻不算多。

Raycast 的插件是全新开发的,在有中心模块的情况下,只需求处理插件渠道的输入输出,项目的查找,缓存操作只需调用中心模块导出的办法就行,整个开发时刻非常短,目前现已提交插件商场审阅,想要尝鲜的朋友能够下载源代码本地构建运转。

后续会陆续完成御三家插件的开发过程文章,敬请期待~