服务端渲染highcharts(不使用任何headless浏览器)

原文为我同事发表于我个人网站,今天转发于sf
贴个原文链接

以下是正文

文章发表时highcharts版本:6.0.7

如何在 纯node环境下(即不使用浏览器或无头浏览器、phantomjs)使用highcharts 生成html文件
由于公司项目需要导出页面成pdf,按照老的导出代码需要经过浏览器生成考虑到有可能会损耗,所以尝试在无浏览器的情况下生成html再导出。因为需要导出的页面需要用到highcharts图表。
因此主要难度在于,不使用浏览器意味着取不到dom,问题变成在获取不到dom的情况下生成highcharts图表。
首先,highcharts的使用是需要传入window对象的

const Highcharts = require(“highcharts”)(window)

所以在bing上搜索highcharts server side (在服务端渲染highcharts)第一篇就是官网的文章Render charts on the server,主要内容为要在服务器上渲染图表 官方推荐使用 PhantomJS, 无头浏览器,但是除了PhantomJS也可以使用Batik and Rhino + env.js 或者 jsdom。

clipboard.png
因为我们的目标就是不使用浏览器所以变成了Batik and Rhino + env.js 或者 jsdom 2选1,介于第一种貌似很麻烦就选择了使用jsdom来解决没有dom的问题,但是官方还提到如果使用jsdom的话他并没有的getBBox方法。

于是开始查找资料,在参考了node-highcharts.js,如下图(主要解决getBBox的问题)

clipboard.png
在有了jsdom的情况下尝试用highcharts生成svg图表再生成html页面,代码如下:

const jsdom = require("jsdom");
const { JSDOM } = jsdom;
 
const { window } = (new JSDOM(``)).window;
const { document } = window;
 
const Highcharts = require("highcharts")(window);
 
// Convince Highcharts that our window supports SVG's
window.SVGAngle = true;
 
// jsdom doesn't yet support createElementNS, so just fake it up
window.document.createElementNS = function (ns, tagName) {
    var elem = window.document.createElement(tagName);
    elem.getBBox = function () {
        return {
            x: elem.offsetLeft,
            y: elem.offsetTop,
            width: elem.offsetWidth,
            height: elem.offsetHeight
        };
    };
    return elem;
};
 
require('highcharts/modules/exporting')(Highcharts);
 
 
 
function getChart(option) {
 
    const div = document.createElement("div");
 
    div.style.width="1000px";
    div.style.height="1000px";
    const chart = Highcharts.chart(div, option);
    return div.outerHTML;
}
 
const mock = {
    chart: {
        renderer: "SVG",
        // animation: false,
    },
    title: {
        text: '123'
    },
    yAxis: {
        title: {
            text: '就业人数'
        }
    },
    series: [{
        name: '安装实施人员',
        data: [43934, 52503, 57177, 69658, 97031, 119931, 137133, 154175]
    }, {
        name: '工人',
        data: [24916, 24064, 29742, 29851, 32490, 30282, 38121, 40434]
    }],
}
 
 
// 调用
// let chart = getChart(mock).replace(/\&quot\;/g, `'`);
let chart = getChart(mock);
 
let tpl = `<!DOCTYPE html>
<html lang="en">
 
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
 
<body>
    <div style="width:100%;height:100%" id="root">
        ${chart}
    </div>
</body>
 
</html>`;
 
console.log(tpl);

至此大概使用jsdom生成highcharts图表再生成html,如上就完成了。
但是运行起来后碰到了一系列的问题,如下图:

clipboard.png
首先,折线图出不来,再者是上面代码我自定义了div的高宽各位1000 生成的html中div的高宽无变化,最后为生成的legend位置重叠。

所以现在的主要问题就是highcharts图表的问题,我们先看看highcharts的配置

发现highcharts图表存在动画效果,并且默认为true,可能就是因为动画效果导致折线图还没出来就被我返回出来了

clipboard.png
因此在图表数据列中都加入animation: false

clipboard.png

果然折线图成功出现。

接下去是legend位置错误的问题 以及为什么div大小不是我设置的值。

对于legend位置错位的问题,其实最简单的解决方法为使用legend的属性itemDistance 去设置一个图标之间的距离,但是这样的话每个图表都要单独去设置一个itemDistance 十分麻烦,所以还是需要找出它什么会错位的问题,本着没有难度也要制造难度原则,读highcharts源码;

