先上个 GitHub 链接

https://github.com/tfcx-th/html-export

前段时间在工作中遇到了一个将页面导出为图片的需求,考虑到这个需求比较常见,于是将其抽象出来放在了 GitHub 上,原版的代码是这样实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
export default class Exporter {
constructor(
name = 'export',
exportElement = 'body',
scrollElement = '',
ignoreElement = []
) {
this.name = name
this.exportElement = exportElement
this.scrollElement = scrollElement
this.ignoreElement = ignoreElement
this.displayMap = new Map()
}

exportImg() {
this._getDisplayMap(this.ignoreElement)
this._toImg().then(url => {
this._download(this.name, url)
})
}

_download(name, url) {
const link = document.createElement('a')
link.href = url
link.download = name
let event
if (window.MouseEvent) {
event = new MouseEvent('click')
} else {
event = document.createEvent('MouseEvents')
event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null)
}
link.dispatchEvent(event)
}

_toImg() {
const app = document.querySelector('#app')
const appHeight = window.getComputedStyle(app).getPropertyValue('height')

// 取消滚动元素的滚动
const scrollEle = document.querySelector(this.scrollElement).parentNode
const scrollEleOverflow = window.getComputedStyle(scrollEle).getPropertyValue('overflow')
app.style.height = 'auto'
scrollEle.style.overflow = 'visible'

this.ignoreElement.forEach(ele => {
const dom = document.querySelector(ele)
dom.style.display = 'none'
})

return html2canvas(document.querySelector(this.exportElement), {
allowTaint: true,
backgroundColor: '#f4f4f4'
}).then(canvas => {
app.style.height = appHeight
scrollEle.style.overflow = scrollEleOverflow
this.ignoreElement.forEach(ele => {
const dom = document.querySelector(ele)
dom.style.display = this.displayMap.get(ele)
})
this.displayMap = new Map()
return canvas.toDataURL('image/png', 1)
})
}

// 记录被忽略的元素的display值,以便后续复原
_getDisplayMap(ignoreElement = []) {
ignoreElement.forEach(ele => {
const dom = document.querySelector(ele)
const display = window.getComputedStyle(dom).getPropertyValue('display')
this.displayMap.set(ele, display)
})
}
}

整个代码的流程并不复杂,主要包括以下几步

  1. 获取导出元素原始高度,取消其滚动,让不导出元素消失

  2. 将整个页面通过 html2canvas 转换成图片

  3. 下载图片

  4. 让页面复原

但是,原版的实现将上述几个流程混杂在一起,整个代码很长,可读性和可维护性都不强,且很难扩展,因此考虑对他进行重构

再次分析业务流程,可以更细致化的流程

1
2
3
4
5
6
7
1. 导出元素高度设置为auto
2. 取消滚动元素滚动
3. 不导出元素不显示
4. 转换为图片并下载
3. 显示不导出元素
2. 恢复滚动
1. 恢复导出元素高度

可以看到,1、2、3都是在对同一类对象进行处理,但是被中间的核心部分将两个阶段隔开了,这和 Koa 的洋葱圈模型十分相似

看一个 Koa 洋葱圈的例子

1
2
3
4
5
6
7
8
9
10
11
app.use(async (ctx, next) => {
console.log('1')
await next()
console.log('2')
})

app.use(async (ctx, next) => {
console.log('3')
await next()
console.log('4')
})

执行这个代码片段可以发现,输出为 1342,和 html-export 实际的执行顺序是一致的,可以通过对 Koa 洋葱圈的模拟来达到想要的效果

html-export 中的洋葱圈是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export default class Onion {
constructor() {
this.mwList = []
}

use(mw) {
if (Object.prototype.toString.call(mw) !== '[object Object]') {
throw new TypeError('Middleware must be a Object')
}
this.mwList.push(mw)
return this
}

start() {
return this._compose(this.mwList)
}

_compose(mwlist = []) {
function dispatch(i) {
if (i > mwlist.length - 1) return
const mw = mwlist[i]
try {
mw.before()
return Promise.resolve(dispatch(i + 1)).then(() => {
mw.after()
})
} catch (err) {
return Promise.reject(err)
}
}
dispatch(0)
}
}

中间件以对象的形式注册,对象中的 before 方法和 after 方法可以按由外到内在由内到外的方式调用

基于这种形式,分别编写四个中间件

  • 用于处理导出元素高度

    middleware/handleHeight.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    export default function handleHeight(exportEle) {
    const ele = document.querySelector(exportEle)
    const eleHeight = window.getComputedStyle(ele).getPropertyValue('height')
    return {
    before: () => {
    ele.style.height = 'auto'
    },
    after: () => {
    ele.style.height = eleHeight
    }
    }
    }
  • 用于处理滚动元素

    middleware/handleScroll.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    export default function handleScroll(scrollEle) {
    const ele = document.querySelector(scrollEle)
    const eleOverflow = window.getComputedStyle(ele).getPropertyValue('overflow')
    return {
    before: () => {
    ele.style.overflow = 'visible'
    },
    after: () => {
    ele.style.overflow = eleOverflow
    }
    }
    }
  • 用于处理不导出元素

    middleware/handleIgnore.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    export default function handleIgnore(ignoreEle) {
    let displayMap = new Map()
    ignoreEle.forEach(ele => {
    const dom = document.querySelector(ele)
    const display = window.getComputedStyle(dom).getPropertyValue('display')
    displayMap.set(ele, display)
    })
    return {
    before: () => {
    ignoreEle.forEach(ele => {
    const dom = document.querySelector(ele)
    dom.style.display = 'none'
    })
    },
    after: () => {
    ignoreEle.forEach(ele => {
    const dom = document.querySelector(ele)
    dom.style.display = displayMap.get(ele)
    })
    displayMap = null
    }
    }
    }
  • 核心导出模块

    middleware/handleExport.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    export default function handleExport(name, exportEle, options = {}) {
    return {
    before: () => {
    html2canvas(document.querySelector(exportEle), options).then(canvas => {
    return canvas.toDataURL('image/png', 1)
    }).then(url => {

    download(name, url)
    })
    },
    after: () => {}
    }
    }

最终主流程可以简化为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function htmlExport(
name = 'export',
exportEle = '#app',
scrollEle = '',
ignoreEle = []
) {
const exporter = new Onion()
exporter
.use(handleHeight(exportEle))
.use(handleScroll(scrollEle))
.use(handleIgnore(ignoreEle))
.use(handleExport(name, exportEle))
.start()
}

可以看到,改造后的代码逻辑清晰,易于维护,同时如果有需要添加的功能可以很方便的通过中间件自行添加