手把手教你写一个自动更新iconfont图标库的npm包

2,679 阅读16分钟

前言

写过插件吗?写过npm包?面试难免会遇到这样的问题。其实,开发npm包应该是一个前端工程师需要掌握的基本技能。今天我手把手教你写一个小而美、真正有用的自动更新iconfont图标库的npm包,包括整个开发思路和最终发布,近5000字超级详细,适合新手食用。

需求分析

iconfont图标库是项目中比较常用的,但每一次的更新都非常麻烦,可以简述为以下4个步骤:

  1. 打开iconfont登录页,输入账号密码,登录
  2. 找到需要下载的图标库详情页
  3. 点击下载按钮,等待下载完成
  4. 将下载好的压缩包解压并重命名为iconfont文件夹,删除原有的iconfont文件夹,将新的iconfont文件夹拷贝到项目目录 即使图标库更新频率不高,但这种鼠标键盘的重复工作,干嘛需要我们手动去操作呢?因此,我们很自然而然地想到一个辅助工具——爬虫。现在的前端早已不是当初的切图仔,啥都得会,前台服务端都能写,还能做各种小程序、Android、iOS甚至Windows软件等,爬虫自然也是小意思啦......

技术选型

其实,前端爬虫技术还是有得选的,比如Cheerio等,不过我最后选择了Puppeteer,至于原因嘛?那时,前女友是老师,学期一结束就要下学期电子版课本备课。我找了很久才在某个网站看到,不过是100多张图片,作为程序员哪能一张张下载?我就用了Puppeteer完成了图片的批量下载并合并成一个PDF。Puppeteer是谷歌的亲儿子,碰巧我又有实践经验,就决定它了!

注:虽然Puppeteer的API比较简单,但如果你之前完全没有接触过,还是建议先简单浏览一遍文档,这样可以更好地理解接下来的代码,当然我也会尽量注释详细一些。

文档地址:Puppeteer中文文档

功能实现

爬虫大多数情况下就是模拟手动操作。接下来,我们按照需求分析所说的4个步骤来写代码。

步骤1:打开iconfont登录页,输入账号密码,点击登录按钮。

const puppeteer = require('puppeteer');
(async () => {
    // 打开Browser和Page
    // 可以简单地把Browser看作浏览器,Page当做标签页,具体可以查看文档
    // 这里就相当于打开一个浏览器,访问登录页
    browser = await puppeteer.launch({
      headless: true, // 无头浏览器,详细看文档
      timeout: 30000 // 超时/ms
    });
    page = await browser.newPage();
    // 打开登录页面,输入账号密码,点击登录按钮
    const loginUrl = 'https://www.iconfont.cn/login';
    await page.goto(loginUrl, { waitUntil: 'networkidle0' });
    await page.type('#userid', '18812345678', { delay: 50 });
    await page.type('#password', 'abc123', { delay: 50 });
    await page.click('.mx-btn-submit');
})()

步骤2:找到需要下载的图标库详情页,根据ID跳转到详情页

 // 图标库的id
let id = '123456';
// 图标库的详情页
let libraryUrl = 'https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=';
await page.goto(libraryUrl + id, { waitUntil: 'networkidle0' });

步骤3:点击下载按钮,等待下载完成

await page._client.send('Page.setDownloadBehavior', {
  behavior: 'allow', //允许下载请求
  downloadPath: 'user/project/test/assest'  //设置下载路径
});

// 点击"下载至本地"按钮
await page.click('.project-manage-bar a');
const start = Date.now();
while (!fs.existsSync('user/project/test/assest/download.zip')) {
  await page.waitForTimeout(1000);
  if (Date.now() - start >= 300000) {
    throw new Error('下载超时');
  }
}
console.log('下载完成');

步骤4:download.zip解压后会变成一个名字为font_XXX的文件夹,删除原有的iconfont文件夹和download.zip压缩包,将font_XXX文件夹重命名为iconfont文件夹

