服務端渲染highcharts(不使用任何headless瀏覽器)

原文爲我同事發表於我我的網站,今天轉發於sf
貼個原文連接css

如下是正文

如何在 純node環境下(即不使用瀏覽器或無頭瀏覽器、phantomjs)使用highcharts 生成html文件
因爲公司項目須要導出頁面成pdf,按照老的導出代碼須要通過瀏覽器生成考慮到有可能會損耗,因此嘗試在無瀏覽器的狀況下生成html再導出。由於須要導出的頁面須要用到highcharts圖表。
所以主要難度在於,不使用瀏覽器意味着取不到dom,問題變成在獲取不到dom的狀況下生成highcharts圖表。
首先,highcharts的使用是須要傳入window對象的html

const Highcharts = require(「highcharts」)(window)

因此在bing上搜索highcharts server side (在服務端渲染highcharts)第一篇就是官網的文章Render charts on the server,主要內容爲要在服務器上渲染圖表 官方推薦使用 PhantomJS, 無頭瀏覽器,可是除了PhantomJS也可使用Batik and Rhino + env.js 或者 jsdom。node

clipboard.png
由於咱們的目標就是不使用瀏覽器因此變成了Batik and Rhino + env.js 或者 jsdom 2選1,介於第一種貌似很麻煩就選擇了使用jsdom來解決沒有dom的問題,可是官方還提到若是使用jsdom的話他並無的getBBox方法。git

因而開始查找資料,在參考了node-highcharts.js,以下圖(主要解決getBBox的問題)github

clipboard.png
在有了jsdom的狀況下嘗試用highcharts生成svg圖表再生成html頁面,代碼以下:npm

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,如上就完成了。
可是運行起來後碰到了一系列的問題,以下圖:segmentfault

clipboard.png
首先,折線圖出不來,再者是上面代碼我自定義了div的高寬各位1000 生成的html中div的高寬無變化,最後爲生成的legend位置重疊。瀏覽器

因此如今的主要問題就是highcharts圖表的問題,咱們先看看highcharts的配置服務器

發現highcharts圖表存在動畫效果,而且默認爲true,可能就是由於動畫效果致使折線圖還沒出來就被我返回出來了app

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);
相關文章
相關標籤/搜索