为了更好的摸鱼,我实现了iconfont图标库自动下载,并发布为npm包

4,588 阅读9分钟

前言

公司图标是上传到阿里iconfont进行管理,各个团队各个项目需要更新图标时,自行下载到本地,然后解压,替换掉项目内的图标文件,这个过程听起来就有点繁琐,按理说采用cdn的引入方式可能会更好一些,但是需要考虑到有些项目需要内网,或者没有网络的情况。为了解决这个问题,我将字体文件发布为npm包,定时去更新版本,然后在项目里,只需要更新npm包,这样子确实是对图标实现了统一管理,也提高了业务部门的开发效率,受到了大家的好评。

但是问题来了,由于团队比较多,又都是使用统一图标库,经常有人需要新的图标,于是总有人催我赶紧更新版本,替换图标的复杂流程再加上需要发布npm包的压力就给我我身上了,有时候正在开心码字、甚至是摸鱼时,总是被不合时宜的催更打断,所以,为了更好的摸鱼,我决定把这个过程自动化,经过一段时间的调研和实践,终有所成效。也有了这篇文章的输出,希望能解决跟我一样处于水深火热的兄弟们。当然也是简历亮点,集成了typescript爬虫cli工具开发npm包发布

准备

正常一个稍微复杂的工具库,都是另外一些工具的组合和编排,我们这个实现iconfont图标库自动下载,自动发布为npm包也不例外,算是爬虫、cli工具开发以及一个npm包发布的相关知识点组合而成,先来介绍一下这次开发使用到的依赖工具。

puppeteer

对于爬虫,可能大家的第一印象就是python,因为总有周围的人这样说。其实大多数后端语言也都能实现爬虫,比如JavaGo等等,如果你是一个全能型选手,你选择怎么样的爬虫方式都可以,如果你跟我一样,只会前端,那选择基于Nodepuppeteer是一个不错的选择,首先,对于前端或多或少都了解一些Node,其次puppeteer使用起来很简单,最后,puppeteer是谷歌出品,功能很完善和强大。

你可以在浏览器中手动执行的绝大多数操作都可以使用 Puppeteer 来完成!

  • 生成页面 PDF。
  • 抓取 SPA(单页应用)并生成预渲染内容(即“SSR”(服务器端渲染))。
  • 自动提交表单,进行 UI 测试,键盘输入等。
  • 创建一个时时更新的自动化测试环境。 使用最新的 JavaScript 和浏览器功能直接在最新版本的Chrome中执行测试。
  • 捕获网站的 timeline trace,用来帮助分析性能问题。
  • 测试浏览器扩展。

安装依赖

npm i puppeteer 
# or yarn add puppeteer

使用puppeteer打开一个网页,然后进行截图

const puppeteer = require('puppeteer');  
  
(async () => {  
    const browser = await puppeteer.launch();  
    const page = await browser.newPage();  
    await page.goto('https://example.com');  
    await page.screenshot({path: 'example.png'});  

    await browser.close();  
})();

fs-extra

Node的文件系统(fs)Api都是采用回调函数来处理异步,而随着Promise的诞生,大多数人更喜欢Promise处理异步的方式,而fs-extrafs的一个扩展,提供了非常多的便利API,并且继承了fs所有方法和为fs方法添加了promise的支持。

使用fs的你:

import fs from 'fs';

fs.readJson('./package.json', (err, packageObj) => {
    if (err) console.error(err)
    console.log(packageObj.version)
})

使用fs-extra的你:

import fs from 'fs-extra';

try {
    const packageObj = await fs.readJson('./package.json')
    console.log(packageObj.version)
} catch (err) {
    console.error(err)
}

shelljs

shelljsUnix ShellNode.js API层的轻量级实现,对于Nodechild_process做了进一层的封装,让我们调用系统命令更加简单,可以支持Windows、Linux、OS X,我们使用它的作用主要是用来执行一些终端命令,比如运行项目打包npm run build,运行发布npm包npm publish拉取git项目模板等等

安装依赖

npm i shelljs 
# or yarn add shelljs

基础使用

import * as shell from 'shelljs';

// 运行项目打包命令
shell.exec('npm run build')
// 终端内容输出
shell.echo('Error: npm run build failed');
// 中断终端命令执行
shell.exit(1)

chalk

chalk是用来设置终端字符串样式,可以通过命令设置我们需要的文本样式。

image.png

安装依赖

npm i chalk 
# or yarn add chalk

基础使用

import { green, yellow } from 'chalk';


