做一个具有异步加载特性的 echarts-vue 组件

crazygorilla 发布于1年前
0 条问题

在 vue 项目使用 echarts 的场景中,以下三点不容忽视:1. 可视化的数据往往是异步加载的;2. 若一个页面存在大量的图表( 尤其当存在关系图和地图时 ),往往会导致该页面的渲染速度很慢并可能在几秒内卡死,产生极差的用户体验。3. 引入 echarts 组件导致编译后的文件过大从而使得首次访问的加载极慢。关于第三点,大家可以参考之前的撰文 优化 Vue 项目编译文件大小。以下针对上述前两点,给出数据异步、延迟渲染的 echarts vue 组件的设计和实现方式,并对实现之中可能存在的问题进行介绍。

组件代码可以访问 Github 查看。

1. 抽离 echarts 公共部分形成基础组件

1.1 调研公共部分

首先,我们需要把 echarts 使用中公共的部分抽离出来,形成基础组件。

让我们在 官网 - 5 分钟上手 ECharts 教程中找到使用 echarts 的步骤:

# 1. 获取一个用于挂在 echarts 的 DOM 元素
let $echartsDOM = document.getElementById('echarts-dom')

# 2. 初始化
let myEcharts = echarts.init($echartsDOM)

# 3. 设置配置项
let option = {...}

# 4. 为 echarts 指定配置
myEcharts.setOption(option)

注:在 Vue 中,首先我们需要使用 import echarts from 'echarts' 以引入 echarts。

由上可知,在 echarts 使用中,除第三步设置配置项以外,其他的步骤都是重复的,即可以抽离出来放入组件中统一实现。

注:其实 option 配置中也存在可以抽离的部分,比如我们可以将 echarts 的颜色、散点大小、折线粗细等提取出来统一赋值,以保证 echarts 风格的统一。但由于不同类型的 ehcarts 图的颜色配置方式不同,因而实现起来相对繁琐,这里不进行说明,有兴趣的同学可以自行尝试。

1.2 实现 echarts 功能

首先我们书写一个简单 i-ehcart.vue,其中,配置项直接复制于官网的教程示例。

<style scoped>
    .echarts {
        width: 100%;
        height: 100%;
    }
</style>

<template>
    <div>
        <div class="echarts" id="echarts-dom"></div>
    </div>
</template>

<script>
    import echarts from 'echarts'

    export default {
        name: 'echarts',
        data() {
            return {}
        },
        mounted() {
            let $echartsDOM = document.getElementById('echarts-dom')
            let myEcharts = echarts.init($echartsDOM)
            let option = {
                title: {
                    text: 'ECharts 入门示例'
                },
                tooltip: {},
                legend: {
                    data: ['销量']
                },
                xAxis: {
                    data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"]
                },
                yAxis: {},
                series: [{
                    name: '销量',
                    type: 'bar',
                    data: [5, 20, 36, 10, 10, 20]
                }]
            }
            myEcharts.setOption(option)
        }
    }
</script>

然后在 App.vue 中引入这一组件,并设置 echarts 的宽高:

<style>
    .echarts-container{
        width: 100%;
        height: 20rem;
    }
</style>

<template>
    <div id="app">
        <i-echart class="echarts-container"></i-echart>
    </div>
</template>

<script>
    import iEchart from './components/i-echart'

    export default {
        name: 'app',
        components: {
            iEchart
        }
    }
</script>

刷新页面后,即可看到柱状图。

1.3 组件化

由于我们需要抽离 option 部分,最好的方式是将其作为组件的属性,即 props 交由调用方配置:

# i-echart.vue

import echarts from 'echarts'

export default {
    name: 'echarts',
    props: {
        option: {
            type: Object,
            default(){
                return {}
            }
        }
    },
    data() {
        return {}
    },
    mounted() {
        let $echartsDOM = document.getElementById('echarts-dom')
        let myEcharts = echarts.init($echartsDOM)
        let option = this.option
        myEcharts.setOption(option)
    }
}

1.4 调用组件

