pingan8787

pingan8787 查看完整档案

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

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

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

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

个人动态

pingan8787 赞了文章 · 3月31日

http状态码竟然可以这样记

标题皮了一下,但是内容应该算是比较用心的,不是直接抄了一下官方文档和一堆抽象的术语,尽量配合实例解释的通俗一些。

基本介绍

状态码(Status Code)和原因短语(Reason Phrase)用于简单描述请求的结果。常见的比如:

  • 200 OK,表示请求成功;
  • 404 Not Found表示请求的资源未找到。

原因短语(上面的ok 和Not Found)是对状态码的解释说明。

形如200这样的状态码,这里的3位数字中第1位数字,通常表示响应的类别(会有一两个例外),大致可以分成以下几类(完整的状态码表,参见http1.1-RFC6):

状态码含义
1xx请求正被处理
2xx请求成功处理
3xx请求需要附加操作,常见的例子如重定向
4xx客户端出错导致请求无法被处理
5xx服务端处理出错

(4,5常被用在排查bug时前后端互相甩锅-_-!)下面详细介绍常用的状态码和原因短语

分类

2xx

200 OK

表示请求已经被正常处理,这个比较常见,就不多说了。

204 NO Content

表示请求成功,但是响应的报文中不含实体主体。通常用于只需要客户端向服务端发送信息,而不需要接受新信息的情况使用

(这么官方且抽象的描述显然不是我的风格),举例:

现在很常见的一种请求类型option,通常被用来做正式请求的预请求,这个请求只需要确认后续的请求能不能通过,即只需要一个结果,而不需要返回其他内容,这类请求成功时就会返回204。

相信大家小时候都经常考试,如果拿试题来打个比方,那么:

  • 其他状态码可以比喻为填空题:客户端提问(发请求),服务端给出详细答案(返回实体内容)。
  • 204可以理解为判断题:客户端提问(发请求),服务端给出判断,对或者错(只要响应头有状态码,不需要实体内容)

206 Partial Content

字面意思:只返回了请求资源的部分。这种情况必须提到提到一个请求头Range——在http的请求中,这个头部用来表示范围请求,例如:

'Range':byte=5001-10000 // 表示本次要请求资源的5001-10000字节的部分

这种情况下,如果服务器接受范围请求并且成功处理,就会返回206,并且在响应的头部返回

'Content-Range':bytes 5001-10000/10000 // 表示整个资源有10000字节,本次返回的范围为 5001-10000字节

3xx

301 Moved Permanently

字面意思:资源被永久重定向了。这种情况下响应的头部字段Location中一般还会返回一个地址,用来表示要新地址。例如:

客户端发起一个请求,要访问a站点,此时收到的响应如下:

301 Moved Permanently 
...
Location:`b.com`
...

//上述内容表示:亲,您请求的资源已经永久转移啦,这边建议您去新的地址b.com访问呢,以后也请直接访问新地址哦

302 Found

字面意思:资源临时重定向了。和301的唯一区别就在于一个是临时,一个是永久:还是举上面的例子,响应如下:

302 Found
...
Location:`b.com`
...
//上述内容表示:亲,您请求的资源被临时转移啦,后面也有可能再次转移,所以这边建议您本次去新的地址b.com访问,以后的话还是先访问原来地址哦,有任何变化mm依然会热心为你解答

303 See Other

这个和302很像,但是有个细微区别是,除了会提示客户端去请求Location以外,还会要求请求要使用Location时使用GET方法。 在这补充一下一个历史背景:

请求返回301 302 303 时,几乎所有浏览器都会把原先的POST请求改为GET请求。虽然FRC1945和RFC2068规范中有规定:不允许客户端在重定向时修改方法。

简单的说,实际的浏览器在处理301和302时,默认就会把原先的POST请求改为GET请求,所以实际上使用303的意义,单纯只是让语义化更清晰点。(303表示服务器明确告诉客户端,你要使用GET方法访问location;如果是302,就是仅仅告诉客户端要访问location,不限制方法,但是实际上客户端自己也会用GET方法访问。)

304 Not Modified

字面意思是:资源未改变,可直接使用缓存。

这种响应一般是GET请求中带有附加条件,例如请求头中含有if-Match,if-Modified-Since等(if-Match表示只请求带有特殊标记的资源,if-Modified-Since表示请求指定时间后未变更的资源,因为本文主要讲解状态码,所以不在此引入太多http头部的相关内容,这里是为了简单解释下附加条件请求的含义)。

这种情况下,服务端不会返回响应主体,含义就是:”从你上次访问以来这个资源都没变过哟,直接使用你本地的缓存就行啦“。