// 输出绿色文本
console.log(green('我是绿色文本'))

// 输出黄色文本
console.log(yellow('我是黄色文本'))

compressing

compressing是一个实现压缩和解压的node工具库,目前支持targziptgzzip等压缩文件,对于异步实现promise封装。

安装依赖

npm i compressing 
# or yarn add compressing

基础使用

import * as compressing from 'compressing';

// compressing.zip.uncompress(sourcePath, targetPath)

// 解压
const handleUncompress = async => (filePath: string) {
  await compressing.zip.uncompress(join(filePath, 'download.zip'), filePath);
}

cli基础知识点

关于cli开发的一些基础知识点,这里就不做展开了,可以看我之前写的文章,从零开始一步步实现,包括遇到的问题,解决方式都讲的很详细。

代码实现

目录结构

image.png

整体流程

虽然流程看着比较多,但是每一步都比较简单,总体代码也就两三百行。

image.png

本地调试

在项目根目录下的 pacakge.json 中增加如下内容:

"bin": {
    "icon-cli": "./bin/icon-cli.js"
 },

对于bin这个属性,大家平时可能比较少接触,这个是用来配置相应命令(比如我们现在配置的icon-cli)的可执行文件,在当前项目命令行执行npm link后,会将 bin 的值路径添加全局链接,之后我们在命令行中执行icon-cli就会执行./bin/icon-cli.js 文件,通过这个入口文件,将整个项目串联起来。

当用户安装带有 bin 字段的包时,如果是全局安装,npm 将会使用符号链接把这些文件链接到全局的 node_modules/.bin 中;如果是本地安装,会链接到当前项目的./node_modules/.bin/。

image.png

开发结束可执行 npm unlink icon-cli 去掉 icon-cli 的链接,如果不幸你执行 npm link 命令之后你改变了你的目录名,在 unlink 时会无效,只能手动去全局的 node_modules 中删除对应的软连接

在项目根目录下添加 bin 目录,然后在 bin 目录下新建 icon-cli.js,文件内容如下:

#! /usr/bin/env node

// #! 符号的名称叫 Shebang,用于指定脚本的解释程序
// 这句脚本的作用是指定用node执行当前脚本文件
// 如果是Linux 或者 macOS 系统下还需要修改此文件的读写权限为 755
// 具体就是通过 chmod 755 icon-cli.js 实现修改

// 将构建目录(lib)下的 index.js 作为脚手架的入口

require('../lib/index');

/src/index.ts

这个文件算是源码入口,实现了icon-cli的两个命令,icon-cli -vicon-cli --version用于查看版本号,icon-cli update <user-name> <password> <project_id>用于实现具体功能,user-namepasswordiconfont的账号密码,通过下图可以发现,每个图标库有对应的projectId,所以project_id是用来跳转到对应图标库。

image.png

使用方式

icon-cli update 178****8982 123456 3726883
import { program } from 'commander';
import update from './order/update';

// 查看版本 icon-cli -v 或 icon-cli --version
/* eslint-disable @typescript-eslint/no-var-requires */
program
  .version(`${require('../package.json').version}`, '-v --version')
  .usage('<command> [options]');

// 创建项目命令 icon-cli update
program
  .command('update <user-name> <password> <project_id>')
  .description('auto handle iconfont')
  .action(async (userName: string, password: string, projectId: string) => {
    // 创建逻辑
    await update(userName, password, projectId);
  });

program.parse(process.argv);

/src/order/update.ts

这个文件是update命令的入口文件,借助于async await实现所有命令的同步执行,一步一步实现完整功能。

/**
 * update 命令的具体任务
 */
import {
  handleInit,
  handleLogin,
  handleToLibrary,
  handleInitDownload,
  handleIknowBtn,
  handleDownload,
  handleClose,
  handleIconFile,
  handleIconMove,
  handleBuild,
  handleChangeVersion,
  handlePublish,
} from '../utils/update';

// create 命令
export default async function update(
  userName: string,
  password: string,
  projectId: string,
): Promise<void> {
  // 初始化
  const { page, browser } = await handleInit();
  // 登录
  await handleLogin(page, userName, password);
  // 跳转到对应图标库
  await handleToLibrary(page, projectId);
  // 初始化下载
  await handleInitDownload(page);
  // 关闭提示弹窗
  await handleIknowBtn(page);
  // 下载
  await handleDownload(page);
  // 关闭浏览器
  await handleClose(page, browser);
  // 处理字体文件
  await handleIconFile();
  // 字体文件移动
  await handleIconMove();
  // 打包
  handleBuild();
  // 修改版本号
  handleChangeVersion();
  // 发包
  handlePublish();
}

