原先逻辑是通过接口/operational_activity_api/v2/generate_poster 传入URL指定元素转发请求后截图DOM返回图片,缺点十分明显,就是耗时太久。
就拿第一期的连胜打卡举例,我们需要请求一张站内的海报图片以及一张站外附带了二维码的站外海报加图片,在4G情况总耗时约为7s,体验极差。

背景

原先逻辑是通过接口/operational_activity_api/v2/generate_poster 传入URL指定元素转发请求后截图DOM返回图片,缺点十分明显,就是耗时太久。
就拿第一期的连胜打卡举例,我们需要请求一张站内的海报图片以及一张站外附带了二维码的站外海报加图片,在4G情况总耗时约为7s,体验极差。

解决方案

引入html2canvas 库,它的原理大体就是通过递归DOM层层绘制在canvas上。

最终收益

通过本地渲染处Base64后分享,加载两张图片总耗时不需半秒(设备:iPhone 13),具体时间可能会根据设备性能有所不同,大幅提高了用户体验。

核心函数

核心函数localGenareteImage,传入一个DOM或者选择器字符串获得base64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const localGenareteImage = (DOMorSelector: HTMLElement | string): Promise<HTMLCanvasElement> => {
const el = typeof DOMorSelector === "string" ? (document.querySelector(DOMorSelector) as HTMLElement) : DOMorSelector;
const width = el.offsetWidth,
height = el.offsetHeight,
canvas = document.createElement("canvas"),
scale = 2; // 放大倍数,提高清晰度,但是会增加大小和性能消耗

canvas.width = width * scale;
canvas.height = height * scale;

const opts = {
useCORS: true,
scale,
canvas,
logging: false,
width,
height,
backgroundColor: "transparent"
};

return html2canvas(el, opts);
};

这样子其实很简单就可以拿到一个DOM的图片了,但我们的需求通常还有预览、分享站内站外。

实践时细节

以下就拿连胜打卡的场景来说,是通过点击分享按钮 -> 吊起预览图片 -> 点击分享(站内 or 站外)
(以下我将用于渲染图片的 DOM 简称为 renderDOM )
所以这张图片是需要触发了某个事件才开始生成的,也就是说一开始 renderDOM 并不需要展示在页面上,需要通过CSS隐藏。其实我认为 renderDOM 不展示出来的话更灵活,具体按实际场景而定。
隐藏的样式:
image
( 隐藏DOM的方式很多,但不能直接 display: none,它必须还存在渲染树上才能被获取到样式属性。)
而这个位置,实际上是贴了一个base64出来:
image
实际结构是这样,.deceit就是 renderDOM,.localImage只是一个展示base64的容器
image
而为什么不直接展示这个 renderDOM ,是因为我需要渲染两张图片用作站内和站外,而这两张图片的区别只是站外多了一个二维码的部分,所以完全可以复用这个 renderDOM
image
.deceit实际是这样的,这样子是不是站内只需要渲染上面部分,站外再全部渲染一次就好了?
这样就能拿到两张不同的base64,然后调用原生的分享就好咯

优化

image
不过这里还能进而再优化一步,就是我们在渲染的时候,红框部分其实被渲染了两次。虽说此时的速度也非常可观了,也能不到一秒就加载完,但这里还是节约一些性能的。
解决方法就是我们只需要渲染红框部分和二维码部分,最后再将它们通过canvas拼接在一起,这样红框部分就只需要渲染一次了。
以下是一个简单的封装,可以将两张图片下上拼接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 上下拼接两个canvas
* @param canvas1 上
* @param canvas2 下
* @returns
*/
function concatenateCanvases(canvas1: HTMLCanvasElement, canvas2: HTMLCanvasElement) {
const width = Math.max(canvas1.width, canvas2.width);
const height = canvas1.height + canvas2.height;

const concatenatedCanvas = document.createElement("canvas");
concatenatedCanvas.width = width;
concatenatedCanvas.height = height;

const ctx = concatenatedCanvas.getContext("2d");

ctx?.drawImage(canvas1, 0, 0);

ctx?.drawImage(canvas2, 0, canvas1.height);

return concatenatedCanvas;
}