// 注意文件操作的步骤,具体的实现查看接下来的完整代码
await compressingZip(savePath);
await deleteDir(savePath);
await renameDir(savePath);
console.log('图标库更新完成');

// 任务执行完毕记得关闭Page和Browser
await page.close();
await browser.close();

我们将变量抽离出来,代码整理并封装成一个函数导出整个模块,完整代码如下:

const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs-extra');
const compressing = require('compressing');

 // 默认超时时间:30秒,Puppeteer打开Browser和Page的超时,下载图标库压缩包超时
const timeout = 30000;
const { loginUrl, projectLibraryUrl } = require('./iconfont.config');

// 获取绝对路径 && 路径拼接
const resolvePath = (filePath) => path.resolve(__dirname, filePath)
const joinPath = (...args) => path.join(...args)

/**
 * @description 输入一个图标库的信息,使用puppeteer模拟登录,下载图标并解压到相应目录
 * @param {String}  id 项目id
 * @param {String}  name 项目名称
 * @param {String}  user 账号(暂时只支持手机号)
 * @param {String}  password 密码
 * @param {String}  filePath 文件保存地址
 */
const downloadScript = async (id, name, user, password, filePath) => {
  // 打开Browser和Page
  browser = await puppeteer.launch({
    headless: true,
    timeout,
    defaultViewport: { // 默认视窗较小,宽高建议设置一下,防止页面需要滚动或者样式乱
      width: 1366,
      height: 768
    },
  });
  page = await browser.newPage();
  // 跳转到登录页面,输入账号密码,点击登录按钮
  await page.goto(loginUrl, { waitUntil: 'networkidle0' });
  spinner.start(chalk.green('开始登录'));
  await page.type('#userid', user, { delay: 50 });
  await page.type('#password', password, { delay: 50 });
  await page.click('.mx-btn-submit');

  // 登录成功后,打开项目链接
  await page.goto(`${projectLibraryUrl}&projectId=${id}`, {
    waitUntil: 'networkidle0'
  })

  // 通过CDP会话设置下载路径,理论上也支持相对路径,已经拼好了绝对路径,当然建议使用绝对路径
  let savePath = resolvePath(filePath);
  await page._client.send('Page.setDownloadBehavior', {
    behavior: 'allow', //允许下载请求
    downloadPath: savePath  //设置下载路径
  });
  
  // 点击"下载至本地"按钮
  await page.click('.project-manage-bar a');
  const start = Date.now(),
        zipPath = joinPath(savePath, 'download.zip');
  while (!fs.existsSync(zipPath)) {
    // 每隔一秒轮询一次,查看download.zip文件是否下载完毕,超时时间设为30秒
    await page.waitForTimeout(1000);
    if (Date.now() - start >= timeout) {
      throw new Error('下载超时');
    }
  }
  console.log('图标下载成功!');
  await page.close();
  await browser.close();

  // 解压 => 删除 => 重命名
  await compressingZip(savePath);
  await deleteDir(savePath);
  await renameDir(savePath);
  console.log('图标库更新完成!')
}

// 解压download.zip变成font_XXX的文件夹
async function compressingZip(savePath) {
  await compressing.zip.uncompress(joinPath(savePath, 'download.zip'), savePath)
}

// 删除原有iconfont文件夹和下载的download.zip
async function deleteDir(savePath) {
  let iconfontFolder = joinPath(savePath, 'iconfont');
  let zipFile = joinPath(savePath, 'download.zip');
  fs.existsSync(iconfontFolder) && await fs.remove(iconfontFolder);
  fs.existsSync(zipFile) && await fs.remove(zipFile);
}

// 将font_XXX的文件夹重命名为iconfont
async function renameDir(savePath) {
  const dirs = fs.readdirSync(savePath);
  for (let dir of dirs) {
    if (dir.startsWith('font_')) {
      await fs.rename(joinPath(savePath, dir), joinPath(savePath, 'iconfont'));
      break;
    }
  }
}

module.exports = downloadScript