/src/utils/update.ts

这边需要的注意的点是npm包的发布,首次必须执行npm login进行登录,然后npm publish进行包的发布,不然会一直报403错误,后续就可以自动发布了,自动发布能实现的原因是借助于.npmrc设置了npm的登录信息。

对于npm包发布可以查看文章

// npm 源
registry=https://registry.npmjs.org/
// 账号:密码 的base64
_auth=aWxwcDoxO****DE0NnBw
// 邮箱
email=xxx@xx.com

image.png

/**
 * update 命令需要用到的所有方法
 */

import { green } from 'chalk';
import {
  printMsg,
  getFilePath,
  TIMEOUT,
  handleUncompress,
  handleDelete,
  handleRename,
} from './common';
import { existsSync, moveSync } from 'fs-extra';
import { join } from 'path';
import * as puppeteer from 'puppeteer';
import * as shell from 'shelljs';

// 图标下载路径
const filePath = getFilePath('temp');
// 最终存放字体文件的路径
const targetPath = getFilePath('fonts');
// 最终需要的字体文件
const fileList = ['iconfont.css', 'iconfont.json'];
// 登录地址
const loginUrl = 'https://www.iconfont.cn/login';
// 对应图标库的基础路径
const projectLibraryUrl =
  'https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=';

/**
 * 初始化
 * 打开Browser和Page,跳转到登录页面
 * @return object(page, browser)
 */

// headless属性用于设置是否无头浏览器,开发时可以设置为false,便于查看具体流程
export async function handleInit(): Promise<any> {
  const browser = await puppeteer.launch({
    headless: false,
    timeout: TIMEOUT,
    defaultViewport: {
      // 默认视窗较小,宽高建议设置一下,防止页面需要滚动或者样式乱
      width: 1366,
      height: 768,
    },
  });
  printMsg(green('✔ 打开Browser'));
  const page = await browser.newPage();
  printMsg(green('✔ 打开Page'));
  await page.goto(loginUrl, { waitUntil: 'networkidle0' });
  return {
    page,
    browser,
  };
}

/**
 * 登录
 */

export async function handleLogin(
  page: any,
  userName: string,
  password: string,
): Promise<void> {
  printMsg(green('✔ 登录开始'));
  // 根据选择器获取对象dom,输入账号密码,点击登录按钮
  await page.type('#userid', userName, { delay: 50 });
  await page.type('#password', password, { delay: 50 });
  await page.click('.mx-btn-submit');
  // 根据当前页面再也没有网络请求,判断为登录结束
  await page.waitForNetworkIdle();
  printMsg(green('✔ 登录成功'));
}

/**
 * 跳转到对应图标库
 */
export async function handleToLibrary(
  page: any,
  projectId: string,
): Promise<void> {
  // 登录成功后,打开对应图标库
  printMsg(green('跳转到对应图标库'));
  // 拼接用户传入的projectId,实现对应图标库跳转
  await page.goto(`${projectLibraryUrl}${projectId}`, {
    waitUntil: 'networkidle0',
  });
  // 根据图标库的下载图标按钮存在,判断跳转图标库成功
  await page.waitForSelector('.project-manage-bar > a.bar-text');
  printMsg(green('图标库管理页跳转成功'));
}

/**
 * 关闭提示弹窗
 */
// 在图标库会弹出一些提示弹窗,无法确定多少个,直接进行遍历,全部关闭
export async function handleIknowBtn(page: any): Promise<void> {
  await page.$$eval('.btn-iknow', (btns) => btns.map((btn) => btn.click()));
}

/**
 * 初始化下载
 */
export async function handleInitDownload(page: any): Promise<void> {
  await page._client.send('Page.setDownloadBehavior', {
    behavior: 'allow', //允许下载请求
    downloadPath: filePath, //设置下载路径
  });
}

/**
 * 处理文件下载
 */
export async function handleDownload(page: any): Promise<void> {
  // 点击下载按钮,触发压缩包下载(下载按钮由于没有特殊标记,所以暴力选择第一个a标签:下载至本地)
  await page.click('.project-manage-bar > a.bar-text');
  const start = Date.now(),
    zipPath = join(filePath, 'download.zip');
  while (!existsSync(zipPath)) {
    // 每隔一秒轮询一次,查看download.zip文件是否下载完毕,超时时间设为30秒
    await page.waitForTimeout(1000);
    if (Date.now() - start >= TIMEOUT) {
      throw new Error('下载超时');
    }
  }
  printMsg('图标下载成功!');
}