然后我们可以将 option 配置抽离到组件调用方,并通过「传参」的方式进行调用:

<i-echart :option="option" class="echarts-container"></i-echart>

1.5 提高组件强壮型

之前我们注意到,在 option 参数中,我们给出了默认值 {},即空对象。这样做其实是有问题的,即在 echarts 中,如果传入的 option 配置对象不含有 series 键,就会抛出错误:

Error: Option should contains series.

默认值处理是需要存在的,即当调用方传入的对象为空或不存在 series 配置时,应在页面上显示一些提示( 对用户友好的提示,而不是对编程人员 ),即避免因报错而造成空白的情况。

此外,当我们像之前那样给 option 这一参数进行类型限制后,倘若调用方传入非对象类型,Vue 会直接抛出错误——这一结果也不是我们想要的。我们应该取消类型限制,并在 option 发生变化时进行依次以下判断:

1. 是否为对象;
2. 是否为空对象;
3. 是否包含 series 键;
4. series 是否为数组;
5. series 数组是否为空。

代码实现如下:

function isValidOption(option){
    return isObject(option) && !isEmptyObject(option)
            && hasSeriesKey(option)
            && isSeriesArray(option) && !isSeriesEmpty(option)
}

function isObject(option) {
    return Object.prototype.isPrototypeOf(option)
}

function isEmptyObject(option){
    return Object.keys(option).length === 0
}

function hasSeriesKey(option){
    return !!option['series']
}

function isSeriesArray(option) {
    return Array.isArray(option['series'])
}

function isSeriesEmpty(option){
    return option['series'].length === 0 
}

注:实际上,当判断出 option 为对象后,可以直接进行第三步的判断。

然后,当判断 option 符合上述三种情况时,在页面上显示如「数据为空」之类的提示:

import echarts from 'echarts'

export default {
    name: 'echarts',
    props: {
        option: {
            default(){
                return {}
            }
        }
    },
    data() {
        return {
            myEcharts: null,
            isOptionAbnormal: false
        }
    },
    mounted() {
        let $echartsDOM = document.getElementById('echarts-dom')
        if(!$echartsDOM) return
        let myEcharts = echarts.init($echartsDOM)
        this.myEcharts = myEcharts
        this.checkAndSetOption()
    },
    watch: {
        option(option){
            this.checkAndSetOption()
        }
    },
    methods: {
        checkAndSetOption(){
            let option = this.option
            if(isValidOption(option)){
                this.myEcharts.setOption(option)
                this.isOptionAbnormal = false
            }else{
                this.isOptionAbnormal = true
            }
        }
    }
}

这里在书写代码时,有以下几点需要注意:

  1. 我们对 DOM 元素获取结果做了校验,即当 option 不符合要求时,ID 为 echarts-dom 的 DOM 元素是不存在的,此时 document.getElementById() 的返回结果为空,不能直接使用 echarts.init(),否则会抛出错误:Error: Initialize failed: invalid dom
  2. 在 Vue 中,初始化的值不会被 watch 钩子捕捉,从而导致组件被调用方调用并赋予 option 参数时不会进入校验。虽然可以使用 immediate: true 使得 watch 钩子能够在属性初始化赋值时被触发,但这样做是不合适的。因为这样设置之后,在 option 初始化从而触发 watch 时,用于挂载 echarts 的 DOM 元素还未存在于页面中,从而导致出现 TypeError: Cannot read property 'setOption' of null 的错误。我们要重点注意 echarts 作用的生命周期,这一点后续还会涉及。

1.6 增强组件功能 - 数据不合法提示

从上面的代码中可以注意到,我们使用 isOptionAbnormal 标识了传入的 option 值是否符合规定。基于这一标识,我们可以对 echarts 组件进行优化,当 option 不合法或数据为空时给出提示信息而不是显示空白甚至报错。

首先,我们修改原组件 i-echart.vue 代码,增加 shadow 层:

<div>
    <div class="shadow" v-if="isOptionAbnormal">
        数据为空
    </div>
    <div class="echarts" v-if="!isOptionAbnormal" id="echarts-dom"></div>
