pingan8787

pingan8787 查看完整档案

厦门编辑黎明大学  |  console.log 编辑EFT  |  FE 编辑 www.pingan8787.com 编辑
编辑

个人博客:http://www.pingan8787.com
Github:https://github.com/pingan8787
语雀:https://www.yuque.com/wangpin...

微信公众号【前端自习课】和千万网友一起,每日清晨,享受一篇前端优秀文章。

目前已连续推送文章 830+ 天,愿每个人的初心都能一直坚持下去!

个人动态

pingan8787 赞了文章 · 1月17日

vue修饰符--可能是东半球最详细的文档(滑稽)

为了方便大家写代码,vue.js给大家提供了很多方便的修饰符,比如我们经常用到的取消冒泡,阻止默认事件等等~
插播一则广告李雷的博客

目录

  • 表单修饰符
  • 事件修饰符
  • 鼠标按键修饰符
  • 键值修饰符
  • v-bind修饰符(实在不知道叫啥名字)

表单修饰符

填写表单,最常用的是什么?input!v-model~而我们的修饰符正是为了简化这些东西而存在的

  • .lazy
<div>
   <input type="text" v-model="value">
   <p>{{value}}</p>
</div>

clipboard.png

从这里我们可以看到,我们还在输入的时候,光标还在的时候,下面的值就已经出来了,可以说是非常地实时。
但是有时候我们希望,在我们输入完所有东西,光标离开才更新视图。

<div>
   <input type="text" v-model.lazy="value">
   <p>{{value}}</p>
</div>

这样即可~这样只有当我们光标离开输入框的时候,它才会更新视图,相当于在onchange事件触发更新。

  • .trim

在我们的输入框中,我们经常需要过滤一下一些输入完密码不小心多敲了一下空格的兄弟输入的内容。

<input type="text" v-model.trim="value">

clipboard.png

为了让你更清楚的看到,我改了一下样式,不过问题不大,相信你已经清楚看到这个大大的hello左右两边没有空格,尽管你在input框里敲烂了空格键。
需要注意的是,它只能过滤首尾的空格!首尾,中间的是不会过滤的

  • .number

看这个名字就知道,应该是限制输入数字或者输入的东西转换成数字,but不是辣么赶单。

clipboard.png

clipboard.png

如果你先输入数字,那它就会限制你输入的只能是数字。
如果你先输入字符串,那它就相当于没有加.number

事件修饰符

  • .stop

由于事件冒泡的机制,我们给元素绑定点击事件的时候,也会触发父级的点击事件。

<div @click="shout(2)">
  <button @click="shout(1)">ok</button>
</div>

//js
shout(e){
  console.log(e)
}
//1
//2

一键阻止事件冒泡,简直方便得不行。相当于调用了event.stopPropagation()方法。

<div @click="shout(2)">
  <button @click.stop="shout(1)">ok</button>
</div>
//只输出1
  • .prevent

用于阻止事件的默认行为,例如,当点击提交按钮时阻止对表单的提交。相当于调用了event.preventDefault()方法。

<!-- 提交事件不再重载页面 -->
<form v-on:submit.prevent="onSubmit"></form>

注意:修饰符可以同时使用多个,但是可能会因为顺序而有所不同。
用 v-on:click.prevent.self 会阻止所有的点击,而 v-on:click.self.prevent 只会阻止对元素自身的点击。
也就是从左往右判断~

  • .self

只当事件是从事件绑定的元素本身触发时才触发回调。像下面所示,刚刚我们从.stop时候知道子元素会冒泡到父元素导致触发父元素的点击事件,当我们加了这个.self以后,我们点击button不会触发父元素的点击事件shout,只有当点击到父元素的时候(蓝色背景)才会shout~从这个self的英文翻译过来就是‘自己,本身’可以看出这个修饰符的用法

<div class="blue" @click.self="shout(2)">
  <button @click="shout(1)">ok</button>
</div>

clipboard.png

  • .once

这个修饰符的用法也是和名字一样简单粗暴,只能用一次,绑定了事件以后只能触发一次,第二次就不会触发。

//键盘按坏都只能shout一次
<button @click.once="shout(1)">ok</button>
  • .capture

从上面我们知道了事件的冒泡,其实完整的事件机制是:捕获阶段--目标阶段--冒泡阶段。
默认的呢,是事件触发是从目标开始往上冒泡。
当我们加了这个.capture以后呢,我们就反过来了,事件触发从包含这个元素的顶层开始往下触发。

   <div @click.capture="shout(1)">
      obj1
      <div @click.capture="shout(2)">
        obj2
        <div @click="shout(3)">
          obj3
          <div @click="shout(4)">
            obj4
          </div>
        </div>
      </div>
    </div>
    // 1 2 4 3 

从上面这个例子我们点击obj4的时候,就可以清楚地看出区别,obj1,obj2在捕获阶段就触发了事件,因此是先1后2,后面的obj3,obj4是默认的冒泡阶段触发,因此是先4然后冒泡到3~

  • .passive

当我们在监听元素滚动事件的时候,会一直触发onscroll事件,在pc端是没啥问题的,但是在移动端,会让我们的网页变卡,因此我们使用这个修饰符的时候,相当于给onscroll事件整了一个.lazy修饰符

<!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 -->
<!-- 而不会等待 `onScroll` 完成  -->
<!-- 这其中包含 `event.preventDefault()` 的情况 -->
<div v-on:scroll.passive="onScroll">...</div>
  • .native

我们经常会写很多的小组件,有些小组件可能会绑定一些事件,但是,像下面这样绑定事件是不会触发的

<My-component @click="shout(3)"></My-component>

必须使用.native来修饰这个click事件(即<My-component @click.native="shout(3)"></My-component>),可以理解为该修饰符的作用就是把一个vue组件转化为一个普通的HTML标签,
注意:使用.native修饰符来操作普通HTML标签是会令事件失效的

鼠标按钮修饰符

刚刚我们讲到这个click事件,我们一般是会用左键触发,有时候我们需要更改右键菜单啥的,就需要用到右键点击或者中间键点击,这个时候就要用到鼠标按钮修饰符

  • .left 左键点击
  • .right 右键点击
  • .middle 中键点击
<button @click.right="shout(1)">ok</button>

键值修饰符

其实这个也算是事件修饰符的一种,因为它都是用来修饰键盘事件的。
比如onkeyup,onkeydown啊

  • .keyCode

如果不用keyCode修饰符,那我们每次按下键盘都会触发shout,当我们想指定按下某一个键才触发这个shout的时候,这个修饰符就有用了,具体键码查看键码对应表

<input type="text" @keyup.keyCode="shout(4)">

为了方便我们使用,vue给一些常用的键提供了别名

//普通键
.enter 
.tab
.delete //(捕获“删除”和“退格”键)
.space
.esc
.up
.down
.left
.right
//系统修饰键
.ctrl
.alt
.meta
.shift

可以通过全局 config.keyCodes 对象自定义按键修饰符别名:

// 可以使用 `v-on:keyup.f1`
Vue.config.keyCodes.f1 = 112

我们从上面看到,键分成了普通常用的键和系统修饰键,区别是什么呢?
当我们写如下代码的时候,我们会发现如果仅仅使用系统修饰键是无法触发keyup事件的。

<input type="text" @keyup.ctrl="shout(4)">

那该如何呢?我们需要将系统修饰键和其他键码链接起来使用,比如

<input type="text" @keyup.ctrl.67="shout(4)">

这样当我们同时按下ctrl+c时,就会触发keyup事件。
另,如果是鼠标事件,那就可以单独使用系统修饰符。

      <button @mouseover.ctrl="shout(1)">ok</button>
      <button @mousedown.ctrl="shout(1)">ok</button>
      <button @click.ctrl.67="shout(1)">ok</button>

大概是什么意思呢,就是你不能单手指使用系统修饰键的修饰符(最少两个手指,可以多个)。你可以一个手指按住系统修饰键一个手指按住另外一个键来实现键盘事件。也可以用一个手指按住系统修饰键,另一只手按住鼠标来实现鼠标事件。

  • .exact (2.5新增)

我们上面说了这个系统修饰键,当我们像这样<button type="text" @click.ctrl="shout(4)"></button>绑定了click键按下的事件,惊奇的是,我们同时按下几个系统修饰键,比如ctrl shift点击,也能触发,可能有些场景我们只需要或者只能按一个系统修饰键来触发(像制作一些快捷键的时候),而当我们按下ctrl和其他键的时候则无法触发。那就这样写。
注意:这个只是限制系统修饰键的,像下面这样书写以后你还是可以按下ctrl + c,ctrl+v或者ctrl+普通键 来触发,但是不能按下ctrl + shift +普通键来触发。

<button type="text" @click.ctrl.exact="shout(4)">ok</button>

然后下面这个你可以同时按下enter+普通键来触发,但是不能按下系统修饰键+enter来触发。相信你已经能听懂了8~

<input type="text" @keydown.enter.exact="shout('我被触发了')">

v-bind修饰符

  • .sync(2.3.0+ 新增)

在有些情况下,我们可能需要对一个 prop 进行“双向绑定”。不幸的是,真正的双向绑定会带来维护上的问题,因为子组件可以修改父组件,且在父组件和子组件都没有明显的改动来源。我们通常的做法是

//父亲组件
<comp :myMessage="bar" @update:myMessage="func"></comp>
//js
func(e){
 this.bar = e;
}
//子组件js
func2(){
  this.$emit('update:myMessage',params);
}

现在这个.sync修饰符就是简化了上面的步骤

//父组件
<comp :myMessage.sync="bar"></comp> 
//子组件
this.$emit('update:myMessage',params);

这样确实会方便很多,但是也有很多需要注意的点

  1. 使用sync的时候,子组件传递的事件名必须为update:value,其中value必须与子组件中props中声明的名称完全一致(如上例中的myMessage,不能使用my-message)
  2. 注意带有 .sync 修饰符的 v-bind 不能和表达式一起使用 (例如 v-bind:title.sync=”doc.title + ‘!’” 是无效的)。取而代之的是,你只能提供你想要绑定的属性名,类似 v-model。
  3. 将 v-bind.sync 用在一个字面量的对象上,例如 v-bind.sync=”{ title: doc.title }”,是无法正常工作的,因为在解析一个像这样的复杂表达式的时候,有很多边缘情况需要考虑。
  • .prop

要学习这个修饰符,我们首先要搞懂两个东西的区别。

Property:节点对象在内存中存储的属性,可以访问和设置。
Attribute:节点对象的其中一个属性( property ),值是一个对象。
可以通过点访问法 document.getElementById('xx').attributes 或者 document.getElementById('xx').getAttributes('xx') 读取,通过 document.getElementById('xx').setAttribute('xx',value) 新增和修改。
在标签里定义的所有属性包括 HTML 属性和自定义属性都会在 attributes 对象里以键值对的方式存在。

其实attribute和property两个单词,翻译出来都是属性,但是《javascript高级程序设计》将它们翻译为特性和属性,以示区分

//这里的id,value,style都属于property
//index属于attribute
//id、title等既是属性,也是特性。修改属性,其对应的特性会发生改变;修改特性,属性也会改变
<input id="uid" title="title1" value="1" :index="index">
//input.index === undefined
//input.attributes.index === this.index

从上面我们可以看到如果直接使用v-bind绑定,则默认会绑定到dom节点的attribute。
为了

  • 通过自定义属性存储变量,避免暴露数据
  • 防止污染 HTML 结构

我们可以使用这个修饰符,如下

<input id="uid" title="title1" value="1" :index.prop="index">
//input.index === this.index
//input.attributes.index === undefined
  • .camel

由于HTML 特性是不区分大小写的。

<svg :viewBox="viewBox"></svg>

实际上会渲染为

<svg viewbox="viewBox"></svg>

这将导致渲染失败,因为 SVG 标签只认 viewBox,却不知道 viewbox 是什么。
如果我们使用.camel修饰符,那它就会被渲染为驼峰名。
另,如果你使用字符串模版,则没有这些限制。

new Vue({
  template: '<svg :viewBox="viewBox"></svg>'
})

最后

不知道有没有漏的,如果有漏的麻烦在评论区告知一声,有建议或者意见也可以提一下,谢谢~

查看原文

赞 256 收藏 185 评论 29

pingan8787 赞了文章 · 1月13日

前端进阶不可错过的 10 个 Github 仓库

2021 年已经来了,相信有一些小伙伴已经开始立 2021 年的 flag 了。在 2020 年有一些小伙伴,私下问阿宝哥有没有前端的学习资料。为了统一回答这个问题,阿宝哥精心挑选了 Github 上 10 个不错的开源项目。

当然这 10 个项目不仅限于前端领域,希望这些项目对小伙伴的进阶能有所帮助。下面我们先来介绍第一个项目 —— build-your-own-x

