基于midway/koa+puppteer的node服务端生成echarts方案

330 阅读3分钟

一、功能背景

前端获取echarts的base64格式图片,需要在服务端开启无头(headless)浏览器截图。

本文引用了《基于nodejs在服务端生成echarts图片方案》中的ejs模版及相应的静态资源
juejin.cn/post/720548…

二、实现思路

启动web服务,基于ejs动态渲染出html并渲染,利用headless浏览器打开页面截图并返回base64

flowchart LR
A["http请求"] --> B["web服务"]-->C["模版渲染+puppteer截图"]-->D["返回base64"]

三、具体实现步骤

1、利用midway/koa构建web服务

具体创建web服务步骤可参考midway官网,下面提供配置文件。

configuration.ts

@Configuration({
  imports: [
    koa,
    validate,
    view,
    staticFile,
    {
      component: info,
      enabledEnvironment: ['local'],
    },
  ],
  importConfigs: [join(__dirname, './config')],
})
export class MainConfiguration {
  @App('koa')
  app: koa.Application;

  async onReady(applicationContext: IMidwayContainer) {
    // add middleware
    this.app.useMiddleware([ReportMiddleware]);
    // add filter
    // this.app.useFilter([NotFoundFilter, DefaultErrorFilter]);
    this.app.useFilter([WeatherEmptyDataErrorFilter, ValidateErrorFilter]);
    //全局注册poppeteer,无需每次调用时引入。
    applicationContext.registerObject('puppeteer', puppeteer);
    //全局注册lodash,无需每次调用时引入。
    applicationContext.registerObject('lodash', lodash);
  }
}

config.default

export default {
  // use for cookie sign key, should change to your own and keep security
  keys: '1736305632632_7030',
  view: {
    defaultExtension: '.ejs',
    defaultViewEngine: 'ejs',
    // cache: false,
    mapping: {
      '.ejs': 'ejs',
    },
    rootDir: {
      default: path.join(__dirname, '../../view'),
      layout: path.join(__dirname, '../../view/layout'),
      debug: path.join(__dirname, '../../view/debug'),
    },
  },
  staticFile: {
    prefix: '/',
    dir: path.join(__dirname, '../../public'),
    maxAge: 31536000,
    buffer: true,
  },
  koa: {
    port: 7001,
  },
} as MidwayConfig;

这里需要额外引入@midwayjs/view-ejs和@midwayjs/static-file。

2、模版渲染+puppteer截图

ejs模版

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <style>
        * {
            margin:0;
            padding: 0
        }
    </style>
</head>
<body>
<div id="container" style="height: <%=chartHeight%>px; width: <%=chartWidth%>px;"></div>
<script type="text/javascript">
    var chartConfig = <%- chartConfigStr %>;
    chartConfig.animation = false;
    // 基于准备好的dom,初始化echarts实例
    var myChart = echarts.init(document.getElementById('container'));

    // 使用刚指定的配置项和数据显示图表。
    myChart.setOption(chartConfig);
</script>
</body>
</html>

renderEharts.service.ts(包含了数据处理、模版渲染、puppteer截图等操作)

// src/service/renderEharts.service.ts
import { Inject, Provide } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import * as Default_options from '../../public/js/defaultOptions.js';
import { PuppeteerService } from './puppeteer.service';
@Provide()
export class RenderEchartsService {
  // 注入PuppeteerService
  @Inject()
  private puppeteerService: PuppeteerService;
  @Inject()
  ctx: Context;
  // 注入lodash
  @Inject('lodash')
  private _: any;
  /**
   * 使用Puppeteer进行测试的异步函数
   * @returns 返回页面标题的Promise
   */
  /**
   * 生成ECharts图表的异步函数
   * @param chartsConfig - 包含图表配置的对象
   * @returns 返回生成的ECharts图表的Base64编码字符串
   */
  async generateEcharts(chartsConfig: any): Promise<string> {
    // 图表配置
    let chartConfig = chartsConfig.chartConfig;
    const chartWidth = chartsConfig.width;
    const chartHeight = chartsConfig.height;
    const version = chartsConfig.version;
    //  模版类型
    const tempType = chartsConfig.tempType;

    // 整合内置options
    const defaultOpt = Default_options[tempType];

    if (defaultOpt) {
      chartConfig = this._.merge({}, defaultOpt, chartConfig);
    }
    // 动态设置柱状图是否展示label
    const isShowLabel = chartsConfig.showLabel;
    if (tempType.includes('bar')) {
      chartConfig.series = chartConfig.series.map(serie => {
        const newSerics = this._.merge({}, serie, {
          label: {
            show: !!isShowLabel,
          },
        });
        return newSerics;
      });
    }
    // 动态设置地图的max
    if (tempType === 'map') {
      const valueData = chartConfig.series[0].data.map(a => {
        return a.value;
      });
      const maxData = valueData.reduce((a, b) => {
        return Math.max(a, b);
      });
      const minData = valueData.reduce((a, b) => {
        return Math.min(a, b);
      });

      chartConfig.visualMap.max = maxData || 100000;
      chartConfig.visualMap.min = minData || 0;
    }

    // // 渲染到请求总用于的body上
    await this.ctx.render('echarts.ejs', {
      chartConfigStr: JSON.stringify(chartConfig),
      chartWidth,
      chartHeight,
      version,
    });
    // 获取渲染后的HTML内容
    const htmlContent = this.ctx.body as string;
    return await this.renderEcharts(version, htmlContent);
  }
  private async renderEcharts(
    version: number,
    htmlContent: string
  ): Promise<string> {
    try {
      // 选择echarts版本
      // 提前加载资源防止后续setContent不生效
      const newPage = await this.puppeteerService.newPage();
      await newPage.addScriptTag({
        url: `http://localhost:7001/charts/echarts${version}.min.js`,
      });
      await newPage.addScriptTag({
        url: 'http://localhost:7001/charts/china.js',
      });
      // 页面直接渲染body上的内容,并等待无请求后
      await newPage.setContent(htmlContent);
      // 截图,返回base64编码
      return await newPage
        .screenshot({ encoding: 'base64', fullPage: true })
        .then(data => {
          newPage.close();
          return `data:image/png;base64,${data}`;
        });
    } catch (error) {
      console.error('Rendering failed, retrying...', error);
      // 出现后重启浏览器
      await this.puppeteerService.destroy();
      await this.puppeteerService.init();
      return this.renderEcharts(version, htmlContent);
    }
  }
}

三、效果展示

调试页面

录制_2025_02_07_10_54_11_802.gif

四、优化点

渲染后无需生成静态的html页面再打开浏览器截图,只需将生成html直接puppteer.setContent即可(需要注意的是,setContent后不会加载js静态资源,需要在前面提前puppteer.addScriptTag进去对应静态资源)。这样做的好处在于能够减少文件读取(fs)操作。

在midway中利用@Singleton()构建单例模式,这样就不用每个请求都打开浏览器,只需服务启动后实例化一个浏览器,在浏览器中开关页面。这样做的好处可大幅提高接口响应速度。

最后可利用pm2自带的cluster模式启动项目,可提高接口QPS。

四、待改进点

1、增加缓存
2、docker镜像方便部署

项目源码

github.com/xiehaojie/r…