</div>

并为其增加样式:

.shadow {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 1rem;
    color: #8590a6;
}

可当我们把 option 修改为 null 后,展示的样式没有按照预期。「数据为空」的字样被挤到一旁。

通过审查元素,我们猜测是由于 echarts 实例生成的 svg 并没有因为 v-if 而消失( 或是 Vue 本身的处理机制 ),而是上移到了兄弟节点。

可见我们需要在 echarts 的挂载元素之上再加一层容器 DOM:

<div>
    <div class="shadow" v-if="isOptionAbnormal">
        数据为空
    </div>
    <div class="wrap-container">
        <div class="echarts" v-if="!isOptionAbnormal" id="echarts-dom"></div>
    </div>
</div>

同时对样式进行修改:

.wrap-container,
.echarts {
    width: 100%;
    height: 100%;
}

.shadow {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 1rem;
    color: #8590a6;
}

这样一来,当 option 不合法时,提示文本确实会出现在合适的位置,但新的问题也出现了:当 option 值由不合法值变为合法值时,echarts 并没有被渲染。

这是由于我们在 option 检测的过程中,只是进行了 setOption,而由于我们使用的 v-if 会在 option 不合法时直接删除 DOM 元素,使得 myEcharts 即 DOM 挂载对象消失,自然 setOption 也没有效果了。

这里有两个方案可以解决:

  1. 重构 checkAndSetOption() 函数,使其能够在 option 改变检测时,对页面中是否存在挂载元素也进行检测,当不存在时,重新进行 echarts.init() 并赋值 myEcharts。即考虑到 option 由「合法到合法」的改变,与「非法到合法」的改变是不同的这一情况;
  2. v-if 改变为 v-show,并将 echarts 挂载元素与提示信息框的布局改为 absolute。

就二者而言,后者显然更易操作,也是我们所采取的方法。

首先,我们把 v-if 修改为 v-show,并为根元素添加类以用于调节样式:

<div class="main-container">
    <div class="shadow" v-show="isOptionAbnormal">
        数据为空
    </div>
    <div class="wrap-container" v-show="!isOptionAbnormal">
        <div class="echarts" id="echarts-dom"></div>
    </div>
</div>

然后进行样式调整:

.main-container{
    position: relative;
}

.wrap-container,
.shadow{
    position: absolute;
}

.wrap-container,
.echarts {
    width: 100%;
    height: 100%;
}

.shadow {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 1rem;
    color: #8590a6;
}

然后,我们再将 option 由不合法到合法进行修改时,便不会出现无法渲染的情况了。

1.7 增强组件功能 - 数据加载提示

在实际场景中,用于渲染的数据常常是异步获取的,在异步加载数据之中,我们可能需要在页面中显示如「正在加载...」的字样来表示加载过程正在进行以提高用户体验。而加载过程就组件而言是无法直接获取的,即需要组件调用方通过某种方式进行控制。

所以,我们需要使用某一参数用于进行加载信息的显示。与之前不合法提示信息的操作方式相同,我们使用绝对定位的元素和 isLoading 属性进行处理:

首先,我们添加 isLoading 属性:

props: {
    option: {
        default() {
            return {}
        }
    },
    isLoading: {
        type: Boolean,
        default: false
    }
},

然后修改 HTML 代码:

<div class="main-container">
    <div class="loading" v-show="isLoading">
        数据加载中...
    </div>
    <div class="shadow" v-show="!isLoading && isOptionAbnormal">
        数据为空
    </div>
    <div class="wrap-container" v-show="!isLoading && !isOptionAbnormal">
        <div class="echarts" id="echarts-dom"></div>
    </div>
</div>

并修改样式:

.main-container{
    position: relative;
}

.wrap-container,
.loading,
.shadow{
    position: absolute;
}

.wrap-container,
.echarts {
    width: 100%;
    height: 100%;
}

.shadow,
.loading{
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 1rem;
    color: #8590a6;
}