build-your-own-x

🤓 Build your own (insert technology here)

https://github.com/danistefan...

WatchStarForkDate
3.5K92.3K8.1K2021-01-04

该仓库涉及了 27 个领域的内容,每个领域会使用特定的语言来实现某个功能。下图是与前端领域相关的内容:

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载2.2万+)及 50 几篇 “重学TS” 教程。

JavaScript Algorithms

📝 Algorithms and data structures implemented in JavaScript with explanations and links to further readings

https://github.com/trekhleb/j...

WatchStarForkDate
3.6K91.6K15.4K2021-01-04

该仓库包含了多种 基于 JavaScript 的算法与数据结构。每种算法和数据结构都有自己的 README,包含相关说明和链接,以便进一步阅读 (还有相关的视频) 。

30 Seconds of Code

Short JavaScript code snippets for all your development needs

https://github.com/30-seconds...

WatchStarForkDate
2K66.9K7.4K2021-01-04

该仓库包含了众多能满足你开发需求,简约的 JavaScript 代码片段。比如以下的 listenOnce 函数,可以保证事件处理器只执行一次。

const listenOnce = (el, evt, fn) => {
  let fired = false;
  el.addEventListener(evt, (e) => {
    if (!fired) fn(e);
    fired = true;
  });
};

listenOnce(
  document.getElementById('my-btn'),
  'click',
  () => console.log('Hello!')
);  // 'Hello!' will only be logged on the first click

Node Best Practices

✅ The Node.js best practices list

https://github.com/goldbergyo...

WatchStarForkDate
1.7K58.5K5.6K2021-01-04

该仓库介绍了 Node.js 应用的最佳实践,包含以下的内容:

RealWorld example apps

"The mother of all demo apps" — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more 🏅

https://github.com/gothinkste...

WatchStarForkDate
1.6K52.5K4.5K2021-01-04

对于大多数的 “Todo” 示例来说,它们只是简单介绍了框架的功能,并没有完整介绍使用该框架和相关技术栈,构建真正应用程序所需要的知识和视角。

RealWorld 解决了这个问题,它允许你选择任意前端框架(React,Vue 或 Angular 等)和任意后端框架(Node,Go,Spring 等)来驱动一个真实的、设计精美的全栈应用程序 “Conduit“ 。下图是目前已支持的前端框架(内容较多,只截取部分内容):

clean-code-javascript

🛁 Clean Code concepts adapted for JavaScript

https://github.com/ryanmcderm...

WatchStarForkDate
1.5K43.9K5.3K2021-01-04

该仓库介绍了如何写出整洁的 JavaScript 代码,比如作者建议使用可检索的名称:

不好的

// 86400000 的用途是什么?
setTimeout(blastOff, 86400000);

好的

// 使用通俗易懂的常量来描述该值
const MILLISECONDS_IN_A_DAY = 60 * 60 * 24 * 1000; //86400000;

setTimeout(blastOff, MILLISECONDS_IN_A_DAY);

该仓库包含了 11 个方面的内容,具体的目录如下图所示:

javascript-questions

A long list of (advanced) JavaScript questions, and their explanations ✨

https://github.com/lydiahalli...

WatchStarForkDate
85027K3.6K2021-01-04

该仓库包含了从基础到进阶的 JavaScript 知识,利用该仓库你可以测试你对 JavaScript 知识的掌握程度,也可以帮助你准备面试。

awesome-design-patterns

A curated list of software and architecture related design patterns.

https://github.com/DovAmir/aw...

WatchStarForkDate
47711.6K9312021-01-04

该仓库包含了软件与架构相关的设计模式的精选列表。在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在1990年代从建筑设计领域引入到计算机科学的。

developer-roadmap

Roadmap to becoming a web developer in 2021

https://github.com/kamranahme...

WatchStarForkDate
7.4K142K21.3K2021-01-04

该仓库包含一组图表,这些图表展示了成为一个 Web 开发者的学习路线图。该仓库含有前端、后端和 DevOps 的学习路线图,这里我们只介绍前端的学习路线图(原图是长图,这里只截取部分区域):

Free Programming Books

📚 Freely available programming books

https://github.com/EbookFound...

WatchStarForkDate
9.2K170K39.8K2021-01-04

该仓库包含了多种语言的免费学习资源列表,下图是中文免费资源列表(内容较多,只截取部分内容):

好的,到这里所有的开源项目都已经介绍完了,如果小伙伴有其他的不错的开源项目,欢迎给阿宝哥留言哟。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载 2.2万+)及 9 篇源码分析系列教程。
查看原文

赞 35 收藏 22 评论 0

pingan8787 发布了文章 · 1月11日

探索 Vue.js 响应式原理

提到“响应式”三个字,大家立刻想到啥?响应式布局?响应式编程?

响应式关键词.png

从字面意思可以看出,具有“响应式”特征的事物会根据条件变化,使得目标自动作出对应变化。比如在“响应式布局”中,页面根据不同设备尺寸自动显示不同样式。

Vue.js 中的响应式也是一样,当数据发生变化后,使用到该数据的视图也会相应进行自动更新。

接下来我根据个人理解,和大家一起探索下 Vue.js 中的响应式原理,如有错误,欢迎指点😺~~

一、Vue.js 响应式的使用

现在有个很简单的需求,点击页面中 “leo” 文本后,文本内容修改为“你好,前端自习课”。

我们可以直接操作 DOM,来完成这个需求:

<span id="name">leo</span>
const node = document.querySelector('#name')
node.innerText = '你好,前端自习课';

实现起来比较简单,当我们需要修改的数据有很多时(比如相同数据被多处引用),这样的操作将变得复杂。

既然说到 Vue.js,我们就来看看 Vue.js 怎么实现上面需求:

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      name: "leo",
    };
  },
  methods: {
    setName() {
      this.name = "你好,前端自习课";
    },
  },
};
</script>

观察上面代码,我们通过改变数据,来自动更新视图。当我们有多个地方引用这个 name 时,视图都会自动更新。

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
  </div>
</template>

当我们使用目前主流的前端框架 Vue.js 和 React 开发业务时,只需关注页面数据如何变化,因为数据变化后,视图也会自动更新,这让我们从繁杂的 DOM 操作中解脱出来,提高开发效率。

二、回顾观察者模式

前面反复提到“通过改变数据,来自动更新视图”,换个说法就是“数据改变后,使用该数据的地方被动发生响应,更新视图”。

是不是有种熟悉的感觉?数据无需关注自身被多少对象引用,只需在数据变化时,通知到引用的对象即可,引用的对象作出响应。恩,有种观察者模式的味道?

关于观察者模式,可阅读我之前写的《图解设计模式之观察者模式(TypeScript)》

1. 观察者模式流程

观察者模式表示一种“一对多”的关系,n 个观察者关注 1 个被观察者,被观察者可以主动通知所有观察者。接下图:

observer.png
在这张图中,粉丝想及时收到“前端自习课”最新文章,只需关注即可,“前端自习课”有新文章,会主动推送给每个粉丝。该过程中,“前端自习课”是被观察者,每位“粉丝”是观察者。

2. 观察者模式核心

观察者模式核心组成包括:n 个观察者和 1 个被观察者。这里实现一个简单观察者模式:

2.1 定义接口

// 观察目标接口
interface ISubject {
    addObserver: (observer: Observer) => void; // 添加观察者
    removeObserver: (observer: Observer) => void; // 移除观察者
    notify: () => void; // 通知观察者
}

// 观察者接口
interface IObserver {
    update: () => void;
}

2.2 实现被观察者类

// 实现被观察者类
class Subject implements ISubject {
    private observers: IObserver[] = [];

    public addObserver(observer: IObserver): void {
        this.observers.push(observer);
    }

    public removeObserver(observer: IObserver): void {
        const idx: number = this.observers.indexOf(observer);
        ~idx && this.observers.splice(idx, 1);
    }

    public notify(): void {
        this.observers.forEach(observer => {
            observer.update();
        });
    }
}

2.3 实现观察者类

// 实现观察者类
class Observer implements IObserver {
    constructor(private name: string) { }

    update(): void {
        console.log(`${this.name} has been notified.`);
    }
}

2.4 测试代码

function useObserver(){
    const subject: ISubject = new Subject();
    const Leo = new Observer("Leo");
    const Robin = new Observer("Robin");
    const Pual = new Observer("Pual");

    subject.addObserver(Leo);
    subject.addObserver(Robin);
    subject.addObserver(Pual);
    subject.notify();

    subject.removeObserver(Pual);
    subject.notify();
}

useObserver();
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 
// [LOG]: "Pual has been notified." 
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 

三、回顾 Object.defineProperty()

Vue.js 的数据响应式原理是基于 JS 标准内置对象方法 Object.defineProperty()方法来实现,该方法不兼容 IE8 和 FF22 及以下版本浏览器,这也是为什么 Vue.js 只能在这些版本之上的浏览器中才能运行的原因。

理解 Object.defineProperty() 对我们理解 Vue.js 响应式原理非常重要

Vue.js 3 使用 proxy 方法实现响应式,两者类似,我们只需搞懂Object.defineProperty()proxy 也就差不多理解了。

1. 概念介绍

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
语法如下:

Object.defineProperty(obj, prop, descriptor)
  • 入参说明:

obj :要定义属性的源对象
prop :要定义或修改的属性名称Symbol
descriptor :要定义或修改的属性描述符,包括 configurableenumerablevaluewritablegetset,具体的可以去参阅文档

  • 出参说明:

修改后的源对象。

举个简单🌰例子:

const leo = {};
Object.defineProperty(leo, 'age', { 
    value: 18,
    writable: true
})
console.log(leo.age); // 18
leo.age = 22;
console.log(leo.age); // 22

2. 实现 getter/setter

我们知道 Object.defineProperty() 方法第三个参数是属性描述符(descriptor),支持设置 getset 描述符:

  • get 描述符:当访问该属性时,会调用此函数,默认值为 undefined ;
  • set 描述符:当修改该属性时,会调用此函数,默认值为 undefined
一旦对象拥有了 getter/setter 方法,我们可以简单将该对象称为响应式对象。

这两个操作符为我们提供拦截数据进行操作的可能性,修改前面示例,添加 getter/setter 方法:

let leo = {}, age = 18;
Object.defineProperty(leo, 'age', { 
    get(){
        // to do something
          console.log('监听到请求数据');
        return age;
    },
    set(newAge){
        // to do something
          console.log('监听到修改数据');
        age = newAge > age ? age : newAge
    }
})
leo.age = 20;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 18

leo.age = 10;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 10

访问 leo 对象的 age 属性,会通过 get 描述符处理,而修改 age 属性,则会通过 set 描述符处理。

四、实现简单的数据响应式

通过前面两个小节,我们复习了“观察者模式”和“Object.defineProperty()” 方法,这两个知识点在 Vue.js 响应式原理中非常重要。

接下来我们来实现一个很简单的数据响应式变化,需求如下:点击“更新数据”按钮,文本更新。

data-change.png

接下来我们将实现三个类:

  • Dep 被观察者类,用来生成被观察者;
  • Watcher 观察者类,用来生成观察者;
  • Observer 类,将普通数据转换为响应式数据,从而实现响应式对象

用一张图来描述三者之间关系,现在看不懂没关系,这小节看完可以再回顾这张图:
observer-watcher-dep.png

1. 实现精简观察者模式

这里参照前面复习“观察者模式”的示例,做下精简:

// 实现被观察者类
class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}
// 实现观察者类
class Watcher {
    constructor(cb) {
        this.cb = cb;
    }
    update(data) {
        this.cb(data);
    }
}

Vue.js 响应式原理中,观察者模式起到非常重要的作用。其中:

  • Dep 被观察者类,提供用来收集观察者( addSub )方法和通知观察者( notify )方法;
  • Watcher 观察者类,实例化时支持传入回调( cb )方法,并提供更新( update )方法;

2. 实现生成响应式的类

这一步需要实现 Observer 类,核心是通过 Object.defineProperty() 方法为对象的每个属性设置 getter/setter,目的是将普通数据转换为响应式数据,从而实现响应式对象

reactive-data.png

这里以最简单的单层对象为例(下一节会介绍深层对象),如:

let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

接下来实现 Observer 类:

// 实现响应式类(最简单单层的对象,暂不考虑深层对象)
class Observer {
    constructor (node, data) {
        this.defineReactive(node, data)
    }

    // 实现数据劫持(核心方法)
    // 遍历 data 中所有的数据,都添加上 getter 和 setter 方法
    defineReactive(vm, obj) {
        //每一个属性都重新定义get、set
        for(let key in obj){
            let value = obj[key], dep = new Dep();
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get() {
                    // 创建观察者
                    let watcher = new Watcher(v => vm.innerText = v);
                    dep.addSub(watcher);
                    return value;
                },
                set(newValue) {
                    value = newValue;
                    // 通知所有观察者
                    dep.notify(newValue);
                }
            })
        }
    }
}