clipboard.png
由上图大概可以看出highcharts生成图表的步骤大致为生成容器、然后根据属性设置容器大小 内外边距,间距,根据属性获取排列折线图数据,创建坐标轴属性列表,linkSeries主要是跟linkedTo属性有关,最后是开始渲染图表。

在这个过程中我发现生成的图表大小不受我们控制的问题大概率会出现在这几步中
经过debugger发现chart.getContainer()即获取容器这步中会使用getChartSize()方法去设置容器的宽高

clipboard.png

clipboard.png

问题在获取offsetWidth,offsetHeight,scrollWidth,,scrollHeight全部为0

所以解决方法为

Object.defineProperty(div, "offsetWidth", {
        configurable: true,
        writable: true,
    });
    Object.defineProperty(div, "offsetHeight", {
        configurable: true,
        writable: true,
    });
    Object.defineProperty(div, "scrollWidth", {
        configurable: true,
        writable: true,
    });
    Object.defineProperty(div, "scrollHeight", {
        configurable: true,
        writable: true,
    });
 
    div.offsetWidth = 1000;
    div.offsetHeight = 1000;
    div.scrollWidth = 1000;
    div.scrollHeight = 1000;
    div.style.paddingLeft = 0;
    div.style.paddingRight = 0;
    div.style["padding-top"] = 0;
    div.style["padding-bottom"] = 0;

因为offsetHeight这些属性为只读属性,无法直接赋值所以通过defineProperty改为可以写入
成功使生成的图表大小变为我们自定义的大小
最后就只剩legend错位的问题
我们接着看
在render()中找到了生成legend的操作

clipboard.png
继续debugger在 legend中找到了生成legend中每一项的 renderItem方法
在其中发现生成每个图例时他是会提前去计算跟下一个图例之间的距离,如下图:

clipboard.png

在没有设置itemWidth 以及并没有legendItemWidth,的情况下每个图例的宽度为,生成的文字element的宽度加上设置的额外每个图例项之间的宽度。
问题在于bBox的没项值全是0
所以导致图例在计算时没加上字体的宽度

clipboard.png
根本原因是下图 获取element的off各属性均返回0

clipboard.png
所以解决方法为

clipboard.png
当然我们并不建议修改源码,因此你可以整个重写 Highcharts.Legend.prototype.renderItem方法将内容全部抄过来 加上我上面那段代码,legend错位问题解决。重写代码如下:

//hack-highcharts.js
module.exports = function hackHighcharts(Highcharts) {
    // 修复legend的itemDistance不能自动计算的问题
    Highcharts.Legend.prototype.renderItem = function (item) {
        /***修改源码开始***/
        //自定义需要用到的参数名
        var H = Highcharts,
            merge = H.merge,
            pick = H.pick;
        /***修改源码结束***/
 
        var legend = this,
            chart = legend.chart,
            renderer = chart.renderer,
            options = legend.options,
            horizontal = options.layout === 'horizontal',
            symbolWidth = legend.symbolWidth,
            symbolPadding = options.symbolPadding,
 
            itemStyle = legend.itemStyle,
            itemHiddenStyle = legend.itemHiddenStyle,
 
            itemDistance = horizontal ? pick(options.itemDistance, 20) : 0,
            ltr = !options.rtl,
            bBox,
            li = item.legendItem,
            isSeries = !item.series,
            series = !isSeries && item.series.drawLegendSymbol ?
                item.series :
                item,
            seriesOptions = series.options,
            showCheckbox = legend.createCheckboxForItem &&
                seriesOptions &&
                seriesOptions.showCheckbox,
            // full width minus text width
            itemExtraWidth = symbolWidth + symbolPadding + itemDistance +
                (showCheckbox ? 20 : 0),
            useHTML = options.useHTML,
            fontSize = 12,
            itemClassName = item.options.className;
 
        if (!li) { // generate it once, later move it
 
            // Generate the group box, a group to hold the symbol and text. Text
            // is to be appended in Legend class.
            item.legendGroup = renderer.g('legend-item')
                .addClass(
                    'highcharts-' + series.type + '-series ' +
                    'highcharts-color-' + item.colorIndex +
                    (itemClassName ? ' ' + itemClassName : '') +
                    (isSeries ? ' highcharts-series-' + item.index : '')
                )
                .attr({ zIndex: 1 })
                .add(legend.scrollGroup);
 
            // Generate the list item text and add it to the group
            item.legendItem = li = renderer.text(
                '',
                ltr ? symbolWidth + symbolPadding : -symbolPadding,
                legend.baseline || 0,
                useHTML
            )
 
                // merge to prevent modifying original (#1021)
                .css(merge(item.visible ? itemStyle : itemHiddenStyle))
 
                .attr({
                    align: ltr ? 'left' : 'right',
                    zIndex: 2
                })
                .add(item.legendGroup);
 
            // Get the baseline for the first item - the font size is equal for
            // all
            if (!legend.baseline) {
 
                fontSize = itemStyle.fontSize;
 
                legend.fontMetrics = renderer.fontMetrics(
                    fontSize,
                    li
                );
                legend.baseline =
                    legend.fontMetrics.f + 3 + legend.itemMarginTop;
                li.attr('y', legend.baseline);
            }
 
            // Draw the legend symbol inside the group box
            legend.symbolHeight = options.symbolHeight || legend.fontMetrics.f;
            series.drawLegendSymbol(legend, item);
 
            if (legend.setItemEvents) {
                legend.setItemEvents(item, li, useHTML);
            }
 
            // add the HTML checkbox on top
            if (showCheckbox) {
                legend.createCheckboxForItem(item);
            }
        }
 
        // Colorize the items
        legend.colorizeItem(item, item.visible);
 
        // Take care of max width and text overflow (#6659)
 
        if (!itemStyle.width) {
 
            li.css({
                width: (
                    options.itemWidth ||
                    options.width ||
                    chart.spacingBox.width
                ) - itemExtraWidth
            });
 
        }
 
        // Always update the text
        legend.setText(item);
 
        // calculate the positions for the next line
        bBox = li.getBBox();
 
        /***修改源码开始***/
        //因为存在可能 text的长度无法取到 现加上判断如果text有内容 但是计算出的宽度为0
        //则自己根据字数以及字体大小计算宽度确保 排版正常
        if (li.textStr.length > 0 && bBox.width === 0) {
            const len = li.textStr.length;
            const fontSize = li.styles.fontSize ? parseInt(li.styles.fontSize.replace("px", "")) : 12;
            bBox.width = len * fontSize;
        }
        /***修改源码结束***/
 
        item.itemWidth = item.checkboxOffset =
            options.itemWidth ||
            item.legendItemWidth ||
            bBox.width + itemExtraWidth;
        legend.maxItemWidth = Math.max(legend.maxItemWidth, item.itemWidth);
        legend.totalItemWidth += item.itemWidth;
        legend.itemHeight = item.itemHeight = Math.round(
            item.legendItemHeight || bBox.height || legend.symbolHeight
        );
    }
}

在引入highcharts后调用一下hack-highcharts.js
至此所有问题解决,生成图表也是正确的

clipboard.png
下面为全部源代码

const jsdom = require("jsdom");const { JSDOM } = jsdom;
const { window } = (new JSDOM(``)).window;
const { document } = window;
const Highcharts = require("highcharts")(window);
 
//将修改renderItem的js引入并传入Highcharts修改其中的renderItem方法
const hackHigcharts = require("./hack-highcharts");
 
//hack
try{
    hackHighcharts(Highcharts);
}catch(error){
     console.error(error);
}
 
// Convince Highcharts that our window supports SVG's
window.SVGAngle = true;
// jsdom doesn't yet support createElementNS, so just fake it up
window.document.createElementNS = function (ns, tagName) { 
   var elem = window.document.createElement(tagName);    
   elem.getBBox = function () {        
       return {            
           x: elem.offsetLeft,            
           y: elem.offsetTop,           
           width: elem.offsetWidth,            
           height: elem.offsetHeight        
           };    
       };    
   return elem;
   };
