开发一款提效神器--VSCode一键上传图片插件的实现

2,785 阅读7分钟

一、怎么提高研发效率?

在工作量没变的情况下,想要提高工作效率,最好最直接的方式就去除重复性的劳动。今天跟大家介绍重复性工作--图片使用。

咱们平时开发时候,有两种方式引用图片,一种是将图片放到项目中根据文件路径来应用,另外一种就是在公司提供的文件管理系统中将图片上传上去然后获取对应的链接地址扔到项目中。如果想要优化一下图片,还需要使用图片压缩工具处理一下。

总之这种传统模式就是复杂。接下去,咱们做一个一键完成上面所有步骤的提效神器。

二、产品分析与设计

该插件怎么玩

从使用角度

  • 咱们产品对外暴露使用非常简单,右击菜单 → 上传图片 → 选择图片 → 链接回写到光标所在地方。

  • 支持文件类型: .jpeg, .jpg, .png

  • 有哪些上传 方式

    • 光标不选中任何字符,直接右击菜单上传
    • 光标选中已经存在的 URL
    • 光标选中相对路径的图片
    • 光标选中含有别名的图片

内部做了什么

  • 先看一下上传图片的整体流程 1.jpeg

  • 如何去获取文件的绝对路径呢 2.jpeg

  • 压缩图片

3.jpeg

  • 将 Stream 上传到 OSS 上

4.jpeg

  • 将路径写入到编辑器中 5.jpeg

三、动一动手

经过上面的一通分析,需求也了解了,技术方案也有了,那么咱们就开始干吧~

搭建项目

使用官网提供的脚手架,创建一个 TypeScript 项目。

npm install -g yo generator-code

yo code

开发

  • npm run watch: watch 模式下编译代码
  • F5:打开扩展开发宿主,其实也是 vscode 编辑器,可以直接调试我们的插件

项目简单介绍

  • package.json 介绍
    • namepublisher: name 是插件名,publisher 是发布者。共同组成了插件的唯一 ID <publisher>.<name>
    • main指向的是插件的入口文件
    • activationEvents 存放被某个行为被触发激活的事件
    • contributes 插件相关配置(其中包含了菜单触发上传文件)
    • 本项目 vscode 需要的配置代码
{
  // ...
  "activationEvents": [
    // 激活的事件
    "file-master-management.uploadFile"
  ],
  "contributes": {
    "commands": [
      {
        "command": "file-master-management.uploadFile",
        "title": "上传图片"
      }
    ],
    "menus": {
      "editor/context": [
        {
          // 编辑聚焦的时候可以右击菜单
          "when": "editorFocus",
          // 定义菜单调用的事件
          "command": "file-master-management.uploadFile",
          "group": "navigation"
        }
      ]
    },
    "configuration": {
      "title": "file-master-management",
      // 用户配置
      "properties": {
        // ...
      }
    }
  },
  "icon": "icon.png" // 图标
  // ...
}

是如何触发程序上传功能呢? 从 package.json 配置中可以看出,在 menu、command 和 activationEvents 的一通组合下,右击菜单【上传图片】执行到了 main 指向的文件 extension.ts

export function activate(context: vscode.ExtensionContext) {
  // 注册 quick-upload.uploadFile 命令并对应的执行函数
  let uploadFileCommand = vscode.commands.registerTextEditorCommand(
    'file-master-management.uploadFile',
    uploadFileMain,
  );
  // 添加订阅
  context.subscriptions.push(uploadFileCommand);
}

接下来,咱们一起来看一下上传图片的实现。

代码实现

上传文件主入口

这个入口做了获取绝对文件路径、压缩图片、将图片上传到 OSS 上、将 URL 插入编辑器中。

export const uploadFileMain = async () => {
  try {
    // 获取绝对路径
    const { filePath, delOriginalPath } = await getFilePath();
    // ...
    // 压缩图片
    const compressRet: ICompressRet = await compress(filePath);
    // 将图片上传到oss上
    const url: string = await upload2OSS(
      compressRet.readStream,
      compressRet.cachePath || delOriginalPath,
    );
    // 将URL插入到编辑器中
    await addUrl2Editor(url);
    // ...
  } catch (error: any) {
    // ...
  }
};

获取绝对文件路径

export const getFilePath = (): Promise<{
  filePath: string;
  delOriginalPath?: string;
}> => {
  return new Promise(async (resolve, reject) => {
    try {
      // 获取当前编辑器的实例
      const activeEditor = await getActiveEditor();
      const document = activeEditor.document;
      const selection = activeEditor.selection;
      const text = document.getText(selection);
      const extname = path.extname(text);
      // 将远程图片转换成本地路径
      if (validUrl.isUri(text) && imgExtname.includes(extname)) {
        return getFilePathFromRemote(text).then((filePath) => {
          resolve({
            filePath,
            delOriginalPath: filePath,
          });
        });
      }
      const { start, end, active } = selection;
      // 当光标未选择任何内容的时候,直接弹出选择图片的弹窗
      if (start.line === end.line && start.character === end.character) {
        getUrlFromOpenDialog()
          .then((filePath) => resolve({ filePath }))
          .catch(reject);
        return;
      }
      // 将相对路径装成绝对路径
      const filePath = (await getFilePathFromAbs(activeEditor))[0];
      resolve({ filePath });
    } catch (error: any) {
      // 兜底 弹窗选择
      getUrlFromOpenDialog()
        .then((filePath) => resolve({ filePath }))
        .catch(reject);
    }
  });
};
将 URL 图片转成图片路径