/**
 * 关闭浏览器
 */
export async function handleClose(page: any, browser: any): Promise<void> {
  await page.close();
  await browser.close();
}

/**
 * 处理字体文件
 */
export async function handleIconFile(): Promise<void> {
  // 解压 => 删除 => 重命名
  printMsg(green('开始处理字体文件'));
  // 因为下载的时download.zip,所以需要进行解压
  await handleUncompress(filePath);
  // 删除掉download.zip
  await handleDelete(filePath);
  // 由于解压出来的文件名称不确定,不好进行路径读取,所以重命名为iconfont
  await handleRename(filePath);
  printMsg(green('字体文件处理完成'));
}

/**
 * 文件移动
 */
// 利用moveSync将fileList: ['iconfont.css', 'iconfont.json']两个文件移动到目标路径fonts
export function handleIconMove(): void {
  printMsg(green('开始移动字体文件'));
  fileList.map(async (file) => {
    const _sourcePath = join(filePath, 'iconfont', file);
    const _targetPath = join(targetPath, file);
    moveSync(_sourcePath, _targetPath);
  });
  printMsg(green('字体文件移动完成'));
}

/**
 * 打包构建
 */
// 由于shell.exec('npm run build')是同步的,所以执行起来挺方便的
export function handleBuild(): void {
  printMsg(green('打包开始'));
  if (shell.exec('npm run build').code !== 0) {
    shell.echo('Error: npm run build failed');
    shell.exit(1);
  }
  printMsg(green('打包完成'));
}

/**
 * 修改版本号
 */
// 使用npm version patch递增小版本号
export function handleChangeVersion(): void {
  printMsg(green('开始修改版本'));
  if (shell.exec('npm version patch').code !== 0) {
    shell.echo('Error: npm version patch failed');
    shell.exit(1);
  }
  printMsg(green('版本修改完成'));
}

/**
 * 发布
 */
// 发布
export function handlePublish(): void {
  printMsg(green('开始发包'));
  if (shell.exec('npm publish').code !== 0) {
    shell.echo('Error: npm publish failed');
    shell.exit(1);
  }
  printMsg(green('发包完成完成'));
}

/src/utils/common.ts

对于icon-cli这个cli工具的所有通用工具函数可以放在这个文件内。

/**
 * 放一些通用的工具方法
 */

import { resolve, join } from 'path';
import { existsSync, remove, readdirSync, rename } from 'fs-extra';
import * as compressing from 'compressing';

export const TIMEOUT = 30000;

/**
 * 获取项目绝对路径
 * @param filePath 项目名
 */
export function getFilePath(filePath: string): string {
  return resolve(process.cwd(), filePath);
}

/**
 * 打印信息
 * @param msg 信息
 */
export function printMsg(msg: string): void {
  console.log(msg);
}

/**
 * 解压
 */
export async function handleUncompress(filePath: string) {
  await compressing.zip.uncompress(join(filePath, 'download.zip'), filePath);
}

/**
 * 删除多余文件
 */
export async function handleDelete(filePath: string) {
  const iconfontFolder = join(filePath, 'iconfont');
  const zipFile = join(filePath, 'download.zip');
  existsSync(iconfontFolder) && (await remove(iconfontFolder));
  existsSync(zipFile) && (await remove(zipFile));
}

/**
 * 文件重命名
 */
// download.zip 解压后的名称会议font_开头,借助这个特性进行文件重命名
export async function handleRename(filePath) {
  const dirs = readdirSync(filePath);
  for (const dir of dirs) {
    if (dir.startsWith('font_')) {
      await rename(join(filePath, dir), join(filePath, 'iconfont'));
      break;
    }
  }
}

小结

文章的相应代码已经提交到Github,也同步发布了npm包,大家可以自己运行跑一下,不一定需要整体运行,可以根据流程图和代码结构,一步一步进行执行和调试,代码的整体扩展性还是不错的,需要更完善的功能可以自行扩展,也可以评论区留言,由我来实现。

image.png

这篇文章算是一个抛砖引玉吧,希望给同是程序员的你多一种思考。对于一些简单,但是流程繁琐的事情,可以考虑自动化,工程化去解决,这样子有挺多好处的吧。

  • 有更多的摸鱼时间
  • 有更多的时间去思考并写出更简洁、更健壮的代码。
  • 能在自己的简历里有更多的亮点
  • 在实现的阐述里,也能体现你是一个善于思考和实践的有能力小伙。