上面代码的核心是 defineReactive 方法,它遍历原始对象中每个属性,为每个属性实例化一个被观察者(Dep),然后分别调用 Object.defineProperty() 方法,为每个属性添加 getter/setter。

  • 访问数据时,getter 执行依赖收集(即添加观察者),通过实例化 Watcher 创建一个观察者,并执行被观察者的 addSub() 方法添加一个观察者;
  • 修改数据时,setter 执行派发更新(即通知观察者),通过调用被观察者的 notify() 方法通知所有观察者,执行观察者 update() 方法。

3. 测试代码

为了方便观察数据变化,我们为“更新数据”按钮绑定点击事件来修改数据:

<div id="app"></div>
<button id="update">更新数据</button>

测试代码如下:

// 初始化测试数据
let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

const app = document.querySelector('#app');

// 步骤1:为测试数据转换为响应式对象
new Observer(app, initData);

// 步骤2:初始化页面文本内容
app.innerText = initData.text;

// 步骤3:绑定按钮事件,点击触发测试
document.querySelector('#update').addEventListener('click', function(){
    initData.text = `我们必须经常保持旧的记忆和新的希望。`;
    console.log(`当前时间:${new Date().toLocaleString()}`)
})

测试代码中,核心在于通过实例化 Observer,将测试数据转换为响应式数据,然后模拟数据变化,来观察视图变化。
每次点击“更新数据”按钮,在控制台中都能看到“数据发生变化!”的提示,说明我们已经能通过 setter 观察到数据的变化情况。

当然,你还可以在控制台手动修改 initData 对象中的 text 属性,来体验响应式变化~~

到这里,我们实现了非常简单的数据响应式变化,当然 Vue.js 肯定没有这么简单,这个先理解,下一节看 Vue.js 响应式原理,思路就会清晰很多。

这部分代码,我已经放到我的 Github,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/Basics-Reactive-Demo.js

可以再回顾下这张图,对整个过程会更清晰:

observer-watcher-dep.png

五、Vue.js 响应式实现

本节代码:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/

这里大家可以再回顾下下面这张官网经典的图,思考下前面讲的示例。

(图片来自:https://cn.vuejs.org/v2/guide/reactivity.html

上一节实现了简单的数据响应式,接下来继续通过完善该示例,实现一个简单的 Vue.js 响应式,测试代码如下:

// index.js
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

是不是很有内味了,下面是我们最终实现后项目目录:

- mini-reactive
    / index.html   // 入口 HTML 文件
  / index.js     // 入口 JS 文件
  / observer.js  // 实现响应式,将数据转换为响应式对象
  / watcher.js   // 实现观察者和被观察者(依赖收集者)
  / vue.js       // 实现 Vue 类作为主入口类
  / compile.js   // 实现编译模版功能

知道每一个文件功能以后,接下来将每一步串联起来。

1. 实现入口文件

我们首先实现入口文件,包括 index.html / index.js  2 个简单文件,用来方便接下来的测试。

1.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <script data-original="./vue.js"></script>
    <script data-original="./observer.js"></script>
    <script data-original="./compile.js"></script>
    <script data-original="./watcher.js"></script>
</head>
<body>
    <div id="app">{{text}}</div>
    <button id="update">更新数据</button>
    <script data-original="./index.js"></script>
</body>
</html>

1.2 index.js

"use strict";
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

console.log(vm.$data.text)
vm.$data.text = '页面数据更新成功!'; // 模拟数据变化
console.log(vm.$data.text)

2. 实现核心入口 vue.js

vue.js 文件是我们实现的整个响应式的入口文件,暴露一个 Vue 类,并挂载全局。

class Vue {
    constructor (options = {}) {
        this.$el = options.el;
        this.$data = options.data();
        this.$methods = options.methods;

        // [核心流程]将普通 data 对象转换为响应式对象
        new Observer(this.$data);

        if (this.$el) {
            // [核心流程]将解析模板的内容
            new Compile(this.$el, this)
        }
    }
}
window.Vue = Vue;

Vue 类入参为一个配置项 option ,使用起来跟 Vue.js 一样,包括 $el 挂载点、 $data 数据对象和 $methods 方法列表(本文不详细介绍)。

通过实例化 Oberser 类,将普通 data 对象转换为响应式对象,然后判断是否传入 el 参数,存在时,则实例化 Compile 类,解析模版内容。

总结下 Vue 这个类工作流程 :
vue-class.png

3. 实现 observer.js

observer.js 文件实现了 Observer 类,用来将普通对象转换为响应式对象:

class Observer {
    constructor (data) {
        this.data = data;
        this.walk(data);
    }

    // [核心方法]将 data 对象转换为响应式对象,为每个 data 属性设置 getter 和 setter 方法
    walk (data) {
        if (typeof data !== 'object') return data;
        Object.keys(data).forEach( key => {
            this.defineReactive(data, key, data[key])
        })
    }

    // [核心方法]实现数据劫持
    defineReactive (obj, key, value) {
        this.walk(value);  // [核心过程]遍历 walk 方法,处理深层对象。
        const dep = new Dep();
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log('[getter]方法执行')
                Dep.target &&  dep.addSub(Dep.target);
                return value
            },
            set (newValue) {
                console.log('[setter]方法执行')
                if (value === newValue) return;
                // [核心过程]当设置的新值 newValue 为对象,则继续通过 walk 方法将其转换为响应式对象
                if (typeof newValue === 'object') this.walk(newValue);
                value = newValue;
                dep.notify(); // [核心过程]执行被观察者通知方法,通知所有观察者执行 update 更新
            }
        })
    }
}

相比较第四节实现的 Observer 类,这里做了调整:

  • 增加 walk 核心方法,用来遍历对象每个属性,分别调用数据劫持方法( defineReactive() );
  • defineReactive() 的 getter 中,判断 Dep.target 存在才添加观察者,下一节会详细介绍 Dep.target
  • defineReactive() 的 setter 中,判断当前新值( newValue )是否为对象,如果是,则直接调用 this.walk() 方法将当前对象再次转为响应式对象,处理深层对象

通过改善后的 Observer 类,我们就可以实现将单层或深层嵌套的普通对象转换为响应式对象

4. 实现 watcher.js

这里实现了 Dep 被观察者类(依赖收集者)和 Watcher 观察者类。

class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}

class Watcher {
    constructor (vm, key, cb) {
        this.vm = vm;   // vm:表示当前实例
        this.key = key; // key:表示当前操作的数据名称
        this.cb = cb;   // cb:表示数据发生改变之后的回调

        Dep.target = this; // 全局唯一
      
        // 此处通过 this.vm.$data[key] 读取属性值,触发 getter
        this.oldValue = this.vm.$data[key]; // 保存变化的数据作为旧值,后续作判断是否更新

        // 前面 getter 执行完后,执行下面清空
        Dep.target = null;
    }
    
    update () {
        console.log(`数据发生变化!`);
        let oldValue = this.oldValue;
        let newValue = this.vm.$data[this.key];
        if (oldValue != newValue) {  // 比较新旧值,发生变化才执行回调
            this.cb(newValue, oldValue);
        };
    }
}

相比较第四节实现的 Watcher  类,这里做了调整:

  • 在构造函数中,增加 Dep.target 值操作;
  • 在构造函数中,增加 oldValue 变量,保存变化的数据作为旧值,后续作为判断是否更新的依据;
  • update() 方法中,增加当前操作对象 key 对应值的新旧值比较,如果不同,才执行回调。

Dep.target当前全局唯一的订阅者,因为同一时间只允许一个订阅者被处理。target当前正在处理的目标订阅者,当前订阅者处理完就赋值为 null 。这里 Dep.target 会在 defineReactive() 的 getter 中使用到。

通过改善后的 Watcher 类,我们操作当前操作对象 key 对应值的时候,可以在数据有变化的情况才执行回调,减少资源浪费。

4. 实现 compile.js

compile.js 实现了 Vue.js 的模版编译,如将 HTML 中的 {{text}} 模版转换为具体变量的值。

compile.js 介绍内容较多,考虑到篇幅问题,并且本文核心介绍响应式原理,所以这里就暂时不介绍 compile.js 的实现,在学习的朋友可以到我 Github 上下载该文件直接下载使用即可,地址:
https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/compile.js

5. 测试代码

到这里,我们已经将第四节的 demo 改造成简易版 Vue.js 响应式,接下来打开 index.html 看看效果:

当 index.js 中执行到:

vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';

页面便发生更新,页面显示的文本内容从“你好,前端自习课”更新成“我们必须经常保持旧的记忆和新的希望。”。

到这里,我们的简易版 Vue.js 响应式原理实现好了,能跟着文章看到这里的朋友,给你点个大大的赞👍

六、总结

本文首先通过回顾观察者模式和 Object.defineProperty() 方法,介绍 Vue.js 响应式原理的核心知识点,然后带大家通过一个简单示例实现简单响应式,最后通过改造这个简单响应式的示例,实现一个简单 Vue.js 响应式原理的示例。

相信看完本文的朋友,对 Vue.js 的响应式原理的理解会更深刻,希望大家理清思路,再好好回味下~

参考资料

  1. 官方文档 - 深入响应式原理 
  2. 《浅谈Vue响应式原理》
  3. 《Vue的数据响应式原理》 
查看原文

赞 10 收藏 7 评论 0

pingan8787 发布了文章 · 2020-12-28

2020 总结 | 21 张图总结我的 2020 年

海沧湾公园.png

生活不可能像你想象的那么好,但也不会像你想象的那么糟。我觉得人的脆弱和坚强都超乎自己的想像,有时我脆弱得一句话就泪流满面,有时又发现自己咬着牙走了很长的路。

回看 2020,我更加喜爱这句话了,每个小句子都有了不同味道。

一、再见 2020 👋

2020疫情复工后,我便开始进入“战斗模式”,深受公众号“全栈修仙之路”作者“阿宝哥”影响🌟,开始把更多时间和精力用来修炼自身,努力成长和进阶,成为一位“靠谱的人”和一名“T 型人才”。

  • 靠谱的人:让自己靠谱,让别人放心;
  • T 型人才:深挖知识深度,拓展知识广度。

(以下数据统计的时间,全部以 2020-12-19 日截止)

🤗🤗🤗

1. 走过的路

疫情期间为了不给国家添麻烦,咱就天天家里窝着,闲来无事就给“貔貅”拍拍照啥的😎。

貔貅

复工后,骑上我的“绿豆”去了好多地方(快把厦门岛逛透透了),算是把疫情期间没去玩的地方都补上了🤠。

骑行.png

还有这杯意义不同的咖啡,和一句“心之所在即为家”😜。

星巴克的味道

当然,除了玩,这一年也做了很多重要的事情😊。

2020 年,写了很多文章(包含未发布),基本都放在语雀上。数一数,将近 150 篇文档是在 2020 年完成的🤔。

语雀

除了文章,我也画了很多图,慢慢形成自己的画图风格。

画图

当然,代码还是少不了的😎。

程序员嘛,当然要看看代码提交次数,这一年提交了这些代码,感觉 Github + Gitlab + Gitee 三个提交记录合并一下,都快铺满了。

  1. Github 提交记录

gitlab 提交记录.png

  1. Gitlab 提交记录

gitlab 提交记录.png

  1. Gitee(码云) 提交记录

gitlab 提交记录.png

另外自己的微信公众号“前端自习课”也完成“连续推送 810+ 天”的成绩,这一年,我也玩起短视频,视频号了,也开始自己做动画,将一些知识点通过动画和大家分享,视频可以查看《1分钟了解 Axios 拦截器实现原理》

1分钟了解 Axios 拦截器实现原理

2020 这一年还有很多事情想和大家分享,考虑到本文主旨和内容篇幅,就不再多介绍咯~有兴趣的朋友欢迎私聊我(微信:pingan8787)💘。

2. 感谢的人

今年最需要感谢的,是阿宝哥和我们“前端突击队学习小组的每位小伙伴啦💐~

🌰最大感受是:原来前端还能这样玩!
🌰最开心的是:团队学习更有动力,你不是一个人在战斗!

在阿宝哥指导下,整理了一份自己的前端技能树,才知道自己的前端技能有几斤几两重,也才有更多动力和更清晰的方向。

前端技能树

在我们学习小组中,采用“专题学习 + 总结输出”的方式一起学习,目前已经沉淀 200+ 篇文章啦!
小组目前 7 人(不含班主任),平均下来每人将近写了 30+ 篇!为小伙伴们点赞👍~