然后再稍微调整了一下renderDOM的结构,拆成两个部分方便截图就好了。

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
<template>
<vanOverlay :show="isShare" teleport="body">
<div class="localImage">
<img v-if="url" :src="url" />
<VanLoading v-else />
</div>
<div class="deceit">

<div id="share__card" class="ignore-1" :data-lang="locale">
<img src="https://ali-hk-cdn.hellotalk8.com/winning-streak/perf2_images/winningShare.png" class="background" />
<div class="streak-day" v-html="t('copyOfLianshengReminder', { number: `<span>${day}</span>` }) + ' !'"></div>
<div class="explain">{{ $t("youHaveBrokenTheRecordForLanguageLearning") }}</div>
<div class="welcome-join">{{ $t("createAWinningStreakTogether") }}</div>
</div>

<div id="share__off__site" class="ignore-2">
<img src="https://ali-hk-cdn.hellotalk8.com/winning-streak/HelloTalk_logo.png" class="logo" />
<div class="text">
<img src="https://ali-hk-cdn.hellotalk8.com/winning-streak/HelloTalk.png" />
<p>Global Language Community</p>
</div>
<img class="QRcode" src="https://ali-hk-cdn.hellotalk8.com/winning-streak/download.png" />
</div>

</div>
</vanOverlay>
</template>

最后,调用原生的分享方法~ 传入站内和站外的base64。
再将base64缓存起来,避免重复渲染重复的图片。

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
const clickShare = async (source: string) => {
let inside!: string, outside!: string;
isShare.value = true;
nextTick(async () => {
if (cacheImage.inside && cacheImage.outside) {
openShare(cacheImage.inside, cacheImage.outside, source, closeShareCallback);
} else {
nextTick(async () => {
let insideCanvas!: HTMLCanvasElement;
inside = await localGenareteImage("#share__card").then((canvas) => {
insideCanvas = canvas;
return cropBottomPixel(canvas).toDataURL("image/png");
});
return insideCanvas;
}).then(async (insideCanvas) => {
outside = await


("#share__off__site").then((canvas) => concatenateCanvases(insideCanvas as any, canvas).toDataURL("image/png"));
cacheImage.inside = inside;
cacheImage.outside = outside;
localImageUrl.value = inside;
openShare(inside, outside, source, closeShareCallback);
});
}
});
};

注意

如果项目中引用了postcss或使用vw等相对单位,需要改为px这种绝对单位,否则图片会根据设备可视窗口改变宽高,这应该不是我们想要的。
如果使用postcss,可以在postcss.config.js中配置一个selectorBlackList,然后使用对应类名就不会被转换了。

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
plugins: {
"postcss-px-to-viewport": {
unitToConvert: "px", // 需要转换的单位,默认为"px"
viewportWidth: 375, // 视窗的宽度,对应的设计稿的宽度
unitPrecision: 5, // 指定`px`转换为视窗单位值的小数位数(很多时候无法整除)
propList: ["*"], // 能转化为vw的属性列表
viewportUnit: "vw", // 指定需要转换成的视窗单位,建议使用vw
fontViewportUnit: "vw", // 字体使用的视口单位
selectorBlackList: [".ignore-"], // 指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名
}
}
};

html2canvas 的坑

这玩意虽然好使但还是有坑的,以下是我之前开发遇到并解决的问题。

1. 黑边

比如截图出来的图片大概会有1px的黑边,出现的情况是在DOM的背景为渐变的时候,具体原因暂时不明,我的解决方法是通过canvas将底部的黑边裁剪掉,简单粗暴。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 裁剪1px
* @param canvas 需要裁剪的canvas
* @returns
*/
function cropBottomPixel(canvas: HTMLCanvasElement) {
const width = canvas.width;
const height = canvas.height;

const newCanvas = document.createElement("canvas");
newCanvas.width = width;
newCanvas.height = height - 1;
const newCtx = newCanvas.getContext("2d");

newCtx?.drawImage(canvas, 0, 0);

return newCanvas;
}

