从 0 到 1 开发一个 node 命令行工具

G2 5.0 推出了服务端渲染的能力,为了让开发者更快捷得使用这部分能力,最写了一个 node 命令行工具 g2-ssr-node:用于把 G2 的 spec 转换成 png、jpeg 或者 pdf 等。基本的使用如下:

bash 复制代码
$ g2-ssr-node g2png -i ./bar.json -o ./bar.png

其中 bar.json是一个如下绘制条形图的 G2 spec:

javascript 复制代码
{
  "type": "interval",
  "data": [
    { "genre": "Sports", "sold": 275 },
    { "genre": "Strategy", "sold": 115 },
    { "genre": "Action", "sold": 120 },
    { "genre": "Shooter", "sold": 350 },
    { "genre": "Other", "sold": 150 }
  ],
  "encode": {
    "x": "genre",
    "y": "sold"
  }
}

最后得到如下的图片 bar.png

那接下来就来看看如何从 0 到 1 实现 g2-ssr-node。

初始化环境

首先新建一个文件夹并且用 npm 初始化项目:

bash 复制代码
$ mkdir g2-ssr-node && cd g2-ssr-node && npm init -y

在得到的 package.json 中增加一个 bin 字段,该字段指向了包里面的可执行文件,也就是最后运行 $ g2-ssr-node 时候执行的文件。

json 复制代码
{
  "bin": "bin/g2-ssr-node.js",
}

接下来新增 bin 目录、新增 g2-ssr-node.js,并且输入以下的内容:

javascript 复制代码
#!/usr/bin/env node

console.log('hello world!')

其中第一行说明用 node 来执行 g2-ssr-node.js 文件,类似于当运行 $ g2-ssr-node的时候调用 $ node g2-ssr-node.js

这之后在项目根目录下运行 npm link 会把当前包安装到全局。如果打开控制台输入 g2-ssr-node能打印出 hello world!, 那么说明开发环境已经搭建好了,可以进一步写代码了。

安装 commander

首先安装 commnder 工具包来简化开发命令行的成本,比如帮助解析参数、展现使用错误和实现提示信息等。

bash 复制代码
npm i commander -D

本文实现的 g2-ssr-node 有一个子命令:g2png,将 G2 的 spec 转换成图片。该子命令有两个参数:

  • -i, --input <filename>: 指定包含需要转换的 spec 的文件地址
  • -o, --output <filename>: 指定输出图表的文件地址

修改 bin/g2-ssr-node.js 如下用于满足上述需求:

javascript 复制代码
#!/usr/bin/env node
const { Command } = require("commander");
const process = require("node:process");
const { version } = require("../package");
const { g2png } = require("./g2png.js");

const program = new Command();

program
  .name("g2-ssr-node")
  .description("CLI for ssr of @antv/g2")
  .version(version);

program
  .command("g2png") // 添加子命令
  .description("Convert a G2 spec to an PNG image") // 添加对子命令的描述
  .option("-i, --input <filename>", "filename for the input spec") // 声明参数
  .option("-o, --output <filename>", "filename for the output image") // 声明参数
  .action((options) => g2png(options).then(() => process.exit())); // 真正的执行函数

program.parse(); // 解析

然后新增 bin/g2png.js这个文件,并且导出g2png(options)这个函数,用于根据指定的配置渲染图片。

javascript 复制代码
// bin/g2png.js

function g2png(options) {
  console.log(options)
}

module.exports = { g2png };

如果一切顺利的话,运行如下测试命令:

bash 复制代码
$ g2-ssr-node g2png -i ./bar.json -o ./bar.png

会在控制台输出:{ input: './bar.json', output: './bar.png' }

当然也可以运行 g2-ssr-node g2png --help看看控制台输出的帮助信息是否符合预期:

sql 复制代码
Usage: g2-ssr-node g2png [options]

Convert a G2 spec to an PNG image

Options:
  -i, --input <filename>   filename for the input spec
  -o, --output <filename>  filename for the output image
  -h, --help               display help for command

如果没有问题的话,就可以进入下一步,实现 g2png 函数。

实现 g2png 函数