304就是3xx里面的一个特例,因为它不算是一个重定向。(一般我们认为重定向要给出一个新的地址让客户端去访问,304如果一定要解释为重定向,只能解释为让客户端转去访问缓存-_-)

307 Temporary Redirect

这个重定向是为了解决前面刚刚介绍的一个历史背景问题:302时浏览器默认会转用GET方法去请求Location,而如果是307, 含义就是严格限制不允许从POST转为GET,这个目前我在实际工作中很少遇到。

4xx

4xx表示一般是客户端发生了错误。(这位前端同学,乖乖接了这个bug吧!)

400 Bad Request

(这个状态码相信大家一定很不陌生,特别是入门的新同学~)400的含义简单粗暴:“对不起,你的请求中有语法错误”,那具体是什么语法错误呢? 答案是 —— 不一定,一般来说响应报文里会有一些提示,例如:

  • “哎呀,你多加了什么请求头,我不能接受呀”
  • “哎呀,你地址不是不是写错了,这个uri不存在啊”
  • “哎呀,你是不是请求方法错了,这个uri之只能用put而不是post”
  • 下面请各位随意补充
  • ...

401 Unauthorized

字面意思:未经过认证。一般在后台系统之类的应用里,用户登录之后会获得一个身份认证信息,然后生成mac之类的信息,放在请求头的Authorization字段里,发送给服务端,如果这个认证信息有问题或者根本没发送,就会出现这个状态码。

403 Forbidden

这个就简单了:禁止访问也就是无权限访问。至于具体为什么禁止,服务器可以在响应内容的实体部分给出,当然也可以不给(没错,我服务端就是这么了不起,为所欲为!)

404 Not Found

很常见了,字面含义,服务端没有找到所请求的资源,经验表明,一般这个错误是客户端的请求url写错了。(别问我怎么知道的)

5xx

5xx表明服务端发生了错误。(真是个令人开心的消息,老板,你看不是我的问题!)

500 Internal Server Error

简单粗暴,服务器故障了。啥?你问我什么故障?我哪知道,我只会熟练地把bug转给隔壁的服务端同学。

评论区有读者提到漏掉了502和504,想想还是补上吧,虽然要说明这俩还得稍微解释下网关和代理(懒性暴露),但是平时开发的时候确实也经常出现,尤其是使用nginx进行调试的时候。顺便安利一下教程 windows下使用nginx调试

502 Bad Gateway

作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应。这里说明下网关和代理服务器的概念:前面我们举例子都是直接客户端向服务器发请求,实际上客户端有时候不是直接向服务器请求,这中间可能存在网关和代理。画个简单的图:
图片描述

图片描述

503 Service Unavailable

服务器暂时无法使用,可能是维护或者升级,反正无法使用。

504 Gateway Timeout

作为网关或者代理工作的服务器访问超时。

小结

本文对常见的一些http状态码和原因做了总结,一方面希望能够给更多入门的同学提供帮助,一方面也做一个归纳总结,方便平时排查bug时速查,文中有一部分可能涉及到了http头部的知识,虽然也想尽可能剥离开来,但是为了解释说明没法也确实完全分割开-_-。


惯例:如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址

查看原文

赞 193 收藏 137 评论 10

pingan8787 赞了文章 · 3月31日

浅谈 JS 防抖和节流

防抖和节流严格算起来应该属于性能优化的知识,但实际上遇到的频率相当高,处理不当或者放任不管就容易引起浏览器卡死。所以还是很有必要早点掌握的。(信我,你看完肯定就懂了)

从滚动条监听的例子说起

先说一个常见的功能,很多网站会提供这么一个按钮:用于返回顶部。
返回顶部按钮

这个按钮只会在滚动到距离顶部一定位置之后才出现,那么我们现在抽象出这个功能需求-- 监听浏览器滚动事件,返回当前滚条与顶部的距离
这个需求很简单,直接写:

function showTop  () {
    var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.onscroll  = showTop

但是!

图片描述

在运行的时候会发现存在一个问题:这个函数的默认执行频率,太!高!了!。 高到什么程度呢?以chrome为例,我们可以点击选中一个页面的滚动条,然后点击一次键盘的【向下方向键】,会发现函数执行了8-9次
图片描述

然而实际上我们并不需要如此高频的反馈,毕竟浏览器的性能是有限的,不应该浪费在这里,所以接着讨论如何优化这种场景。

防抖(debounce)

基于上述场景,首先提出第一种思路:在第一次触发事件时,不立即执行函数,而是给出一个期限值比如200ms,然后:

  • 如果在200ms内没有再次触发滚动事件,那么就执行函数
  • 如果在200ms内再次触发滚动事件,那么当前的计时取消,重新开始计时

效果:如果短时间内大量触发同一事件,只会执行一次函数。

实现:既然前面都提到了计时,那实现的关键就在于setTimeout这个函数,由于还需要一个变量来保存计时,考虑维护全局纯净,可以借助闭包来实现:

/*
* fn [function] 需要防抖的函数
* delay [number] 毫秒,防抖期限值
*/
function debounce(fn,delay){
    let timer = null //借助闭包
    return function() {
        if(timer){
            clearTimeout(timer) //进入该分支语句,说明当前正在一个计时过程中,并且又触发了相同事件。所以要取消当前的计时,重新开始计时
            timer = setTimeout(fn,delay) 
        }else{
            timer = setTimeout(fn,delay) // 进入该分支说明当前并没有在计时,那么就开始一个计时
        }
    }
}

当然 上述代码是为了贴合思路,方便理解(这么贴心不给个赞咩?),写完会发现其实 time = setTimeout(fn,delay)是一定会执行的,所以可以稍微简化下:


/*****************************简化后的分割线 ******************************/
function debounce(fn,delay){
    let timer = null //借助闭包
    return function() {
        if(timer){
            clearTimeout(timer) 
        }
        timer = setTimeout(fn,delay) // 简化写法
    }
}
// 然后是旧代码
function showTop  () {
    var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.onscroll = debounce(showTop,1000) // 为了方便观察效果我们取个大点的间断值,实际使用根据需要来配置

此时会发现,必须在停止滚动1秒以后,才会打印出滚动条位置。

到这里,已经把防抖实现了,现在给出定义:

  • 对于短时间内连续触发的事件(上面的滚动事件),防抖的含义就是让某个时间期限(如上面的1000毫秒)内,事件处理函数只执行一次。

节流(throttle)

继续思考,使用上面的防抖方案来处理问题的结果是:

  • 如果在限定时间段内,不断触发滚动事件(比如某个用户闲着无聊,按住滚动不断的拖来拖去),只要不停止触发,理论上就永远不会输出当前距离顶部的距离。

但是如果产品同学的期望处理方案是:即使用户不断拖动滚动条,也能在某个时间间隔之后给出反馈呢?(此处暂且不论哪种方案更合适,既然产品爸爸说话了我们就先考虑怎么实现)
图片描述

其实很简单:我们可以设计一种类似控制阀门一样定期开放的函数,也就是让函数执行一次后,在某个时间段内暂时失效,过了这段时间后再重新激活(类似于技能冷却时间)。

效果:如果短时间内大量触发同一事件,那么在函数执行一次之后,该函数在指定的时间期限内不再工作,直至过了这段时间才重新生效。

实现 这里借助setTimeout来做一个简单的实现,加上一个状态位valid来表示当前函数是否处于工作状态:

function throttle(fn,delay){
    let valid = true
    return function() {
       if(!valid){
           //休息时间 暂不接客
           return false 
       }
       // 工作时间,执行函数并且在间隔期内把状态位设为无效
        valid = false
        setTimeout(() => {
            fn()
            valid = true;
        }, delay)
    }
}
/* 请注意,节流函数并不止上面这种实现方案,
   例如可以完全不借助setTimeout,可以把状态位换成时间戳,然后利用时间戳差值是否大于指定间隔时间来做判定。
   也可以直接将setTimeout的返回的标记当做判断条件-判断当前定时器是否存在,如果存在表示还在冷却,并且在执行fn之后消除定时器表示激活,原理都一样
    */

// 以下照旧
function showTop  () {
    var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.onscroll = throttle(showTop,1000) 

运行以上代码的结果是:

  • 如果一直拖着滚动条进行滚动,那么会以1s的时间间隔,持续输出当前位置和顶部的距离

其他应用场景举例

讲完了这两个技巧,下面介绍一下平时开发中常遇到的场景:

  1. 搜索框input事件,例如要支持输入实时搜索可以使用节流方案(间隔一段时间就必须查询相关内容),或者实现输入间隔大于某个值(如500ms),就当做用户输入完成,然后开始搜索,具体使用哪种方案要看业务需求。
  2. 页面resize事件,常见于需要做页面适配的时候。需要根据最终呈现的页面情况进行dom渲染(这种情形一般是使用防抖,因为只需要判断最后一次的变化情况)

思考总结

上述内容基于防抖和节流的核心思路设计了简单的实现算法,但是不代表实际的库(例如undercore js)的源码就直接是这样的,最起码的可以看出,在上述代码实现中,因为showTop本身的很简单,无需考虑作用域和参数传递,所以连apply都没有用到,实际上肯定还要考虑传递argument以及上下文环境(毕竟apply需要用到this对象)。这里的相关知识在本专栏《柯里化》和《this对象》的文章里也有提到。本文依然坚持突出核心代码,尽可能剥离无关功能点的思路行文因此不做赘述。


惯例:如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址

查看原文

赞 457 收藏 274 评论 61

pingan8787 发布了文章 · 3月18日

探索 Snabbdom 模块系统原理

snabbdom-cover

近几年随着 React、Vue 等前端框架不断兴起,Virtual DOM 概念也越来越火,被用到越来越多的框架、库中。Virtual DOM 是基于真实 DOM 的一层抽象,用简单的 JS 对象描述真实 DOM。本文要介绍的 Snabbdom 就是 Virtual DOM 的一种简单实现,并且 Vue 的 Virtual DOM 也参考了 Snabbdom 实现方式。

对于想要深入学习 Vue Virtual DOM 的朋友,建议先学习 Snabbdom,对理解 Vue 会很有帮助,并且其核心代码 200 多行。

本文挑选 Snabbdom 模块系统作为主要核心点介绍,其他内容可以查阅官方文档《Snabbdom》

snabbdom-cover

一、Snabbdom 是什么

Snabbdom 是一个专注于简单性、模块化、强大特性和性能的虚拟 DOM 库。其中有几个核心特性:

  1. 核心代码 200 行,并且提供丰富的测试用例;
  2. 拥有强大模块系统,并且支持模块拓展和灵活组合;
  3. 在每个 VNode 和全局模块上,都有丰富的钩子,可以在 Diff 和 Patch 阶段使用。

接下来从一个简单示例来体验一下 Snabbdom。

1. 快速上手

安装 Snabbdom:

npm install snabbdom -D

接着新建 index.html,设置入口元素:

<div id="app"></div>

然后新建 demo1.js 文件,并使用 Snabbdom 提供的函数:

// demo1.js
import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

const patch = init([])
let vnode = h('div#app', 'Hello Leo')
const app = document.getElementById('app')
patch(app, vnode)

这样就实现一个简单示例,在浏览器打开 index.html,页面将显示 “Hello Leo” 文本。
img-1.png

接下来,我会以 snabbdom-demo 项目作为学习示例,从简单示例到模块系统使用的示例,深入学习和分析 Snabbdom 源码,重点分析 Snabbdom 模块系统。

二、Snabbdom-demo 分析

Snabbdom-demo 项目中的三个演示代码,为我们展示如何从简单到深入 Snabbdom。
首先克隆仓库并安装:

$ git clone https://github.com/zyycode/snabbdom-demo.git
$ npm install

虽然本项目没有 README.md 文件,但项目目录比较直观,我们可以轻松的从 src 目录找到这三个示例代码的文件:

  • 01-basicusage.js
  • 02-basicusage.js
  • 03-modules.js -> 本文核心介绍

接着在 index.html 中引入想要学习的代码文件,默认 <script data-original="./src/01-basicusage.js"></script>  ,通过 package.json 可知启动命令并启动项目:

$ npm run dev

1. 简单示例分析

当我们要研究一个库或框架等比较复杂的项目,可以通过官方提供的简单示例代码进行分析,我们这里选择该项目中最简单的 01-basicusage.js 代码进行分析,其代码如下:

// src/01-basicusage.js

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

const patch = init([])

let vnode = h('div#container.cls', 'Hello World')
const app = document.getElementById('app') // 入口元素

const oldVNode = patch(app, vnode)

// 假设时刻
vnode = h('div', 'Hello Snabbdom')
patch(oldVNode, vnode)

运行项目以后,可以看到页面展示了“Hello Snabbdom”文本,这里你会觉得奇怪,前面的 “Hello World” 文本去哪了

img-2.png

原因很简单,我们把 demo 中的下面两行代码注释后,页面便显示文本是 “Hello World”:

vnode = h('div', 'Hello Snabbdom')
patch(oldVNode, vnode)

这里我们可以猜测 patch() 函数可以将 VNode 渲染到页面。更进一步可以理解为,这边第一个执行 patch() 函数为首次渲染,第二次执行 patch() 函数为更新操作

img-3.png

2. VNode 介绍

这里可能会有小伙伴疑惑,示例中的 VNode 是什么?这里简单解释下:

VNode,该对象用于描述节点的信息,它的全称是虚拟节点(virtual node)。与 “虚拟节点” 相关联的另一个概念是 “虚拟 DOM”,它是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。“虚拟 DOM” 由 VNode 组成的。
—— 全栈修仙之路 《Vue 3.0 进阶之 VNode 探秘》

其实 VNode 就是一个 JS 对象,在 Snabbdom 中是这么定义 VNode 的类型:

export interface VNode {
  sel: string | undefined; // selector的缩写
  data: VNodeData | undefined; // 下面VNodeData接口的内容
  children: Array<VNode | string> | undefined; // 子节点
  elm: Node | undefined; // element的缩写,存储了真实的HTMLElement
  text: string | undefined; // 如果是文本节点,则存储text
  key: Key | undefined; // 节点的key,在做列表时很有用
}

export interface VNodeData {
  props?: Props
  attrs?: Attrs
  class?: Classes
  style?: VNodeStyle
  dataset?: Dataset
  on?: On
  hero?: Hero
  attachData?: AttachData
  hook?: Hooks
  key?: Key
  ns?: string // for SVGs
  fn?: () => VNode // for thunks
  args?: any[] // for thunks
  [key: string]: any // for any other 3rd party module
}

在 VNode 对象中含描述节点选择器 sel 字段、节点数据 data 字段、节点所包含的子节点 children 字段等。

在这个 demo 中,我们似乎并没有看到模块系统相关的代码,没事,因为这是最简单的示例,下一节会详细介绍。

我们在学习一个函数时,可以重点了解该函数的“入参”和“出参”,大致就能判断该函数的作用。

从这个 demo 主要执行过程可以看出,主要用到有三个函数: init() / patch() / h() ,它们到底做什么用的呢?我们分析一下 Snabbdom 源码中这三个函数的入参和出参情况:

3. init() 函数分析

init() 函数被定义在 package/init.ts 文件中:

// node_modules/snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    // 省略其他代码
}

其参数类型如下:

function init(modules: Array<Partial<Module>>, domApi?: DOMAPI): (oldVnode: VNode | Element, vnode: VNode) => VNode

export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}>
  
export interface DOMAPI {
  createElement: (tagName: any) => HTMLElement
  createElementNS: (namespaceURI: string, qualifiedName: string) => Element
  createTextNode: (text: string) => Text
  createComment: (text: string) => Comment
  insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void
  removeChild: (node: Node, child: Node) => void
  appendChild: (node: Node, child: Node) => void
  parentNode: (node: Node) => Node | null
  nextSibling: (node: Node) => Node | null
  tagName: (elm: Element) => string
  setTextContent: (node: Node, text: string | null) => void
  getTextContent: (node: Node) => string | null
  isElement: (node: Node) => node is Element
  isText: (node: Node) => node is Text
  isComment: (node: Node) => node is Comment
}

init() 函数接收一个模块数组 modules 和可选的 domApi 对象作为参数,返回一个函数,即 patch() 函数。
domApi 对象的接口包含了很多 DOM 操作的方法。
这里的 modules 参数本文将重点介绍。

4. patch() 函数分析

init() 函数返回了一个 patch() 函数,其类型为:

// node_modules/snabbdom/src/package/init.ts

patch(oldVnode: VNode | Element, vnode: VNode) => VNode

patch() 函数接收两个 VNode 对象作为参数,并返回一个新 VNode。

5. h() 函数分析

h() 函数被定义在 package/h.ts 文件中:

// node_modules/snabbdom/src/package/h.ts

export function h(sel: string): VNode
export function h(sel: string, data: VNodeData | null): VNode
export function h(sel: string, children: VNodeChildren): VNode
export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode{
    // 省略其他代码
}

h() 函数接收多种参数,其中必须有一个 sel 参数,作用是将节点内容挂载到该容器中,并返回一个新 VNode。

6. 小结

通过前面介绍,我们在回过头看看这个 demo 的代码,大致调用流程如下:

img-4.png

三、深入 Snabbdom 模块系统

学习完前面这些基础知识后,我们已经知道 Snabbdom 使用方式,并且知道其中三个核心方法入参出参情况和大致作用,接下来开始看本文核心 Snabbdom 模块系统。

1. Modules 介绍

Snabbdom 模块系统是 Snabbdom 提供的一套可拓展可灵活组合的模块系统,用来为 Snabbdom 提供操作 VNode 时的各种模块支持,如我们组建需要处理 style 则引入对应的 styleModule,需要处理事件,则引入 eventListenersModule 既可,这样就达到灵活组合,可以支持按需引入的效果。

Snabbdom 模块系统的特点可以概括为:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。

当然 Snabbdom 模块系统还有其他内置模块:

模块名称模块功能示例代码
attributesModule为 DOM 元素设置属性,在属性添加和更新时使用 setAttribute 方法。h('a', { attrs: { href: '/foo' } }, 'Go to Foo')
classModule用来动态设置和切换 DOM 元素上的 class 名称。h('a', { class: { active: true, selected: false } }, 'Toggle')
datasetModule为 DOM 元素设置自定义数据属性(data- *)。然后可以使用 HTMLElement.dataset 属性访问它们。h('button', { dataset: { action: 'reset' } }, 'Reset')
eventListenersModule为 DOM 元素绑定事件监听器。h('div', { on: { click: clickHandler } })
propsModule为 DOM 元素设置属性,如果同时使用 attributesModule,则会被 attributesModule 覆盖。h('a', { props: { href: '/foo' } }, 'Go to Foo')
styleModule为 DOM 元素设置 CSS 属性。h('span', {style: { color: '#c0ffee'}}, 'Say my name')

2. Hooks 介绍

Hooks 也称钩子,是 DOM 节点生命周期的一种方法。Snabbdom 提供丰富的钩子选择。模块既使用钩子来扩展 Snabbdom,也在普通代码中使用钩子,用来在 DOM 节点生命周期中执行任意代码。

这里大致介绍一下所有的 Hooks:

钩子名称触发时机回调参数
prepatch 阶段开始。none
init已添加一个 VNode。vnode
create基于 VNode 创建了一个 DOM 元素。emptyVnode, vnode
insert一个元素已添加到 DOM 元素中。vnode
prepatch一个元素即将进入 patch 阶段。oldVnode, vnode
update一个元素开始更新。oldVnode, vnode
postpatch一个元素完成 patch 阶段。oldVnode, vnode
destroy一个元素直接或间接被删除。vnode
remove一个元素直接从 DOM 元素中删除。vnode, removeCallback
postpatch 阶段结束。none

模块中可以使用这些钩子:precreateupdatedestroyremovepost
单个元素可以使用这些钩子:initcreateinsertprepatchupdatepostpatchdestroyremove

Snabbdom 是这么定义钩子的:

// snabbdom/src/package/hooks.ts

export type PreHook = () => any
export type InitHook = (vNode: VNode) => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type InsertHook = (vNode: VNode) => any
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

export interface Hooks {
  pre?: PreHook
  init?: InitHook
  create?: CreateHook
  insert?: InsertHook
  prepatch?: PrePatchHook
  update?: UpdateHook
  postpatch?: PostPatchHook
  destroy?: DestroyHook
  remove?: RemoveHook
  post?: PostHook
}

接下来我们通过 03-modules.js 文件的示例代码,我们需要样式处理事件操作,因此引入这两个模块,并进行灵活组合

// src/03-modules.js

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

// 1. 导入模块
import { styleModule } from 'snabbdom/src/package/modules/style'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'

// 2. 注册模块
const patch = init([ styleModule, eventListenersModule ])

// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {
  style: { backgroundColor: '#4fc08d', color: '#35495d' },
  on: { click: eventHandler }
}, [
  h('h1', 'Hello Snabbdom'),
  h('p', 'This is p tag')
])