2. 背景图片模糊

如果使用了 background: url(…) 这样的属性时,背景图片会出现锯齿。
我的解决方法是通过一个img标签定位在容器上设置 z-index: -1 做背景。

组件封装

暂时存放于keep_and_increase项目下的src/components/sharePoster.vue

template部分

1
2
3
4
5
6
7
8
9
<div class="share-poster-container" :style="{ width: pxToVw(width), height: pxToVw(height) }">
<div class="localImage" v-show="!debug">
<img v-if="base64" :src="base64" />
<VanLoading v-else />
</div>
<div :class="{ deceit: !debug }" ref="deceit">
<slot></slot>
</div>
</div>

localImage用于展示renderDOM生成后的图片,deceit就是存放renderDOM
可以通过debug这个参数来调试renderDOM。

script部分

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
<script lang="ts" setup>
import html2canvas from "html2canvas";
import { ref, nextTick } from "vue";
import { pxToVw } from "@/utils/common";

defineProps<{
debug?: boolean; // debug就展示真实的DOM 没有就展示img(src为DOM变成图片后的base64)
width: number;
height: number;
}>();

const emit = defineEmits(["getBase64"]);

const deceit = ref<HTMLElement | null>(null);
const base64 = ref("");

const localGenareteImage = (DOMorSelector: HTMLElement | string): Promise<HTMLCanvasElement> => {
const el = typeof DOMorSelector === "string" ? (document.querySelector(DOMorSelector) as HTMLElement) : DOMorSelector;
const width = el.offsetWidth,
height = el.offsetHeight,
canvas = document.createElement("canvas"),
scale = 4; // 放大倍数,提高清晰度,但是会增加大小和性能消耗

canvas.width = width * scale;
canvas.height = height * scale;

const opts = {
useCORS: true,
scale,
canvas,
logging: false,
width,
height,
backgroundColor: "transparent"
};

return html2canvas(el, opts);
};

/**
* 处理黑边 有时候黑边也可能在右侧 需要自己调整细节。。
* @param canvas 需要裁剪的canvas
* @returns
*/
function cropBottomPixel(canvas: HTMLCanvasElement) {
const width = canvas.width;
const height = canvas.height;

const newCanvas = document.createElement("canvas");
newCanvas.width = width - 3;
newCanvas.height = height - 1;
const newCtx = newCanvas.getContext("2d");

newCtx?.drawImage(canvas, -3, 0);

return newCanvas;
}

const provideImageUrl = () => {
emit("getBase64", base64.value);
}

nextTick(async () => {
if (deceit.value?.children[0]) {
base64.value = await localGenareteImage(deceit.value.children[0] as HTMLElement).then((canvas) => {
return cropBottomPixel(canvas).toDataURL("image/png");
});
provideImageUrl();
}
});

</script>

style部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<style lang="less" scoped>
.share-poster-container {
position: relative;
overflow: hidden;
}
.deceit {
width: 0;
height: 0;
overflow: hidden;
}
.localImage {
display: flex;
align-items: center;
height: 100%;
width: 100%;
img {
width: 100%;
height: 100%;
user-select: none;
pointer-events: none;
}
}
</style>

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<sharePoster :width="350" :height="350" @get-base64="getBase64">
<div class="ignore-1 xxx"> xxx </div>
</sharePoster>

// ...

<script lang="ts" setup>
import { ref } from "vue";

const imgSrc = ref("")
const getBase64 = (res) => {
imgSrc.value = res; // ...
console.log(res) // base64
}

</script>

通过插槽将内容插入组件中,通过get-base64就会返回插槽部分的内容图片base64。

兼容低版本方案

因为连胜打卡是离线包场景,所以不需要考虑低版本的问题。而目前需要复用到非离线包场景,所以考虑到5440前的IOS在调用socialShare时传入base64形式的图片会出现网络错误的情况。为了解决这个问题所以找到服务端在之前webim的上传OSS接口上加了一个类型提供我们上传这个截图,得到一个url路径传递给IOS客户端,这样就不会有问题了。

