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

如何在 纯node环境下(即不使用浏览器或无头浏览器、phantomjs)使用highcharts 生成html文件

由于公司项目需要导出页面成pdf,按照老的导出代码需要经过浏览器生成考虑到有可能会损耗,所以尝试在无浏览器的情况下生成html再导出。因为需要导出的页面需要用到highcharts图表。

因此主要难度在于,不使用浏览器意味着取不到dom,问题变成在获取不到dom的情况下生成highcharts图表。

开始前 附上使用的highcharts,jsdom版本

201807061003.png

首先,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。

微信图片_20180704145521

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

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

微信图片_20180704150903

在有了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,如上就完成了。
但是运行起来后碰到了一系列的问题,如下图:
微信图片_20180704151301

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

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

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

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

20180704152619

果然折线图成功出现。

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

对于legend位置错位的问题,其实最简单的解决方法为使用legend的属性itemDistance 去设置一个图标之间的距离,但是这样的话每个图表都要单独去设置一个itemDistance 十分麻烦,

所以还是需要找出它什么会错位的问题,本着没有难度也要制造难度原则,读highcharts源码;

20180704154646

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

在这个过程中我发现生成的图表大小不受我们控制的问题大概率会出现在这几步中

经过debugger发现chart.getContainer()即获取容器这步中会使用getChartSize()方法去设置容器的宽高

20180704170228

20180704170240

问题在获取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的操作

20180704160427

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

20180704161550

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

20180704162639

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

20180704164121

所以解决方法为

20180704164601

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

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
        );
    }
}

至此所有问题解决,生成图表也是正确的

20180704171837

下面为全部源代码

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);

You may also like

3条评论

发表评论

邮箱地址不会被公开。