为了更好讲解,省略了一些细节处理和打印提醒的代码,感兴趣地话可以到GitHub上查看源码,总共也就100来行。到此为止,我们的项目核心代码已完成50%,接下来看看怎么做成一个npm包并发布了。

项目目录

讲一下npm包的基本规范,项目目录如下图所示:

bin
│  cli.js
lib
│  updateOne.js
│  downloadScript.js
package.json
README.md
.gitignore
  • bin: 二进制可执行文件。所有的命令都是放在这里,一般我们在这里建一个cli.js文件,文件的开头为“#!/usr/bin/env node”
  • lib: 放置脚本文件,比如我们现在写的这个功能文件就可以放在这里
  • package.json: 需要重点关注的属性如下
    • bin: { "iconfont-manager": "./bin/cli.js"},这个特别关键,设置执行的命令和对应文件
    • version: 每次发布时都需要修改版本号,可手动改package.json的version属性;可npm version [ newversion | major | minor | patch ],这是比较常用的参数,依次对应大版本、小版本、补丁,version会自动加1、0.1、0.0.1,其他参数自行查一下,唉老实说,估计没几个人完整看过这个npm命令行文档。。。
    • repository: { "type": "git", "url": "项目的GitHub链接"},给npm包设置GitHub项目链接
    • homepage: "项目主页",可以设置独立的网站域名,如果是小项目的,设置为GitHub地址加#README.md即可
  • README.md: 直接在页面上展示的,功能说明文档不可马虎。 当然还有一些其他的文件,这里就不细说了,目前已足够使用。

bin/cli.js代码如下

#!/usr/bin/env node
const program = require('commander');

program.command('updateOne')
  .alias('uo')
  .description('更新单个图标库,需输入所有信息')
  .arguments('<id> <name> <user> <password> <filePath>')
  .action((id, name, user, password, filePath) => {
    require('../lib/updateOne')(id, name, user, password, filePath)
  })

program.parse(process.argv);

lib/updateOne.js代码如下

const downloadScript = require('./downloadScript');

async function updateOne(id, name, user, password, filePath) {
  await downloadScript(id, name, user, password, filePath, false, true)
}

module.exports = (...args) => {
  return updateOne(...args).catch(err => {
    throw err
  })
}

由此可知,新增一个命令的统一步骤:

  1. bin/cli.js中新增命令,声明参数等
  2. lib目录下创建一个相应的命令文件
  3. 实现这个命令的具体功能

简单测试

测试方法有两种:

  1. 项目目录下执行node ./bin/cli.js 命令 [参数]
node ./bin/cli.js updateOne <id> <name> <user> <password> <filePath>
  1. 项目目录下执行npm link,接下来就可以直接使用iconfont-manager作为全局命令,iconfont-manager 命令 [参数],推荐使用这种方式。
npm link
iconfont-manager updateOne <id> <name> <user> <password> <filePath>

发布npm包

  1. 注册npm:www.npmjs.com/signup
  2. 登录npm:在项目目录下输入npm login,输入npm的账号和密码
  3. 发布到npm:修改package.json的version,npm publish。稍微注意一下,为避免重名无法发布的问题,开发一个包之前就应该到npm上搜索一下包名是否存在。

到此为止,第一个npm包就发布成功了!全局安装,然后测试一下。

npm install iconfont-manager -g
iconfont-manager updateOne <id> <name> <user> <password> <filePath>

但是,每次更新需要输入那么多信息,还是很麻烦的?我们需要考虑一下如何扩展功能和优化。

功能扩展与优化

为解决每次更新都需要输入许多信息的问题,一开始,我是这么做的:

  1. 项目目录下创建好一个iconfont.config.js的配置文件
  2. 执行命令时会自动读取这个配置文件,获得id、name、user、password、filePath等信息, 但是这样也会有两个问题:1.团队小伙伴的账号密码不同,总不能共用一个公共账号一个文件吧?2.每个项目目录下都要创建一个这个文件。

update命令的实现

