{"action":"created","issue":{"url":"https://api.github.com/repos/whinc/blog/issues/8","repository_url":"https://api.github.com/repos/whinc/blog","labels_url":"https://api.github.com/repos/whinc/blog/issues/8/labels{/name}","comments_url":"https://api.github.com/repos/whinc/blog/issues/8/comments","events_url":"https://api.github.com/repos/whinc/blog/issues/8/events","html_url":"https://github.com/whinc/blog/issues/8","id":347903258,"node_id":"MDU6SXNzdWUzNDc5MDMyNTg=","number":8,"title":"H5 实现保存图片的采坑记录","user":{"login":"whinc","id":5096493,"node_id":"MDQ6VXNlcjUwOTY0OTM=","avatar_url":"https://avatars2.githubusercontent.com/u/5096493?v=4","gravatar_id":"","url":"https://api.github.com/users/whinc","html_url":"https://github.com/whinc","followers_url":"https://api.github.com/users/whinc/followers","following_url":"https://api.github.com/users/whinc/following{/other_user}","gists_url":"https://api.github.com/users/whinc/gists{/gist_id}","starred_url":"https://api.github.com/users/whinc/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/whinc/subscriptions","organizations_url":"https://api.github.com/users/whinc/orgs","repos_url":"https://api.github.com/users/whinc/repos","events_url":"https://api.github.com/users/whinc/events{/privacy}","received_events_url":"https://api.github.com/users/whinc/received_events","type":"User","site_admin":false},"labels":[],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":12,"created_at":"2018-08-06T12:29:50Z","updated_at":"2018-12-18T09:46:39Z","closed_at":null,"author_association":"OWNER","body":"## 需求背景\r\n\r\n一句话描述需求：答题并根据答案生成你是什么食物，可长按保存图片。\r\n\r\n体验地址：<https://personal.webank.com/s/hj/op-mall/food-festival/index.html>\r\n\r\n![示例图片](https://github.com/whinc/blog/blob/master/assets/8/result1.png?raw=true)\r\n\r\n## 问题1：字体文件太大\r\n\r\n活动页用到了特殊的中文字体“汉仪小麦”，完整的字体文件约 6.9MB，而实际活动页只用到了很少的一部分字体，全部加载会增加整个页面的加载时间，需要对字体进行按需裁剪。\r\n\r\n使用 [fontmin](https://github.com/ecomfe/fontmin) 工具进行字体裁剪，将要裁剪的文字集合保存到文本文件`keep.txt`中（有重复文字没关系会自动去重），通过下面命令裁剪字体文件：\r\n```bash\r\n# input.ttf 输入的字体文件\r\n# output 裁剪后的字体输出目录，包含了多种字体格式\r\nfontmin -t \"$(cat keep.txt)\" input.ttf output\r\n```\r\n在 CSS 中引入裁剪后的字体文件，下面这段代码是 fontmin 自动生成的，其根据每个平台所支持的字体格式选择最合适的：\r\n```css\r\n@font-face {\r\n    font-family: \"HYXiaoMaiTiJ\";\r\n    src: url(\"hyxm.eot\"); /* IE9 */\r\n    src: url(\"hyxm.eot?#iefix\") format(\"embedded-opentype\"), /* IE6-IE8 */\r\n    url(\"hyxm.woff\") format(\"woff\"), /* chrome, firefox */\r\n    url(\"hyxm.ttf\") format(\"truetype\"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */\r\n    url(\"hyxm.svg#HYXiaoMaiTiJ\") format(\"svg\"); /* iOS 4.1- */\r\n    font-style: normal;\r\n    font-weight: normal;\r\n}\r\n```\r\n活动页中用到了约1100个不同汉字，裁剪后大小从 6.9MB 降为 284KB 左右，体积缩减了约 96.5%，从实际体验上看，可以接受了。\r\n```bash\r\n-rw-r--r-- 1 whincwu staff 284K Jul 24 15:57 hyxm.eot\r\n-rw-r--r-- 1 whincwu staff 710K Jul 24 15:57 hyxm.svg\r\n-rw-r--r-- 1 whincwu staff 284K Jul 24 15:57 hyxm.ttf\r\n-rw-r--r-- 1 whincwu staff 284K Jul 24 15:57 hyxm.woff\r\n```\r\n\r\n## 问题2：保存网页快照到本地图片\r\n\r\n如果可以调用原生的截图接口，实现这个需求应该是比较简单的。但这是一个纯 H5 页面，要求使用 web 技术实现，有两种思路：\r\n**方案一**：调用后台接口，后台渲染网页成图片返回给前端\r\n优点：前端不需要太多工作量；后台可以利用成熟的网页渲染库处理；不存在跨域等问题。\r\n缺点：增加了后台工作量和复杂度；调用接口返回需要时间，前端会有一定的等待期，这会造成体验下降；前端页面发生需求变化时，需同时更新后台渲染模板，可能会造成更新不及时，增加了维护成本。\r\n\r\n**方案二**：前端遍历 DOM 结构将页面绘制到画布上后，导出成 base64 图片\r\n优点：不依赖后台；及时性好；体验好。\r\n缺点：对前端技术挑战比较大（好在有现成的第三方库完成这件工作）；图片跨域会有一些问题。\r\n\r\n我选择了第二种方案，使用 [html2canvas](https://github.com/niklasvh/html2canvas) 库对网页的部分进行“快照”，html2canvas 这个库是一个纯前端的库，其原理是读取 DOM 上的样式信息，在 canvas 上绘制按照 W3C 的 CSS 规范渲染 DOM，只支持部分 CSS 属性，不过也足够一般的使用场合了。”截图“的代码如下：\r\n```js\r\nconst el = document.querySelector('#result')\r\nhtml2canvas(el).then(canvas => {\r\n  const img = new Image()\r\n  img.style.display = \"block\"\r\n  // 将 canvas 导出成 base64\r\n  img.src = canvas.toDataURL('image/jpeg')\r\n  // 添加图片到预览\r\n  document.querySelector('#preview').appendChild(img)\r\n})\r\n```\r\n\r\n> 去年在做 Webank App 存本取息存款心意卡时也用过这个库，当时是 0.5-beta 版，那个版本存在不少问题，例如不支持高清屏导致图片模糊，好在截止做这个活动页时，版本已经来到了 1.0-alpha 版，解决了那一版的很多问题，节省了不少时间。\r\n\r\n## 问题3：微信分享网页无反应\r\n\r\n开发完后给测试同学体验时，发现微信通过右上角分享出去时，没有任何反应或者发送失败。\r\n\r\n以前听过微信分享图片有限制，怀疑是画布导出的图片过大，超出了微信分享缩略图大小限制。\r\n\r\n控制台打印图片大小出来确实比较大，在我的小米 MIX2 上大概 300 KB 左右（这个值在不同分辨率手机上会不同，分辨率越高越大），解决办法就是降低图片大小，canvas 的 toDataURL 接口有两个参数，第一个参数是导出的图片类型，第二个参数是图片质量（仅当图片类型是 jpeg 时有效）。下调导出图片质量，再尝试分享是否成功，如此返回下调多次后，在图片尺寸降低到几十KB 后，图片终于可以分享成功了。\r\n```js\r\n img.src = canvas.toDataURL('image/jpeg', 0.92)\r\n```\r\n分享的问题虽然解决，但是图片清晰度严重不足，这个解决办法行不通。\r\n\r\n在查阅部分网上资料后，发现微信分享网页时，自动提取页面内第一张可见图片作为缩略图，缩略图最大不能超过 32KB，否则发送失败。既然微信分享时的缩略图是取第一张图片，那就放一张小于32kb的图片在页面内最前面，作为分享时的缩略图，只要它不占用空间就不会影响布局，这个方法最终实践简单可行。\r\n\r\n>注意：这个图片不能设置为`display: none`，否则微信取不到该图片。\r\n\r\n上面这种解决方法最后还是没有使用，原因是有更好的处理方式了。在同事的帮助下，使用还未被和谐的[微信 JSAPI](https://github.com/Tencent/weui/wiki/%E5%BE%AE%E4%BF%A1JSAPI) 设置分享缩略图，既解决了分享缩略图的问题，还可以定制分享的标题和描述内容。\r\n\r\n## 问题4：资源预加载并展示进度\r\n\r\n活动页中用到了大量的图片，网络慢时会出现图片加载不完全的情况，而且页面中有不少动画，如果图片没有加载完，动画看起来比较诡异。所以，图片预加载是非常必要，其原理是利用浏览器的缓存机制——优先使用已经下载过的图片。\r\n\r\n常见实现是在页面中将一些要用到的图片以`<img/>`标签的形式放入页面中且使其不可见，浏览器自动解析下载这些图片，后面再用到相同的图片时，无需再次下载就立即可用。也可以通过 JS 动态创建`HTMLImageElement`实例来下载图片，这里我使用了第二种，主要是方便 JS 操作。\r\n\r\n图片资源通过动态创建`Image`并设置其`src`属性触发浏览器加载，监听 load 和 error 事件来统计已完成的数量，代码如下：\r\n```js\r\n// 待加载的资源路径\r\nconst preloadAssets = [ ... ]\r\nconst total = preloadAssets.length\r\nlet current = 0\r\n\r\nconst onLoad = (url) => {\r\n  ++current\r\n  const percent = parseInt(current / total * 100)\r\n  updateProcess(percent)\r\n  if (current >= total) {\r\n    // 加载完成，跳转到活动首页\r\n  }\r\n}\r\n\r\npreloadAssets.forEach(url => {\r\n  const img = new Image ()\r\n  img.src = url\r\n  img.onload = () => onLoad(url)\r\n  img.onerror = () => onLoad(url)\r\n})\r\n```\r\n\r\n如果资源很多要加载比较久，可以加一个最大时长限制，超过时限后，即使没加载完也进去首页（资源依然在后台异步加载），这样可以让用户可以尽快与页面互动，减少用户在等待页的流失。\r\n\r\n## 问题5：字体预加载及展示问题\r\n\r\n字体文件和图片一样也需要预加载，不过是放在首页`index.html`中进行的。\r\n\r\n这里有个坑，字体文件渲染时才会加载，如果页面预加载时没有地方用到，浏览器不会去下载，解决办法在页面看不见的地方放一个使用字体的标签，触发浏览器加载字体资源。\r\n\r\n预加载字体后，发现 Loading 页使用了自定义字体的进度百分数一开始不显示，然后突然从大于 0 的某个百分数开始显示。\r\n\r\n这里又是一个坑（与其说是坑，倒不如说是知识点的欠缺--!），自定义的字体在加载完成之前，字体如何展示取决于`font-display`属性（用法可参考这篇文章 [font-display 的使用](https://www.zcfy.cc/article/controlling-font-performance-with-font-display)），这个属性支持 5 个值`auto | block | swap | fallback | optional`，大部分浏览器默认是`block`，表示在字体加载完成前，以一种”隐形“字体（或者说不带墨水的字体）显示文字，即文字占用空间但是不可见，这也是造成上面问题的源头。这个属性的另一个值`swap`，表示字体加载完成前，先以后备字体显示文字，待字体加载完后立即替换成自定义的字体。`swap`取值正好可以解决上面问题，代码如下：\r\n```html\r\n<html>\r\n  <head>\r\n    <style>\r\n      @font-face {\r\n        font-family: \"hyxm\";\r\n        src: url(\"./fonts/hyxm.eot\"); /* IE9 */\r\n        src: url(\"./fonts/hyxm.woff\") format(\"woff\"), /* chrome, firefox */\r\n          url(\"./fonts/hyxm.ttf\") format(\"truetype\"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */\r\n          url(\"./fonts/hyxm.svg#HYXiaoMaiTiJ\") format(\"svg\"), /* iOS 4.1- */\r\n          url(\"./fonts/hyxm.eot?#iefix\") format(\"embedded-opentype\"); /* IE6-IE8 */\r\n        font-style: normal;\r\n        font-weight: normal;\r\n        /* 在自定义字体加载前，先使用后备字体 */\r\n        font-display: swap;\r\n      }\r\n    </style>\r\n  </head>\r\n  <body>\r\n    <!-- 浏览器只有渲染时才加载字体文件，所以这里放一个不可见的字体，触发浏览器加载字体资源 -->\r\n    <span style=\"visibility: hidden; position: absolute; top: -10em; font-family: hyxm\">hyxm</span>\r\n  </body>\r\n</html>\r\n```\r\n\r\n## 问题6：IOS 上不支持音频自动播放\r\n\r\n`<audio>` ios上无法自动播放，设置`autoplay`属性或者调用`play()`都不行，网上查了下遇到类似问题的不少，原因是 IOS 对此进行了限制，必须在用户发生交互行为时才能播放（避免了页面打开各种广告声音~~），从 Chrome  66 开始也加入了这项限制，更多细节查阅这边文章[Chrome 66禁止声音自动播放之后](https://fed.renren.com/2018/05/13/audio-auto-play/)。\r\n\r\n比较常见的解决办法，是在用户触摸屏幕任何地方后开始播放音频，这样能在最早的时机开始音频的播放，而且兼容性好。\r\n```js\r\nel.addEventListener('touchstart', function () {\r\n  if (!hasInteracted) { // 只触发一次\r\n    hasInteracted = true\r\n    musicEl.play()\r\n  }\r\n})\r\n```\r\n\r\n## 问题7：长按图片A分享图片B\r\n\r\n活动页答题完成后的结果页（下面左图），用户可以长按保存图片或分享图片（下面右图）。\r\n![](https://github.com/whinc/blog/blob/master/assets/8/result2.png?raw=true)\r\n\r\n根据前面的解释，在结果页会对网页进行快照得到一张 base64 图片，并替换网页原来的内容，这样用户可以长按图片触发微信的操作弹窗，这里的长按的图片和被分享图片必然是同一样的图片。但是，需求有要求看到的内容和分享的图片是不一致的，很矛盾。\r\n\r\n确实很矛盾，但还是得想办法。对比上面两个图片发现只有底部不一样，所以思路是在分享图片（下面右图）上方盖一层（下面左图），这样就可以达到预期效果：看到的是左图，长按食物图片时分享的是右图。\r\n![](https://github.com/whinc/blog/blob/master/assets/8/result3.png?raw=true)\r\n\r\n但是使用这种遮罩的方式，又会产生一个新的问题，长按二维码时，分享出去的是二维码而不是上面右图，这是因为长按事件是传递到了遮罩层上的二维码图片，触发了图片默认的长按行为。\r\n\r\n解决办法是将遮罩层的根元素加上`pointer-event: none`属性，关于该属性的用法可参考这篇文章 [pointer-events，一个神奇的css属性](https://www.jianshu.com/p/0112df5b0e17)。这个属性可以让事件忽略某个元素及其子节点，仿佛这个元素不存在一般。添加该属性后，长按事件可以透过遮罩层直达底层的分享图片，从而触发待分享图片的长按事件。经过这样处理后，无论长按界面哪个地方（按钮除外）都分享的是上面右图，问题解决。\r\n\r\n## 问题8：微信长按图片无法识别其中的二维码\r\n\r\n该情况出现在部分 iphone 手机上，安卓出现很少。起初怀疑是二维码图片太小导致的，更换一张大图就可以识别了，正准备更换大图，发现同事用一张同等尺寸的二维码替换后，之前不能识别的手机却可以识别了，这就纳闷了。。。\r\n\r\n对比了下我们两个生成的二维码，发现我的二维码密度较低，看了下生成二维码的工具，还有一个容错率可选，容错率越高，二维码密度越高，我的二维码容错率选的是 7%，同事选的是 30%，提高容错率后重新生成二维码替换，问题解决。\r\n\r\n## 总结\r\n\r\n上面是以我的视角列举了一些开发遇到的问题，还有一些其他小问题在这里没有列举。活动中的动效是 UI 的同事精心调制的，其中也有不少值得学习的地方。\r\n\r\n这次活动页的开发过程中所踩过的坑，也暴露出部分知识点的欠缺，这部分后面是需要“补课\"的。"},"comment":{"url":"https://api.github.com/repos/whinc/blog/issues/comments/448159911","html_url":"https://github.com/whinc/blog/issues/8#issuecomment-448159911","issue_url":"https://api.github.com/repos/whinc/blog/issues/8","id":448159911,"node_id":"MDEyOklzc3VlQ29tbWVudDQ0ODE1OTkxMQ==","user":{"login":"pinguo-hufangyan","id":17963733,"node_id":"MDQ6VXNlcjE3OTYzNzMz","avatar_url":"https://avatars3.githubusercontent.com/u/17963733?v=4","gravatar_id":"","url":"https://api.github.com/users/pinguo-hufangyan","html_url":"https://github.com/pinguo-hufangyan","followers_url":"https://api.github.com/users/pinguo-hufangyan/followers","following_url":"https://api.github.com/users/pinguo-hufangyan/following{/other_user}","gists_url":"https://api.github.com/users/pinguo-hufangyan/gists{/gist_id}","starred_url":"https://api.github.com/users/pinguo-hufangyan/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/pinguo-hufangyan/subscriptions","organizations_url":"https://api.github.com/users/pinguo-hufangyan/orgs","repos_url":"https://api.github.com/users/pinguo-hufangyan/repos","events_url":"https://api.github.com/users/pinguo-hufangyan/events{/privacy}","received_events_url":"https://api.github.com/users/pinguo-hufangyan/received_events","type":"User","site_admin":false},"created_at":"2018-12-18T09:46:39Z","updated_at":"2018-12-18T09:46:39Z","author_association":"NONE","body":"> > 谢谢你。我还想问一下 你是写了两个div吗 我看一个是id=\"result\"，用于生成快照，一个class=\"result-wrapper\"用与用户显示，请问思路是这样的吗\r\n> \r\n> 是的，因为用户看到的内容和实际分享的内容不同，所以使用了两个 DIV 容器。\r\n\r\n好的  。谢谢楼主。功能是实现了，"}}
{"actor":{"display_login":"pinguo-hufangyan"}}