核心逻辑就是,将图片下载到本地,后将文件路径返回出去

const getFilePathFromRemote = (url: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    // 下载图片到本地
    download(url, path.join(__dirname))
      .then((_) => {
        // 将下载后的路径返回出去
        resolve(path.join(__dirname, path.basename(url)));
      })
      .catch((error) => {
        // ...
      });
  });
};
弹窗选择图片并获取图片路径

使用 vscode 内置功能选择图片,获取选中的图片路径并返回出去

export const getUrlFromOpenDialog = (
  options?: OpenDialogOptions,
): Promise<string> => {
  return new Promise((resolve, reject) => {
    const defaultOptions: OpenDialogOptions = {
      canSelectFiles: true,
      canSelectMany: false,
      title: '请选择上传文件',
      openLabel: '确认上传',
      filters: { Images: imgExtname },
    };
    // 弹出弹窗
    vscode.window
      .showOpenDialog(Object.assign(defaultOptions, options))
      .then((uri) => {
        const filePath = get(uri || {}, '0.path');
        if (!filePath) {
          reject(makeErrorMsg({ message: '文件选择失败' }, '本地文件选择'));
        }
        resolve(filePath);
      });
  });
};
将相对路径转换成绝对路径

这里主要分两种路径,一个是../../ 这种形式的相对路径,还有一种是含有别名的相对路径 @/xx/xxx

./../ 这个方式比较简单,直接使用 path 包就能处理了,

比较复杂的是 含有别名的相对路径 @/xx/xxx

含有别名的相对路径 首先需要从根目录下 [tj]sconfig.json 解析出对应的 paths 别名映射,然后与光标选中的图片路径做整合,形成完整的绝对路径

const getFilePathFromAbs = async (
  activeEditor: TextEditor,
): Promise<string[]> => {
  return new Promise(async (resolve, reject) => {
    try {
      const document: TextDocument = activeEditor.document;
      const selection: Selection = activeEditor.selection;
      const text = document.getText(selection);
      // 获取jsconfig tsconfig paths别名映射
      const aliasPathMap = await getAliasPathMap(document.uri);
      // 将选中的图片本地路径与别名结合生成绝对路径
      const matchFullPaths = aliasPathMap
        .map((map) => {
          const aliasRex = new RegExp(`^${map.key}`);
          const match = aliasRex.test(text);
          if (match) {
            const fullPath = path.join(map.value, text.replace(aliasRex, ''));
            if (
              imgExtname.includes(path.extname(fullPath)) &&
              fs.statSync(fullPath).isFile()
            ) {
              return fullPath;
            }
          }
        })
        .filter((p) => !!p) as string[];
      // 如果与别名匹配上了绝对路径,则返回对应的路径
      // @/resources/images/xxx.png → /Users/xxx/resources/images/xxx.png
      if (matchFullPaths && matchFullPaths?.length) {
        resolve(matchFullPaths);
        return;
      }

      // 上面别名为匹配到绝对路径,下面继续路径拼接
      // ../../images/xxx.png → /Users/xxx/resources/images/xxx.png
      try {
        // try 方式 路径还有别名导致 fs.statSync 解析失败导致异常
        const filePath = path.join(
          path.normalize(path.dirname(document.fileName)),
          text,
        );
        if (
          imgExtname.includes(path.extname(filePath)) &&
          fs.statSync(filePath).isFile()
        ) {
          resolve([filePath]);
          return;
        }
      } catch (error) {}
      reject(
        makeErrorMsg(
          { message: `根据别名未找到对应${imgExtname.toString()}文件` },
          '解析alias失败',
        ),
      );
    } catch (error) {
      reject(makeErrorMsg(error, '解析alias失败'));
    }
  });
};

我们来看一下 paths 别名映射的绝对路径怎么获取的:

/**
 * 获取 别名配置文件 jsconfig.json tsconfig.json
 * @returns
 */
const findTsConfigFiles = async (workFolder: vscode.WorkspaceFolder) => {
  const include = new vscode.RelativePattern(workFolder, '[tj]sconfig.json');
  const exclude = new vscode.RelativePattern(workFolder, '**/node_modules/**');
  const files = await vscode.workspace.findFiles(include, exclude);
  const parsedFiles = [];

  for (const file of files) {
    // 将[tj]config.json 装成 JSON
    try {
      const fileUri = vscode.Uri.file(file.fsPath);
      const fileContents = await vscode.workspace.fs.readFile(fileUri);
      const parsedFile = (JSON5 as any).default.parse(fileContents.toString());
      parsedFiles.push(parsedFile);
    } catch (error) {
      console.log(error);
    }
  }
  return parsedFiles;
};
/**
 * 获取ts配置的别名
 * @returns
 */
