美文网首页
反思录:Angular实现svg和png图片下载

反思录:Angular实现svg和png图片下载

作者: lambeta | 来源:发表于2019-04-30 13:35 被阅读0次

    我经常思考,在面临一个不确定问题时,以往的经验究竟有无辅助作用?如果把经验遗忘会产生何种程度的影响?在上下求索未果之后,如何找回曾经的感觉,恰若灵光一现?凡此种种,终是要思考总结的,这篇文章便是我的反思之作。

    本篇文章会记述一些实用的svg与png之间的转换技巧并强调一种思考原则。

    概述

    • 技巧
    1. svg和png图片转换和下载
    2. 解决chrome data url too large下载问题
    3. 解决@ViewChild未及时刷新问题
    • 原则
    1. 永远从问题最近的地方开始分析

    理解下面这些内容的前提是具备一些Angular的编程基础,要求大致处于能自定义component的水平。

    假意需求

    当我说“假意需求”的时候,其实是将解决方案视作眼下的需求,目的是方便理解。在这个项目中,我们需要把页面上的已经存在的svg元素转换成可下载的svg和png链接。svg是矢量图,适合打印成海报;而png清晰度有限,用作在线预览。

    背景知识

    下面是svg(Scalable Vector Graphics)和canvas在编程方式、技术原理、使用范围以及转换程度这4个维度上的对比和评估。这些知识是理解实现svg转换为png的基础。

    编程方式

    svg是矢量图形语言,canvas提供画布标签和绘制API;
    svg提供各种图形,滤镜和动画。canvas只有绘制API,相对原始。

    技术原理

    svg是矢量图,提供了很多图形,还有完整的动画,事件机制,本身可以独立使用;
    canvas基于像素,是一种HTML元素,只能通过脚本绘制。

    适用范围

    svg被主流浏览器和svg阅读器支持,canvas只有主流浏览器支持;
    svg适用于大面积渲染区域的程序和静态文档,如google地图。canvas适合小范围图像密集型场景,如游戏。

    转换程度

    svg较难以转换成png或者jpeg格式的图片,不过canvas较容易。

    技巧

    假设主页面app.component.html面已经有一个component,它的内容如下:

    <app-template #template></app-template>
    

    其中<app-template></app-template>是一个自定义的component,它代表了一个svg文件,svg的内容存放在template.component.html中,而template.component.ts的定义如下:

    // template.component.ts
    @Component({
      selector: 'app-template',
      templateUrl: './template.component.html',
      styleUrls: ['./template.component.scss'],
    })
    export class TemplateComponent implements OnInit {
      ngOnInit() {
      }
    }
    

    当然,这个template.component需要在app.module.ts中声明后才能在app.component.html中使用。

    注意,#template是Angular5之后引入的语法,它的全称是Template reference variable (#var),功能在于引用其所指向的DOM元素。

    接下来要解决的就是如何在component中引用页面上的svg元素并将它转化成png格式的图片。

    svg和png图片转换和下载

    1. 获取元素

    Angular中提供一种叫做ViewChild的注解,可以帮助我们引用到页面中的svg元素,此处就是#template.

    @Component({
      selector: 'app-root',
      templateUrl: './app.component.html',
      styleUrls: ['./app.component.scss'],
    })
    export class AppComponent implements OnDestroy {
      @ViewChild('template')
      template: { svgRef: ElementRef };
      
      ngOnDestroy(): void {
      }
    }
    

    获取svg元素的方式为this.template.svgRef.nativeElement.

    2. 图片转换

    有了svg元素,接下来需要考虑的是如何对其编程。svg和html在浏览器的内存中都是以DOM树的形式存在,所以想要对svg进行编程,就得利用svg的DOM interface. 比如说我们要获取<svg>元素中的各项属性,就需要使用SVGSVGElement编程接口

    svg转换成png并不直接,但是我们知道canvas转换成png非常简单。所以有种思路是将svg转换成canvas再转成png. canvas有个drawImage函数,可以将图片绘制到画布上,该函数的输入源是HTMLImageElement或者另外的canvas元素。

    也就是说,如果我们能把svg转换成HTMLImageElement<img>,那么上述过程就顺理成章连成一串了。

    第一步是将svg元素转换成DataURL.

    private toSvgDataURL(viewerSvg: SVGSVGElement): string {
      const svg = viewerSvg.cloneNode(true) as SVGSVGElement;
      svg.setAttribute('width', '600px');
      const base64Data = btoa(unescape(encodeURIComponent(svg.outerHTML)));
      return `data:image/svg+xml;base64,${base64Data}`;
    }
    

    第二步是将DataURL转换成<img>.

    function loadImage(url: string): Observable<HTMLImageElement> {
      const result = new Subject<HTMLImageElement>();
      const image = document.createElement('img');
      image.src = url;
      image.addEventListener('load', () => {
        result.next(image);
      });
      return result.asObservable();
    }
    

    第三步是将<img>转换成canvas.

    private toPngDataURL(img: HTMLImageElement): string {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext('2d').drawImage(img, 0, 0);
      return canvas.toDataURL('image/png');
    }
    

    canvas转成png图片就是上述一句toDataURL的调用。

    3. 图片下载

    上面的三个步骤可以合起来。

    private generateDownloadUrl() {
      const svgDataURL = this.toSvgDataURL(this.template.svgRef.nativeElement);
      loadImage(svgDataURL)
      .pipe(map(this.toPngDataURL))
      .subscribe(url => {
        this.pngUrl = url;
        this.svgUrl = svgDataURL;
      });
    }
    

    <a>元素的href属性是可以接受DataURL的,所以我们把svg dataURL和png dataURL赋值给成员变量pngUrl与svgUrl即可,最后标注download属性表示这是一条下载链接。

    <a [href]="svgUrl" target="_blank" download="template.svg">下载 SVG 版本</a>
    <a [href]="pngUrl" target="_blank" download="template.png">下载 PNG 版本</a>
    

    解决chrome data url too large下载问题

    上述过程看上去顺利流畅,但是事实上一旦图片过大,在下载时,chrome浏览器会抛出网络错误。这是chrome/chormium内核存在已久的bug[1],stackoverflow上给出的绕行方案是用URL.createObjectURL(blob)取而代之。

    private toSvg(viewerSvg: SVGSVGElement): string {
      const svg = viewerSvg.cloneNode(true) as SVGSVGElement;
      svg.setAttribute('width', '600px');
      const blob = new Blob([svg.outerHTML], {type: 'image/svg+xml'});
      const url = URL.createObjectURL(blob);
      return url;
    }
    

    对于png的处理也可以很灵活。

    private toPng(img: HTMLImageElement): Observable<string> {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext('2d').drawImage(img, 0, 0);
      const result = new Subject<string>();
      canvas.toBlob(blob => {
        const url = URL.createObjectURL(blob);
        result.next(url);
      });
    
      return result.asObservable();
    }
    

    不过,因为浏览器的安全警告,url需要经过sanitize才能放行。这在Angular里可以导入DomSanitizer处理。

    import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
    
    ... 
     constructor(private sanitizer: DomSanitizer) {
     }
    

    原来的代码得返回SafeResourceUrl.

    private toSvg(viewerSvg: SVGSVGElement): SafeResourceUrl {
      const svg = viewerSvg.cloneNode(true) as SVGSVGElement;
      svg.setAttribute('width', '600px');
      const blob = new Blob([svg.outerHTML], {type: 'image/svg+xml'});
      const url = URL.createObjectURL(blob);
      const safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
      return safeUrl;
    }
    
    private toPng(img: HTMLImageElement): Observable<SafeResourceUrl> {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext('2d').drawImage(img, 0, 0);
      const result = new Subject<SafeResourceUrl>();
      canvas.toBlob(blob => {
        const url = this.sanitizer.bypassSecurityTrustResourceUrl(URL.createObjectURL(blob));
        result.next(url);
      });
    
      return result.asObservable();
    }
    

    原来的合并操作相应修改。

    private generateDownloadUrl() {
      this.svgUrl = this.toSvg(this.template.svgRef.nativeElement);
      const svgDataURL = this.toSvgDataURL(this.template.svgRef.nativeElement);
      loadImage(svgDataUrl)
      .pipe(flatMap(this.toPng)) // 此处有坑
      .subscribe(url => {
        this.pngUrl = url;
      });
    }
    

    值得注意的是原来的pipe map改成了flatMap,因为toPng返回还是一个Observable,而不是简单的值。

    这样看上去是没有问题的,但是如上面这段代码的注释:此处有坑。坑在哪里?稍后我会在原则处作深入探讨,现在暂且搁置,进入下一个技术话题。

    解决@ViewChild未及时刷新问题

    @ViewChild取得页面元素可能不是最新的,Angular的Change detection需要时间完成刷新,所以有很短时间的延迟[2]。这对于我的程序而言是不能容忍的。延迟虽不能容忍,但是等待刷新之后再处理图片还是可以的,所以解决方案就是等待一秒钟再做图片转换。

    private waitForViewChildReady() {
      return new Promise<string>((resolve) => {
        const wait = setTimeout(() => {
          clearTimeout(wait);
          resolve('workaround!');
        }, 1000);
      });
    }
    

    终章程序调用如下。

    this.waitForViewChildReady()
    .then(this.generateDownloadUrl())
    .catch(err => console.error(err))
    

    原则

    原则是用来指导实践的。

    永远从问题最近的地方开始分析

    不要用战术上的勤奋掩饰战略上的懒惰

    我个人对Angular并不十分熟悉,在实现svg和png图片下载功能的过程中遇到一些坑,这些坑有深有浅,深的直接面向stackoverflow编程绕过,浅的靠个人能力解决。只不过,对解决这些浅坑的过度自信却让我的思维陷入懒惰,导致了长时间的浪费。

    这里的浅坑就是Javascript臭名昭著的this scope问题。

    回顾一下上面有坑的代码,

    loadImage(svgDataUrl)
      .pipe(flatMap(this.toPng)) // 此处有坑
      .subscribe(url => {
        this.pngUrl = url;
      });
    

    toPng的代码如下,

    private toPng(img: HTMLImageElement): Observable<SafeResourceUrl> {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext('2d').drawImage(img, 0, 0);
      const result = new Subject<SafeResourceUrl>();
      canvas.toBlob(blob => {
        const url = this.sanitizer.bypassSecurityTrustResourceUrl(URL.createObjectURL(blob));
        result.next(url);
      });
    
      return result.asObservable();
    }
    

    程序运行时,抛出了一个错误cannot read bypassSecurityTrustResourceUrl of undefined.

    第一反应是我是不是写错了变量名,再三验证之后发现没有写错。然而这一步其实完全没必要,原因在于这些变量都是编辑器辅助补全的。

    紧接着,我在toBlob方法插入了console.log(this.sanitizer),运行后打印的结果是undefined。这能说明什么?程序执行到这里了?其实这种做法也没必要,因为控制台的错误信息明确表明这段代码执行到了,并且出错了。

    然后,我开始思考“难道我写的Angular的注入方式不对?”,在遍寻Angular的官方文档和样例之后,我确信注入方式没有问题。这步有可取之处,因为对Angular本身不够熟悉,查文档是合理的行为,但是解决思路离目标太远,程序的问题应该通过debug解决。

    无奈之下,我开始怀疑包依赖下载出现问题,所以用了最愚蠢的方法,删除node_modules,然后重新下载全部依赖。这是一步耗时的操作,最大的浪费就发生在这里。我把原来对于探索问题总结的基本原则分析得从最近的路开始[3]忘得一干二净。尝试无果之后,我没有从牛角尖中跳出来,遗忘了花时间放空自己[4]原则,还是持续纠结,直至最后放弃。

    第二天早上,喝了杯咖啡,脑袋清醒了些。在toPng方法外,我插入console.log(this.sanitizer),发现这个对象完好地出现在命令行中,此刻突然灵感一现,回忆起几年前写过一篇关于Javascript作用域的文章[5],可不就是this指针的问题么?

    loadImage(svgDataUrl)
      .pipe(flatMap(this.toPng.bind(this))) // 注意此处bind(this)
      .subscribe(url => {
        this.pngUrl = url;
      });
    

    所以用bind(this)锁定this的指向,然后发现程序运行正常,一切就都豁然开朗了。值得一提的是,这只是最便宜的修复,其实更可取的做法是写全函数体。

    loadImage(svgDataUrl)
      .pipe(flatMap(img => this.toPng(img))) // 注意此处完整函数体
      .subscribe(url => {
        this.pngUrl = url;
      });
    

    回想起来,为了节省几个单词,我耗费了好多时间去趟这个坑,这是不值当的。这其中的问题不乏因为我写过很多函数式代码,所以倾向简洁的表达;但是更值得警醒的是,在面临不确定性问题时懒惰的思维方式,用一句套话训斥自己——不要用战术上的勤奋掩饰战略上的懒惰。

    我们都知道试验是学习的高效方式,但是切不可乱碰乱撞、期待问题不翼而飞,我们应当遵循经过验证的原则切中要害、一击制胜,切记切记。

    诸君共勉。

    于 2019-04-30


    1. Data url is too large

    2. Angular ViewChid Refresh Issue

    3. Truffle Linker 的解释

    4. 如何高效地学习编程语言

    5. Javascript scope

    相关文章

      网友评论

          本文标题:反思录:Angular实现svg和png图片下载

          本文链接:https://www.haomeiwen.com/subject/lbamnqtx.html