function eventHandler() {
  console.log('clicked.')
}

const app = document.getElementById('app')
patch(app, vnode)

上面代码中,引入了 styleModule 和 eventListenersModule 两个模块,并且作为参数组合,传入 init() 函数中。
此时我们可以看到页面上显示的内容已经有包含样式,并且点击事件也能正常输出日志 'clicked.'

img-5.png

这里我们看下 styleModule 模块源码,把代码精简一下:

// snabbdom/src/package/modules/style.ts

function updateStyle (oldVnode: VNode, vnode: VNode): void {
    // 省略其他代码
}

function forceReflow () {
  // 省略其他代码
}

function applyDestroyStyle (vnode: VNode): void {
  // 省略其他代码
}

function applyRemoveStyle (vnode: VNode, rm: () => void): void {
  // 省略其他代码
}

export const styleModule: Module = {
  pre: forceReflow,
  create: updateStyle,
  update: updateStyle,
  destroy: applyDestroyStyle,
  remove: applyRemoveStyle
}

在看看 eventListenersModule 模块源码:

// snabbdom/src/package/modules/eventlisteners.ts

function updateEventListeners (oldVnode: VNode, vnode?: VNode): void {
    // 省略其他代码
}

export const eventListenersModule: Module = {
  create: updateEventListeners,
  update: updateEventListeners,
  destroy: updateEventListeners
}