然后,我们便可以在组件调用方中,使用 is-loading 来控制了:

<i-echart :option="option" :is-loading="true" class="echarts-container"></i-echart>

1.8 组件复用问题

组件的最大用处是复用,但当我们将之前写的组件进行复用时,会发现出现了问题:

<i-echart :option="option" class="echarts-container"></i-echart>
<i-echart :option="option" class="echarts-container"></i-echart>

此时,我们发现页面中并没有出现两个 echarts 图,而是只有第一个。通过浏览器审查元素,我们可以发现,只有第一个组件被正确地挂载了。这是为什么呢?

这是因为 echarts 进行 init 挂载时使用的是 DOM 元素的 ID。而在组件中,我们设置的 ID 是固定的( 注意与 scoped css 进行区分 )。即多个组件的 ID 是相同的,故而只有一个组件会被 echarts 挂载。

那么该如何解决这个问题呢?方法也很简单,只要保持每个元素获得唯一的 ID 就可以了。而对于唯一 ID,我们可以通过时间戳和随机数来实现。

修改组件代码,为组件挂载的 DOM 设置随机的 ID:

首先,我们设置一个随机 ID:

data() {
    return {
        randomId: 'echarts-dom' + Date.now() + Math.random()
    }
},

并将其 echarts 元素的 ID 修改为该值:

<div class="echarts" :id="randomId"></div>

然后将 mounted 生命周期中的 DOM 组件 ID 修改为我们随机生成的值:

mounted() {
    let $echartsDOM = document.getElementById(this.randomId)
    ...
}

此时,我们才真正完成了基础组件的构建。

2. 延迟加载

这里指的延迟加载,是 echarts 的渲染只在页面滚动到特定高度的时候才会进行。

由于 echarts 组件渲染需要性能( 尤其是地图、关系图 ),对于存在大量 echarts 的页面,如果在页面加载时全部进行渲染,可能会导致页面卡顿而降低用户体验。因而,我们需要对 echarts 进行按需加载。

完成这一功能需要以下步骤:

  1. 监听页面滚动事件;
  2. 滚动事件中获取 echarts 的位置;
  3. 在页面当前位置达到 echarts 位置的时候进行 echarts 的初始化。

下面我们就逐步完成这些功能。在此之前,我们需要添加一个高度足够的占位 DOM,以检测效果:

<div style="height: 50rem;"></div>

2.1 监听页面滚动

我们可以使用 window.onscroll = function(){} 来监听页面的滚动,但这种方式只能同时作用于一个组件。想要在所有组件中生效,我们需要使用 window.addEventListener('scroll', function(){})。注意,绑定的生命周期为 mounted

mounted: {
    window.addEventListener('scroll', () => {
        console.log(this.randomId)
    })
    ...
}

注意,这里使用了箭头函数以维持 this 的指向。

接下来,我们要使用以下方法获取浏览器下边界的绝对位置,用以与之后 DOM 元素的上边界进行对比以判断当前是否应该进行渲染:

 window.addEventListener('scroll', () => {
    let windowHeight = document.documentElement.clientHeight||window.innerHeight
    let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
    let windowBottom = +scrollTop + +windowHeight
    console.log(windowBottom)
})

2.2 获取组件当前位置

接下来要获取组件的位置。在这之前,我们要首先解决获取组件 DOM 元素的问题,这里有两种方式:

  1. 借助 ID,通过 document.getElementById() 获取;
  2. 采用 Vue 中的 $ref 获取。

这里我们使用第二种方式。

首先,我们在组件上加入 ref 属性:

<div class="main-container" ref="selfEcharts">
    ...
</div>

然后,通过以下方式,获取组件本身:

this.$refs.selfEcharts

可以看到,与 ID 不同,ref 是组件内唯一的( 而不是全局唯一 )。

之后,我们通过以下方式获取组件的上边缘位置:

this.$refs.selfEcharts.offsetTop

注:这里也可以使用 lodash_.get() 来获取 offset 值,以避免 Cannot read property of undefined 的错误:

_.get(this.$refs, 'selfEcharts.offsetTop', 0)

2.3 控制 setOption 时机

基于以上代码,我们可以通过对比浏览器下边缘及组件的位置,从而控制 setOption 的时机,以达到延迟加载的效果。

我们把之前的 this.checkAndSetOption() 放入高度判断中:

window.addEventListener('scroll', () => {
    ...
    
    if(windowBottom >= selfTop){
        this.checkAndSetOption()
    }
})

注:为了更明显地检测效果,我们可以在 checkAndSetOption() 上加上 setTimeout

2.4 功能优化

大家可以注意到,以上代码存在两个可以优化的部分:

  1. 窗口滚动的检测频率过高,当存在多个 echarts 时,可能造成性能消耗;
  2. 当窗口滚动到合适位置触发渲染后,滚动检测对于该组件而言就没有意义了,这时应该将该事件解除绑定。

2.4.1 使用 throttle 控制触发频率

这里我们引入 lodash,并使用 throttle 来控制滚动监测的触发频率:

首先引入 lodash:

import _ from 'lodash'

然后限制触发间隔为 500 ms:

window.addEventListener('scroll', _.throttle(() => {
    let windowHeight = document.documentElement.clientHeight||window.innerHeight
    let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
    let windowBottom = +scrollTop + +windowHeight
    let selfTop = _.get(this.$refs, 'selfEcharts.offsetTop', 0)
    if(windowBottom >= selfTop){
        this.checkAndSetOption()
    }
}, 500))

2.4.2 解绑事件

若想用 document.removeEventListener() 解绑事件,首先我们要抽离事件本身,将匿名函数转为实名函数。

首先,我们要将检测事件提取到 methods 之中:

methods: {
    checkAndSetOption() {
        let option = this.option
        if (isValidOption(option)) {
            this.myEcharts.setOption(option)
            this.isOptionAbnormal = false
        } else {
            this.isOptionAbnormal = true
        }
    }
}

为了保证 addEventListener 和 removeEventListener 时操作的是同一个函数,这里我们使用 data 添加实名函数:

data() {
    return {
        scrollEvent:  _.throttle(this.checkPosition, 500)
    }
}

然后在事件绑定中使用这一实名函数:

window.addEventListener('scroll', this.scrollEvent)

之后在检测到窗口滚动到合适高度的时候进行事件解绑:

checkPosition() {
    ...
    
    if (windowBottom >= selfTop) {
        this.checkAndSetOption()
        window.removeEventListener('scroll', this.scrollEvent)
    }
},

2.4 数据异步与页面滚动先后顺序的问题

当我们回顾自己的代码,可以发现,在实际应用中,其实是存在问题的。

由于用于渲染 echarts 的数据常常是异步获取的,也就是说,option 可能会在异步调用结束之后更新,从而触发 option 的 watch,进而导致 this.checkOption() 执行,最终使得 setOption 在页面没有滚动到合适位置时就触发了。

为了解决这个问题,我们应该让 setOption 的过程受制于一个标识位,而该标识位会在页面滚动到合适位置时置为 true,从而杜绝由于 option 更新、触发 watch 而导致的漏洞。

首先,我们要添加一个新的 data,取名为为 isPositionReady

data: {
    ...
    
    isPositionReady: false
}

然后,在 checkAndSetOption() 中加入对该标识位的判断:

checkAndSetOption() {
    ...
    
    if(this.isPositionReady !== true) return
    
    ...
}

最后,在位置检测方法 checkPosition() 中,当达到合适位置时,将该标识位置为 true:

checkPosition() {
    ...
    
    if (windowBottom >= selfTop) {
        this.isPositionReady = true
        
        ...
    }
}

此时,以上漏洞就被修补了。

2.5 初始化检测

事实上,以上组件中还有一个漏洞,让我们改变组件调用方的代码来发现它:

<div id="app">
    <i-echart :option="option" class="echarts-container"></i-echart>
    <div style="height: 50rem;"></div>
    <i-echart :option="option" class="echarts-container"></i-echart>
    <i-echart :option="option" class="echarts-container"></i-echart>