require('highcharts/modules/exporting')(Highcharts);
 
 
function getChart(option) {
    const div = document.createElement("div");
    Object.defineProperty(div, "offsetWidth", {
        configurable: true,
      writable: true,    
    });    
    Object.defineProperty(div, "offsetHeight", {
      configurable: true,        
      writable: true,        
    });    
    Object.defineProperty(div, "scrollWidth", {
      configurable: true,        
      writable: true,    
    });    
    Object.defineProperty(div, "scrollHeight", {
      configurable: true,        
      writable: true,    
    });
    div.offsetWidth = 1000;    
    div.offsetHeight = 1000;    
    div.scrollWidth = 1000;    
    div.scrollHeight = 1000;    
    div.style.paddingLeft = 0;    
    div.style.paddingRight = 0;    
    div.style["padding-top"] = 0;    
    div.style["padding-bottom"] = 0;
    const chart = Highcharts.chart(div, option);    
    return div.outerHTML;
 }
const mock = {
   chart:{        
      renderer: "SVG",        
      // animation: false,    
      },    
      title:{        
         text: '123'    
      },    
      yAxis:{        
          title: {            
              text: '就业人数'        
              }   
     },    
     series: [{
       name: '安装实施人员',
       animation: false,        
       data: [43934, 52503, 57177, 69658, 97031, 119931, 137133, 154175]
       },     
       {        
       name: '工人',        
       animation: false,        
       data: [24916, 24064, 29742, 29851, 32490, 30282, 38121, 40434]
     }],
     }
 
// 调用// let chart = getChart(mock).replace(/\&quot\;/g, `'`);
 
let chart = getChart(mock);
 
let tpl = `<!DOCTYPE html><html>
    <head>    
    <meta charset="UTF-8">    
    <meta name="viewport" content="width=device-width, initial-scale=1.0">    
    <meta http-equiv="X-UA-Compatible" content="ie=edge">    
    <title>Document</title></head>
    <body>    
        <div style="width:100%;height:100%" id="root">
            ${chart}    
        </div></body>
     </html>`;
console.log(tpl);
3k 声望
223 粉丝
0 条评论
推荐阅读
「彻底弄懂」this全面解析
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在 哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this就是记录的其中一个属性,会在 函数执行的过程中用到...

wuwhs17阅读 2.4k

封面图
反编译微信小程序获取小程序前端源码wxapkg
研究反编译的原因就是我自己辛苦了半个月写的小程序,忘记备份放在桌面,心急体验Win11系统 重装系统忘记备份源码,后悔莫及。 后来网上找了反编译的教程,反编译已经上线了的小程序 于是自己尝试了一下,真的可...

TANKING13阅读 10.1k评论 7

封面图
用了那么久的 SVG,你还没有入门吗?
其实在大部分的项目中都有 直接 或 间接 使用到 SVG 和 Canvas,但是在大多数时候我们只是选择 简单了解 或 直接跳过,这有问题吗?没有问题,毕竟砖还是要搬的!

熊的猫17阅读 1.6k评论 2

封面图
Next.js-集成状态管理器共享access token以及刷新access token解决方案
SSR和SPA最大的区别就是SSR会区分客户端Client和服务端Server,并且SSR之间只能通过cookie才能在Client和Server之间通信,例如:token信息,以往我们在SPA项目中是使用localStorage或者sessionStorage来存储,但...

Awbeci4阅读 9.1k评论 2

🖼️ 如何解决 SVG 图片中字体失效的问题
如果你喜欢我的文章,希望点赞👍 收藏 📁 评论 💬 三连支持一下,谢谢你,这对我真的很重要!「SVG 图片中字体失效」的修复方案很简单,只想看答案翻到最后看结论就行。如果想看我的排查思路和具体原因可以从头开始...

卤代烃6阅读 1.5k

Just for fun——C#应用和Nodejs通讯
进程通信常见的进程通讯的方法有:管道(Pipe)命名管道信号消息队列其他管道是比较简单基础的技术了,所以看看它。Node IPC支持Node官方文档中Net模块写着:IPC SupportThe net module supports IPC with named ...

pigLoveRabbit3阅读 6.8k评论 2

「过程详解」async await综合题
如果你之前跟我一样一直对async await熟悉又陌生的话(熟悉是可能每天都在用,陌生是针对一些组合题又丈二和尚摸不着头脑),不妨可以边看边练,总结规律,相信会逐渐清晰并有所得。本文对每个案例都详细描述了代...

wuwhs5阅读 1.3k

封面图
3k 声望
223 粉丝
宣传栏