明显可以看出,两个模块返回的都是个对象,并且每个属性为一种钩子,如 pre/create 等,值为对应的处理函数,每个处理函数有统一的入参。

继续看下 styleModule 中,样式是如何绑定上去的。这里分析它的 updateStyle 方法,因为元素创建(create 钩子)和元素更新(update 钩子)阶段都是通过这个方法处理:

// snabbdom/src/package/modules/style.ts

function updateStyle (oldVnode: VNode, vnode: VNode): void {
  var cur: any
  var name: string
  var elm = vnode.elm
  var oldStyle = (oldVnode.data as VNodeData).style
  var style = (vnode.data as VNodeData).style

  if (!oldStyle && !style) return
  if (oldStyle === style) return
  
  // 1. 设置新旧 style 默认值
  oldStyle = oldStyle || {}
  style = style || {}
  var oldHasDel = 'delayed' in oldStyle

  // 2. 比较新旧 style
  for (name in oldStyle) {
    if (!style[name]) {
      if (name[0] === '-' && name[1] === '-') {
        (elm as any).style.removeProperty(name)
      } else {
        (elm as any).style[name] = ''
      }
    }
  }
  for (name in style) {
    cur = style[name]
    if (name === 'delayed' && style.delayed) {
      // 省略部分代码
    } else if (name !== 'remove' && cur !== oldStyle[name]) {
      if (name[0] === '-' && name[1] === '-') {
        (elm as any).style.setProperty(name, cur)
      } else {
        // 3. 设置新 style 到元素
        (elm as any).style[name] = cur
      }
    }
  }
}