针对上面两个痛点,我想起了vue-cli,每次创建完项目时,都会询问是否保存当前项目选项作为模板,而这个模板信息存放在哪里呢?查看源码之后发现,它是在系统目录下建了一个全局的配置文件.vuerc,通过读写这个文件获取和修改模板信息。同样地,我们也在系统目录下新建一个.iconfontrc的文件,将所有的项目信息保存在里面。

{
  "projects": [
    {
      "id": "2936807",
      "name": "仓库系统",
      "user": "18812345678",
      "password": "abc123",
      "filePath": "/Users/wupeng/project/warehouse/src/assets"
    },
    {
      "id": "2291089",
      "name": "门户网站",
      "user": "18812345678",
      "password": "abc123",
      "filePath": "初始化后,这个字段是空的,需要手动设置图标保存的绝对路径"
    }
  ]
}

在lib目录下新增读取这个文件的模块readConfig.js

const fs = require('fs-extra');
const path = require('path');
const homedir = require('os').homedir();

// 解析用户目录下的.iconfontrc文件
const readConfig = async function () {
  let config = []
  if(!fs.existsSync(path.join(homedir, '.iconfontrc'))) {
    console.error(`.iconfontrc不存在,请在${homedir}目录下新建该文件`);
  } else {
    try {
      config = await fs.readJSON(path.join(homedir, '.iconfontrc'));
    } catch(err) {
      console.error(err)
    }
  }
  return config
}

module.exports = readConfig

在lib目录下新增写入这个文件的模块writeConfig.js

const fs = require('fs-extra');
const path = require('path');
const homedir = require('os').homedir();

// 解析用户目录下的.iconfontrc文件
const writeConfig = async function (content) {
  if(!fs.existsSync(path.join(homedir, '.iconfontrc'))) {
    console.error(`.iconfontrc不存在,请使用iconfont-manager init <phoneNumber> <password>进行初始化或新建该文件或自行在${homedir}目录下新建该文件`);
  } else {
    try {
      await fs.writeJSON(path.join(homedir, '.iconfontrc'), content);
    } catch(err) {
      console.error(err)
    }
  }
}

module.exports = writeConfig

在bin/cli.js中新增update命令

program.command('update')
  .alias('u')
  .description('更新已保存信息在本地的图标库')
  .arguments('<projectId>')
  .action((projectId) => {
    require('../lib/update')(projectId)
  })

在lib目录下新增一个update.js,用来更新.iconfontrc文件中存在的图标库

const readConfig = require("./readConfig");
const downloadScript = require('./downloadScript');

// 根据projectIds匹配.iconfontrc中对应的图标库信息,执行更新脚本
async function update(projectId) {
  const { projects } = await readConfig();
  const projectItem = projects.find(item => Number(item.id) === Number(proejctId))
  const { id, name, user, password, filePath} = projectItem;
  await downloadScript(id, name, user, password, filePath);
}

module.exports = (...args) => {
  return update(...args).catch(err => {
    throw err
  })
}

简单测试,图标库更新成功!

iconfont-manager update 2936807

init命令的实现

我们继续。一般人之前可能有十几个项目已经在使用iconfont图标库了,不可能让他们自己新建.iconfontrc的文件,然后一个个图标库信息填入到文件中吧?现在不是正在使用爬虫吗?那就干脆再写个爬虫脚本,把账号下的所有图标库信息爬取下来,然后自动创建一个.iconfontrc文件进行保存。

很快啊,在bin/cli.js新增init命令

program.command('init')
  .alias('i')
  .description('爬取所有的图标库的信息并保存在本地')
  .arguments('<phoneNumber> <password>')
  .action((phoneNumber, password) => {
    require('../lib/init')(phoneNumber, password)
  })

在lib目录下新增init.js

const initScript = require('./initScript');
const path = require('path');
const fs = require('fs-extra');
const chalk = require('chalk');
const homedir = require('os').homedir();