g2png 主要包含三个步骤:

  • 根据 input 地址读取 JSON 内容,解析成 JavaScript 对象,得到需要渲染的 spec。
  • 将得到的 spec 通过 renderImage 函数渲染成 node canvas 中的 canvas 对象。
  • 将 canvas 对象转换成 png 流并且写入 output 地址。

首先我们安装 node canvas。 node canvas 是一个实现了 canvas 标准的 node 库,主要用于做 canvas 的服务端渲染,更多的 API 参考其文档

bash 复制代码
$ npm i canvas -D

修改 bin/g2png.js代码如下:

javascript 复制代码
const fs = require("fs");
const { renderImage } = require("./renderImage.js");

function readJSONSync(input) {
  const data = fs.readFileSync(input, "utf-8");
  return JSON.parse(data);
}

async function g2png({ input, output }) {
  console.log(`Start converting ${input} to ${output} ...`);

  // 读取并且转化 Spec
  const spec = await readJSONSync(input);

  // 将 Spec 渲染成 node canvas 中的 canvas
  const canvas = await renderImage(spec);

  // 将 canvas 转化成 png 流并且写入 output 地址
  const out = fs.createWriteStream(output);
  const stream = canvas.createPNGStream();
  stream.pipe(out);
  return new Promise((resolve, reject) => {
    out
      .on("finish", () => {
        console.log(`Convert ${input} to ${output} successfully.`);
        resolve();
      })
      .on("error", () => reject());
  });
}

module.exports = { g2png };

那么接下来我们来看看 renderImage 函数的实现。

实现 renderImage 函数

首先新建 bin/renderImage.js文件,并且输入如下的代码。这段代码将一个 node-canvas 创建的 canvas 对象传给了 G,用于创建一个画布,然后 G2 将 spec 渲染到这个画布上,并且将 canvas 返回。

javascript 复制代码
// bin/renderImage.js
const { createCanvas } = require("canvas");
const { stdlib, render: renderChart } = require("../dist/g2.js");
const { Canvas } = require("../dist/g");
const { Renderer } = require("../dist/g-canvas");

async function renderImage(options) {
  // 创建 canvas
  const { width = 640, height = 480, ...rest } = options;
  const [gCanvas, canvas] = createGCanvas(width, height);

  // 根据 spec 和 context 渲染图表到 canvas 上
  const spec = { ...rest, width, height };
  const context = {
    canvas: gCanvas,
    library: stdlib,
    createCanvas: () => createCanvas(300, 150),
  };
  await new Promise((resolve) => renderChart(spec, context, resolve));
  return canvas;
}

function createGCanvas(width, height, type) {
  const canvas = createCanvas(width, height, type);
  const offscreenCanvas = createCanvas(1, 1);
  const renderer = new Renderer();

  // 移除一些和 DOM 相关的交互
  const htmlRendererPlugin = renderer.getPlugin("html-renderer");
  const domInteractionPlugin = renderer.getPlugin("dom-interaction");
  renderer.unregisterPlugin(htmlRendererPlugin);
  renderer.unregisterPlugin(domInteractionPlugin);

  return [
    new Canvas({
      width,
      height,
      canvas,
      renderer,
      offscreenCanvas,
      devicePixelRatio: 2, // 解决高分辨率屏不清晰的问题
    }),
    canvas,
  ];
}

module.exports = { renderImage };

这里大家可能注意到了在上面代码中并没有直接从 G2 或者 G 中导入我们需要的函数,那么接下来就来看看为什么。

javascript 复制代码
// renderImage.js
// 从 dist 导出
const { stdlib, render: renderChart } = require("../dist/g2.js");
const { Canvas } = require("../dist/g");
const { Renderer } = require("../dist/g-canvas");

打包 commonjs

JavaScript 常见的模块系统有两种 ESM 和 commonjs,而低版本的 Node 只支持 commonjs。虽然 G2 和 G 都提供了 commonjs 的版本,但是它们的依赖却不一定,比如 D3.js 只提供了 ESM 版本。为了让 g2-ssr-node 能在低版本的 Node 中运行,需要把 G2 和 G 以及它们的依赖都打包成 commonjs。

首先分别创建以下三个文件,并且输入以下的内容:

javascript 复制代码
// @antv/g2.js
export * from '@antv/g2';
javascript 复制代码
// @antv/g.js
export * from '@antv/g';
javascript 复制代码
// @antv/g-canvas.js
export * from '@antv/g-canvas'