3. init() 分析

接着我们看下 init() 函数内部如何处理这些 Module。

首先在 init.ts 文件中,可以看到声明了默认支持的 Hooks 钩子列表:

// snabbdom/src/package/init.ts

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']

接着看 hooks 是如何使用的:

// snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {  // 创建 cbs 对象,用于收集 module 中的 hook
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: []
  }
    // 收集 module 中的 hook,并保存在 cbs 中
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      const hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        (cbs[hooks[i]] as any[]).push(hook)
      }
    }
  }
    // 省略其他代码,稍后介绍
}

上面代码中,创建 hooks 变量用来声明默认支持的 Hooks 钩子,在 init() 函数中,创建 cbs 对象,通过两层循环,保存每个 module 中的 hook 函数到 cbs 对象的指定钩子中。

通过断点可以看到这是 demo 中,cbs 对象是下面这个样子:

img-6.png

这里 cbs 对象收集了每个 module 中的 Hooks 处理函数,保存到对应 Hooks 数组中。比如这里的 create 钩子中保存了 updateStyle 函数和 updateEventListeners 函数。

img-7.png

到这里, init() 函数已经保存好所有 module 的 Hooks 处理函数,接下来就要看看 init() 函数返回的 patch() 函数,这里面将用到前面保存好的 cbs 对象。