// 输入账号密码,远程爬取账号下的所有图标库并写入到用户目录下的.iconfontrc文件中
async function init (phoneNumber, password) {
  const projects = await initScript(phoneNumber, password);
  const file = path.join(homedir, '.iconfontrc');
  !fs.existsSync(file) && await fs.createFile(file);
  await fs.writeJSON(file, { projects });
  console.log(`
  初始化完毕,你可以${chalk.green('iconfont-manager ls')}查看你的所有项目.
  请设置好图标的保存目录(绝对路径)再进行其他操作,设置方法如下:
  1.在${chalk.green(homedir)}目录下找到${chalk.green('.iconfontrc')}文件直接编辑;
  2.执行${chalk.green('iconfont-manager ui')}命令通过图形化界面进行配置.`);
}

module.exports = (...args) => {
  return init(...args).catch(err => {
    throw err
  })
}

在lib目录下新增initScript.js实现功能

const puppeteer = require('puppeteer');
const log = console.log;
const chalk = require('chalk');

const { loginUrl, projectLibraryUrl } = require('./iconfont.config');

const initScript = async (user, password) => {
  // 打开Browser和Page,跳转到登录页面
  const browser = await puppeteer.launch({ headless: true, timeout: 30000 });
  log(chalk.green('✔ 打开Browser'));
  const page = await browser.newPage();
  log(chalk.green('✔ 打开Page'));
  await page.goto(loginUrl, { waitUntil: 'networkidle0' });

  // 输入账号密码,点击登录按钮
  log(chalk.green('✔ 登录开始'));
  await page.type('#userid', user, { delay: 50 });
  await page.type('#password', password, { delay: 50 });
  await page.click('.mx-btn-submit');
  await page.waitForNetworkIdle();
  log(chalk.green('✔ 登录成功'));

  // 登录成功后,打开项目库页面
  await page.goto(projectLibraryUrl, {
    waitUntil: 'networkidle0'
  })

  const projects = await page.$$eval('.J_scorll_project_own .nav-item, .J_scorll_project_corp .nav-item', (els, user, password) => {
    let list = []
    for(let i = 0; i < els.length; i++) {
      list.push({
        id: els[i].getAttribute('mx-click').match(/\((\S*)\)/)[1],
        name: els[i].innerText,
        user,
        password,
        filePath: ''
      })
    }
    return list
  }, user, password)

  await page.close();
  await browser.close();

  return projects
}

module.exports = initScript

ls命令的实现

即使.iconfontrc文件中已经保存好了id,但我们也不可能把每个项目的id背下来吧?为此,我们可以直接读取.iconfontrc文件,将所有的图标库信息通过列表打印出来,然后我们复制我们需要的id,执行update命令即可。

在bin/cli.js下新增ls命令

program.command('ls')
  .alias('l')
  .description('查看所有图标库的列表')
  .action(() => {
    require('../lib/ls')()
  })

在lib目录下新增ls.js

const readConfig = require('./readConfig');

async function ls () {
  const { projects } = await readConfig()
  console.table(projects, ['id', 'name', 'user', 'filePath']);
}

module.exports = () => {
  return ls().catch(err => {
    throw err
  })
}

当然还有一些其他的命令,我这里就不再一一列举了,具体的代码可到GitHub在查看,具体的功能和使用场景接下来会讲。

使用场景

全局安装iconfont-manager,接下来就可以在任意目录下更新你的图标库了

npm install iconfont-manager -g

1. 初始化项目

安装好iconfont-manager后,操作步骤:

  1. 输入iconfont官网的手机号和密码(目前不支持GitHub账号授权登录,毕竟不科学上网的话,GitHub时不时会抽风。若之前是GitHub账号,绑定手机号即可)。
  2. 自动执行爬虫脚本,将账号的所有iconfont图标库信息爬取并存储在用户目录.iconfontrc文件(不同平台的用户目录不同,注意查看命令执行之后的提示,如下图所示),目前这个配置文件的位置不能随意更改。
  3. 修改.iconfontrc文件的filePath属性,设置各个图标库对应的保存地址(请使用绝对路径),也可通过功能5图形化界面管理进行设置。
iconfont-manager init <phoneNumber> <password>