const getAliasPathMap = (resource: Uri): Promise<Mapping[]> => {
  return new Promise(async resolve => {
    let mappings: Mapping[] = [];
    try {
      const workFolder = vscode.workspace.getWorkspaceFolder(resource);
      if (workFolder) {
        // 获取别名配置文件 jsconfig.json tsconfig.json
        const parsedFiles = await findTsConfigFiles(workFolder);
        for (const parsedFile of parsedFiles) {
          const baseUrl = parsedFile?.compilerOptions?.baseUrl || '.';
          const paths = parsedFile?.compilerOptions?.paths || {};
          // baseUrl paths 是 tsconfig.json 里面的配置
          for (const pathMap of Object.entries(paths)) {
            const [key, values] = pathMap;
            if (typeof values === 'string') {
              mappings.push({
                key,
                value: path.join(workFolder.uri.fsPath, baseUrl, values),
              });
            } else if (Array.isArray(values)) {
              values.map(value => {
                mappings.push({
                  key,
                  value: path.join(workFolder.uri.fsPath, baseUrl, value),
                });
              });
            }
          }
        }
      }
    } catch {}
    // 将路径中含有*的去除
    resolve(mappings.map(({ key, value }) => ({ key, value: path.normalize(value.replace(/*/gi, '')) })));
  });
};

别名对应的绝对路径已经获取到了,剩下的就是将光标选中的路径与别名组合成完整的绝对路径。

// 获取paths别名映射的绝对路径
const aliasPathMap = await getAliasPathMap(document.uri);
const matchFullPaths = aliasPathMap
  .map((map) => {
    const aliasRex = new RegExp(`^${map.key}`);
    const match = aliasRex.test(text);
    if (match) {
      //map.value:别名映射的绝对路径
      // text: 光标选中的图片路径
      // 组合成完成的图片绝对路径
      const fullPath = path.join(map.value, text.replace(aliasRex, ''));
      if (
        imgExtname.includes(path.extname(fullPath)) &&
        fs.statSync(fullPath).isFile()
      ) {
        return fullPath;
      }
    }
  })
  .filter((p) => !!p) as string[];

压缩图片

使用 tinify 每月免费的 500 个图片压缩,主要是因为 tinify 压缩率高达 50% - 70%,而且肉眼看不出来(毕竟人家也是商业用途,值得信赖)。

Tinify 压缩

tinify key 需要自己去申请 TinyPNG 申请 key ,并且每个月免费压缩 500 次数(我们开发一般够用了)

export const tinifyCompress = (filePath: string): Promise<ICompressRet> => {
  return new Promise((resolve, reject) => {
    const tinifyKey = configuration.tinifyKey;
    tinify.key = tinifyKey;
    const basename = path.basename(filePath);
    const cachePath = path.resolve(__dirname, basename);
    // 将文件压缩输出到本地,然后转成Stream
    tinify
      .fromFile(filePath)
      .toFile(cachePath)
      .then(() => {
        const readStream: ReadStream = filePath2ReadStream(cachePath);
        resolve({ readStream, cachePath });
      })
      .catch(error => {
      });
  });

将图片访问地址插入编辑器中

将我们获取的图片文件,上传到 OSS 上,并获取返回

终于到了最后一步了,将获取的 URL 写入到编辑器中,这里区分两种,一个是直接插入,一个是替换,咱们从代码上来看看这两个区别

/**
 * 将URL插入到编辑器内
 * @param url
 */
export const addUrl2Editor = (url: string) => {
  return new Promise(async (resolve, reject) => {
    try {
      const activeEditor = await getActiveEditor();
      const selection = activeEditor.selection;
      const { start, end, active } = selection;
      // 光标处于未选中状态,直接将URL插入
      if (start.line === end.line && start.character === end.character) {
        activeEditor.edit((editBuilder) => {
          editBuilder.insert(active, url);
          resolve('');
        });
      } else {
        // 光标选中时候,直接替换
        activeEditor.edit((editBuilder) => {
          editBuilder.replace(selection, url);
          resolve('');
        });
      }
    } catch (error) {
      return reject(makeErrorMsg(error, '将URL插入到编辑器内'));
    }
  });
};

打包

官网给的方式 直接 vsce package 我这边会失败的,因为我这边是 pnpm 项目,vsce package 会执行

npm list --production --parseable --depth=99999 这在 pnpm 项目中由于依赖关系的不同处理而失败,Support pnpm Issue 给了比较好的方式 vsce package --no-dependencies,直接声明没有依赖,这样就会打包成功,

还有一种方式就是使用 vsce package --yarn

打包后生成插件 就可以在 vscode 使用了

四、总结

总的来说,我们主要做了这几个工作,解析图片地址 → 压缩图片 → 上传到 OSS → 将 URL 回显到编辑器内。

如果在我们项目中大量使用到图片的情况下,这个插件可以帮我节约许多时间,同时图片经过压缩,对于页面性能也有一定的提升。