4. patch() 分析

init() 函数中最终返回一个 patch() 函数,这边形成一个闭包,闭包里面可以使用到 init() 函数作用域定义的变量和方法,因此在 patch() 函数中能使用 cbs 对象。

patch() 函数会在不同时机点(可以参照前面的 Hooks 介绍),遍历 cbs 对象中不同 Hooks 处理函数列表。

// snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    // 省略其他代码
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()  // [Hooks]遍历 pre Hooks 处理函数列表

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode) // 当 oldVnode 参数不是 VNode 则创建一个空的 VNode
    }

    if (sameVnode(oldVnode, vnode)) {  // 当两个 VNode 为同一个 VNode,则进行比较和更新
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      createElm(vnode, insertedVnodeQueue) // 当两个 VNode 不同,则创建新元素

      if (parent !== null) {  // 当该 oldVnode 有父节点,则插入该节点,然后移除原来节点
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()  // [Hooks]遍历 post Hooks 处理函数列表
    return vnode
  }
}

patchVnode() 函数定义如下:

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // 省略其他代码
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)  // [Hooks]遍历 update Hooks 处理函数列表
    }
  }

createVnode() 函数定义如下:

  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // 省略其他代码
    const sel = vnode.sel
    if (sel === '!') {
      // 省略其他代码
    } else if (sel !== undefined) {
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)  // [Hooks]遍历 create Hooks 处理函数列表
      const hook = vnode.data!.hook
    }
    return vnode.elm
  }

removeNodes() 函数定义如下:

  function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void {
    // 省略其他代码
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (ch != null) {
        rm = createRmCb(ch.elm!, listeners)
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // [Hooks]遍历 remove Hooks 处理函数列表
      }
    }
  }

这部分代码跳转较多,总结一下这个过程,如下图:

img-8.png

四、自定义 Snabbdom 模块

前面我们介绍了 Snabbdom 模块系统是如何收集 Hooks 并保存下来,然后在不同时机点执行不同的 Hooks。

在 Snabbdom 中,所有模块独立在 src/package/modules 下,使用的时候可以灵活组合,也方便做解耦和跨平台,并且所有 Module 返回的对象中每个 Hooks 类型如下:

// snabbdom/src/package/init.ts

export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}>

// snabbdom/src/package/hooks.ts
export type PreHook = () => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