.iconfontrc格式如下:

{
  "projects": [
    {
      "id": "2936807",
      "name": "仓库系统",
      "user": "18812345678",
      "password": "abc123",
      "filePath": "初始化后,这个字段是空的,需要手动设置图标保存的绝对路径"
    },
    {
      "id": "2291089",
      "name": "门户网站",
      "user": "18812345678",
      "password": "abc123",
      "filePath": "/Users/wupeng/project/warehouse/src/assets"
    }
  ]
}

2. 查看所有图标库

使用场景:查看保存的所有图标库,可以快速复制图标库id,执行更新图标库任务

基本原理:读取用户目录下的.iconfontrc文件,将所有的图标库信息通过列表的形式展现。

iconfont-manager ls

3. 更新单个图标库

使用场景:更新图标库,只需输入一个图标库id(可以使用功能2先打印出来,复制你需要的id)

iconfont-manager update <projectId>

4. 更新多个图标库

使用场景:同时更新多个图标库,多个id空格隔开

iconfont-manager update <projectId...>

5. 图形化界面管理

使用场景:浏览器打开一个图形化管理界面,可在这个界面修改图标库信息,执行更新等操作;其实功能1初始化之后也可以通过这个命令打开一个图形化界面,在这个界面补充好filePath保存路径,记得点击“全部保存”按钮。

iconfont-manager ui

6. 更新临时项目

使用场景:如果.iconfontrc文件中没有这个图标库,可以使用这个命令临时下载一个图标库,信息将不会保存到.iconfontrc

iconfont-manager updateOne <id> <name> <user> <password> <filePath>

7. 新增一个项目

使用场景:来了一个新项目,把要图标库下载到项目目录中。项目信息会写入.iconfontrc文件中,同时最后一个参数可以选择是否立即更新图标库,该参数默认是false,如果是true,信息保存进配置文件同时,也会自动更新图标库。

iconfont-manager add <id> <name> <user> <password> <filePath> [immediately]

8.我使用频率最高的场景

美工告诉我某某图标库已更新,我悠闲地打开命令行工具,在任意目录输入iconfont-manager ls,在列表中找到并复制美工说的那个图标库对应的id,输入iconfont-manager update id,然后就可以静待图标库自动更新完成了。

项目总结

核心技术:将图标库的信息保存在本地配置文件,通过node.js读取本地配置文件,执行不同的指令,使用Puppeteer去爬取iconfont官网并下载图标库,再将下载好的文件解压保存在指定目录。

思路比编码更重要:其实整个代码量并不多也不难,这也是我一开始所说的“小而美”的npm包。思路怎么来?一是自己的需求,一开始我自己下载的时候也觉得很麻烦,后面也有单个项目的脚本下载的,再到今天这个在任意目录下更新的版本,其实也是经过多次升级。二是借鉴,我在搭建Vue脚手架时,看了vue-cli的源码,而在这个项目中就发挥了作用,.iconfontrc借鉴了.vuerc,iconfont-manager ui借鉴了vue ui,不过ui命令实现的不太好,虽然功能都实现了,但后面会找时间进行重构一下。

关于Puppeteer:这种爬虫工具,其实不建议同时开启多个,一是占内存,二是出错的话不好定位,除非你有特殊的大数据需求之类的。所以那些同时更新多个图标库的命令我都是更新完一个再进行下一个的。另外,强烈建议大家好好学一下这个东西,有时候还是很有用的。

多说两句

1、如果你用的是iconfont图标库,可直接安装试试npm install iconfont-manager -g,如果你的项目仍需对下载的iconfont进行处理,也可将这个项目clone下来,加上你的逻辑实现你想要的功能;如果你用的是其他图标库,照葫芦画瓢很容易,可以按这个思路开发一个属于你的自动更新图标库的npm包。

2、爬虫是需要维护的,页面元素、请求接口、防爬机制等随时可能变化,因此没有哪个爬虫可以用10年,可能一个不注意就爬进去10年,谨慎使用。

项目地址