学习小组

2020 年 11 月的某一天,思考了最近学习的知识和接下来的需要做的事情,于是有了下面的这篇字数少,内容多的笔记(用手机敲的,就是有点手酸🙁):

学习总结

慢慢的,越来越发现,学得越多,发现自己要学的越多。🤣

这里再次感谢阿宝哥,感谢“前端突击队”的小伙伴们。未来继续冲🦆!

3. 遗憾的事

这一年,比较遗憾的事,是自己与阿里插肩而过呀🥺~倒也让我发现更多不足。

遗憾的事

这里也非常感谢内推的小伙伴,还有几位面试官,人都挺不错。😃

我们闽南人嘛,喜欢“爱拼才会赢”,所以,趁年轻多拼多创。

4. 点赞的事

这一年为自己坚持的几件事情点赞~
①自己微信公众号“前端自习课”连续推送 810+ 天文章,为此我把所有文章分类做了一张词云图,如下:

前端自习课

可以看出,我主要分享的内容包括:“JS”、“CSS”、“拓展”和“Web技术”。🔔

②自己坚持的每月学习文章整理,也超过 40+ 个月了,截图如下:

详细请看 github 地址:https://github.com/pingan8787/Leo_Reading

github 学习记录

二、你好 2021 👏

2021 年即将到来,希望新的一年,每一个“下次一定”都能实现完成承诺。

⚽️⚽️⚽️

1. 加油,前端工程师

在这前端生涯的第五年伊始,回想自己踩过的坑,走过的弯路,才慢慢领悟自己的前端生涯应该如何去走。

曾经和多数人一样,时常迷失学习什么知识,看到什么火,就去学什么,到头来,效果并不好。

未来自己的前端生涯,更应该站在巨人肩膀上,看向更远的地方。定个小目标呗,早日晋升技术专家。

接下来的时间里,做好自己在工作中的身份,做一个优秀的前端工程师。

桌面

2. 加油,小儿子

作为家中最小的孩子,被催婚已经成为这一年的常事,哈哈。

也许性格如此,加上独自在外工作,每天只想把事情做得更好,学更多知识,提升自己的价值。

很幸运这一年遇到了女孩 C。

接下来的时间里,做好自己在家里的身份,做一个让父母放心的好儿子。

五店市.png

3. 加油,骑行侠

我这人,兴趣爱好不太多,比如:骑行🚴、足球⚽️、敲代码💻。

骑行让我如此着迷。

换上衣服,12 月的寒风,也依然无法阻挡我的脚步。

接下来的时间里,坚持自己的热爱,做一个勇往直前大胆创的闽南人。

寒风.png

4. 加油,前端自习课

运营公众号“前端自习课”以后,认识了许多小伙伴,看见了许多从前的自己。

后来也慢慢和大家分享一些自己的经验和经历。

深刻记得,我简历中最后一句话:“希望自己的成⻓之路能帮助更多人,也希望在这个世界留下自己的一些足迹”。

接下来的时间里,坚持自己的初心,做一个对这个社区、这个社会有帮助的人。
zhihu.png

三、总结

每一年的总结,都是五味杂陈,才发现这一年来,自己又进步和成长了。

回顾篇头的一句话:“有时又发现自己咬着牙走了很长的路”。有时候一瞬间,一个偶然,发现自己原来咬着牙前进这么久,改变这么多。

最后,再思考一句话,希望对大家能有不同感受:

除去睡眠,人的一生有一万多天。但是人与人之间的区别就在于,你究竟是活了一万多天,还是仅仅活了一天,却重复了一万多次。

希望未来的我们,会感到自己的每一天都是崭新的。
武磊
像武磊一样努力,加油!

最后欢迎关注我呀~

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。
查看原文

赞 18 收藏 4 评论 11

pingan8787 关注了标签 · 2020-12-28

2020总结

你的 2020 是怎样的?

你的技术之路有了哪些新的变化?有哪些有意义的事情值得回顾和总结?欢迎在这个标签写下你的总结。


充满变数的 2020 年,技术行业从业者肩上的责任超越了以往任何历史时期。

突如其来的疫情让全人类经历了一次“数字化生存”大考,政企上云、传统行业的数字化转型也在大环境中被催化。作为新基建的底层支撑,芯片、服务器、操作系统、中间件、数据库等一系列信创技术,在全国范围内被广泛关注。

日新月异的技术革命,数字经济的新一轮爆发,背后是无数开发者和科技企业夜以继日的付出。他们面对不断变化的外部环境,扎根行业,他们信奉技术力量,敢于技术创新,践行技术信仰,他们是技术先锋,探索改变世界的方向。

关注 21

pingan8787 赞了文章 · 2020-12-24

想要复制图像?Clipboard API 了解一下

在写了 这个 29.7 K 的剪贴板 JS 库有点东西! 这篇文章之后,收到了小伙伴提的两个问题:

1.clipboard.js 这个库除了复制文字之外,能复制图像么?

2.clipboard.js 这个库依赖的 document.execCommand API 已被废弃了,以后应该怎么办?

(图片来源:https://developer.mozilla.org...

接下来,本文将围绕上述两个问题展开,不过在看第一个问题之前,我们先来简单介绍一下 剪贴板 📋。

剪贴板(英语:clipboard),有时也称剪切板、剪贴簿、剪贴本。它是一种软件功能,通常由操作系统提供,作用是使用复制和粘贴操作短期存储数据和在文档或应用程序间转移数据。它是图形用户界面(GUI)环境中最常用的功能之一,通常实现为匿名、临时的数据缓冲区,可以被环境内的大部分或所有程序使用编程接口访问。 —— 维基百科

通过以上的描述我们可以知道,剪贴板架起了一座桥梁,使得在各种应用程序之间,传递和共享信息成为可能。然而美中不足的是,剪贴板只能保留一份数据,每当新的数据传入,旧的便会被覆盖。

了解完 剪贴板 📋 的概念和作用之后,我们马上来看一下第一个问题:clipboard.js 这个库除了复制文字之外,能复制图像么?

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载近2.1万)及 50 几篇 “重学TS” 教程。

一、clipboard.js 能否复制图像?

clipboard.js 是一个用于将 文本 复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb

(图片来源:https://clipboardjs.com/#exam...

当你看到 “A modern approach to copy text to clipboard” 这个描述,你是不是已经知道答案了。那么实际的情况是怎样呢?下面我们来动手验证一下。在 这个 29.7 K 的剪贴板 JS 库有点东西! 这篇文章中,阿宝哥介绍了在实例化 ClipboardJS 对象时,可以通过 options 对象的 target 属性来设置复制的目标:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html
let clipboard = new ClipboardJS('.btn', {
  target: function() {
    return document.querySelector('div');
  }
});

利用 clipboard.js 的这个特性,我们可以定义以下 HTML 结构:

<div id="container">
   <img data-original="http://cdn.semlinker.com/abao.png" width="80" height="80"/>
   <p>大家好,我是阿宝哥</p>
</div>
<button class="btn">复制</button>

然后在实例化 ClipboardJS 对象时设置复制的目标是 #container 元素:

const clipboard = new ClipboardJS(".btn", {
  target: function () {
    return document.querySelector("#container");
  }
});

之后,我们点击页面中的 复制 按钮,对应的效果如下图所示:

观察上图可知,页面中的图像和文本都已经被复制了。对于文本来说,大家应该都很清楚。而对于图像来说,到底复制了什么?我们又该如何获取已复制的内容呢?针对这个问题,我们可以利用 HTMLElement 对象上的 onpaste 属性或者监听元素上的 paste 事件。

这里我们通过设置 document 对象的 onpaste 属性,来打印一下粘贴事件对应的事件对象:

document.onpaste = function (e) {
  console.dir(e);
}

当我们点击 复制 按钮,然后在页面执行 粘贴 操作后,控制台会打印出以下内容:

通过上图可知,在 ClipboardEvent 对象中含有一个 clipboardData 属性,该属性包含了与剪贴板相关联的数据。详细分析了 clipboardData 属性之后,我们发现已复制的图像和普通文本被封装为 DataTransferItem 对象。

为了更方便地分析 DataTransferItem 对象,阿宝哥重新更新了 document 对象的 onpaste 属性:

在上图中,我们可以清楚的看到 DataTransferItem 对象上含有 kindtype 属性分别用于表示数据项的类型(string 或 file)及数据对应的 MIME 类型。利用 DataTransferItem 对象提供的 getAsString 方法,我们可以获取该对象中保存的数据:

相信看完以上的输出结果,小伙伴们就很清楚第一个问题的答案了。那么如果想要复制图像的话,应该如何实现呢?其实这个问题的答案与小伙伴提的第二个问题的答案是一样的,我们可以利用 Clipboard API 来实现复制图像的问题及解决 document.execCommand API 已被废弃的问题。

接下来,我们的目标就是实现复制图像的功能了,因为要利用到 Clipboard API,所以阿宝哥先来介绍一下该 API。

二、Clipboard API 简介

Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。该 API 用于取代通过 document.execCommand API 来实现剪贴板的操作。

在实际项目中,我们不需要手动创建 Clipboard 对象,而是通过 navigator.clipboard 来获取 Clipboard 对象:

在获取 Clipboard 对象之后,我们就可以利用该对象提供的 API 来访问剪贴板,比如:

navigator.clipboard.readText().then(
  clipText => document.querySelector(".editor").innerText = clipText);

以上代码将 HTML 中含有 .editor 类的第一个元素的内容替换为剪贴板的内容。如果剪贴板为空,或者不包含任何文本,则元素的内容将被清空。这是因为在剪贴板为空或者不包含文本时,readText 方法会返回一个空字符串。

在继续介绍 Clipboard API 之前,我们先来看一下 Navigator API: clipboard 的兼容性:

(图片来源:https://caniuse.com/mdn-api_n...

异步剪贴板 API 是一个相对较新的 API,浏览器仍在逐渐实现它。由于潜在的安全问题和技术复杂性,大多数浏览器正在逐步集成这个 API。对于浏览器扩展来说,你可以请求 clipboardRead 和 clipboardWrite 权限以使用 clipboard.readText() 和 clipboard.writeText()。

好的,接下来阿宝哥来演示一下如何使用 clipboard 对象提供的 API 来操作剪贴板,以下示例的运行环境是 Chrome 87.0.4280.88

三、将数据写入到剪贴板

3.1 writeText()

writeText 方法可以把指定的字符串写入到系统的剪贴板中,调用该方法后会返回一个 Promise 对象:

<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
   async function copyPageUrl() {
     try {
       await navigator.clipboard.writeText(location.href);
       console.log("页面地址已经被拷贝到剪贴板中");
     } catch (err) {
       console.error("页面地址拷贝失败: ", err);
     }
  }
</script>

对于上述代码,当用户点击 拷贝当前页面地址 按钮时,将会把当前的页面地址拷贝到剪贴板中。

3.2 write()

write 方法除了支持文本数据之外,还支持将图像数据写入到剪贴板,调用该方法后会返回一个 Promise 对象。

<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
   async function copyPageUrl() {
     const text = new Blob([location.href], {type: 'text/plain'});
     try {
       await navigator.clipboard.write(
         new ClipboardItem({
           "text/plain": text,
         }),
       );
       console.log("页面地址已经被拷贝到剪贴板中");
     } catch (err) {
       console.error("页面地址拷贝失败: ", err);
     }
  }
</script>

在以上代码中,我们先通过 Blob API 创建 Blob 对象,然后使用该 Blob 对象来构造 ClipboardItem 对象,最后再通过 write 方法把数据写入到剪贴板。介绍完如何将数据写入到剪贴板,下面我们来介绍如何从剪贴板中读取数据。

对 Blob API 感兴趣的小伙伴,可以阅读 你不知道的 Blob 这篇文章。

四、从剪贴板中读取数据

4.1 readText()

readText 方法用于读取剪贴板中的文本内容,调用该方法后会返回一个 Promise 对象:

<button onclick="getClipboardContents()">读取剪贴板中的文本</button>
<script>
   async function getClipboardContents() {
     try {
       const text = await navigator.clipboard.readText();
       console.log("已读取剪贴板中的内容:", text);
     } catch (err) {
       console.error("读取剪贴板内容失败: ", err);
     }
   }
</script>

对于上述代码,当用户点击 读取剪贴板中的文本 按钮时,如果当前剪贴板含有文本内容,则会读取剪贴板中的文本内容。

4.2 read()

read 方法除了支持读取文本数据之外,还支持读取剪贴板中的图像数据,调用该方法后会返回一个 Promise 对象:

<button onclick="getClipboardContents()">读取剪贴板中的内容</button>
<script>
async function getClipboardContents() {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      for (const type of clipboardItem.types) {
        const blob = await clipboardItem.getType(type);
        console.log("已读取剪贴板中的内容:", await blob.text());
      }
    }
  } catch (err) {
      console.error("读取剪贴板内容失败: ", err);
    }
  }