然后使用 Rollup 和它的一系列插件来将上述的文件打包成 commonjs 模块:

javascript 复制代码
npm i rollup @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-terser -D

配置文件如下:

javascript 复制代码
// rollup.config.js
const resolve = require("@rollup/plugin-node-resolve");
const cjs = require("@rollup/plugin-commonjs");
const json = require("@rollup/plugin-json");
const terser = require("@rollup/plugin-terser");

const common = {
  plugins: [resolve(), cjs(), json(), terser()],
  external: ["@antv/g"],
};

module.exports = [
  {
    input: "antv/g2.js",
    output: {
      file: "dist/g2.js",
      format: "cjs", // 打包成 commonjs
    },
    ...common,
  },
  {
    input: "antv/g.js",
    output: {
      file: "dist/g2.js",
      format: "cjs", // 打包成 commonjs
    },
    ...common,
  },
  {
    input: "antv/g-canvas.js",
    output: {
      file: "dist/g2-canvas.js",
      format: "cjs", // 打包成 commonjs
    },
    ...common,
  },
];

然后在控制台输入 npx rollup -c就会发现已经多了一个 dist 文件夹,这之后就可以验证是是否成功了。

验证是否成功

第一步,在项目根目录下创建一个 bar.json文件,并且输入以下内容:

json 复制代码
{
  "type": "interval",
  "data": [
    { "genre": "Sports", "sold": 275 },
    { "genre": "Strategy", "sold": 115 },
    { "genre": "Action", "sold": 120 },
    { "genre": "Shooter", "sold": 350 },
    { "genre": "Other", "sold": 150 }
  ],
  "encode": { "x": "genre", "y": "sold" },
  "viewStyle": { "plotFill": "white" }
}

接下来运行:

bash 复制代码
$ g2-ssr-node g2png -i ./bar.json -o ./bar.png

如果没有问题的话,就会在项目的根目录出现一张名叫 bar.png的图片。

小结

到这里简单版本的 g2-ssr-node 就已经开发完成了,发包的过程就不在这里赘述了。最后大体的代码结构如下:

markdown 复制代码
- antv
  - g2.js
  - g.js
  - g-canvas.js
- dist
  - g2.js
  - g.js
  - g-canvas.js
- bin
  - g2-ssr-node.js
  - g2png.js
  - renderImage.js
- rollup.config.js
- bar.json
- package.json

除了更多的能力之外(比如将 G2 spec 转换成 jpeg 和 pdf,以及通过 API 的形式调用),还需要考虑 Test、Lint、CI 和文档一些相关的问题。完整的代码、能力和文档可以在 g2-ssr-node 查看。当然感兴趣的小伙伴也可以提 PR,去实现将 G2 spec 转换成 SVG 命令,参考 SVG Output

最后,g2-ssr-node 算是 G2 5.0 生态中的新成员,而目前 G2 5.0 在收集相关的一些生态,当然也可以在这里提出一些想法,组团来实现。期望大家一起,让 G2 变得更好,让数据可视化社区活跃起来!

相关推荐
天天扭码1 天前
前端如何实现RAG?一文带你速通,使用RAG实现长期记忆
前端·node.js·ai编程
hxmmm1 天前
自定义封装 vue多页项目新增项目脚手架
前端·javascript·node.js
濮水大叔1 天前
VonaJS是如何做到文件级别精确HMR(热更新)的?
typescript·node.js·nestjs
小胖霞1 天前
全栈系列(15)github Actions自动化部署前端vue
前端·node.js·github
LYFlied1 天前
【一句话概述】Webpack、Vite、Rollup 核心区别
前端·webpack·node.js·rollup·vite·打包·一句话概述
程序员爱钓鱼1 天前
Node.js 编程实战:MongoDB 基础与 Mongoose 入门
后端·node.js·trae
程序员爱钓鱼1 天前
Node.js 编程实战:MySQL PostgreSQL数据库操作详解
后端·node.js·trae
古韵1 天前
当 API 文档走进编辑器会怎样?
vue.js·react.js·node.js
小胖霞2 天前
企业级全栈项目(14) winston记录所有日志
vue.js·前端框架·node.js
Anita_Sun2 天前
🎨 基础认知篇:打破单线程误区
node.js