接口封装

以下是对之前webim的上传OSS接口做了一个简单封装(以下示例中的business为十一周年的)

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import http from '@/utils/request'
import md5 from 'crypto-js/md5'
import store from "@/store"
import type { TokenDetails } from "../typings"

const base64ToFile = (base64String: string) => {
const fileName = generateMD5(base64String) + '.png';
const binaryData = atob(base64String.split(',')[1]);
const uint8Array = new Uint8Array(Uint8Array.from(binaryData, char => char.charCodeAt(0)));

const blob = new Blob([uint8Array], { type: 'image/png' });
return new File([blob], fileName, { type: 'image/png' });
};

export const uploadOSS = async (base64String: string) => {
const file = base64ToFile(base64String),
file_type = getFileType(file.type),
file_name = file.name,
area_code = store.getters.userInfo.areacode;
try {
const details = await getFileUploadToken(file_type, file_name, area_code);
const params = {
key: details.key_name,
policy: details.policy,
OSSAccessKeyId: details.accessid,
success_action_status: '200',
callback: '',
signature: details.signature,
},
formData = new FormData(),
fileCDNUrl = details.host + '/' + details.key_name;
formData.append('name', details.key_name);
for (const key in params) {
if(key !== 'callback') {
formData.append(key, params[key as keyof typeof params]);
}
}
formData.append('file', file)
await uploadFile(details.host, formData)
return fileCDNUrl
} catch (error) {
console.log(error)
return base64String
}
}

function uploadFile (url: string, formData: FormData) {
// 上传文件
return http({
headers: {},
withCredentials: false,
url,
method: 'post',
data: formData,
})
}

function getFileUploadToken (file_type: number, file_name: string, area_code: string): Promise<TokenDetails> {
// 获取上传文件的token信息
var data = { file_type, file_name, code: area_code, business: 'anniversary' }
const url = process.env.VUE_APP_UPLOAD_OSS_URL

const headers = {
'Content-Type': 'application/json',
'x-ht-os': getDeviceType(),
'x-ht-lang': getLocales()
}
return new Promise(resolve => {
// data = xtea.xTEAEncryptWithKey(JSON.stringify(data), token) // 进行加密
http({
url,
method: 'post',
data,
headers
})
.then((res) => {
resolve(res.data)
})
.catch(reject)
})
}

function getDeviceType() {
const userAgent = navigator.userAgent;
const platform = navigator.platform;

if (/iPhone|iPad|iPod/i.test(userAgent)) {
return 0;
} else if (/Android/i.test(userAgent)) {
return 1;
} else if (/Mac|Win|Linux/i.test(platform)) {
return 3;
} else {
return 2;
}
}

const browserLanguages = {
"zh-hant": "Chinese_yy",
"zh-mo": "Chinese_yy",
"zh-tw": "Chinese_yy",
"zh-hk": "Chinese_yy",
"tc": "Chinese_yy",
"zh-cn": "Chinese",
"zh-sg": "Chinese",
"zh": "Chinese",
"en": "English",
"ja": "Japanese",
"ko": "Korean",
"es": "Spanish",
"fr": "French",
"de": "German",
"pt": "Portuguese",
"it": "Italian",
"ru": "Russian",
"tr": "Turkish",
"ar": "Arabic",
}

const formatBrowserLang = (param: string) => {
let Lang = param.toLowerCase()
if (Lang.includes("zh")) {
Lang = browserLanguages[Lang as keyof typeof browserLanguages]
} else {
const otherLang = Lang.substring(0, 2);
Lang = browserLanguages[otherLang as keyof typeof browserLanguages];
}
return Lang
}

/**
* 极简版浏览器语言判断
*/
const getLocales = () => {
// @ts-ignore
let lang = formatBrowserLang(navigator.language || navigator.userLanguage); // 常规浏览器语言和IE浏览器
if(!lang) lang = 'English'
return lang
}