</script>

对于上述代码,当用户点击 读取剪贴板中的内容 按钮时,则会开始读取剪贴板中的内容。到这里 clipboard 对象中涉及的 4 个 API,阿宝哥都已经介绍完了,最后我们来看一下如何实现复制图像的功能。

五、实现复制图像的功能

在最后的这个示例中,阿宝哥将跟大家一步步实现复制图像的核心功能,除了复制图像之外,还会同时支持复制文本。在看具体代码前,我们先来看一下实际的效果:

在上图对应的网页中,我们先点击 复制 按钮,则图像和文本都会被选中。之后,我们在点击 粘贴 按钮,则控制台会输出从剪贴板中读取的实际内容。在分析具体的实现方式前,我们先来看一下对应的页面结构:

<div id="container">
   <img data-original="http://cdn.semlinker.com/abao.png" width="80" height="80" />
   <p>大家好,我是阿宝哥</p>
</div>
<button onclick="writeDataToClipboard()">复制</button>
<button onclick="readDataFromClipboard()">粘贴</button>

上面的页面结构很简单,下一步我们来逐步分析一下以上功能的实现过程。

5.1 请求剪贴板写权限

默认情况下,会为当前的激活的页面自动授予剪贴板的写入权限。出于安全方面考虑,这里我们还是主动向用户请求剪贴板的写入权限:

async function askWritePermission() {
  try {
    const { state } = await navigator.permissions.query({
      name: "clipboard-write",
    });
      return state === "granted";
  } catch (error) {
      return false;
  }
}

5.2 往剪贴板写入图像和普通文本数据

要往剪贴板写入图像数据,我们就需要使用 navigator.clipboard 对象提供的 write 方法。如果要写入图像数据,我们就需要获取该图像对应的 Blob 对象,这里我们可以通过 fetch API 从网络上获取图像对应的响应对象并把它转化成 Blob 对象,具体实现方式如下:

async function createImageBlob(url) {
  const response = await fetch(url);
  return await response.blob();
}

而对于普通文本来说,只需要使用前面介绍的 Blob API 就可以把普通文本转换为 Blob 对象:

function createTextBlob(text) {
  return new Blob([text], { type: "text/plain" });
}

在创建完图像和普通文本对应的 Blob 对象之后,我们就可以利用它们来创建 ClipboardItem 对象,然后再调用 write 方法把这些数据写入到剪贴板中,对应的代码如下所示:

async function writeDataToClipboard() {
  if (askWritePermission()) {
    if (navigator.clipboard && navigator.clipboard.write) {
        const textBlob = createTextBlob("大家好,我是阿宝哥");
        const imageBlob = await createImageBlob(
          "http://cdn.semlinker.com/abao.png"
        );
        try {
          const item = new ClipboardItem({
            [textBlob.type]: textBlob,
            [imageBlob.type]: imageBlob,
          });
          select(document.querySelector("#container"));
          await navigator.clipboard.write([item]);
          console.log("文本和图像复制成功");
        } catch (error) {
          console.error("文本和图像复制失败", error);
        }
      }
   }
}

在以上代码中,使用了一个 select 方法,该方法用于实现选择的效果,对应的代码如下所示:

function select(element) {
  const selection = window.getSelection();
  const range = document.createRange();
  range.selectNodeContents(element);
  selection.removeAllRanges();
  selection.addRange(range);
}

通过 writeDataToClipboard 方法,我们已经把图像和普通文本数据写入剪贴板了。下面我们来使用 navigator.clipboard 对象提供的 read 方法,来读取已写入的数据。如果你需要读取剪贴板的数据,则需要向用户请求 clipboard-read 权限。

5.3 请求剪贴板读取权限

这里我们定义了一个 askReadPermission 函数来向用户请求剪贴板读取权限:

async function askReadPermission() {
  try {
    const { state } = await navigator.permissions.query({
      name: "clipboard-read",
    });
    return state === "granted";
  } catch (error) {
    return false;
  }
}

当调用 askReadPermission 方法后,将会向当前用户请求剪贴板读取权限,对应的效果如下图所示:

5.4 读取剪贴板中已写入的数据

创建好 askReadPermission 函数,我们就可以利用之前介绍的 navigator.clipboard.read 方法来读取剪贴板的数据了:

async function readDataFromClipboard() {
  if (askReadPermission()) {
    if (navigator.clipboard && navigator.clipboard.read) {
      try {
        const clipboardItems = await navigator.clipboard.read();
        for (const clipboardItem of clipboardItems) {
          console.dir(clipboardItem);
          for (const type of clipboardItem.types) {
            const blob = await clipboardItem.getType(type);
            console.log("已读取剪贴板中的内容:", await blob.text());
          }
        }
      } catch (err) {
         console.error("读取剪贴板内容失败: ", err);
      }
     }
   }
}

其实,除了点击 粘贴 按钮之外,我们还可以通过监听 paste 事件来读取剪贴板中的数据。需要注意的是,如果当前的浏览器不支持异步 Clipboard API,我们可以通过 clipboardData.getData 方法来读取剪贴板中的文本数据:

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  } else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('已获取的文本数据: ', text);
});

而对于图像数据,则可以通过以下方式进行读取:

const IMAGE_MIME_REGEX = /^image\/(p?jpeg|gif|png)$/i;

document.addEventListener("paste", async (e) => {
  e.preventDefault();
  if (navigator.clipboard) {
    let clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
       for (const type of clipboardItem.types) {
         if (IMAGE_MIME_REGEX.test(type)) {
           const blob = await clipboardItem.getType(type);
           loadImage(blob);
           return;
         }
        }
     }
   } else {
       const items = e.clipboardData.items;
       for (let i = 0; i < items.length; i++) {
         if (IMAGE_MIME_REGEX.test(items[i].type)) {
         loadImage(items[i].getAsFile());
         return;
       }
    }
  }
});

以上代码中的 loadImage 方法用于实现把复制的图片插入到当前选区已选择的区域中,对应的代码如下:

function loadImage(file) {
  const reader = new FileReader();
  reader.onload = function (e) {
    let img = document.createElement("img");
    img.src = e.target.result;

    let range = window.getSelection().getRangeAt(0);
    range.deleteContents();
    range.insertNode(img);
  };
  reader.readAsDataURL(file);
}

在前面代码中,我们监听了 document 对象的 paste 事件。除了该事件之外,与剪贴板相关的常见事件还有 copycut 事件。篇幅有限,阿宝哥就不继续展开介绍了,感兴趣的小伙伴可以自行阅读相关资料。好的,至此本文就已经结束了,希望阅读完本文之后,大家对异步的 Clipboard API 会有些了解,有写得不清楚的地方,欢迎你随时跟阿宝哥交流哟。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载近 2万)及 9 篇源码分析系列教程。

查看 ”复制图片“ 完整示例(Gist)

六、参考资源

查看原文

赞 6 收藏 4 评论 0

pingan8787 赞了文章 · 2020-12-18

这个 29.7 K 的剪贴板 JS 库有点东西!

2020 年即将结束了,不知不觉 源码分析 专题已经写了 9 篇文章,往期的 8 篇文章介绍了 AxiosBetterScrollkoa-composeFileSaver.js 等优秀的开源项目,该专题的每篇文章阿宝哥都花了挺多时间与精力。不过值得欣慰的是,专题中的多篇文章受到了社区小伙伴和公众号粉丝的认可与鼓励,这让阿宝哥有继续写该专题的动力,这里真心地感谢大家的支持。

对往期 8 篇文章感兴趣的小伙伴,可以阅读 如何更好地阅读源码?这八篇文章给你答案 这篇文章。

好的,我们马上回到正题。本期阿宝哥将介绍一个被 157317 个项目引用的 JS 开源库 —— clipboard.js。相信挺多小伙伴在项目中,也用到了这个库。那么这个库背后的工作原理是什么?感兴趣的小伙伴,跟阿宝哥一起来揭开这背后的秘密吧。

一、clipboard.js 简介

clipboard.js 是一个用于将文本复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb

(图片来源:https://clipboardjs.com/#exam...

那么为什么会有 clipboard.js 这个库呢?因为作者 zenorocha 认为:

将文本复制到剪贴板应该不难。它不需要几十个步骤来配置,也不需要加载数百 KB 的文件。最最重要的是,它不应该依赖于 Flash 或其他任何框架。

该库依赖于 SelectionexecCommand API,几乎所有的浏览器都支持 Selection API,然而 execCommand API 却存在一定的兼容性问题:

(图片来源:https://caniuse.com/?search=e...

(图片来源:https://caniuse.com/?search=e...

当然对于较老的浏览器,clipboard.js 也可以优雅地降级。好的,现在我们来看一下如何使用 clipboard.js。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

二、clipboard.js 使用

在使用 clipboard.js 之前,你可以通过 NPM 或 CDN 的方式来安装它:

NPM

npm install clipboard --save

CDN

<script data-original="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script>

clipboard.js 使用起来很简单,一般只要 3 个步骤:

1.定义一些标记

<input id="foo" type="text" value="大家好,我是阿宝哥">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>

2.引入 clipboard.js

<script data-original="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script>

3.实例化 clipboard

<script>
  var clipboard = new ClipboardJS('.btn');

  clipboard.on('success', function(e) {
    console.log(e);
  });
    
  clipboard.on('error', function(e) {
    console.log(e);
  });
</script>

以上代码成功运行之后,当你点击 “复制” 按钮时,输入框中的文字会被选中,同时输入框中的文字将会被复制到剪贴板中,对应的效果如下图所示:

除了 input 元素之外,复制的目标还可以是 divtextarea 元素。在以上示例中,我们复制的目标是通过 data-* 属性 来指定。此外,我们也可以在实例化 clipboard 对象时,设置复制的目标:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html
let clipboard = new ClipboardJS('.btn', {
  target: function() {
    return document.querySelector('div');
  }
});

如果需要设置复制的文本,我们也可以在实例化 clipboard 对象时,设置复制的文本:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html
let clipboard = new ClipboardJS('.btn', {
  text: function() {
    return '大家好,我是阿宝哥';
  }
});

关于 clipboard.js 的使用,阿宝哥就介绍到这里,感兴趣的小伙伴可以查看 Github 上 clipboard.js 的使用示例。由于 clipboard.js 底层依赖于 SelectionexecCommand API,所以在分析 clipboard.js 源码前,我们先来了解一下 SelectionexecCommand API。

三、Selection 与 execCommand API

3.1 Selection API

Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。如果要获取用于检查或修改的 Selection 对象,可以调用 window.getSelection 方法。

Selection 对象所对应的是用户所选择的 ranges (区域),俗称 拖蓝。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:

let selection = window.getSelection();
let range = selection.getRangeAt(0);

以上示例演示了如何获取选区中的第一个区域,其实除了获取选区中的区域之外,我们还可以通过 createRange API 创建一个新的区域,然后将该区域添加到选区中:

<div>大家好,我是<strong>阿宝哥</strong>。欢迎关注<strong>全栈修仙之路</strong></div>
<script>
   let strongs = document.getElementsByTagName("strong");
   let s = window.getSelection();

   if (s.rangeCount > 0) s.removeAllRanges(); // 从选区中移除所有区域
   for (let i = 0; i < strongs.length; i++) {
     let range = document.createRange(); // 创建range区域
     range.selectNode(strongs[i]); // 让range区域包含指定节点及其内容
     s.addRange(range); // 将创建的区域添加到选区中
   }
</script>

以上代码用于选中页面中所有的 strong 元素,但需要注意的是,目前只有使用 Gecko 渲染引擎的浏览器,比如 Firefox 浏览器实现了多个区域。

在某些场景下,你可能需要获取选中区域中的文本。针对这种场景,你可以通过调用 Selection 对象的 toString 方法来获取被选中区域中的纯文本。

3.2 execCommand API

document.execCommand API 允许运行命令来操作网页中的内容,常用的命令有 bold、italic、copy、cut、delete、insertHTML、insertImage、insertText 和 undo 等。下面我们来看一下该 API 的语法:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

相关的参数说明如下:

  • aCommandName:字符串类型,用于表示命令的名称;
  • aShowDefaultUI:布尔类型,用于表示是否展示用户界面,一般为 false;
  • aValueArgument:额外参数,一些命令(比如 insertImage)需要额外的参数(提供插入图片的 URL),默认为 null。

调用 document.execCommand 方法后,该方法会返回一个布尔值。如果是 false 的话,表示操作不被支持或未被启用。对于 clipboard.js 这个库来说,它会通过 document.execCommand API 来执行 copycut 命令,从而实现把内容复制到剪贴板。

那么现在问题来了,我们有没有办法判断当前浏览器是否支持 copycut 命令呢?答案是有的,即使用浏览器提供的 API —— Document.queryCommandSupported,该方法允许我们确定当前的浏览器是否支持指定的编辑命令。

clipboard.js 这个库的作者,也考虑到了这种需求,所以提供了一个静态的 isSupported 方法,用于检测当前的浏览器是否支持指定的命令:

// src/clipboard.js
static isSupported(action = ['copy', 'cut']) {
  const actions = (typeof action === 'string') ? [action] : action;
  let support = !!document.queryCommandSupported;

  actions.forEach((action) => {
    support = support && !!document.queryCommandSupported(action);
  });

  return support;
}

Document.queryCommandSupported 兼容性较好,大家可以放心使用,具体的兼容性如下图所示:

(图片来源:https://caniuse.com/?search=q...

介绍完 SelectionexecCommandqueryCommandSupported API,接下来我们开始分析 clipboard.js 的源码。

如果你想了解阅读源码的思路与技巧,可以阅读 使用这些思路与技巧,我读懂了多个优秀的开源项目 这篇文章。

四、clipboard.js 源码解析

4.1 Clipboard 类

看源码的时候,阿宝哥习惯从最简单的用法入手,这样可以快速地了解内部的执行流程。下面我们来回顾一下前面的示例:

<!-- 定义一些标记 -->
<input id="foo" type="text" value="大家好,我是阿宝哥">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>

<!-- 实例化 clipboard -->
<script>
  let clipboard = new ClipboardJS('.btn');

  clipboard.on('success', function(e) {
    console.log(e);
  });
    
  clipboard.on('error', function(e) {
    console.log(e);
  });
</script>

通过观察以上的代码,我们可以快速地找到切入点 —— new ClipboardJS('.btn')。在 clipboard.js 项目内的 webpack.config 配置文件中,我们可以找到 ClipboardJS 的定义:

module.exports = {
  entry: './src/clipboard.js',
  mode: 'production',
  output: {
    filename: production ? 'clipboard.min.js' : 'clipboard.js',
    path: path.resolve(__dirname, 'dist'),
    library: 'ClipboardJS',
    globalObject: 'this',
    libraryExport: 'default',
    libraryTarget: 'umd'
  },
  // 省略其他配置信息
}

基于以上的配置信息,我们进一步找到了 ClipboardJS 指向的构造函数:

import Emitter from 'tiny-emitter';
import listen from 'good-listener';

class Clipboard extends Emitter {
  constructor(trigger, options) {
    super();
    this.resolveOptions(options);
    this.listenClick(trigger);
  }
}

在示例中,我们并没有设置 Clipboard 的配置信息,所以我们先不用关心 this.resolveOptions(options) 的处理逻辑。顾名思义 listenClick 方法是用来监听 click 事件,该方法的具体实现如下:

listenClick(trigger) {
  this.listener = listen(trigger, 'click', (e) => this.onClick(e));
}

listenClick 方法内部,会通过一个第三方库 good-listener 来添加事件处理器。当目标触发 click 事件时,就会执行对应的事件处理器,该处理器内部会进一步调用 this.onClick 方法,该方法的实现如下:

// src/clipboard.js
onClick(e) {
  const trigger = e.delegateTarget || e.currentTarget;

  // 为每次点击事件,创建一个新的ClipboardAction对象
  if (this.clipboardAction) {
    this.clipboardAction = null;
  }
  this.clipboardAction = new ClipboardAction({
    action    : this.action(trigger),
    target    : this.target(trigger),
    text      : this.text(trigger),
    container : this.container,
    trigger   : trigger,
    emitter   : this
  });
}

onClick 方法内部,会使用事件触发目标来创建 ClipboardAction 对象。当你点击本示例 复制 按钮时,创建的 ClipboardAction 对象如下所示:

相信看完上图,大家对创建 ClipboardAction 对象时,所使用到的方法都有了解。那么 this.actionthis.targetthis.text 这几个方法是在哪里定义的呢?通过阅读源码,我们发现在 resolveOptions 方法内部会初始化上述 3 个方法:

// src/clipboard.js
resolveOptions(options = {}) {
  this.action = (typeof options.action === 'function') 
    ? options.action :  this.defaultAction;
  this.target = (typeof options.target === 'function') 
    ? options.target : this.defaultTarget;
  this.text = (typeof options.text === 'function')
    ? options.text : this.defaultText;
  this.container = (typeof options.container === 'object')   
    ? options.container : document.body;
}

resolveOptions 方法内部,如果用户自定义了处理函数,则会优先使用用户自定义的函数,否则将使用 clipboard.js 中对应的默认处理函数。由于我们在调用 Clipboard 构造函数时,并未设置 options 参数,所以将使用默认的处理函数:

由上图可知在 defaultActiondefaultTargetdefaultText 方法内部都会调用 getAttributeValue 方法来获取事件触发对象上自定义属性,而对应的 getAttributeValue 方法也很简单,具体代码如下:

// src/clipboard.js
function getAttributeValue(suffix, element) {
  const attribute = `data-clipboard-${suffix}`;
  if (!element.hasAttribute(attribute)) {
    return;
  }
  return element.getAttribute(attribute);
}

介绍完 Clipboard 类,接下来我们来重点分析一下 ClipboardAction 类,该类会包含具体的复制逻辑。

4.2 ClipboardAction 类

在 clipboard.js 项目中,ClipboardAction 类被定义在 src/clipboard-action.js 文件内:

// src/clipboard-action.js
class ClipboardAction {
  constructor(options) {
    this.resolveOptions(options);
    this.initSelection();
  }
}

Clipboard 类的构造函数一样,ClipboardAction 类的构造函数会优先解析 options 配置对象,然后调用 initSelection 方法,来初始化选区。在 initSelection 方法中会根据 texttarget 属性来选择不同的选择策略:

initSelection() {
  if (this.text) {
    this.selectFake();
  } else if (this.target) {
    this.selectTarget();
  }
}

对于前面的示例,我们是通过 data-* 属性 来指定复制的目标,即 data-clipboard-target="#foo",相应的代码如下:

<input id="foo" type="text" value="大家好,我是阿宝哥">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>

所以接下来我们先来分析含有 target 属性的情形,如果含有 target 属性,则会进入 else if 分支,然后调用 this.selectTarget 方法:

// src/clipboard-action.js
selectTarget() {
  this.selectedText = select(this.target);
  this.copyText();
}

selectTarget 方法内部,会调用 select 函数获取已选中的文本,该函数是来自 clipboard.js 作者开发的另一个 npm 包,对应的代码如下:

// https://github.com/zenorocha/select/blob/master/src/select.js
function select(element) {
  var selectedText;

  if (element.nodeName === 'SELECT') {
    element.focus();
    selectedText = element.value;
  }
  else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
    var isReadOnly = element.hasAttribute('readonly');

    if (!isReadOnly) {
      element.setAttribute('readonly', '');
    }

    element.select();
    element.setSelectionRange(0, element.value.length);

    if (!isReadOnly) {
      element.removeAttribute('readonly');
    } 
      selectedText = element.value;
    }
  else {
    // 省略相关代码 
  }
  return selectedText;
}

因为在以上示例中,我们复制的目标是 input 元素,所以我们先来分析该分支的代码。在该分支中,使用了 HTMLInputElement 对象的 selectsetSelectionRange 方法:

  • select:用于选中一个 <textarea> 元素或者一个带有 text 字段的 <input> 元素里的所有内容。
  • setSelectionRange:用于设定 <input><textarea> 元素中当前选中文本的起始和结束位置。

在获取选中的文本之后,selectTarget 方法会继续调用 copyText 方法来复制文本:

copyText() {
  let succeeded;
  try {
    succeeded = document.execCommand(this.action);
  } catch (err) {
    succeeded = false;
  }
  this.handleResult(succeeded);
}

前面阿宝哥已经简单介绍了 execCommand API,copyText 方法内部就是使用这个 API 来复制文本。在完成复制之后,copyText 方法会调用 this.handleResult 方法来派发复制的状态信息:

handleResult(succeeded) {
  this.emitter.emit(succeeded ? 'success' : 'error', {
    action: this.action,
    text: this.selectedText,
    trigger: this.trigger,
    clearSelection: this.clearSelection.bind(this)
  });
}

看到这里有些小伙伴可能会问 this.emitter 对象是来自哪里的?其实 this.emitter 对象就是 Clipboard 实例:

// src/clipboard.js
class Clipboard extends Emitter {
  onClick(e) {
    const trigger = e.delegateTarget || e.currentTarget;
    // 省略部分代码
    this.clipboardAction = new ClipboardAction({
      // 省略部分属性
      trigger   : trigger,
      emitter   : this // Clipboard 实例
    });
  }
}

而对于 handleResult 方法派发的事件,我们可以通过 clipboard 实例来监听对应的事件,具体的代码如下:

let clipboard = new ClipboardJS('.btn');

clipboard.on('success', function(e) {
  console.log(e);
});
    
clipboard.on('error', function(e) {
  console.log(e);
});

在继续介绍另一个分支的处理逻辑之前,阿宝哥用一张图来总结一下上述示例的执行流程:

下面我们来介绍另一个分支,即含有 text 属性的情形,对应的使用示例如下:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html
let clipboard = new ClipboardJS('.btn', {
  text: function() {
    return '大家好,我是阿宝哥';
  }
});

当用户在创建 clipboard 对象时,设置了 text 属性,则会执行 if 分支的逻辑,即调用 this.selectFake 方法:

// src/clipboard-action.js
class ClipboardAction {
  constructor(options) {
    this.resolveOptions(options);
    this.initSelection();
  }
  
  initSelection() {
    if (this.text) {
      this.selectFake();
    } else if (this.target) {
      this.selectTarget();
    }
  }
}

selectFake 方法内部,它会先创建一个假的 textarea 元素并设置该元素的相关样式和定位信息,并使用 this.text 的值来设置 textarea 元素的内容,然后使用前面介绍的 select 函数来获取已选择的文本,最后通过 copyText 把文本拷贝到剪贴板:

// src/clipboard-action.js
selectFake() {
  const isRTL = document.documentElement.getAttribute('dir') == 'rtl';

  this.removeFake(); // 移除事件监听并移除之前创建的fakeElem

  this.fakeHandlerCallback = () => this.removeFake();
  this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true;

  this.fakeElem = document.createElement('textarea');
  // Prevent zooming on iOS
  this.fakeElem.style.fontSize = '12pt';
  // Reset box model
  this.fakeElem.style.border = '0';
  this.fakeElem.style.padding = '0';
  this.fakeElem.style.margin = '0';
  // Move element out of screen horizontally
  this.fakeElem.style.position = 'absolute';
  this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px';
  // Move element to the same position vertically
  let yPosition = window.pageYOffset || document.documentElement.scrollTop;
  this.fakeElem.style.top = `${yPosition}px`;

  this.fakeElem.setAttribute('readonly', '');
  this.fakeElem.value = this.text;

  this.container.appendChild(this.fakeElem);

  this.selectedText = select(this.fakeElem);
  this.copyText();
}

为了让大家能够更直观了解 selectFake 方法执行后的页面效果,阿宝哥截了一张实际的效果图:

其实 clipboard.js 除了支持拷贝 inputtextarea 元素的内容之外,它还支持拷贝其它 HTML 元素的内容,比如 div 元素:

<div>大家好,我是阿宝哥</div>
<button class="btn" data-clipboard-action="copy" data-clipboard-target="div">Copy</button>

针对这种情形,在 clipboard.js 内部仍会利用前面介绍的 select 函数来选中目标元素并获取需拷贝的内容,具体的代码如下所示:

function select(element) {
  var selectedText;

  if (element.nodeName === 'SELECT') {
      element.focus();
      selectedText = element.value;
  }
  else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
      // 省略相关代码 
  }
  else {
     if (element.hasAttribute('contenteditable')) {
        element.focus();
     }

     var selection = window.getSelection(); // 获取选取
     var range = document.createRange(); // 新建区域

     range.selectNodeContents(element); // 使新建的区域包含element节点的内容
     selection.removeAllRanges(); // 移除选取中的所有区域
     selection.addRange(range); // 往选区中添加新建的区域
     selectedText = selection.toString(); // 获取已选中的文本
    }

    return selectedText;
}

在获得要拷贝的文本之后,clipboard.js 会继续调用 copyText 方法把对应的文本拷贝到剪贴板。到这里 clipboard.js 的核心源码,我们差不多都分析完了,希望阅读本文后,大家不仅了解了 clipboard.js 背后的工作原理,同时也学会了如何利用事件派发器来实现消息通信 及 SelectionexecCommand API 等相关的知识。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近 2万)及 9 篇源码分析系列教程。

五、参考资源

查看原文

赞 14 收藏 7 评论 0

pingan8787 赞了文章 · 2020-12-11

详解,从后端导出文件到前端(Blob)下载过程

前言

对于不是从事音视频方面的同学来说,很多情况下都是通过 window.location.href 来下载文件。这种方式,一般是前后端的登录态是基于 Cookie + Session 的方式,由于浏览器默认会将本地的 cookie 塞到 HTTP 请求首部字段的 Set-Cookie 中,从而实现来带用户的 SessionId,所以,我们也就可以用 window.location.href 来打开一个链接下载文件。

当然,还有一种情况,不需要登录态的校验(比较che)。

众所周知,还有另一种登录态的处理方式 JWT (JSON Web Token)。这种情况,一般会要求,前端在下载文件的时候在请求首部字段中添加 Token 首部字段。那么,这样一来,我们就不能直接通过 window.location.href 来下载文件。

不过,幸运的是我们有 Blob,它是浏览器端的类文件对象,基于二进制数据,我们可以通过它来优雅地处理文件下载,不限于音视频、PDF、Excel 等等。所以,今天我们就从后端导出文件到 HTTP 协议、非简单请求下的预检请求、以及最后的 Blob 处理文件,了解一番何为其然、如何使其然?

后端(Koa2)导出文件(Excel)

首先,我们从后端导出文件讲起。这里,我选择 Koa2 来实现 Excel 的导出,然后搭配 node-xlsx 这个库,从而实现 Excel 的二进制数据的导出。它看起来会是这样:

const xlsx = require("node-xlsx")

router.get("/excelExport", async function (ctx, next) {
    // 数据查询的结果
    const res = [
      ["name", "age"],
      ["五柳", "22"],
    ];
    // 生成 excel 对应的 buffer 二进制文件
    const excelFile = xlsx.build([{name: "firstSheet", data: res}])
    // 设置文件名相关的响应首部字段
    ctx.set("Content-Disposition", "attachment;filename=test.xlsx;filename*=UTF-8")
    // 返回给前端
    ctx.body = buffer;
  });
这里就不对数据库查询做展开,只是模拟了一下查询后的结果 res

然后我们用浏览器请求一下这个接口,我们会看到在响应首部(Response Headers)字段中的 Content-Typeapplication/octet-stream,它是二进制文件默认的 MIME 类型。

Connection: keep-alive
Content-Disposition: attachment;filename=test.xlsx;filename*=UTF-8
Content-Length: 14584
Content-Type: application/octet-stream
Date: Sun, 23 Aug 2020 11:33:16 GMT
MIME 类型,即 Multipurpose Internal Mail Extension,用于表示文件、文档或字节流。

HTTP 协议认识二进制文件流

如果,我们没有参与后端返回 Excel 的这个过程。那么,HTTP 协议可以帮助我们减少交流,并且懂得我们前端需要如何进行相应的处理。这里会涉及到三个 HTTP 实体首部字段:

  • Content-Type
  • Content-Length
  • Content-Disposition

那么,我们分别来看看它们在 HTTP 文件传输过程中的特殊意义

Content-Type

Content-Type 我想这个老生常谈的实体首部字段,大家不会陌生。它用来说明实体主体内容对象的媒体类型,遵循 type/subtype 的结构。常见的有 text/plaintext/htmlimage/jpegapplication/jsonapplication/javascript 等等。

在我们这里二进制文件,它没有特定的 subtype,即都是以 application/octet-stream 作为 Content-Type 的值。即如上面我们所看到:

Content-Type: application/octet-stream
所以,只要我们熟悉 Content-Type,那么在开发中的交流成本就可以减少。

Content-Length

Content-Length 又是一个眼熟的实体首部字段,它表示传输的实体主体的大小,单位为字节。而在我们这个栗子,表示传输的 Excel 二进制文件大小为 14584

Content-Disposition

Content-Disposition 这个实体首部字段,我想前端同学大多数是会有陌生感。它用来表示实体主体内容是用于显示在浏览器内作为文件下载。它对应的 value 有这么几个内容:

  • formdata,表示实体主体是 formdata 的形式。
  • inline,表示实体主体内容显示在浏览器内。
  • attachment,表示实体主体内容作为文件下载。
  • filename,表示文件编码格式或文件名,例如 filename*=UTF-8 表示文件的编码,filename=test.xlsx 表示下载时的文件名。
  • name,表示 formdata 上传文件时,对应 typefileinputname 值,例如 <input type="file" name="upload" />,此时对应的 name 则为 upload
需要注意的是,对于 Content-Dispositionformdata 它仅仅是一个信息提示的作用,并不是实现实体主体内容为 formdata,这是 Content-Type 负责的。

那么回到,今天这个栗子,它的 Content-Disposition 为:

Content-Disposition: attachment;filename=test.xlsx;filename*=UTF-8

所以,现在我们知道它主要做了这么几件事:

  • 告知浏览器需要将二进制文件作为附件下载
  • 附件的文件名为 test.xlsx
  • 附件对应的编码为 UTF-8

Blob 优雅地处理文件(Excel)下载

为什么说是优雅?因为,Blob 它可以处理很多类型文件,并且是受控的,你可以控制从接收到二进制文件流、到转化为 Blob、再到用其他 API 来实现下载文件。因为,如果是 window.location.href 下载文件,诚然也可以达到一样的效果,但是你无法在拿到二进制文件流到下载文件之间做个性化的操作。

并且,在复杂情况下的文件处理,Blob 必然是首要选择,例如分片上传下载音视频文件的拼接等等。所以,在这里我也推崇大家使用 Blob 处理文件下载。

并且,值得一提的是 XMLHttpRequest 默认支持了设置 responseType,通过设置 reposponseTypeblob,可以直接将拿到的二进制文件转化为 Blob

当然 axios 也支持设置 reponseType,并且我们也可以设置 responseTypearraybuffer,但是我想没这个必要拐弯抹角。

然后,在拿到二进制文件对应的 Blob 对象后,我们需要进行下载操作,这里我们来认识一下这两种使用 Blob 实现文件下载的方式。

URL.createObjectURL

在浏览器端,我们要实现下载文件,无非就是借助 a 标签来指向一个文件的下载地址。所以 window.location.href 的本质也是这样。也因此,在我们拿到了二进制文件对应的 Blob 对象后,我们需要为这个 Blob 对象创建一个指向它的下载地址 URL

URL.createObjectURL 方法则可以实现接收 FileBlob 对象,创建一个 DOMString,包含了对应的 URL,指向 BlobFile 对象,它看起来会是这样:

"blob:http://localhost:8080/a48aa254-866e-4c66-ba79-ae71cf5c1cb3"

完整的使用 BlobURL.createObjectURL 下载文件的 util 函数:

export const downloadFile = (fileStream, name, extension, type = "") => {
  const blob = new Blob([fileStream], {type});
  const fileName = `${name}.${extension}`;
  if ("download" in document.createElement("a")) {
    const elink = document.createElement("a");
    elink.download = fileName;
    elink.style.display = "none";
    elink.href = URL.createObjectURL(blob);
    document.body.appendChild(elink);
    elink.click();
    URL.revokeObjectURL(elink.href);
    document.body.removeChild(elink);
  } else {
    navigator.msSaveBlob(blob, fileName);
  }
};

FileReader

同样地,FileReader 对象也可以使得我们对 Blob 对象生成一个下载地址 URL,它和 URL.createObject 一样可以接收 FileBlob 对象。

这个过程,主要由两个函数完成 readAsDataURLonload,前者用于将 BlobFile 对象转为对应的 URL,后者用于接收前者完成后的 URL,它会在 e.target.result 上。

完整的使用 BlobFileReader 下载文件的 util 函数:

const readBlob2Url  =  (blob, type) =>{
  return new Promise(resolve => {
    const reader = new FileReader()
    reader.onload = (e) => {
      resolve(e.target.result)
    }
    reader.readAsDataURL(blob)
  })
}

写在最后

如果,仅仅是用一个 Blob 这个浏览器 API 处理文件下载,可能带给你的收益并没有多少。但是,通过了解从后端文件导出、HTTP 协议、Blob 处理文件下载这整个过程,就可以构建一个完整的技术思维体系,从而获取其中的收益。唯有知其然,方能使其然。 这也是前段时间看到的很符合我们作为一个不断学习的从业者的态度。也因此,良好的技术知识储备,能让我们拥有很好的编程思维和设计思想。

文章对应的 DEMO,我已经上传到 GitHub 上了,有兴趣的同学可以去 clone 下来了解。

往期文章回顾

❤️爱心三连击

通过阅读,如果你觉得有收获的话,可以爱心三连击!!!

查看原文

赞 33 收藏 28 评论 4

pingan8787 赞了文章 · 2020-12-10

Math.random() 还能这样玩?

相信大家对 Math.random 函数都不会陌生,调用该函数后会返回一个伪随机数,对应的取值范围是 [0, 1)。在日常工作中,应用的比较多的场景是生成 UUID,比如:

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

当然除了上述方法外,还有其他的方法可以用来生成 UUID,感兴趣的小伙伴可以参考一下 Stack Overflow 上 “how-to-create-a-guid-uuid” 这一篇问答。Math.random 除了上述的应用场景之外,还可以应用在游戏、动画、随机数据、生成音乐或艺术图片等场景。

好的,废话不多说,接下来我们马上来一起感受一下 Math.random 的魅力。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

霓虹灯六角形粒子动画

(图片来源:https://codepen.io/towc/pen/m...

生成音乐

(图片来源:https://codepen.io/jakealbaug...

文字打乱效果

(图片来源:https://codepen.io/soulwire/p...

石头剪刀布游戏

(图片来源:https://codepen.io/studiojvla...

随机密码生成器

(图片来源:https://codepen.io/nourabusou...

随机背景颜色

(图片来源:https://codepen.io/meodai/pen...

生成艺术图案

(图片来源:https://codepen.io/tjoen/pen/...

看完以上的示例,你是不是觉得很惊讶。其实这些示例是阿宝哥从 “lots-of-ways-to-use-math-random-in-javascript” 这篇文章中介绍的例子从挑选出来的,感谢作者 Jwahir Sundai 为我们提供了那么👍的使用示例。如果你对其他的示例感兴趣的话,可以自行阅读一下该文章哟。

虽然 Math.random 函数能帮助我们实现很酷炫的动画或很好玩的功能,但该函数并不是真的随机,对应的算法被称为 伪随机数生成器(Pseudo Random Number Generator)。因为 Math.random 不能提供像密码一样安全的随机数字,所以不要使用它来处理有关安全的事情。针对信息安全的场景,你可以使用 Web Crypto API 来代替,并使用更精确的 window.crypto.getRandomValues() 方法。

参考资源

查看原文

赞 37 收藏 23 评论 1

pingan8787 赞了文章 · 2020-12-08

聊一聊 15.5K 的 FileSaver,是如何工作的?

FileSaver.js 是在客户端保存文件的解决方案,非常适合在客户端上生成文件的 Web 应用程序。它简单易用且兼容大多数浏览器,被作为项目依赖应用在 6.3 万的项目中。在近期的项目中,阿宝哥再一次使用到了它,所以就想写篇文章来聊一聊这个优秀的开源项目。

一、FileSaver.js 简介

FileSaver.js 是 HTML5 的 saveAs() FileSaver 实现。它支持大多数主流的浏览器,其兼容性如下图所示:

(图片来源:https://github.com/eligrey/Fi...

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

1.1 saveAs API

FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })

saveAs 方法支持三个参数,第一个参数表示它支持 Blob/File/Url 三种类型,第二个参数表示文件名(可选),而第三个参数表示配置对象(可选)。如果你需要 FlieSaver.js 自动提供 Unicode 文本编码提示(参考:字节顺序标记),则需要设置 { autoBom: true}

1.2 保存文本

let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, "hello.txt");

1.3 保存线上资源

FileSaver.saveAs("https://httpbin.org/image", "image.jpg");

如果下载的 URL 地址与当前站点是同域的,则将使用 a[download] 方式下载。否则,会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,将进行数据下载并使用 Blob URL 实现文件下载。如果不支持 CORS 机制的话,将会尝试使用 a[download] 方式下载。

标准的 W3C File API Blob 接口并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 Blob.js 来解决兼容性问题。

(图片来源:https://caniuse.com/?search=blob

1.4 保存 Canvas 画布内容

let canvas = document.getElementById("my-canvas");
canvas.toBlob(function(blob) {
  saveAs(blob, "abao.png");
});

需要注意的是 canvas.toBlob() 方法并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 canvas-toBlob.js 来解决兼容性问题。

(图片来源:https://caniuse.com/?search=t...

在以上的示例中,我们多次见到 Blob 的身影,因此在介绍 FileSaver.js 源码时,阿宝哥先来简单介绍一下 Blob 的相关知识。

二、Blob 简介

Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示不可变的类似文件对象的原始数据。

2.1 Blob 构造函数

Blob 由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:

MIME(Multipurpose Internet Mail Extensions)多用途互联网邮件扩展类型,是设定某种扩展名的文件用一种应用程序来打开的方式类型,当该扩展名文件被访问的时候,浏览器会自动使用指定应用程序来打开。多用于指定一些客户端自定义的文件名,以及一些媒体文件打开方式。

常见的 MIME 类型有:超文本标记语言文本 .html text/html、PNG 图像 .png image/png、普通文本 .txt text/plain 等。

在 JavaScript 中我们可以通过 Blob 的构造函数来创建 Blob 对象,Blob 构造函数的语法如下:

var aBlob = new Blob(blobParts, options);

相关的参数说明如下:

  • blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。
  • options:一个可选的对象,包含以下两个属性:

    • type —— 默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。
    • endings —— 默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。

介绍完 Blob 之后,我们再来介绍一下 Blob URL。

2.2 Blob URL

Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像,下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:

blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。它允许引用 <img><a> 中的 Blob,但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。

上述的 Blob URL 看似很不错,但实际上它也有副作用。 虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那不会很快发生。因此,如果我们创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。

针对这个问题,我们可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。

好的,现在我们已经介绍了 Blob 和 Blob URL。如果你还意犹未尽,想深入理解 Blob 的话,可以阅读 你不知道的 Blob 这篇文章,接下来我们开始分析 FileSaver.js 的源码。

如果你想了解阅读源码的思路与技巧,可以阅读 使用这些思路与技巧,我读懂了多个优秀的开源项目 这篇文章。

三、FileSaver.js 源码解析

在 FileSaver.js 内部提供了三种方案来实现文件保存,因此接下来我们将分别来介绍这三种方案。

3.1 方案一

当 FileSaver.js 在保存文件时,如果当前平台中 a 标签支持 download 属性且非 MacOS WebView 环境,则会优先使用 a[download] 来实现文件保存。在具体使用过程中,我们是通过调用 saveAs 方法来保存文件,该方法的定义如下:

FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })

通过观察 saveAs 方法的签名,我们可知该方法支持字符串和 Blob 两种类型的参数,因此在 saveAs 方法内部需要分别处理这两种类型的参数,下面我们先来分析字符串参数的情形。

3.1.1 字符串类型参数

在前面的示例中,我们演示了如何利用 saveAs 方法来保存线上的图片:

FileSaver.saveAs("https://httpbin.org/image", "image.jpg");

在方案一中,saveAs 方法的处理逻辑如下所示:

// Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
function saveAs(blob, name, opts) {
  var URL = _global.URL || _global.webkitURL;
  var a = document.createElement("a");
  name = name || blob.name || "download";

  a.download = name;
  a.rel = "noopener";

  if (typeof blob === "string") {
    a.href = blob;
    if (a.origin !== location.origin) { // (1)
      corsEnabled(a.href)
        ? download(blob, name, opts)
        : click(a, (a.target = "_blank"));
    } else { // (2)
      click(a);
    }
  } else {
    // 省略处理Blob类型参数
  }
}

在以上代码中,如果发现下载资源的 URL 地址与当前站点是非同域的,则会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,就会调用 download 方法进行文件下载。首先我们先来分析 corsEnabled 方法:

function corsEnabled(url) {
  var xhr = new XMLHttpRequest();
  xhr.open("HEAD", url, false);
  try {
    xhr.send();
  } catch (e) {}
  return xhr.status >= 200 && xhr.status <= 299;
}

corsEnabled 方法的实现很简单,就是通过 XMLHttpRequest API 发起一个同步的 HEAD 请求,然后判断返回的状态码是否在 [200 ~ 299] 的范围内。接着我们来看一下 download 方法的具体实现:

function download(url, name, opts) {
  var xhr = new XMLHttpRequest();
  xhr.open("GET", url);
  xhr.responseType = "blob";
  xhr.onload = function () {
    saveAs(xhr.response, name, opts);
  };
  xhr.onerror = function () {
    console.error("could not download file");
  };
  xhr.send();
}

同样 download 方法的实现也很简单,也是通过 XMLHttpRequest API 来发起 HTTP 请求,与大家熟悉的 JSON 格式不同的是,我们需要设置 responseType 的类型为 blob。此外,因为返回的结果是 blob 类型的数据,所以在成功回调函数内部会继续调用 saveAs 方法来实现文件保存。

而对于不支持 CORS 机制或同域的情形,它会调用内部的 click 方法来完成下载功能,该方法的具体实现如下:

// `a.click()` doesn't work for all browsers (#465)
function click(node) {
  try {
    node.dispatchEvent(new MouseEvent("click"));
  } catch (e) {
    var evt = document.createEvent("MouseEvents");
    evt.initMouseEvent(
      "click", true, true, window, 0, 0, 0, 80, 20, 
      false, false, false, false, 0, null
    );
    node.dispatchEvent(evt);
  }
}

click 方法内部,会优先调用 node 对象上的 dispatchEvent 方法来派发 click 事件。当出现异常的时候,会在 catch 语句进行相应的异常处理,catch 语句中的 MouseEvent.initMouseEvent() 方法用于初始化鼠标事件的值。但需要注意的是,该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性

3.1.2 blob 类型参数

同样,在前面的示例中,我们演示了如何利用 saveAs 方法来保存 Blob 类型数据:

let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, "hello.txt");

blob 类型参数的处理逻辑,被定义在 saveAs 方法体的 else 分支中:

// Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
function saveAs(blob, name, opts) {
  var URL = _global.URL || _global.webkitURL;
  var a = document.createElement("a");
  name = name || blob.name || "download";

  a.download = name;
  a.rel = "noopener";

  if (typeof blob === "string") {
     // 省略处理字符串类型参数
  } else {
    a.href = URL.createObjectURL(blob);
    setTimeout(function () {
      URL.revokeObjectURL(a.href);
    }, 4e4); // 40s
    setTimeout(function () {
      click(a);
    }, 0);
  }
}

对于 blob 类型的参数,首先会通过 createObjectURL 方法来创建 Object URL,然后在通过 click 方法执行文件保存。为了能及时释放内存,在 else 处理分支中,会启动一个定时器来执行清理操作。此时,方案一我们已经介绍完了,接下去要介绍的方案二主要是为了兼容 IE 浏览器。

3.2 方案二

在 Internet Explorer 10 浏览器中,msSaveBlob 和 msSaveOrOpenBlob 方法允许用户在客户端上保存文件,其中 msSaveBlob 方法只提供一个保存按钮,而 msSaveOrOpenBlob 方法提供了保存和打开按钮,对应的使用方式如下所示:

window.navigator.msSaveBlob(blobObject, 'msSaveBlob_hello.txt');
window.navigator.msSaveOrOpenBlob(blobObject, 'msSaveBlobOrOpenBlob_hello.txt');

了解完上述的知识和方案一中介绍的 corsEnableddownloadclick 方法后,再来看方案二的代码,就很清晰明了。在满足 "msSaveOrOpenBlob" in navigator 条件时, FileSaver.js 会使用方案二来实现文件保存。跟前面一样,我们先来分析 字符串类型参数 的处理逻辑。

3.2.1 字符串类型参数
// Use msSaveOrOpenBlob as a second approach
function saveAs(blob, name, opts) {
  name = name || blob.name || "download";
  if (typeof blob === "string") {
    if (corsEnabled(blob)) { // 判断是否支持CORS
      download(blob, name, opts);
    } else {
      var a = document.createElement("a");
      a.href = blob;
      a.target = "_blank";
      setTimeout(function () {
        click(a);
      });
    }
  } else {
    // 省略处理Blob类型参数
  }
}
3.2.2 blob 类型参数
// Use msSaveOrOpenBlob as a second approach
function saveAs(blob, name, opts) {
  name = name || blob.name || "download";
  if (typeof blob === "string") {
    // 省略处理字符串类型参数
  } else {
    navigator.msSaveOrOpenBlob(bom(blob, opts), name); // 提供了保存和打开按钮
  }
}

3.3 方案三

如果方案一和方案二都不支持的话,FileSaver.js 就会降级使用 FileReader API 和 open API 新开窗口来实现文件保存。

3.3.1 字符串类型参数
// Fallback to using FileReader and a popup
function saveAs(blob, name, opts, popup) {
  // Open a popup immediately do go around popup blocker
  // Mostly only available on user interaction and the fileReader is async so...
  popup = popup || open("", "_blank");
  if (popup) {
    popup.document.title = popup.document.body.innerText = "downloading...";
  }

  if (typeof blob === "string") return download(blob, name, opts);
    // 处理Blob类型参数
}
3.3.2 blob 类型参数

对于 blob 类型的参数来说,在 saveAs 方法内部会根据不同的环境选用不同的方案,比如在 Safari 浏览器环境中,它会利用 FileReader API 先把 Blob 对象转换为 Data URL,然后再把该 Data URL 地址赋值给新开的窗口或当前窗口的 location 对象,具体的代码如下:

// Fallback to using FileReader and a popup
function saveAs(blob, name, opts, popup) {
  // Open a popup immediately do go around popup blocker
  // Mostly only available on user interaction and the fileReader is async so...
  popup = popup || open("", "_blank");
  if (popup) { // 设置新开窗口的标题
    popup.document.title = popup.document.body.innerText = "downloading...";
  }

  if (typeof blob === "string") return download(blob, name, opts);

  var force = blob.type === "application/octet-stream"; // 二进制流数据
  var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari;
  var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);

  if (
    (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
    typeof FileReader !== "undefined"
  ) {
    // Safari doesn't allow downloading of blob URLs
    var reader = new FileReader();
    reader.onloadend = function () {
      var url = reader.result;
      url = isChromeIOS
        ? url
        : url.replace(/^data:[^;]*;/, "data:attachment/file;"); // 处理成附件的形式
      if (popup) popup.location.href = url;
      else location = url;
      popup = null; // reverse-tabnabbing #460
    };
    reader.readAsDataURL(blob);
  } else {
    // 省略Object URL的处理逻辑
  }
}

其实对于 FileReader API 来说,除了支持把 File/Blob 对象转换为 Data URL 之外,它还提供了 readAsArrayBuffer()readAsText() 方法,用于把 File/Blob 对象转换为其它的数据格式。在 玩转前端二进制 文章中,阿宝哥详细介绍了 FileReader API 在前端图片处理场景中的应用,阅读完该文章之后,你们将能轻松看懂以下转换关系图:

最后我们再来看一下 else 分支的代码:

function saveAs(blob, name, opts, popup) {
  popup = popup || open("", "_blank");
  if (popup) {
    popup.document.title = popup.document.body.innerText = "downloading...";
  }

  // 处理字符串类型参数
  if (typeof blob === "string") return download(blob, name, opts);

  if (
    (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
    typeof FileReader !== "undefined"
  ) {
    // 省略FileReader API处理逻辑
  } else {
    var URL = _global.URL || _global.webkitURL;
    var url = URL.createObjectURL(blob);
    if (popup) popup.location = url;
    else location.href = url;
    popup = null; // reverse-tabnabbing #460
    setTimeout(function () {
      URL.revokeObjectURL(url);
    }, 4e4); // 40s
  }
}

到这里 FileSaver.js 这个库的源码已经分析完成了,跟着阿宝哥阅读上述源码之后,是不是觉得写一个兼容性好、简单易用的第三方库是多么不容易。在实际项目中,如果你需要保存超过 blob 大小限制的超大文件,或者没有足够的内存空间,你可以考虑使用更高级的 StreamSaver.js 库来实现文件保存功能。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 8 篇源码分析系列教程。

四、参考资源

查看原文

赞 29 收藏 18 评论 0

认证与成就

  • 获得 638 次点赞
  • 获得 11 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • word-file-transform

    Word 文档解析工具,轻松将 Word 文档导入富文本编辑器,支持自定义文档图片上传😄

  • webpack-quickly-starter

    快速创建一个本地 Webpack 开发环境,已内置多种常用 Webpack 优化方式😄(使用建议:用于学习 Webpack 时快速创建本地环境)

  • Cute-JavaScript

    一本很简单的JavaScript入门手册,目前已包括:ECMAScript规范(ES6,ES7,ES8,ES9)内容,还有JavaSCript基础知识的总结,接下来继续维护,后续还会添加面试题等。

  • Cute-FrontEnd

    王平安前端知识库整理,公众号【前端自习课】,欢迎关注!

注册于 2016-11-08
个人主页被 5.5k 人浏览