前言
公司图标是上传到阿里iconfont
进行管理,各个团队各个项目需要更新图标时,自行下载到本地,然后解压,替换掉项目内的图标文件,这个过程听起来就有点繁琐,按理说采用cdn
的引入方式可能会更好一些,但是需要考虑到有些项目需要内网,或者没有网络的情况。为了解决这个问题,我将字体文件发布为npm
包,定时去更新版本,然后在项目里,只需要更新npm
包,这样子确实是对图标实现了统一管理,也提高了业务部门的开发效率,受到了大家的好评。
但是问题来了,由于团队比较多,又都是使用统一图标库,经常有人需要新的图标,于是总有人催我赶紧更新版本,替换图标的复杂流程再加上需要发布npm包的压力就给我我身上了,有时候正在开心码字、甚至是摸鱼时,总是被不合时宜的催更打断,所以,为了更好的摸鱼,我决定把这个过程自动化,经过一段时间的调研和实践,终有所成效。也有了这篇文章的输出,希望能解决跟我一样处于水深火热的兄弟们。当然也是简历亮点,集成了typescript
,爬虫
,cli工具开发
,npm包发布
。
准备
正常一个稍微复杂的工具库,都是另外一些工具的组合和编排,我们这个实现iconfont图标库自动下载,自动发布为npm包也不例外,算是爬虫、cli工具开发以及一个npm包发布的相关知识点组合而成,先来介绍一下这次开发使用到的依赖工具。
puppeteer
对于爬虫,可能大家的第一印象就是python
,因为总有周围的人这样说。其实大多数后端语言也都能实现爬虫,比如Java
、Go
等等,如果你是一个全能型选手,你选择怎么样的爬虫方式都可以,如果你跟我一样,只会前端,那选择基于Node
的puppeteer
是一个不错的选择,首先,对于前端或多或少都了解一些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-extra
是fs
的一个扩展,提供了非常多的便利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
shelljs
是Unix Shell
在Node.js API
层的轻量级实现,对于Node
的child_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
是用来设置终端字符串样式,可以通过命令设置我们需要的文本样式。
安装依赖
npm i chalk
# or yarn add chalk
基础使用
import { green, yellow } from 'chalk';
// 输出绿色文本
console.log(green('我是绿色文本'))
// 输出黄色文本
console.log(yellow('我是黄色文本'))
compressing
compressing
是一个实现压缩和解压的node
工具库,目前支持tar
、gzip
、tgz
、zip
等压缩文件,对于异步实现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
开发的一些基础知识点,这里就不做展开了,可以看我之前写的文章,从零开始一步步实现,包括遇到的问题,解决方式都讲的很详细。
代码实现
目录结构
整体流程
虽然流程看着比较多,但是每一步都比较简单,总体代码也就两三百行。
本地调试
在项目根目录下的 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/。
开发结束可执行 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 -v
或 icon-cli --version
用于查看版本号,icon-cli update <user-name> <password> <project_id>
用于实现具体功能,user-name
和password
是iconfont的账号密码,通过下图可以发现,每个图标库有对应的projectId,所以project_id是用来跳转到对应图标库。
使用方式
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
/**
* 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包,大家可以自己运行跑一下,不一定需要整体运行,可以根据流程图和代码结构,一步一步进行执行和调试,代码的整体扩展性还是不错的,需要更完善的功能可以自行扩展,也可以评论区留言,由我来实现。
这篇文章算是一个抛砖引玉吧,希望给同是程序员的你多一种思考。对于一些简单,但是流程繁琐的事情,可以考虑自动化,工程化去解决,这样子有挺多好处的吧。
- 有更多的摸鱼时间
- 有更多的时间去思考并写出更简洁、更健壮的代码。
- 能在自己的简历里有更多的亮点
- 在实现的阐述里,也能体现你是一个善于思考和实践的有能力小伙。