const getFileType = (fileType: string) => {
let _fileType = 0;
if (fileType == "video/mp4") {
//文件格式的判断
_fileType = 3;
} else if (fileType == "audio/mp3" || fileType == "audio/mpeg") {
_fileType = 2;
} else if (fileType.indexOf("image") > -1) {
_fileType = 1;
} else {
_fileType = 0
}
return _fileType
}

const generateMD5 = (data: string) => md5(data).toString();

使用示例

从这个文件中导出这个uploadOSS,入参就是一个base64。再将上面组件的实例中再添加一道函数就完事。

1
2
3
4
5
6
7
const getBase64 = (base64string: string) => {
Promise.resolve(base64string)
.then(async (res) => {
imageUrl.value = res;
return shareImageUrl.value = await uploadOSS(res)
})
};

发现问题

在十一周年活动上线之后观察到话题页有少数用户分享的图片会出现问题

  • 截图的字体偏大或字体不统一
    • 这样设置本身看到的页面和App就有所差异
      image
    1. 目前市面上有一些安卓手机能够按比例放大手机内字体的大小,这会导致app、webview中的字体也被强制改变,此时的截图就会有所差异。
    2. 如果用户手机设置了比较特别的字体也会发生差异。
  • 由于浏览器本身就对canvas的尺寸有做限制,比如 Chrome 的限制就是 125w(非官方数据),虽然说这个数值很大,但如果是截取长图,例如 2024.2.5 哑巴开口引流页的聊天记录截图,10 轮对话下来的长度大概就 2000 左右了,这时候一般移动端的页面宽度在 375 左右,最后再为了提高清晰度增加一下缩放倍率2(由于设备像素比的问题,一般都会设置为2来保证一定的清晰度),此时轻易就打破这个限制了。((2000 * 2) * (375 * 2)= 300w)
    image
    • 我想到的一个解决思路大概就是将长图分段去截,例如把列表中的每个成员遍历去截,最终按比例缩小逐个绘制在等比例缩放后的画布上。 (失败原因: 亲身体验了一下,因为无论如何想要得到一张高清的长图,分辨率才是至关重要的,所以不管分多少段最终拼接也还需要一张巨大的 canvas 来实现,但这依然会被浏览器限制。)
    • 通过服务器用 NodeJS 库 puppeteer 来实现。(需要与服务端商讨)
    • 卡顿、内存泄露问题
      html2canvas 会加载所有的 dom,过滤不要的 dom,
      image
      index.html
      image
      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
          
      const localGenareteImage = (DOMorSelector: HTMLElement | string): Promise<HTMLCanvasElement> => {
      const el = typeof DOMorSelector === "string" ? (document.querySelector(DOMorSelector) as HTMLElement) : DOMorSelector;

      const width = el.offsetWidth,
      height = el.offsetHeight,
      canvas = document.createElement("canvas"),
      scale = 2; // 放大倍数,提高清晰度,但是会增加大小和性能消耗

      canvas.width = width * scale;
      canvas.height = height * scale;

      const opts = {
      useCORS: true,
      scale,
      canvas,
      logging: false,
      width,
      height,
      backgroundColor: "transparent",
      ignoreElements: e => {
      if (
      e.contains(el) ||
      el.contains(e) ||
      e.tagName === 'STYLE' ||
      e.tagName === 'LINK' ||
      e.getAttribute('data-html2canvas') != null // header里面的样式不能筛掉
      ) {
      return false;
      }
      return true;

      }
      }

      return html2canvas(el, opts);
      };

      后续

      有时间可以把这个封装成一个组件或一个npm包,可以大幅减少类似场景的开发。

      补充

      使用html2canvas时候,如果截图内包含了img元素,这个img元素需要在url增加时间戳,同时增加允许跨域的参数不然在浏览器测试截图,关闭缓存情况下(disable cache关闭),会出现内存跨域导致img无法生成,加载失效

动态的img

1
img2.crossOrigin = "Anonymous";

一定要有crossorigin=”anonymous”

1
2
3
4
<img
crossorigin="anonymous"
:src="props.props_type_img + `?${random6Number()}`"
/>