</div>

刷新页面,我们发现原本应该渲染的第一个 echarts 组件并没有展示出来。也就是说,通过我们之前的代码,所有 echarts 组件的渲染都必须由页面滚动事件触发。

而对于那些原本就处于页面靠上位置的组件而言,理应在页面加载后就立刻渲染而无需等待滚动。修补这个问题也很简单,只要在 mounted 生命周期中进行一次 checkPosition 检测即可:

 mounted() {
    ...
    
    this.checkPosition()
    
    ...
}

自此,一个具有延迟加载功能的 echarts 组件就完成了。接下来,我们需要对该组件进行进一步优化,以适应更多的场景需求。

3. echarts 重绘

这里的重绘指的是 ehcarts 中的 resize() 方法。用于在某些时刻进行 echarts 的调整,包括:

  1. 组件宽度设置为百分比,浏览器宽度发生变化时;
  2. 页面收缩元素状态改变,如侧边栏收缩导致内容区宽度变化;

3.1 页面宽度改变事件

echarts 并不会主动地随着浏览器宽度的改变而调整,需要我们在页面改变时间中主动触发。实现的方式也很简单,只要按照之前的思路监听 window resize 事件即可。( 注意,这里同样要考虑控制监听频率的问题 ):

window.addEventListener('resize', _.throttle(() => {
    this.myEcharts.resize()
    console.log('---')
}, 500))

3.2 主动重绘

对于一些场景,如含有侧边栏的页面而言,侧边栏收缩时,也需要对 echarts 进行 resize 调整。而此时,浏览器宽高通常是不会变化的。

因而我们需要有一个机制,能够让组件调用方主动触发以使组件进行 resize。由于当前版本的 Vue 是不能直接调用组件的方法的,想要做到这一点,我们可以使用以下两种方法:

  1. 使用时间戳;
  2. 使用随机数

采用时间戳或随机数赋值组件的属性,在组件调用方检测到侧边栏一类组件状态改变等需要 echarts 组件主动触发 resize 时,重新生成随机数或重新获取时间戳。而在组件中,对属性的变化进行检测,即当属性变化时,执行 resize

添加用于触发主动重绘的属性:

props: {
    resizeSignature: {
        default: ''
    }
}

添加对该属性的监听,并在变化时执行 resize

resizeSignature(){
    this.myEcharts.resize()
}

此时,只要在调用方改变 resize-signature 即可使 echarts 主动调用 resize

4. echarts 点击事件回调

在一些场景中,我们可能需要对 echarts 的点击事件进行捕捉以进行下一步的处理( 如:数据下钻 )。

为了支持这一类场景,我们需要为 echarts 添加点击监听事件,并将该事件及其参数上抛至组件调用方。

绑定 echarts 点击事件:

mounted () {
    ...
    
    let myEcharts = echarts.init($echartsDOM)
    myEcharts.on('click', params => {
        this.echartsClicked(params)
    })
    
    ...
}

向上抛出事件及其参数:

methods: {
    echartsClicked(params) {
        this.$emit('echarts-clicked', params)
    }
}

在组件调用方捕捉该事件和参数:

<i-echart :option="option" @echarts-clicked="echartsClicked" class="echarts-container"></i-echart>
methods:{
    echartsClicked(params){
        console.log(params)
    }
}

参考

  1. 如何判断对象为空 - segmentfault
  2. vue中如何首次赋值不触发watch? - segmentfault
  3. echart 注意事项-初始化和销毁 - segmentfault
  4. echarts 官方教程
  5. 在 vue 中获取 dom 元素 - CSDN
  6. 用Javascript获取页面元素的位置 - 阮一峰的网络日志
  7. JS添加事件和解绑事件:addEventListener()与removeEventListener() - CSDN
  8. addEventListener的第三个参数
  9. js网页滚动条滚动事件实例分析

查看原文: 做一个具有异步加载特性的 echarts-vue 组件

  • blackladybug
  • bluelion
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。