因此,如果开发者需要自定义模块,只需实现不同 Hooks 并导出即可。

接下来我们实现一个简单的模块 replaceTagModule,用来将节点文本自动过滤掉 HTML 标签

1. 初始化代码

考虑到方便调试,我们直接在 node_modules/snabbdom/src/package/modules/ 目录中新建 replaceTag.ts 文件,然后写个最简单的 demo 框架:

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

const replaceTagPre = () => {
    console.log("run replaceTagPre!")
}

const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => {
    console.log("run updateReplaceTag!", oldVnode, vnode)
}

const removeReplaceTag = (vnode: VNode): void => {
    console.log("run removeReplaceTag!", vnode)
}

export const replaceTagModule: Module = {
    pre: replaceTagPre,
    create: updateReplaceTag,
    update: updateReplaceTag,
    remove: removeReplaceTag
}

接下来引入到 03-modules.js 代码中,并简化下代码:

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

// 1. 导入模块
import { styleModule } from 'snabbdom/src/package/modules/style'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'
import { replaceTagModule } from 'snabbdom/src/package/modules/replaceTag';

// 2. 注册模块
const patch = init([
  styleModule,
  eventListenersModule,
  replaceTagModule
])

// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', '<h1>Hello Leo</h1>')

const app = document.getElementById('app')
const oldVNode = patch(app, vnode)

let newVNode = h('div', '<div>Hello Leo</div>')

patch(oldVNode, newVNode)

刷新浏览器,就可以看到 replaceTagModule 的每个钩子都被正常执行:

img-9.png

2. 实现 updateReplaceTag() 函数

我们删除掉多余代码,接下来实现 updateReplaceTag() 函数,当 vnode 创建和更新时,都会调用该方法。

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

const regFunction = str => str && str.replace(/\<|\>|\//g, "");

const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => {
    const oldVnodeReplace = regFunction(oldVnode.text);
    const vnodeReplace = regFunction(vnode.text);
    if(oldVnodeReplace === vnodeReplace) return;
    vnode.text = vnodeReplace;
}

export const replaceTagModule: Module = {
    create: updateReplaceTag,
    update: updateReplaceTag,
}
  

updateReplaceTag() 函数中,比较新旧 vnode 的文本内容是否一致,如果一致则直接返回,否则将新的 vnode 的替换后的文本设置到 vnode 的 text 属性,完成更新。

其中有个细节:

vnode.text = vnodeReplace;

这里直接对 vnode.text 进行赋值,页面上的内容也随之发生变化。这是因为 vnode 是个响应式对象,通过调用其 setter 方法,会触发响应式更新,这样就实现页面内容更新。

于是我们看到页面内容中的 HTML 标签被清空了。

img-10.png

3. 小结

这个小节中,我们实现一个简单的 replaceTagModule 模块,体验了一下 Snabbdom 模块灵活组合的特点,当我们需要自定义某些模块时,便可以按照 Snabbdom 的模块开发方式,开发自定义模块,然后通过 Snabbdom 的 init() 函数注入模块即可。

我们再回顾一下 Snabbdom 模块系统特点:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。

五、通用模块生命周期模型

下面我将前面 Snabbdom 的模块系统,抽象为一个通用模块生命周期模型,其中包含三个核心层:

  1. 模块定义层

在本层可以按照模块开发规范,自定义各种模块。

  1. 模块应用层

一般是在业务开发层或组件层中,用来导入模块。

  1. 模块初始化层

一般是在开发的模块系统的插件中,提供初始化函数(init 函数),执行初始化函数会遍历每个 Hooks,并执行对应处理函数列表的每个函数。

抽象后的模型如下:

image.png

在使用 Module 的时候就可以灵活组合搭配使用啦,在模块初始化层,就会做好调用。

六、总结

本文主要以 Snabbdom-demo 仓库为学习示例,学习了 Snabbdom 运行流程和 Snabbdom 模块系统的运行流程,还通过手写一个简单的 Snabbdom 模块,带大家领略一下 Snabbdom 模块的魅力,最后为大家总结了一个通用模块插件模型。

大家好好掌握 Snabbdom 对理解 Vue 会很有帮助。

查看原文

赞 5 收藏 3 评论 0

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>'
})

最后

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

查看原文

赞 257 收藏 184 评论 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 篇源码分析系列教程。
查看原文

赞 37 收藏 24 评论 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的数据响应式原理》 
查看原文

赞 11 收藏 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 年,技术行业从业者肩上的责任超越了以往任何历史时期。

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

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

关注 22

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

认证与成就

  • 获得 659 次点赞
  • 获得 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
个人主页被 6.6k 人浏览