Coder Social home page Coder Social logo

blog's Introduction

瓜皮的博客

简介

前端党一枚,想在学习的过程中有一定的产出来巩固知识。目前还没想好怎么构建体系,学到什么就写什么吧。

散文

面试题

实践相关

力扣

blog's People

Contributors

guapi233 avatar

Stargazers

douzhier avatar

Watchers

 avatar

blog's Issues

【实践相关】contentEditable实现支持EMOJI的输入框

contentEditable实现支持EMOJI的输入框

尽可能为你提供一些思路。

contentEditable

contentEditable是实现本功能的核心属性,在div什么开启此元素后,div便拥有了接收输入的功能。

  <div contenteditable ></div>

如何实现placeholder效果?

虽然div拥有了接收输入的功能,但毕竟placeholder是输入框独有的特性,所以需要我们自行实现,placeholder具有以下特点:

  • 在输入区无文本时显示
  • 在输入区有文本时隐藏
  • 不能被选中,不会被光标所捕获

分析了placeholder的特点,只需按照这个功能实现即可,方法也很简单,只需要创建一个元素,在其中输入placeholder的内容,将其定位到相应的位置即可。

.placeholder {
  &::before {
    content: attr(placeholder);
    position: absolute;
    top: 0;
    left: 0;
    color: #99a2aa;
  }
}

光标是什么?如何记录光标的位置?

注意:一下内容为非IE的解释方法,在IE中有部分API与内容中不同,如果想在IE中实现效果,只需替换成IE支持的API即可

在HTML里面,光标是一个对象,光标对象是只有当你选中某个元素的时候才会出现的。最主要的两个对象分别为selectionrange

首先来说selection,它是保存浏览器中当前光标的信息对象(例如光标所处的元素,偏移位置等),通过window.getSelection()获取,理所当然,既然作为信息对象,那么它身上的属性是只读的。并且只是存储当前光标的信息,这是因为部分浏览器支持按住ctrl选中多个独立的区域,创建多个光标(chrome禁止了这个特性,所以一般情况下只有一个光标),那么如果修改光标的位置呢,这就需要我们找到真正的光标对象了。

range对象就是真正控制个体光标的对象了,可以通过selection对象身上的getRangeAt(索引,一般为0),你可能有些疑惑,为什么不是cursor,而是range,这其实我们的输入光标不是一个点,而是一个片段区域,是有开始点结束点的,我们对文字按下左键向右拉的时候,就看到了文字变成蓝色,那个就是光标的开始和结束,当我们直接点一下的时候,光标在,其实只是开始和结束点重叠了。而range对象中就包含了操作当前输入光标位置的方法

插入文本/元素

想要往文本区插入内容不难,甚至可以说方法百出,但是这里真正需要注意的是怎么在保持光标位置正常的情况下插入内容

  private insertElm(element: any) {
    // 1. 判断光标存在的元素,根据不同类型的元素做出不同的处理(光标可能处于的节点类型在 focus 函数中有表明)
    this.cursorNode = this.cursorNode || this.ctextarea;

    if (this.cursorNode.nodeType !== 3) {
      // 1.1 在空的文本域中直接插入元素
      const textNode = document.createTextNode("");
      this.cursorNode.prepend(textNode);
      if (typeof element === "string") {
        this.cursorNode.insertAdjacentText("afterbegin", element);
      } else {
        this.cursorNode.insertAdjacentElement("afterbegin", element);
      }

      this.cursorNode = textNode;
      this.cursorPos = 0;
    } else {
      if (typeof element === "string") {
        this.cursorNode.insertData(this.cursorPos, element);
        this.cursorPos += element.length;
      } else {
        // 1.1 从光标处切割文本节点
        const afterNode = this.cursorNode.splitText(this.cursorPos);
        // 1.2 在切开的两个文本节点间插入元素
        this.cursorNode = afterNode;
        this.cursorPos = 0;
        afterNode.parentNode.insertBefore(element, afterNode);
      }
    }

    // 2. 提醒父组件内容状态变化 & 重新固定光标
    this.$emit("update:value", this.ctextarea.innerHTML);
  }

上面的代码一共分出了四种情况处理插入:

  1. 在元素节点中插入文本节点
  2. 在元素节点中插入元素节点
  3. 在文本节点中插入文本节点
  4. 在文本节点中插入元素节点

这四种情况使用的插入方式不同,且同步光标坐标的方式也不同。有两种插入数据格式不奇怪,但是为什么会有两种数据插入位置呢,这是因为当div中没有输入内容时,这时光标聚焦在div身上,而一旦div中输入了数据,光标就会聚焦到div中的文本节点身上,并且如果触发了回车换行事件,文本域div就会再创建一个内部div用于换行,这样就造成了光标要么处于div元素身上,要么处于文本节点身上。上面的代码给出的四种解决方案为:

  1. 在元素节点中插入文本节点,首先插入一个空文本节点,在将文本节点插入到元素节点内部的最前面,最后将光标设置在空文本的0位置
  2. 在元素节点中插入文本节点,首先插入一个空文本节点,在将元素节点插入到元素节点内部的最前面,最后将光标设置在空文本的0位置
  3. 在文本节点中插入文本节点,直接插入文本节点,并延长光标位置为插入的长度
  4. 在文本节点中插入元素节点,首先插入一个空文本节点,将文本节点沿着光标位置切割成两个新的文本节点,将元素节点插入到两个文本节点中间,最后将光标设置在切割完的第二个文本节点的0位置

【实践相关】利用v-model的原理为组件封装双向绑定功能

利用v-model的原理为组件封装双向绑定功能

Vue中的表单元素默认都支持v-model指令,用于帮助我们完成数据的双向绑定,实际上它内部的原理实现非常的简单。

<input v-model="val" /> {{ val }}

上面的代码中如果输入框中的val值发生变化,那么旁边的展示val同样会发生变化,那么我们可以用下方的代码来替代上面的代码实现相同的功能。

<input :value="val" @input="val=$event.target.value" /> {{ val }}

实际上,上面的代码只是下面代码的一种语法糖,二者的内部实现并无差异,v-model在编译后就会变成下面的代码。

实际用途

我们可以利用上面的特性,来对自定义组件进行双向绑定功能的添加。

<c-input v-model="val" /> // 最终会编译为下面的代码
<c-input :value="val" @input="val=$event.target.value" />

那么自定义组件c-input内部只需要接收value传值和对input事件做监听即可。

{
    props: {
        value: {
            type: String,
            default: ""
        }
    },
    methods: {
        // 将该方法和上面的value绑到对应元素上即可
        input(event) {
            this.$emit("input", event.target.value);
        }
    }
}

CSS3中的sticky定位

CSS3中的sticky定位

position的含义是指定位类型,取值类型可以有:staticrelativeabsolutefixedinheritsticky,这里sticky是CSS3新发布的一个属性,用于设置

sticky规则

  • 设置了position: sticky的元素并不脱离文档流,仍然保留元素原本在文档流中的位置。
  • 当元素在容器中被滚动超过指定的偏移值时,元素在容器内固定在指定位置。亦即如果你设置了top: 50px,那么在sticky元素到达距离相对定位的元素顶部50px的位置时固定,不再向上移动(相当于此时fixed定位)。
  • 元素固定的相对偏移是相对于离它最近的具有滚动框的祖先元素,如果祖先元素都不可以滚动,那么是相对于viewport来计算元素的偏移量。

兼容问题

截止到2020-10-21,position: sticky;的支持情况一般,主流浏览器除了Firafox以及Safari全面支持外,Chrome、Edge部分支持(IE6-11全部不支持),移动端代理大多也是部分支持状态。

效果预览

【面试题】模块化的差异

模块化的差异

简述AMD、CMD、COMMONJS、ESMODULE的差异

涉及知识点

  • 前端模块化

解题思路

依赖前置与依赖就近

AMD提倡依赖前置,即需要引入的模块已经做好前置准备,如以下代码:

define([模块A, 模块B], function (A, B) {
    // 第三模块内代码
})

上面的代码中,模块A和模块B已经在第三个模块之前前置了,如果想到更改模块A的依赖,只需要更改前方的引用即可,而无需修改第三个模块内的代码。

换句话说,第三个模块是一个相对独立的模块,内部的引用是由外部传进来的,不会造成额外的引用耦合,需要什么模块就传入什么模块。

CMD、COMMONJS、ESMODULE这三个规范提倡依赖就近,即需要在模块内填写需要引入模块的引用,这种规范会造成一定的引用耦合,不过优点就是很明显的可以知道该模块引用了哪些其他模块。

动态引入与静态引入

ESMODULE使用的是静态引入,即引入语句不能处于任何逻辑语句中,也就是下面这段代码再ESMODULE中是不被允许的:

if (true) {
    import A from "../a.js";
} else {
    import B from "../b.js";
}

这么做好处就是可以对文件进行一个预处理工作,因为引入语句本身不会存在不确定性,所以能在运行时之前构建出文件之间的引用关系,事实上webpack底层也确实进行了相关的工作。

而其他的三种规范支持的是动态引入,即引入语句的执行结果是最先存在于运行时环境的,也就是说类似于上面的代码在这三种规范中是被允许的。

这么做确实从语言层面上提高了模块的灵活性,不过也为模块引用带来了一定不确定性,无法像ESMODULE那样对模块进行相应的预处理工作。

理解JavaScript执行上下文

一直对JavaScript执行的环境不太理解,只知道一些什么变量提升呀,闭包呀表面概念,一问为什么脑子当场就断开连接,所以在参考了大大的文章后,整理一份自己关于执行上下文的理解。

什么是执行上下文

简而言之,执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。个人理解的是执行上下文就是一个隐形的对象,这个对象上面记录了程序当前执行所依赖的环境因素。

有些地方,比起上下文,我觉得环境这个词更容易让人理解,所以我在下文也会在合适的地方使用环境这个词语来代替上下文,因为任何一句代码执行,都需要一个执行时的环境,这个环境提供了一些代码执行所需的条件。就好像分析历史事件时,我们必不可少得要分析它所处的时代环境一样,对于代码也是如此。

执行上下文的分类

  • 全局执行上下文:是程序最外层的执行环境,当程序开始执行之前,这个执行环境就会被创建,直到程序执行结束才会被释放,且一个程序至多有一个全局执行环境

  • 函数执行上下文:每当遇到一个函数调用语句时,就会创建一个对应于该函数的执行环境,并且在该函数执行完毕后,该函数的执行环境就会被销毁,一个程序可以同时存在多个函数执行环境

  • eval执行上下文eval函数能将一段字符串文本当做程序语句执行,其内部有独特的执行环境(非严格模式下会直接作用于外部作用域,严格模式下则会创建一个内部作用域),这里不展开讨论

    "use strict"; // 如果关闭严格模式会打印2
    
    var a = "aa";
    eval("var a = 2");
    console.log(a); // "aa"
  • 模块执行上下文:存在与nodejs中,我们使用的module,exports就存在于此,这里不做讨论

执行上下文中的成员

  • 变量对象VO(variable object):

    VO用于存储当前执行环境中所拥有的变量以及函数,这些变量函数可以被在当前环境中执行的代码所调用

  • 作用域链 (scope chain):

    作用域链用于规定当前环境下的变量以什么样的方式及顺序进行查找

  • this

    this用于记录调用方法的对象

执行上下文栈

在了解执行执行上下文栈之前,先来看一道题目,请说出下面两段代码的执行结果,以及它们有何不同:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

你可能很轻易得看出它们其实最后返回的都是local scope,那么它们到底有什么不同呢(老师,我知道,它们的写法不同,老师:爬),我们先来看一下执行栈这个玩意到底是啥。

我们知道万物运动发展总有个顺序,浏览器分析和执行代码也一样,总要按着个顺序来,最容易让人想到的是浏览器是按照行一行一行的分析代码并执行它们的,不过仔细一想这个结论就错了,因为语言中是存在作用域的:

var a = 12;

function b () {
    var a = 13;
    console.log(a); // 浏览器咋知道这个a是函数b内的
}
console.log(a); // 浏览器咋知道这个a是全局的

如果想当然的按照一行一行的去分析和执行代码,那么代码中作用域早就乱套了,其实小伙伴们可能已经知道了,浏览器一行一行代码执行不假,但是在那之前浏览器还会按照以为单位去分析代码,也就是在代码执行之前,浏览器已经将代码分为了一段一段的代码片段,然后有序的组织这些代码片段来完成程序运作。

那么这一个一个的代码块是啥呢,没错,其实就是我们上面提到的不同类型的执行上下文:全局执行上下文就是一个最大的代码片段,这个代码片段包括了程序中所有的代码,并且它其中还包括了以函数执行上下文分割出的一个一个的小的代码片段。这些代码片段身上都有一个执行上下文,而执行栈就是存放执行上下文的地方,浏览器按照执行顺序依次将代码片段对应的执行上下文放入执行栈中,并在一个代码片段执行完毕后将其弹出。

知道了执行顺序的原理,下面按照这样的原理分析一段js代码:

function fun3() {
    return "没了,哈哈";
}

function fun2() {
    fun3();
}

function fun1() {
    fun2();
}

fun1();

按照全局执行上下文和函数执行上下文的定义,我们发现上面的代码中有3处函数调用,那么就表明这段代码一共产生了3 + 1(全局)个执行上下文,根据上面的执行顺序原理,来看看浏览器如何处理这些执行上下文:

// 伪代码,由于我们还不知道执行上下文的内容是啥,所以用文字表示

// 创建执行栈(execution stack => ECStack)
const ECStack = [];

// 先将全局执行上下文放入执行栈,然后开始执行代码
ECStack.push(全局执行上下文)

// 遇到fun1(),创建fun1的上下文放入执行栈,然后开始执行fun1()
ECStack.push(fun1的执行上下文)

// 执行fun1时发现里面有fun2(),创建fun2的上下文放入执行栈,然后开始执行fun2()
ECStack.push(fun2的执行上下文)

// 执行fun2时发现里面有fun3(),创建fun3的上下文放入执行栈,然后开始执行fun3()
ECStack.push(fun3的执行上下文)

// 执行fun3时没有发现里面有函数调用,遇到return语句,fun3函数执行完毕,弹出执行栈
ECStack.pop()

// 因为fun3()执行完了,fun2中也没有其它代码,fun2函数执行完毕,弹出执行栈
ECStack.pop()

// 因为fun2()执行完了,fun1中也没有其它代码,fun1函数执行完毕,弹出执行栈
ECStack.pop()

// 因为fun1()执行完了,全局中耶没有其它代码,程序执行完毕,弹出执行栈
ECStack.pop()

那么最后我们再回到本小节开始提到的问题,以执行栈变化的角度来看一下两段代码的执行过程

第一段代码:

// 执行checkscope函数
ECStack.push(checkscope的上下文)

// 发现checkscope函数最后执行了f函数
ECStack.push(f的上下文)

// f函数执行完毕
ECStack.pop()

// 因为f函数执行完毕,return语句成功返回,checkscope执行完毕
ECStack.pop()
// 执行checkscope函数
ECStack.push(checkscope的上下文)

// return语句成功返回f函数,checkscope函数执行完毕
ECStack.pop()

// checkscope()执行完毕,后面的"()"开始执行checkscope函数返回的f函数
ECStack.push(f的上下文)

// f函数执行完毕
ECStack.pop()

是不是有些不一样呢?

理解了执行上下文栈其实就是理解了程序的运行流程,上面我们简单得介绍了执行上下文中的“成员”,下面我们就具体得介绍它们是什么,起到什么作用,如何运作。

VO(variable object)

上面我们提到:

VO用于存储当前执行环境中所拥有的变量以及函数,这些变量函数可以被在当前环境中执行的代码所调用

在正式介绍VO之前,我们先了解一个老朋友,全局对象,以下内容引自W3school

全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。

在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。

例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

“通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性”,例如:

console === window.console; // true
var a = 12;
function b() { console.log(a) };
console.log(this.a, window.a); // 12 12
this.b(); // 12
this === window; // true

我们可以通过window访问定义在全局的变量以及函数,这不正是VO所具有的特性吗,没错,全局对象其实就是全局执行上下文的VO(变量对象)

函数上下文中的变量对象(AO)

函数上下文中的变量对象有点特殊,不是称为variable object,而是activation object,简称AO,其实二者没有本质区别,作用也相同,叫法不同将二者区别开来的原因是因为AO是一种特殊的VO,通过AO,不仅能访问函数中定义的变量和函数,还可以访问传进来的参数和特殊对象arguments,但是它们并不是一开始就能访问的,而是需要等到该函数开始执行时,函数上下文中的VO才会activation化为AO,简单得说:

AO = VO + arguments + params,AO就是函数上下文的变量对象,VO就是全局上下文的变量对象。

VO的初始化流程

我们仍然将程序分为分析执行两个阶段,VO的初始化是在代码的分析阶段:

  1. 如果当前上下文是函数上下文(再次提醒遇到函数执行语句时才会创建上下文),分析函数的所有形参:
    • 将形参名称与对应的值绑定到AO身上,并将值挂到对应位置的arguments上
    • 对于没对应实参的形参,值设为undefined
  2. 函数声明
    • 如果遇到函数声明语句,将函数的名称与该函数的引用挂到当前上下文的VO身上
    • 如果VO身上已经存在与该函数名称相同的标识符,覆盖
  3. 变量声明
    • 如果遇到var变量声明,将变量名与undefined挂到当前上下文的VO身上
    • 如果VO身上已经存在与该变量名称相同的标识符,则忽略该var声明

举个例子:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}

foo(1);

在执行完分析阶段后,函数foo执行上下文身上的AO为:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c() {},
    d: undefined
}

foo函数开始执行时,上面的对象就是其AO的初始状态,然后根据代码的执行,发生状态变化,比如当foo函数执行完毕后,AO对象最终的结果为:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c() {},
    d: reference to FunctionExpression "d"
}

这就是上下文中变量对象(VO/AO)的真面目了,正是因为它们的存在,我们才可以通过它们来访问我们定义的变量,同时也解释了为什么会有变量提升,以及为什么函数声明可以先调用后赋值的原因,最后总结本小节的内容,概括如下:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

##作用域链

在开始正式解释作用域链之前,还是要提一些前置知识,我们知道函数在声明赋值的那一刻其作用域就已经决定了,而不是根据调用语句动态变化的,根据这一特性也拓展出了闭包这一概念,举个例子:

var inner;

function wrapA() {
  var item = "我在wrapA中";

  inner = function () {
    console.log(item);
  };
}

function wrapB() {
  var item = "我在wrapB中";

  inner();
}

wrapA();
wrapB(); // 我在wrapA中

很显然,上面的例子就是一个典型的闭包代码,inner函数没有去拿warpB中的item就说明inner函数的父亲一开始就已经决定好了,完全不受inner()调用位置的影响。

综上所述,JavaScript中的函数是典型的静态作用域,或者说叫做词法作用域

作用域链的构建过程

了解词法作用域对了解作用域链创建的过程有重大帮助,我们现在知道了函数的作用域受函数静态声明赋值的地方影响,那么js是如何创建出每一个函数中的作用域链,使得这些函数可以按着这条链条向上查询变量的呢?

托词法作用域的福,其实每个函数在声明赋值时身上都有一个[[Scopes]]隐藏属性,你可以见过这个属性,因为使用console.dir打印函数时可以看到这个属性,这个属性其实就记录了所有父变量的引用层级链,但是注意:[[Scopes]]并不代表完整的作用域链,具体的原因等我们待会再说。

下面我们举个例子来展示下[[Scopes]]大概的样子:

function foo() {
    function bar() {
        ...
    }
}

函数创建时,各自的[[Scopes]]为:

// 伪代码

foo.[[Scopes]] = [
    globalContext.VO
];

bar.[[Scopes]] = [
    fooContext.AO,
    globalContext.VO
];

上面我们提到了,[[Scopes]]并不是完整的作用域链,那么什么时候完整的作用域链会产生呢?其实不难想到,当我们在一个函数中使用一个变量时,会如何查找该变量的值呢?肯定是先去自身AO上找,找不到在沿着作用域链向上找,那么要构建完整的作用域链,就必须等到自身AO创建完毕后,下面我们就一起来看一下一条完整的作用域链是如何构建的:

// 示例函数

var scope = "global scope";
function checkscope(){
    var scope2 = 'local scope';
    return scope2;
}
checkscope();
  1. 浏览器遇到function checkscope() {}声明语句,保存当前的作用域链到其内部属性[[Scopes]]

    checkscope.[[Scopes]] = [
        globalContext.VO
    ];
  2. 浏览器遇到checkscope()执行语句,创建该函数的执行上下文,激活AO,并将上下文压入执行栈

    ECStack = [
        checkscopeContext,
        globalContext
    ];
  3. 开始进行分析AO对象内容前的准备工作,使用[[Scopes]]属性创建上下文中的作用域链

    checkscopeContext = {
        Scope: checkscope.[[Scopes]],
    }
  4. 开始创建AO对象中的内容

    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope2: undefined
        }
        Scope: checkscope.[[Scopes]],
    }
  5. 将AO压入该函数的作用域链顶端

    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope2: undefined
        },
        Scope: [AO, [[Scopes]]]
    }
  6. 分析工作结束,开始执行函数,并根据函数内部代码,修改对应的AO内容

    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope2: 'local scope'
        },
        Scope: [AO, [[Scopes]]]
    }
  7. 根据Scope找到scope2的值,并将其返回,函数上下文从执行栈中弹出

    ECStack = [
        globalContext
    ];

这样一条作用域链从构建到销毁的全过程就结束了,上面的过程虽然有好多细节上的不同,但是足够我们了解作用域链的大概了,比如为什么外面的代码没法访问函数中的变量,其实就是外面的执行上下文中的作用域链没有该函数的AO引用,理所当然也就拿不到AO中的变量引用了。

V8优化细节:V8为了优化闭包,不会在[[Scopes]]存储一条完整的作用域链,而是只会将在内部函数中使用到,也就是产生了闭包的变量引用存下来,其他的变量都会虽然外部函数的结束而销毁。换句话说,如果你没有利用闭包引用外部函数中的变量,那么无论你这个函数嵌套了多少层,它的作用域链总是只有自身的AO和全局对象。

this

this内容略,这里推荐一篇文章,作者冴羽 ,大大通过ECMA规范实现的角度,讲解了一些例如Reference、MemberExpression等规范内属性,ECMA正是对MemberExpression进行求值,判断结果是否为Reference

类型,进行对应的this绑定。

大大这种写法的动机是这样的一句函数调用(false || foo.bar)(),我一开始认为最终调用中的thisfoo,但其实它内部是window,其实(false || foo.bar)部分就是MemberExpression,对其求值后结果不为Reference类型,所以this隐式处理(严格为undefined,非严格为window)。

个人理解是||foo.bar进行求值操作了,直接拿到了bar()的引用,包括,已经=这两个运算符,都对右侧操作对象进行了求值,而(foo.bar)()中的小括号运算符没有对其中内容运算的必要,所以foo.bar这条引用路径仍被保留,不过这些都是个人通过表象推测的,真正原理还是要通过规范来获取。

捋一下

还是下面这个例子,我们从头捋一下它们的执行上下文的构建过程(注意,下面的流程只是大概的过程,例如作用域链方面由于浏览器优化可能会与实际流程不同):

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

第一个

  1. 首先创建全局执行上下文,将全局对象绑定到VO,并压入执行栈

    ECStack = [
        globalContext
    ];
  2. 遇到function checkscope(){}声明语句,记录该函数存在的词法作用域链

    checkscope.[[Scopes]] = [
        globalContext.VO
    ]
  3. 遇到checkscope()执行语句,压入执行栈并开始构建该函数的AO对象:

    ECStack = [
        checkscopeContext,
        globalContext
    ]
    
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope: undefined,
            f: reference to function f(){},
        }
    }
  4. 构建AO的途中发现function f(){}声明语句,记录该函数的词法作用域链:

    f.[[Scopes]] = [
        checkscopeContext.AO,
        globalContext.VO
    ]
  5. checkscope函数的AO构建完毕,开始通过[[Scopes]]连接作用域链:

    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope: undefined,
            f: reference to function f(){},
        },
        scope: [AO, [[Scopes]]] // [[Scopes]] = globalContext.VO
    }
  6. 开始执行checkscope()this设置为window(非严格),并根据其中的代码更新AO对象:

    AO.scope: undefined => "local scope";
  7. 执行到return部分发现f()执行语句,压入执行栈并开始构建该函数的AO对象:

    ECStack = [
        fContext,
        checkscopeContext,
        globalContext
    ]
    
    fContext = {
        AO: {
            arguments: {
                length: 0
            },
        }
    }
  8. f函数的AO构建完毕,开始通过[[Scopes]]连接作用域:

    fContext = {
        AO: {
            arguments: {
                length: 0
            },
        },
        scope: [AO, [[Scopes]]] // [[Scopes]] = checkscopeContext.AO, globalContext.VO
    }
  9. f函数开始执行,this设置为window,执行f时发现自身的AO中没有scope变量,开始向上去checkscopeContext.AO中寻找,发现值为local scope,返回并结束函数执行,从执行栈中弹出

    ECStack = [
        checkscopeContext,
        globalContext
    ]
  10. f()执行完毕,checkscope顺利返回结果,执行完毕并弹出执行栈:

    ECStack = [
        globalContext
    ]

第二个

第二段代码的执行过程和第一段代码的前6步相同,在第7步开始发生变化:

  1. 执行到返回语句,顺利将函数f返回,结束执行并弹出执行栈:

    ECStack = [
        globalContext
    ]
  2. 返回的f函数继续通过后面的()开始调用,压入执行栈并开始构建该函数的AO对象:

    ECStack = [
        fContext,
        globalContext
    ]
    
    fContext = {
        AO: {
            arguments: {
                length: 0
            },
        }
    }
  3. f函数的AO构建完毕,开始通过[[Scopes]]连接作用域:

    fContext = {
        AO: {
            arguments: {
                length: 0
            },
        },
        scope: [AO, [[Scopes]]] // [[Scopes]] = checkscopeContext.AO, globalContext.VO
        // 这里的checkscopeContext.AO通过闭包的形式被引用着(其实只有scope被引用)
    }
  4. f函数开始执行,this设置为window,执行f时发现自身的AO中没有scope变量,开始向上去checkscopeContext.AO中寻找,发现值为local scope,返回并结束函数执行,从执行栈中弹出:

    ECStack = [
        globalContext
    ]

ES6之后的执行上下文

在ES6,执行上下文内部结构发生了很大的变化,因为出现了有较强颠覆性的特性内容:let/const/class以及block/caseBlock作用域,let/const不能出现提升现象,并且还需要具备块级作用域的特性,这和上面执行上下文支持的var所具有的行为完全不同,这可怎么办呢?于是ECMA规范干脆变更了执行上下文的结构实现规范:

新版执行上下文中,有两个重要的概念:

  • 词法环境:LexicalEnvironment
  • 变量环境:VariableEnvironment

词法环境(LexicalEnvironment)

词法环境由三个部分构成:

  • 环境记录EnvironmentRecord:用于存放变量和函数声明的地方,分为objectdeclarative两种类型,简单理解就是前者是全局上下文中的ER类型,后者是函数上下文中的ER类型
  • 外层引用Outer:提供了访问父词法环境的引用,全局上下文这里为null,简单理解就是原来的作用域链,不过现在只存父级的,是真正的一级一级向上找
  • this绑定ThisBinding:确定当前环境中this的指向

速记:

词法环境分类 = 全局 / 函数 / 模块
词法环境 = ER + outer + this
ER分类 = declarative(DER) + object(OER)
全局ER = DER + OER

变量环境(VariableEnvironment)

在ES6前,声明变量都是通过var关键词声明的,在ES6中则提倡使用letconst来声明变量,为了兼容var的写法,于是使用变量环境来存储var声明的变量。

var关键词有个特性,会让变量提升,而通过let/const声明的变量则不会提升。为了区分这两种情况,就用不同的词法环境去区分。

变量环境本质上仍是词法环境,但它只存储var声明的变量,这样在初始化变量时可以赋值为undefined

函数环境记录更新

ECMA针对函数环境记录额外添加了一些内部属性,用于辨别不同的种类或不同调用方式的函数:

内部属性 Value 说明 补充
[[ThisValue]] Any 函数内调用this时引用的地址,我们常说的函数this绑定就是给这个内部属性赋值
[[ThisStatus]] "lexical" / "initialized" / "uninitialized" 若等于lexical,则为箭头函数,意味着this是空的; 强行new箭头函数会报错TypeError错误
[[FunctionObject]] Object 在这个对象中有两个属性[[Call]][[Construct]],它们都是函数,如何赋值取决于如何调用函数 正常的函数调用赋值[[Call]],而通过newsuper调用函数则赋值[[Construct]]
[[HomeObject]] Object / undefined 如果该函数(非箭头函数)有super属性(子类),则[[HomeObject]]指向父类构造函数 若你写过extends就知道我在说什么
[[NewTarget]] Object / undefined 如果是通过[[Construct]]方式调用的函数,那么[[NewTarget]]非空 在函数中可以通过new.target读取到这个内部属性。以此来判断函数是否通过new来调用的

[ThisStatus]]全称为[[ThisBindingStatus]]

例子

let a = 10;
const b = 20;
var sum;

function add(e, f){
    var d = 40;
    return d + e + f 
}

let utils = {
    add
}

sum = utils.add(a, b)

完整的执行上下文如下所示:

GlobalExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            type: 'object',
            add: <function>,
            a: <uninitialized>,
            b: <uninitialized>,
            utils: <uninitialized>,
        },
        outer: null,
        this: <globalObject>
    },
    VariableEnvironment: {
        EnvironmentRecord: {
            type: 'object',
            sum: undefined
        },
        outer: null,
        this: <globalObject>
    },
}

// 当运行到函数add时才会创建函数执行上下文
FunctionExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            type: 'declarative',
            arguments: {0: 10, 1: 20, length: 2},
            [[ThisValue]]: <utils>,
            [[NewTarget]]: undefined,
            ...
        },
        outer: <GlobalLexicalEnvironment>,
        this: <utils>
    },
    VariableEnvironment: {
        EnvironmentRecord: {
            type: 'declarative',
            d: undefined
        },
        outer: <GlobalLexicalEnvironment>,
        this: <utils>
    },
}

执行上下文创建后,进入到执行环节,变量在执行过程中赋值、读取、再赋值等。直至程序运行结束。 我们注意到,在执行上下文创建时,变量a、b都是<uninitialized>的,而sum则被初始化为undefined。这就是为什么你可以在声明之前访问var定义的变量(变量提升),而访问let/const定义的变量就会报引用错误的原因。

函数与块级作用域

我们上面提到除了通过var关键字声明的变量会存放在变量环境以外,其它的都会存放于词法环境,那么理论上function 函数声明也会享有词法环境的特性——拥有块级作用域,但是如果你去支持ES6的浏览器进行尝试后会发现事实并非如此,块级作用域中的函数声明依然能被外面访问到,这是为什么呢?

其实,在阮老师的ES6入门中已经给出了答案:

原来,如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式

  • 允许在块级作用域内声明函数。
  • 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
  • 同时,函数声明还会提升到所在的块级作用域的头部。

简单来说,就是因为老代码兼容问题,如果贸然修改{}内函数的行为,那么很可能会导致大批量老代码崩溃,因此才有的这种妥协做法,总之就是不要在{}书写函数声明,要写也要写函数表达式,避免不必要的麻烦。

参考

【实践相关】VSCode隐藏不常用文件

VSCode隐藏不常用文件

我们在使用VSCode进项项目开发时,经常会有一些文件不会经常打开,但是这些文件仍然会显示在文件目录中。尤其是工程项目的开发,经常会有一堆的工具配置文件,使得文件目录变得非常庞杂,这时候我们可以通过配置VSCode来隐藏这些文件。

解决方案

  1. 在VSCode顶部菜单栏找到查看,点击并打开命令面板

  2. 在输入栏中输入settins,选择打开工作区设置,注意是工作区,不是用户设置

  3. 在设置中随意修改一项配置,不用真得修改,只需要将原来的值删除重新输入即可,这一步的目的是为了让VSCode在当前目录生成.vscode文件夹

  4. 打开.vscode文件夹中的settings.json,在其中添加属性files.exclude,该属性的值是一个对象,对象的键是要隐藏的文件或目录,值为true的话就会隐藏该文件或目录,下面是一个Vue工程项目的隐藏配置,可以用于参考:

    {
      "files.exclude": {
        "**/.git": true,
        "**/.svn": true,
        "**/.hg": true,
        "**/CVS": true,
        "**/.DS_Store": true,
        "**/README.md": true,
        "**/node_modules": true,
        "**/shims-tsx.d.ts": true,
        "**/shims-vue.d.ts": true,
        "**/.browserslistrc": true,
        "**/.eslintrc.js": true,
        "**/.gitignore": true,
        "**/babel.config.js": true,
        "**/package-lock.json": true,
        "**/tsconfig.json": true
      }
    }

【实践相关】组件库打包与发布

组件库打包与发布

适用于Vue组件库的包括

创建空的项目

首先先创建一个空的Vue项目,执行指令:vue create [项目名称]

创建组件所在文件夹

将默认的组件与不需要的文件夹删掉,创建一个专门用于存放组件的文件夹,将组件移入文件夹中(组件中使用的字体图标等资源也需要移入该文件夹中)。

配置Vue

创建在根目录创建vue.config.js文件,并配置以下信息:

const path = require("path");

module.exports = {
  pages: {
    index: {
      // 修改项目的入口文件
      entry: "examples/main.js",
      template: "public/index.html",
      filename: "index.html",
    },
  },

  // 拓展webpack配置,使 components 加入编译(component为举例组件文件夹)
  chainWebpack: (config) => {
    config.module
      .rule("js")
      .include.add(path.resolve(__dirname, "components"))
      .end()
      .use("babel")
      .loader("babel-loader")
      .tap((options) => {
        // 修改它的选项
        return options;
      });
  },
};

打包前的准备

如果不想将包上传至npm可以跳过此步骤

  • 打开package.json
  • 确保其中name在npm中唯一
  • private修改为false
  • 指定包入口文件字段main,值为入口文件路径
  • 可以创建.npmignore文件来过滤上传文件

打包组件库

Vue打包指令:vue-cli-service build --target lib [入口路径]

发布到npm

登录:npm login

发布:npm publish

PS:发布前需保证源为npm本源

【实践相关】Koa2集成 JWT 鉴权

Koa2集成 JWT 鉴权

涉及到的关键中间件:koa-jwtjsonwebtoken

通过 koa-jwt 进行路由筛选

// app.js
// 1. 引入 koa-jwt
const JWT = require("koa-jwt");

// 2. 设置 jwt 所需的密钥,以及不需要鉴权的公开路由
const jwt = JWT({ secret: require("./config/index").JWT_SECRET }).unless({
  path: [/^\/public/, /^\/login/],
});

// 3. 安装
app.use(jwt);

注意:如果使用 koa-jwt 的同时还使用了 koa2-cors 中间件,需要将 koa2-cors 中间件的安装放置在 koa-jwt 上方,否则会导致复杂请求的 OPTIONS 请求被鉴权干掉。

统一处理 jwt 鉴权失败的路由返回

// app.js
app.use((ctx, next) => {
  return next().catch((err) => {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = "Protected resource, use Authorization header to get access\n";
    } else {
      throw err;
    }
  });
});

通过 jsonwebtoken 创建 token

// 参数:payload、密钥、options
let token = jsonwebtoken.sign({ _id: "Mob" }, config.JWT_SECRET, {
  // 用于设置 token 的过期时间,1d代表一天,1h代表一小时,以此类推
  expiresIn: "1d",
});

在登录接口将token返回,下一次客户端只需携带Authorization: bearer [token]请求头即可正常访问接口。

【实践相关】Vue-cli4.x配置代理

Vue-cli4.x配置代理

在做一个音乐app项目时遇到了这个问题,因为API是用得QQ音乐的,所以无法直接访问。

之前是了解过代理相关的内容的,所以知道要用上代理了,但是一直没有实践过,而且4.x脚手架封装了一部分配置操作,搞得更不知道何从下手。

答案

好在网上有相关内容的博客,按照博客来编写,首先需要在vue.config.js中添加以下配置项:

module.exports = {
  devServer: {
    proxy: {
      "/api/getDiscList": {
        target: "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg",
        changeOrigin: true,
      },
    },
  },
};

上面的配置项可以搭建一个最基本的代理,当有接口访问devServer的/api/getDiscList接口时,服务器就会将该请求代理至target服务器。changeOrigintrue的话会将代理服务器的host字段修改为target,可以躲避一些服务器的检测。

坑1.0

本来我是以为问题就这么被解决了,但是当我访问接口时却得到了404响应,在搜索引擎中没有得到想要的答案后,通过网友的提点,得知是缺少了pathRewrite字段:原来,代理服务器会默认将代理路由的key合并到target请求上发送出去,于是上面的请求发出去的urlhttps://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg/api/getDiscList,这显然是错误的,而pathRewrite字段则可以通过正则的方式替换掉key,确保正确的url

module.exports = {
  devServer: {
    proxy: {
      "/api/getDiscList": {
        target: "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg",
        changeOrigin: true,
        pathRewrite: {
    	  "^/api/getDiscList": "",
        },
      },
    },
  },
};

上面是将开头为/api/getDiscListkey替换为空字符串,这样target与空字符串拼接也不会带来任何影响。

坑2.0

做完上面的工作后,发现请求是可以正常发出了,但是却得到了目的服务器那边错误的状态码,这是因为QQ音乐的这个接口不止检测host字段,还会对referer 字段进行判断,如果不为符合条件的值,则会打回这条请求。

解决方法并不困难,只需要让代理服务器在请求目的服务器时带上一个符合条件的referer属性即可,那么问题就是如果做到这件事,还是因为4.x脚手架做了封装的原因,我不知道如何以及何时为代理服务器的request对象挂载对应header

最后是通过webpack的文档得知其使用了http-proxy-middleware这一代理服务器库,在通过该库的文档得知了合适的钩子函数,最后解决了问题。

module.exports = {
  devServer: {
    proxy: {
      "/api/getDiscList": {
        target: "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg",
        changeOrigin: true,
        pathRewrite: {
          "^/api/getDiscList": "",
        },
        onProxyReq(onProxyReq, req, res) {
          onProxyReq.setHeader("referer", "https://c.y.qq.com/");
        },
      },
    },
  },
};

【面试题】MongoDB与MySQL的优势

MongoDB与MySQL的优势

MongoDB与MySQL各自的优势以及使用场景

涉及知识点

  • NoSQL与关系型数据库

解题思路

灵活性方面

  • MongoDB插入数据没有表格的限制,因此在存储比较灵活的数据,即结构有很强的变化性的数据时,效率非常的高。
  • MySQL因为有关系表格限制的因素,在数据灵活性方面较弱,但是如果是存储一些结构比较规范的数据时,因为业界已经沉淀了许多数据操作的优化方案,所以性能要强于NoSQL。

【实践相关】在VSCode中自定义代码片段

在VSCode中自定义代码片段

好处就不用我多说了,可以快速生产一些重复且固定的代码片段。

执行步骤

  1. 在VSCode顶部菜单栏找到【文件】——【首选项】——【用户片段】

  2. 在输入框中输入你想要的代码片段生效的语言类型

  3. 比如:输入vue,选择提示的vue.json,在其中仿照一下格式书写自定义代码片段:

    {
    	"Vue Ts":{
    		"prefix":"tsvue", // 快捷指令名称
    		"body":[  // 代码片段
    			"<template>\n\t<div>\n\n\t</div>\n</template>\n\n",
    			"<script lang=\"ts\">\nimport{Component,Vue}from 'vue-property-decorator';\n\n@Component\nexport default class ${1:ClassName} extends Vue{\n$0\n}\n</script>\n\n",
    			"<style lang=\"scss\" scope>\n\n</style>\n"
    		],
    		"description":"生成vue文件"  // 提示信息
    	}
    }
    }

【面试题】图片上传前的预览

图片上传前的预览

你现在正在制作一个上传头像模块,上选择完图片后需要将选择的图片展示出来供用户浏览操作。

涉及知识点

  • FileReader

解题思路

这道题分为两种情况:高版本浏览器以及低版本浏览器

低版本中的解决方案

先来说一个低版本,或者说传统的解决方案:

  1. 放置文件输入框
  2. 监听文件输入框的change事件
  3. 在事件回调发生后将用户选择的文件通过AJAX发送至后端,后端完成存储后再将图片的存储路径返还至前端
  4. 前端将拿到的图片通过<img />展示出来

这样做的缺点很明显:用户的每一次头像尝试都需要经过一次服务器,服务器中也会额外存储许多无效的头像图片。

高版本中的解决方案

高版本是指支持FileReader的版本,什么是FileReader呢?

FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。 ——MDN

也就是使用FileReader的话,无需提前上传浏览器也可以预览图片,极大加快了预览的速度,具体事例代码如下:

<!--  HTML -->
<input type="file" onchange="handleFile()" />
<img src="" alt="" />
// js
function handleFile() {
  let file = document.querySelector("input[type=file]").files[0];
  let preview = document.querySelector("img");

  // 初始化FileReader
  let fileReader = new FileReader();

  fileReader.addEventListener("load", () => {
    preview.src = fileReader.result;
  });

  if (file) {
    fileReader.readAsDataURL(file);
  }
}

【实践相关】Vue深度选择器

Vue深度选择器

如果你希望 scoped 样式中的一个选择器能够作用得“更深”,例如影响子组件,你可以使用 >>> 操作符:

<style scoped>
.a >>> .b { /* ... */ }
</style>

上述代码将会编译成:

.a[data-v-f3f3eg9] .b { /* ... */ }

有些像 Sass 之类的预处理器无法正确解析 >>>。这种情况下你可以使用 /deep/::v-deep 操作符取而代之——两者都是 >>> 的别名,同样可以正常工作。

具体应用

在实战中,一般是用于解决添加scoped属性导致CSS样式无法穿透至子组件或者是内部插槽的问题。

/* 在scss中应用 */
.c-dialog {
  &__footer {
    padding: 10px 20px 20px;
    text-align: right;
    box-sizing: border-box;

    ::v-deep .c-button:first-child {
      margin-right: 20px;
    }
  }
}

/* 上面的深度选择器代码会被编译为 */
.c-dialog__footer[随机标识] > c-button:first-child {
    /* ... */
}

【实践相关】Vue-cli4.x配置Scss全局样式

Vue-cli4.x配置Scss全局样式

本来以为只需要将存放全局变量的样式表直接导入main.js就可以了,结果惨遭打脸。

解决方案

vue.config.js中配置如下配置即可,无需在main.js中引用:

module.exports = {
  css: {
    loaderOptions: {
      sass: {
        // 替换路径即可
        prependData: `@import "./src/config/globalTheme.scss";`,
      },
    },
  },
};

Vue-Router 钩子执行顺序

Vue-Router 钩子执行顺序

路由守卫分类

  • 全局的
  • 单个路由独享的
  • 组件内的

全局路由守卫

【全局的】:是指路由实例上直接操作的钩子函数,他的特点是所有路由配置的组件都会触发,直白点就是触发路由就会触发这些钩子函数,如下的写法。钩子函数按执行顺序包括beforeEachbeforeResolve(2.5+)afterEach三个(以下的钩子函数都是按执行顺序讲解的):

const router = new VueRouter({ ... })

router.beforeEach((to, from, next) => {
  // ...
})

[beforeEach]:在路由跳转前触发,参数包括to,from,next(参数会单独介绍)三个,这个钩子作用主要是用于登录验证,也就是路由还没跳转提前告知,以免跳转了再通知就为时已晚。

[beforeResolve](2.5+):这个钩子和beforeEach类似,也是路由跳转前触发,参数也是to,from,next三个,和beforeEach区别官方解释为:

区别是在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用。

即在 beforeEach 和 组件内beforeRouteEnter 之后,afterEach之前调用。

[afterEach]:和beforeEach相反,他是在路由跳转完成后触发,参数包括to,from没有了next(参数会单独介绍),他发生在beforeEachbeforeResolve之后,beforeRouteEnter(组件内守卫,后讲)之前。

单路由独享路由守卫

【路由独享的】是指在单个路由配置的时候也可以设置的钩子函数,其位置就是下面示例中的位置,也就是像Foo这样的组件都存在这样的钩子函数。目前他只有一个钩子函数beforeEnter

const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      beforeEnter: (to, from, next) => {
        // ...
      }
    }
  ]
})

[beforeEnter]:和beforeEach完全相同,如果都设置则在beforeEach之后紧随执行,参数to、from、next

组件内路由守卫

【组件内的】:是指在组件内执行的钩子函数,类似于组件内的生命周期,相当于为配置路由的组件添加的生命周期钩子函数。钩子函数按执行顺序包括beforeRouteEnterbeforeRouteUpdate (2.2+)beforeRouteLeave三个,执行位置如下:

<template>
  ...
</template>
export default{
  data(){
    //...
  },
  beforeRouteEnter (to, from, next) {
    // 在渲染该组件的对应路由被 confirm 前调用
    // 不!能!获取组件实例 `this`
    // 因为当守卫执行前,组件实例还没被创建
  },
  beforeRouteUpdate (to, from, next) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 /foo/:id,在 /foo/1 和 /foo/2 之间跳转的时候,
    // 由于会渲染同样的 Foo 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 可以访问组件实例 `this`
  },
  beforeRouteLeave (to, from, next) {
    // 导航离开该组件的对应路由时调用
    // 可以访问组件实例 `this`
  }
}
<style>
  ...
</style>

[beforeRouteEnter]:路由进入之前调用,参数包括to,from,next。该钩子在全局守卫beforeEach和独享守卫beforeEnter之后,全局beforeResolve和全局afterEach之前调用,要注意的是该守卫内访问不到组件的实例,也就是thisundefined,也就是他在beforeCreate生命周期前触发。在这个钩子函数中,可以通过传一个回调给 next来访问组件实例。在导航被确认的时候执行回调,并且把组件实例作为回调方法的参数,可以在这个守卫中请求服务端获取数据,当成功获取并能进入路由时,调用next并在回调中通过 vm访问组件实例进行赋值等操作,(next中函数的调用在mounted之后:为了确保能对组件实例的完整访问)。

 beforeRouteEnter (to, from, next) {
  // 这里还无法访问到组件实例,this === undefined
  next( vm => {
    // 通过 `vm` 访问组件实例
  })
}

[beforeRouteUpdate] (v 2.2+):在当前路由改变时,并且该组件被复用时调用,可以通过this访问实例。参数包括to,from,next。可能有的同学会疑问,what is 路由改变 or what is 组件被复用?

  • 对于一个带有动态参数的路径 /foo/:id,在 /foo/1/foo/2 之间跳转的时候,组件实例会被复用,该守卫会被调用
  • 当前路由query变更时,该守卫会被调用

[beforeRouteLeave]:导航离开该组件的对应路由时调用,可以访问组件实例this,参数包括to,from,next

至此,所有钩子函数介绍完毕。

屡一下哈:

全局路由钩子:

  • beforeEach(to,from, next)
  • beforeResolve(to,from, next)
  • afterEach(to,from);

独享路由钩子:

  • beforeEnter(to,from, next);

组件内路由钩子:

  • beforeRouteEnter(to,from, next)
  • beforeRouteUpdate(to,from, next)
  • beforeRouteLeave(to,from, next)

不知道你是否还记得to、from、next这三个参数

下面请重头把这几个钩子函数的参数看一遍,细心的同学可以看见在afterEach钩子中参数没有next,为什么呢?

3.导航守卫回调参数

to:目标路由对象;

from:即将要离开的路由对象;

next:他是最重要的一个参数,他相当于佛珠的线,把一个一个珠子逐个串起来。以下注意点务必牢记:

  1. 但凡涉及到有next参数的钩子,必须调用next() 才能继续往下执行下一个钩子,否则路由跳转等会停止。

  2. 如果要中断当前的导航要调用next(false)。如果浏览器的 URL 改变了 (可能是用户手动或者浏览器后退按钮),那么 URL 地址会重置到from路由对应的地址。(主要用于登录验证不通过的处理)

  3. 当然next可以这样使用,next('/') 或者 next({ path: '/' }): 跳转到一个不同的地址。意思是当前的导航被中断,然后进行一个新的导航。可传递的参数与router.push中选项一致。

  4. 在beforeRouteEnter钩子中next((vm)=>{})内接收的回调函数参数为当前组件的实例vm,这个回调函数在生命周期mounted之后调用,也就是,他是所有导航守卫和生命周期函数最后执行的那个钩子。

  5. next(error): (v2.4.0+) 如果传入 next 的参数是一个 Error 实例,则导航会被终止且该错误会被传递给 router.onError() 注册过的回调。

【造轮子】低配axios

【造轮子】低配axios

😁:文章请结合案例食用。

axios是一个基于promise的HTTP库,它的特性包括:

  • 可以在浏览器中发送 XMLHttpRequests
  • 可以在 node.js 发送 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 能够取消请求
  • 自动转换 JSON 数据
  • 客户端支持保护安全免受 XSRF 攻击

这些特性使得axios非常易用,在前端领域广受好评,从Vue放弃维护Vue resource转推axios这点就可以看出它的强大。

这篇文章会剖析axios最主要的部分,它们包括:

  • axios是如何使用Promise来处理HTTP请求的?
  • axios中的拦截器是如何做到的?
  • axios是如何中断请求的?

Axios、axios、instance之间的关系

axios中存在3个最基本的”角色“,分别为Axiosaxiosinstance,其中axios我们最为熟知,它是axios库暴露给我们用以发送请求的函数:

// 发送 POST 请求(源自官方例子)
axios({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
});

instance则是我们通过axios.create()创造出来的定制请求方法,它的功能和axios一样,用来发送请求,我们可以在创建instance时提前设置好一些固定配置项:

const instance = axios.create({
  baseURL: 'https://some-domain.com/api/',
  timeout: 1000,
  headers: {'X-Custom-Header': 'foobar'}
});

instance({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
});

Axios则比较隐蔽,它属于axios库中的内部角色。Axios是一个“类”角色,规定了一系列关于请求的属性和方法,其中最为重要的是一个叫request的方法,该方法是请求的入口函数,所有的请求都从该方法开始。

三者的关系为:

  • Axios:与请求相关的属性和方法都定义在该类上
  • instance:Axios的“实例”
  • axios:Axios的“实例” + 特殊配置

之所以“实例”打引号是因为后二者从语法层面并不是Axios的真正实例,我们知道类的实例化结果只能为一个对象,而axiosinstance却是两个可执行的函数类型。它们被称为Axios“实例”的原因是从逻辑层面获得了Axios规定了所有的属性和方法。

axiosinstance本质上都是相同的,它们都是Axios规定的request()的拓展,只不过axios作为默认使用的对象,身上有一些instance没有的配置,是一个特殊的instance。比如axios.createcreate()因为不是Axios类上定义的,而是单独给axios添加的,所以instance身上没有该方法。

// axios和instance都是被这个函数创造出来的
function createInstance(defaultConfig) {
  var context = new Axios(defaultConfig);
  // 这里可以看出instance的本质就是将Axios.prototype.request单独拿出来进行“加工”
  var instance = bind(Axios.prototype.request, context);

  // Copy axios.prototype to instance(将Axios原型上的属性方法复制到创建出的instance上)
  utils.extend(instance, Axios.prototype, context);

  // Copy context to instance(将Axios实例对象身上的属性方法复制到创建出的instance上)
  utils.extend(instance, context);

  return instance;
}

axios的执行流程

上面我们得知axiosinstance的本质就是就是Axios.prototype.request(),该方法是一切请求的入口,其实完整的axios执行流程为:Axios.prototype.request() --> dispatchRequest() --> xhrAdapter()

其中第一个函数是入口函数,主要功能是使用Promise串连起整条请求链,并在其中调用第二个函数;第二个函数则是主要对请求的请求数据格式和响应格式进行一定的处理,并在其中调用第三个函数;而第三个函数则是真正的发送请求,获得数据。

如何使用Promise串连请求

Axios.prototype.request()中有一段重要的代码段:

// ✨:串连整条Promise链
let chain = [dispatchRequest, undefined]; // undefined用来占位
let promise = Promise.resolve(config);

this.interceptors.request.forEach((interceptor) => {
  chain.unshift(interceptor.onResolved, interceptor.onRejected);
});

this.interceptors.response.forEach((interceptor) => {
  chain.push(interceptor.onResolved, interceptor.onRejected);
});

while (chain.length) {
  promise = promise.then(chain.shift(), chain.shift()); // 一次弹出两个,不占位就会错位传入
}

axios会首先将设置的interceptors拦截器保存在数组中,在执行请求函数时,会将它们按规则填充到执行函数中(request拦截器晚设置早执行,response拦截器晚设置完执行),最后通过Promise.prototype.then本身的串连特性将这些处理函数进行串连执行。同时这也是为什么axios拦截的实现原理。用图表示这部分的逻辑为:

axios如何中断请求的

axios的中断是使用了XMLHttpRequest对象身上的abort方法,但是axios本身是基于Promise来构建的,而我们知道Promise一旦启动时不能中断的,所以axios执行中断后请求的Promise会变为rejected状态。

// axios官方的取消例子
const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});

// cancel the request
cancel();

实现原理也很简,CancelToken实例化出的对象身上有一个Promise,而executor的参数c就是该Promise的resolve方法,而该Promise的成功回调就是将请求abort掉。

// xhrAdapter.js
if (config.cancelToken) {
  config.cancelToken.promise.then((message) => {
    if (!request) return;

    request.abort();
    reject(message);

    request = null;
  });
}

【实践相关】Vue集成 CKEdit5

Vue集成 CKEdit5

CKEditor 5 - 官网

CKEditor即大名鼎鼎的FCKeditor(文本编辑器),它终于在最近发布新版本了,与增加版本号不同,这次完全把它改名了,更名为CKeditor。这应该是和它的开发公司CKSource(波兰华沙的公司)的名字有关吧,该公司的另一个产品为CKFinder(一个Ajax文件管理器),这次可能为了保持一致,将FCK更改为CK,但是版本号继承了下来,为CKeditor3.0版。

做个人项目正好需要使用到富文本功能,之前没怎么接触过这方面,只听说web富文本中的水非常深。深知自己还是个小菜鸟,所以也不打算自己逞强造玩具轮子,然后就找到了这款CKEdit

正巧CKEdit已经迭代到了5版本,已经支持了目前主流的web框架,大大减少了对接时带来繁琐步骤。

快速集成

在官方提供的 CKEditor 5 demo 中,有以下几个支持快速集成的版本,就是官方已经把功能模块都集成进去了,直接安装即可快速上手使用。

我安装的是最经典的Classic版本,想要在Vue中集成,除了安装Classic版本的核心包之外,还需要安装一个

名为@ckeditor/ckeditor5-vue的套件,这是官方提供的与 Vue 对接的编辑器框架,内部没有直接集成任何版本的 CKEditor ,但提供了对接任意版本 CKEditor 的接口,源码在这,其实就是将编辑器的初始化工作封装在了一个组件中,同时暴露出了必要的配置项。

npm install --save @ckeditor/ckeditor5-vue @ckeditor/ckeditor5-build-classic

安装完毕之后,官方文档中建议把 ckeditor5-vue 进行全局安装,也就是在 main.js 中引入,ckeditor5-build-classic组件内部引用,其实这就看自身需求情况了,一般情况下都建议声明一个独立的组件,将这两个包在该组件中引入,然后完成配置工作,封装成一个独立的组件供其他地方使用。

<template>
  <div id="app">
    <ckeditor
      :editor="editor"
      v-model="editorData"
      :config="editorConfig"
    ></ckeditor>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import CKEditor from "@ckeditor/ckeditor5-vue";
import ClassicEditor from "@ckeditor/ckeditor5-build-classic";

Vue.use(CKEditor);

@Component
export default class Test extends Vue {
  private editor = ClassicEditor;
  private editorData = "<p>Content of the editor.</p>";
  private editorConfig = {
    ckfinder: {
      // 后端处理上传逻辑返回json数据,包括uploaded(选项true/false)和url两个字段,
      uploadUrl: "http://localhost:3000" + "/uploadFile" 
    },
    language: "zh-cn",
    image: {
      toolbar: ["imageTextAlternative", "imageStyle:full", "imageStyle:side"]
    },
    table: {
      contentToolbar: ["tableColumn", "tableRow", "mergeTableCells"]
    },
    licenseKey: ""
  };
}
</script>

<style lang="scss">
.ck-editor__editable {
  min-height: 100vh;
}
</style>

拓展CKEdit的功能

上面提到的快速集成虽然快捷方便,但是功能非常有限,并且最麻烦的是,快速集成之后,再想到进行功能拓展,实现起来就有较为麻烦了,所以一般情况下建议在源码层面集成所需的功能。

这位老哥的文章详细介绍了源码层面手动集成所需功能的步骤,非常详细具体。

这里我采用得是另一种方法,其实CKEdit官网已经为我们提供了傻瓜拖拽式的定制化功能(更新:这种方法经过验证存在不可预测的问题(直接覆盖node_modules在Edge中可以正常运行,但在Chrome就会崩掉,在项目中直接导入定制包会导致webpack热更新打包卡死),所以建议还是按照下面的源码构建方法进行操作)。

  1. 首先先将上面的快速集成做完。
  2. 然后在官网将定制完成后的包下载下来后,在项目下的node_modules中找到@ckeditor/ckeditor5-build-classic,将定制包中的内容,主要是build/覆盖到此处。
  3. 最后,定制包中下的simple/已经为我们提供了定制后的样例,按照样例中的配置,完成相关配置即可。

下面贴上我的配置:

{
    toolbar: [
      "heading",
      "|",
      "bold",
      "italic",
      "underline",
      "strikethrough",
      "|",
      "link",
      "bulletedList",
      "numberedList",
      "|",
      "indent",
      "outdent",
      "|",
      "imageUpload",
      "imageInsert",
      "|",
      "blockQuote",
      "horizontalLine",
      "insertTable",
      "mediaEmbed",
      "|",
      "code",
      "codeBlock",
      "|",
      "fontColor",
      "fontBackgroundColor",
      "alignment",
      "|",
      "undo",
      "redo"
    ],
    ckfinder: {
      // 后端处理上传逻辑返回json数据,包括uploaded(选项true/false)和url两个字段,
      uploadUrl: "http://localhost:3000" + "/uploadFile" 
    },
    language: "zh-cn",
    image: {
      toolbar: ["imageTextAlternative", "imageStyle:full", "imageStyle:side"]
    },
    table: {
      contentToolbar: ["tableColumn", "tableRow", "mergeTableCells"]
    },
    licenseKey: ""
  }

源码构建(强烈建议使用该方法进行集成)

CKEdit5的文档已经详细记录了如果在Vue中进行自定义功能构建,大致步骤分为以下几步:

  1. 在项目中安装必要依赖项:

    npm install --save \
        @ckeditor/ckeditor5-vue \
        @ckeditor/ckeditor5-dev-webpack-plugin \
        @ckeditor/ckeditor5-dev-utils \
        postcss-loader@3 \
        [email protected]
  2. 配置vue.config.js(需要Vue-cli为3.x及以上)

    const path = require( 'path' );
    const CKEditorWebpackPlugin = require( '@ckeditor/ckeditor5-dev-webpack-plugin' );
    const { styles } = require( '@ckeditor/ckeditor5-dev-utils' );
    
    module.exports = {
        // The source of CKEditor is encapsulated in ES6 modules. By default, the code
        // from the node_modules directory is not transpiled, so you must explicitly tell
        // the CLI tools to transpile JavaScript files in all ckeditor5-* modules.
        transpileDependencies: [
            /ckeditor5-[^/\\]+[/\\]src[/\\].+\.js$/,
        ],
    
        configureWebpack: {
            plugins: [
                // CKEditor needs its own plugin to be built using webpack.
                new CKEditorWebpackPlugin( {
                    // See https://ckeditor.com/docs/ckeditor5/latest/features/ui-language.html
                    language: 'en',
    
                    // Append translations to the file matching the `app` name.
                    translationsOutputFile: /app/
                } )
            ]
        },
    
        // Vue CLI would normally use its own loader to load .svg and .css files, however:
        //	1. The icons used by CKEditor must be loaded using raw-loader,
        //	2. The CSS used by CKEditor must be transpiled using PostCSS to load properly.
        chainWebpack: config => {
            // (1.) To handle editor icons, get the default rule for *.svg files first:
            const svgRule = config.module.rule( 'svg' );
    
            // Then you can either:
            //
            // * clear all loaders for existing 'svg' rule:
            //
            //		svgRule.uses.clear();
            //
            // * or exclude ckeditor directory from node_modules:
            svgRule.exclude.add( path.join( __dirname, 'node_modules', '@ckeditor' ) );
    
            // Add an entry for *.svg files belonging to CKEditor. You can either:
            //
            // * modify the existing 'svg' rule:
            //
            //		svgRule.use( 'raw-loader' ).loader( 'raw-loader' );
            //
            // * or add a new one:
            config.module
                .rule( 'cke-svg' )
                .test( /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/ )
                .use( 'raw-loader' )
                .loader( 'raw-loader' );
    
            // (2.) Transpile the .css files imported by the editor using PostCSS.
            // Make sure only the CSS belonging to ckeditor5-* packages is processed this way.
            config.module
                .rule( 'cke-css' )
                .test( /ckeditor5-[^/\\]+[/\\].+\.css$/ )
                .use( 'postcss-loader' )
                .loader( 'postcss-loader' )
                .tap( () => {
                    return styles.getPostCssConfig( {
                        themeImporter: {
                            themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' ),
                        },
                        minify: true
                    } );
                } );
        }
    };
  3. 完成基础依赖项配置后,就可以选择需要集成的功能模块了,需要注意的是,源码构建版本所需的基础包不是@ckeditor/ckeditor5-build-classic而是@ckeditor/ckeditor5-editor-classic,如果你不清楚自己需要哪些依赖包,那么可以去官方提供的傻瓜式在线构建那里选择功能,完成在线构建并将打包后的包下载下来,根据其中src/ckeditor.js其中的依赖项进行安装。

  4. 完成功能包下载后,就可以封装富文本组件了,这里同样不建议在全局已经导入@ckeditor/ckeditor5-vue,将其单独引入到一个组件中,完成对应的封装,其实就和上面快速集成的步骤一样,只不过需要修改一行代码,就是将import ClassicEditor from "@ckeditor/ckeditor5-build-classic";修改为import ClassicEditor from "./editorCore.js";,当然后面的路径你可以随意指定,主要是这个文件中需要填写什么,其实只需要参考在线构建包中的src/ckeditor.js即可,下面贴上我的editorCore配置,可以参考着进行配置:

    /**
     * CKEdit5 核心构建文件
     * @license Copyright (c) 2014-2020, CKSource - Frederico Knabben. All rights reserved.
     * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
     */
    import ClassicEditor from "@ckeditor/ckeditor5-editor-classic/src/classiceditor.js";
    import Alignment from "@ckeditor/ckeditor5-alignment/src/alignment.js";
    import Autoformat from "@ckeditor/ckeditor5-autoformat/src/autoformat.js";
    import Autosave from "@ckeditor/ckeditor5-autosave/src/autosave.js";
    import BlockQuote from "@ckeditor/ckeditor5-block-quote/src/blockquote.js";
    import Bold from "@ckeditor/ckeditor5-basic-styles/src/bold.js";
    import CKFinder from "@ckeditor/ckeditor5-ckfinder/src/ckfinder.js";
    import CKFinderUploadAdapter from "@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter.js";
    import Code from "@ckeditor/ckeditor5-basic-styles/src/code.js";
    import CodeBlock from "@ckeditor/ckeditor5-code-block/src/codeblock.js";
    import Essentials from "@ckeditor/ckeditor5-essentials/src/essentials.js";
    import FontBackgroundColor from "@ckeditor/ckeditor5-font/src/fontbackgroundcolor.js";
    import FontColor from "@ckeditor/ckeditor5-font/src/fontcolor.js";
    import Heading from "@ckeditor/ckeditor5-heading/src/heading.js";
    import HorizontalLine from "@ckeditor/ckeditor5-horizontal-line/src/horizontalline.js";
    
    import Image from "@ckeditor/ckeditor5-image/src/image.js";
    import ImageCaption from "@ckeditor/ckeditor5-image/src/imagecaption.js";
    import ImageInsert from "@ckeditor/ckeditor5-image/src/imageinsert.js";
    import ImageResize from "@ckeditor/ckeditor5-image/src/imageresize.js";
    import ImageStyle from "@ckeditor/ckeditor5-image/src/imagestyle.js";
    import ImageToolbar from "@ckeditor/ckeditor5-image/src/imagetoolbar.js";
    import ImageUpload from "@ckeditor/ckeditor5-image/src/imageupload.js";
    
    import Italic from "@ckeditor/ckeditor5-basic-styles/src/italic.js";
    import Link from "@ckeditor/ckeditor5-link/src/link.js";
    import List from "@ckeditor/ckeditor5-list/src/list.js";
    import Markdown from "@ckeditor/ckeditor5-markdown-gfm/src/markdown.js";
    import MediaEmbed from "@ckeditor/ckeditor5-media-embed/src/mediaembed.js";
    import Paragraph from "@ckeditor/ckeditor5-paragraph/src/paragraph.js";
    import PasteFromOffice from "@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice";
    import Strikethrough from "@ckeditor/ckeditor5-basic-styles/src/strikethrough.js";
    import Table from "@ckeditor/ckeditor5-table/src/table.js";
    import TableToolbar from "@ckeditor/ckeditor5-table/src/tabletoolbar.js";
    import TextTransformation from "@ckeditor/ckeditor5-typing/src/texttransformation.js";
    import Underline from "@ckeditor/ckeditor5-basic-styles/src/underline.js";
    
    // 创建自定义编辑器 继承自 基础编辑器包
    class Editor extends ClassicEditor {}
    
    // 自定义功能插件
    Editor.builtinPlugins = [
      Alignment,
      Autoformat,
      Autosave,
      BlockQuote,
      Bold,
      CKFinder,
      CKFinderUploadAdapter,
      Code,
      CodeBlock,
      Essentials,
      FontBackgroundColor,
      FontColor,
      Heading,
      HorizontalLine,
      Image,
      ImageCaption,
      ImageInsert,
      ImageResize,
      ImageStyle,
      ImageToolbar,
      ImageUpload,
      Italic,
      Link,
      List,
      Markdown,
      MediaEmbed,
      Paragraph,
      PasteFromOffice,
      Strikethrough,
      Table,
      TableToolbar,
      TextTransformation,
      Underline,
    ];
    
    // 默认配置
    Editor.defaultConfig = {
      // 工具栏展示列表
      toolbar: [
        "heading",
        "|",
        "bold",
        "italic",
        "underline",
        "strikethrough",
        "|",
        "link",
        "bulletedList",
        "numberedList",
        "|",
        "imageUpload",
        "imageInsert",
        "|",
        "blockQuote",
        "horizontalLine",
        "insertTable",
        "mediaEmbed",
        "|",
        "code",
        "codeBlock",
        "|",
        "fontColor",
        "fontBackgroundColor",
        "alignment",
        "|",
        "undo",
        "redo",
      ],
      // ckfinder: {
      //   uploadUrl: config.baseUrl + "/uploadImg" // 后端处理上传逻辑返回json数据,包括uploaded(选项true/false)和url两个字段,
      // },
      language: "zh-cn",
      // 视频上传功能(CKEditor默认只支持一些外网视频,所以需要自定义)
      mediaEmbed: {
        providers: [
          {
            name: "myprovider",
            url: [/^lizzy.*\.com.*\/media\/(\w+)/, /^www\.lizzy.*/, /^.*/],
            html: (match) => {
              //获取媒体url
              const input = match["input"];
              //console.log('input' + match['input']);
              return (
                '<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 70%;">' +
                `<iframe src="${input}" ` +
                'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
                'frameborder="0" allowtransparency="true" allow="encrypted-media">' +
                "</iframe>" +
                "</div>"
              );
            },
          },
        ],
      },
      image: {
        toolbar: [
          "imageTextAlternative",
          "|",
          "imageStyle:alignLeft",
          "imageStyle:full",
          "imageStyle:alignRight",
        ],
        styles: ["full", "alignLeft", "alignRight"],
      },
      table: {
        contentToolbar: ["tableColumn", "tableRow", "mergeTableCells"],
      },
    };
    
    export default Editor;
  5. 最后一点无关紧要的建议,富文本组件可以使用以下结构,方便进行管理:

    - RichText  // 组件文件夹
     -- core // 核心构建文件
      --- ckeditor.js
     -- index.vue  // 组件

自定义图片上传

官方教程中上传图片有三种方法:

  1. 使用CKEditor自带云服务,图片上传到CKEditor服务器
  2. 使用CKFinder框架,在初始化CKEditor时,需要定义 ckfinder的uploadUrl参数,参数为上传到自己服务器的地址
  3. 自己写上传功能,定义UploadAdapter类,实现upload()abort() 方法,并对UploadAdapter进行调用。

第一种方案一般情况下都会pass掉,第二、三种方案其实都可以,但是第三种方案更加灵活一些,更加容易结合业务。下面是第三种方案的操作步骤:

  1. 创建UploadAdapter类,构造函数中接收一个loader参数,该参数由外部传入,身上存放着需要上传的文件,在类中实现upload方法,该方法返回一个Promise,在Promise中发送请求,并且返回结果

    // 示例代码
    import axios from "@/utils/axios";
    
    export default class UploadAdapter {
      constructor(private loader: any) {}
    
      async upload() {
        const data = new FormData();
        data.append("file", await this.loader.file);
        data.append("allowSize", "10"); // 允许图片上传的大小/兆
    
        return new Promise((resolve, reject) => {
          axios.post("/uploadImg", data).then((data: any) => {
            if (data.res) {
              resolve({
                default: data.url,
              });
            } else {
              reject("上传失败");
            }
          });
        });
      }
    
      abort() {
        console.log("upload abort");
      }
    }
  2. 在CKEdit5-vue提供的ready钩子中完成自定义的上传插件安装,安装步骤如下:

      private onReady(editor: any) {
        editor.plugins.get("FileRepository").createUploadAdapter = (
          loader: any
        ) => {
          return new UploadAdapter(loader);
        };
      }
  3. 后台以以下数据格式返回结果:

    {
        uploaded: true,
        url: 图片路径
    }

自动保存功能

如果想使用该功能需要安装@ckeditor/ckeditor5-autosave/src/autosave.js插件,安装之后可在配置对象中添加autosave字段配置项:

autosave: {
      waitingTime: 1500,  // 间隔时间
      save(editor: any) {  // 自动保存函数
        // 用于获取纯文本
        const contentArea: any = document.querySelector(".ck.ck-content");
	
        // 在Vue中可以对外暴露 save Prop,然后在这调用父组件传入的保存函数
        // this.save &&
        //   this.save({
        //     text: editor.getData(),
        //     plainText: contentArea.textContent,
        //   });
      },
}

注意:在save方法中由于上下文丢失的原因是无法访问在Vue中的this的,也就意味着无法拿到双向绑定的数据。这时候有两种解决方法:

  1. 因为在对象中禁忌使用箭头函数(如果用了的话会导致双向绑定的数据失去效果),所以可以采用that法:在全局声明一个变量that,在组件初始化钩子里将组件实例赋值给that,并在save函数中使用that访问组件身上的属性方法
  2. 如果只想获取输入的内容,无需调用组件的其他属性的话,可以采用上面示例代码中的方法,通过参数editor身上的getData获取内容

最后

如果代码使用git等版本控制工具管理,记着将定制后的包存放起来,因为一般情况下node_modules是不会推送到远程仓库的,如果其他地方需要拉取代码时需要重新覆盖@ckeditor下的文件。(更新:使用源码构建后就无须再关心这种情况)。

【造轮子】低配Vuex

【造轮子】低配Vuex

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 也集成到 Vue 的官方调试工具 devtools extension,提供了诸如零配置的 time-travel 调试、状态快照导入导出等高级调试功能。

测试Demo

本文所编写测试的Demo,可以在这里获取。

为什么我们可以在Vue组件中使用Vuex中的数据?

在回答这个问题前,首先要先捋清楚Vue中父子组件的加载顺序:

父beforeCreate -> 父created -> 父beforeMount -> 子beforeCreate -> 子created -> 子beforeMount -> 子mounted -> 父mounted

为什么这样设计这里不做过渡探讨,捋清楚这样的加载顺序后,我们就可以回答标题上的问题了,为什么我们可以在Vue组件中使用Vuex中的数据呢?

怀着这样的疑问我们打开了Vuex的install方法,这是Vue插件规定的安装函数,也是Vuex对Vue进行的拓展逻辑入口,在那里,我们发现了这样一句代码:Vue.mixin({ beforeCreate: vuexInit });

mixin是Vue提供的用于复用功能的特性,会将提供的对象混入进组件对象中从而拓展组件对象的工作。这句代码的意思是,在全局内的每一个Vue组件中的beforeCreated生命周期中混入一个名为vuexInit的方法,这样代码中的所有Vue组件在执行beforeCreate钩子时都会自动执行该函数。

我们想要的答案好像就要找到了,进入vueInit函数,查看内部实现,我们发现了这样一段逻辑代码:

// mixin.js

const options = this.$options;

if (options.store) {
  // 如果当前组件的 $options 中存在 $store, 直接添加
  this.$store =
    typeof options.store === "function" ? options.store() : options;
} else if (options.parent && options.parent.$sotre) {
  // 因为组件的加载顺序是先从父组件开始,所以可以从父组件上拿下来,
  // 这也是为什么需要在 main.js 中明确指定store的原因
  this.$store = options.parent.$store;
}

因为上文提到了,这段代码最终会被混入到组件中的beforeCreate中的,所以上下文中的this自然也就变成了当前组件的实例。那么this.$options是什么呢?它其实是Vue内部提供的用于收集定义于data外的属性的对象,即如果在组件对象上直接定义的属性会被该对象收集(parent属性时默认提供的)。

从上面的代码我们可以看出,这部分逻辑是想从当前组件上的$options对象中寻找名为$store的属性,如果没有找到则会去父组件上的$options寻找。

那么为什么会去父组件上找呢?你可能有些思绪了,没错,就是我们在上文提到的父子组件加载顺序。这是因为父组件的数据初始化是优先于子组件的,并且,使用过Vuex的你应该知道,我们在手动引入Vuex时,同Vue-router一样,需要在main.js中将我们写好的store配置数据传入到初始化对象中,这样一来,最外层的Vue组件就拥有了我们编写好的Vuex状态数据,那么顺着每个组件中的混入逻辑,最外层组件身上的store数据就可以一直传递到最内部的组件身上。

这样一来,所有组件身上都拥有了名为$store的属性。

Vuex是如何实现数据响应式的

我们知道Vuex与全局对象以及localStorage的一个很重要的区别就是可以实现数据的响应式修改:只需要在其中一个组件中修改数据,其余应用到相关数据的组件都会产生响应。其实这个特性的底层实现非常简单,我们可以在Vuex的源码中发现这样一段逻辑:

this._watherVM = new Vue();

是不是很惊讶,因为Vue已经将响应式数据比较完善的实现了,所以Vuex只需要拿来用即可。

知道了这一原理后,我们就可以开始着手编写Store构造函数了:

// Store.js

//  引入Vue,用于构建数据响应中心
import Vue from "vue";

export default class Store {
  // options 为传入的构造对象,包括state、getters、mutation等规则
  constructor(options) {
    // 初始化 state
    this.vm = new Vue({
      data: {
        state: options.state,
      },
    });

    // 初始化 getters
    let getters = options.getters || {};
    this.getters = {};
    Object.keys(getters).forEach((key) => {
      // 在$store.getters上定义每个传入的getters,值为传入state的getters函数的调用结果
      Object.defineProperty(this.getters, key, {
        get: () => {
          return getters[key](this.vm.state);
        },
      });
    });
  }

  // 利用 Class语法 的便利,将this.vm.state 代理给 this.state
  get state() {
    return this.vm.state;
  }
}

到目前为止,我们已经可以在Vue组件中使用$store.state$store.getters啦。

mutations & actions 实现

mutationsactions与上面的state、getters类似,只需要再多实现两个触发方法commit、dispatch即可,唯一一点需要注意的是:因为dispatch的第一个参数是Store对象本身,并且要在dispatch中通过Store对象调用其身上的commit的方法,如果用户没有用参数直接接受Store对象,而是使用结构语法直接拿到了commit方法,通过直接函数调用就会导致内部this的丢失。

actions: {
  // 可以正常调用
  enNameLog(that, string) {
    that.commit("nameLog", string);
  },
  // 异常调用 this丢失
  enNameLog2({ commit }, string) {
    commit("nameLog", string);
  },
},

解决方法也很简单,在Store类中注册dispatch时,使用箭头函数保存this即可。

完整的代码如下:

// Store.js
  constructor() {
  // ...other code    
      
  // 初始化 mutations
    let mutations = options.mutations || {};
    this.mutations = {};
    Object.keys(mutations).forEach((key) => {
      this.mutations[key] = mutations[key];
    });

    // 初始化 actions
    let actions = options.actions || {};
    this.actions = {};
    Object.keys(actions).forEach((key) => {
      this.actions[key] = actions[key];
    });
  }

  // dispatch方法
  dispatch = (method, ...payload) => {
    // 调用action
    this.actions[method](this, ...payload);
  };

  // commit方法
  commit = (method, ...payload) => {
    // 调用mutation
    this.mutations[method](this.state, ...payload);
  };

【面试题】连等符运行机制考察

连等符运行机制考察

  • 引用赋值考察
  • 连等符运行机制考察

题目

说出以下代码的执行结果并解释为什么:

var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };

console.log(a.x);
console.log(b.x);

答案

console.log(a.x); // undefined
console.log(b.x); // { n: 2 }

这道题最令人疑惑的是a.x = a = { n: 2 };这一句代码,考察的知识点也很少见(主要是连等符很少用)。

首先,连等符是从右向左执行,也就是上面的代码可以转化为(🤔?):

a = { n: 2 };
a.x = a;

但是如果你真的将a.x = a = { n: 2 };拆解成上面两句代码来执行,就会发现执行结果和答案不一样了:

var a = { n: 1 };
var b = a;
a = { n: 2 };
a.x = a;

console.log(a.x); // { n: 2 }
console.log(b.x); // undefined

可能这才是你最开始期望的结果,那我们先来看看拆解开来的这段代码是如何运行的:

  1. 首先在内存中创建{ n: 1 },并让变量a指向它;

  2. 因为引用赋值的原因,并不会创建一个新的{ n: 1 }赋值给变量b,而是将a指向的{ n: 1}赋值给b。现在,ab指向同一个对象;

  3. 在内存中创建{ n: 2 },并让变量a指向它。现在,内存中有两个对象,a指向{ n: 2 }b指向{ n: 1 }

  4. 通过变量a为对象{ n: 2 }添加一个x属性,并将它的值设为a指向的{ n: 2 }(先获取到{ n: 2 },再将它作为自身的属性);

  5. 打印结果,现在a指向的对象为{ n: 2, x: { n: 2 } },故a.x{ n: 2 }b指向的对象为{ n: 1 },故b.xundefined

但是,如果使用连等符来执行操作的话,答案就不一样了,这其实是因为连等符的一条运行机制:

在连等开始之前程序会把所有引用都保存下来,连等过程中,引用不会发生变化,而是等整条语句执行完毕后,一起发生改变。

根据这条机制,我们再来看看这道面试题的执行过程:

  1. 首先在内存中创建{ n: 1 },并让变量a指向它;

  2. 因为引用赋值的原因,并不会创建一个新的{ n: 1 }赋值给变量b,而是将a指向的{ n: 1}赋值给b。现在,ab指向同一个对象;

  3. 在内存中创建{ n: 2 },并让变量a指向它,但是由于连等语句还未结束,所以变量a仍然指向{ n: 1 }。再通过变量a为**对象{ n: 1 }**添加一个x属性,值为{ n: 2 }

  4. 连等语句结束,变量a指向{ n: 2 }

  5. 打印结果,现在a指向的对象为{ n: 2 },故a.xundefinedb指向的对象为{ n: 1, x: { n: 2 } },故b.x{ n: 2 }

答案修正

  1. 在上面,我提到了a.x = a = { n: 2 };可以拆分为a = { n: 2 }, b = a,其实这句话是错误的,虽然从结果上来看没什么问题,但其实更准确的拆分结果为a = { n: 2 }, b = { n: 2 }。这是因为我错误得将连等运算符的执行过程1 = 2 = 3理解成了2 = 3, 1 = 2,而其实它正确的执行顺序应该为2 = 3, 1 = 3

    下面的代码解释了这个错误:

    var a = {};
    Object.defineProperty(a, "name", {
      get() {
        return "1";
      },
    });
    // a.name为只读,只会返回"1"
    
    var b = {};
    b.name = a.name = "2";
    // 如果连等的执行顺序为2=3,1=2,那么a和b的name都会为1
    
    console.log(b.name, a.name); // 2 1

【实践相关】MongoDB集合命名规范

MongoDB集合命名规范

一定一定要以单词的复数形式集合名字,不然在通过mongoose操作时会发现自己操作的数据库与自己创建的不是一个集合,经常会操作出出乎意料的数据。

这是因为mongoose在与数据集合关联时,会自动将集合名转换为复数形式

追加

文档中的属性如果是一个数组,属性名也要为复数

【实践相关】利用 .sync 简化 Vue数据双向绑定

利用 .sync 简化 Vue数据双向绑定

默认情况下,父组件到子组件呈单向数据流传递,且一个组件身上只能定义一个v-model,如果其他prop也要实现双向绑定该怎么办?最简单的方法是在父组件中监听子组件 Emit 的事件,然后手动更新数据:

// 子组件:监听输入框修改,通过 $emit 向父组件传递输入框内容
<template>
    <div>
        <input @input="onInput" :value="value"/>
    </div>
</template>

<script>
export default {
    props: {
        value: {
            type: String
        }
    },
    methods: {
        onInput(e) {
            this.$emit("valueChanged", e.target.value)
        }
    }
}
</script>
// 父组件:监听子组件提供的自定义事件,并手动修改变量内容
<template>
    <info :value="myValue" @valueChanged="e => myValue = e"></info>
</template>

<script>
    inport info from './info.vue';
    export default {
        components: {
            info,
        },
        data() {
            return {
                myValue: 1234,
            }
        },
    }
</script>

上面这种写法太麻烦了,这么多代码只是实现了一个属性的双向数据流传递,那么,有没有简单的方法呢?

.sync 用法

通过.sync可以简化上面代码,只需要修两个地方:

  1. 组件内触发的事件名称以 update:propName 命名,相应的上述info组件改为 update:value
  2. 父组件 v-bind: value 加上.sync修饰符,即 v-bind: value.sync
    这样父组件就不用再手动绑定 @update:value 事件了

注意:自定义事件名格式为update:propName,不能有多余的空格。

为单个 Prop 实现双向数据流

// 子组件
...
methods: {
    onInput(e) {
        // ✨:自定义事件名被修改为了 update:xxx
        this.$emit("update:value", e.target.value)
    }
}
// 父组件
<info :value.sync="myValue"></info>

为多个 Prop 实现双向数据流

当然你不断重复上面的流程也可以,不过 Vue 提供了更方便的方式:v-bind.sync="对象",对象中的键为 Prop 名。

// 父组件
<info v-bind.sync="obj"></info>
...
<script>

data() {
    obj: {a: '', b: ''}
}
..
</script>
// 子组件
// ✨:直接使用 a 即可,无需通过 obj
<input type="text" :value="a" @input="change" />

<script>
    
  change() {
    // ✨:直接修改 a 和 b 即可,无需通过 obj
    this.$emit("update:a", (e.target as any).value);
    this.$emit("update:b", (e.target as any).value);
  }
</script>

注意:通过v-bind.sync的方式只能传入属性名,无法使用表达式,即下面的这种方式是不被允许的:

v-bind.sync="{a: a, b: b}"  // 会报错

小结

一个组件需要提供多个双向绑定的属性时使用,只能选用一个属性来提供 v-model 功能,但如果有其他属性也要提供双向绑定,就需要 .sync

【实践相关】Vue生命周期钩子和async

Vue生命周期钩子和async

问题描述

mounted生命周期钩子中无法访问$refs

原因

我对created添加了async标记,在其中使用了await

Vue的生命周期钩子是不支持async的,所以在created还在await卡着的时候,mounted会提前到created执行,这时候,因为我的页面中ref元素包含v-if、v-for等依赖于异步数据的指令,还没有加载完成,所以在mounted中显示undefined

至于为什么不支持async

不支持,因为整个 diff 和 render 的算法都是基于组件生命周期同步执行的前提下的。而且任意一个组件的生命周期可能异步推迟最后的渲染完成是绝对不应该出现的设计。 ——yyx990803 #7333

解决方案

  1. 首先,将存放有异步方法的代码片段和存放有ref的代码片段放到同一个生命周期中,created / mounted都可以,因为一旦给它们加上async,就会如上所述的完全脱离原来的执行顺序
  2. 通过await阻塞异步方法后面的代码片段,等待异步执行结束后再进行ref访问
  3. 异步执行完后也不能立马访问ref,这是因为数据和视图不是同步执行的(具体参考JavaScript EventLoop),所以需要使用Vue提供的$nextTick将含有ref的代码片段包裹起来,等待本次执行的视图渲染完毕后,就可以调用ref

【面试题】使用NodeJS遍历文件夹

使用NodeJS遍历文件夹

使用NodeJS编写实现遍历文件夹获取所用文件名的代码。

涉及知识点

  • 递归
  • Node核心模块使用

解题思路

const fs = require("fs");
const path = require("path");

const readDir = (entry) => {
  // 读取路径下所有的文件以及文件夹
  const dirInfo = fs.readdirSync(entry);

  dirInfo.forEach((item) => {
    // 拼接路径
    const location = path.join(entry, item);

    // 获取文件的信息
    const info = fs.statSync(location);

    // 如果是文件夹,则继续递归,否则输入路径
    if (info.isDirectory()) {
      console.log(`dir:${location}`);
      readDir(location);
    } else {
      console.log(`file:${location}`);
    }
  });
};

readDir(__dirname);

【面试题】Node如何做版本升级?为什么要用nvm?

Node如何做版本升级?为什么要用nvm?

Node如何做版本升级?为什么要用nvm?

解题思路

Node如何做版本升级:

  • 需要使用最新的ES语法
  • 加速webpack的打包(这是因为webpack底层会优先使用最新的Node特性来加速打包的流程)

为什么要使用nvm:

  • 方便Node环境管理

    比如需要同时开发多个版本的Node项目,那么就需要不断地删除安装Node环境,而nvm允许电脑上同时存在多个Node环境,从而可以做到快速切换的效果。

深入浅出Event Loop

深入浅出Event Loop

我在实现Promise那篇文章中提及到了Event Loop,并且引用了其中的两种异步队列来阐述实现Promise中的一些细节。不过Event Loop可远远没有那么简单,事实上关于它有许多我们应该注意到的细节,这些细节会让我们了解到我们的代码将在何时运行,以助于我们更加有把握的让代码按照我们理想中的顺序执行。

为什么Javascript要设计成单线程?

单线程规定了js一次只能做一件事,这意味着如果前面的任务非常耗时,那么后面的任务就只能在那里干瞪着眼愣着等前面的老哥跑完。如果老哥慢是因为计算量太大,导致CPU忙不过来了,那也就算了,关键是原因往往不是这样,大部分的情况都是CPU在那里闲着,因为IO设备太慢(比如HTTP请求),不得不等拿到结果后,才向下执行,而在浏览器中,如果一旦js卡顿,页面就必须停止渲染,因为渲染引擎不知道JS代码中到底有没有对DOM的操作,强行渲染只会呈现出错误的页面,这也是为什么无限循环会阻塞一切页面操作的原因。

从上面可以看出单线程存在着非常严重的弊端,那么为什么js还要被设计成单线程语言呢?有人说是因为Eich乐意,虽然我觉得这个观点很有趣,不过我还是愿意相信更为科学一点的解释。

以下证论截自阮一峰老师的博客:

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

一句话说就是:多线程对于高交互的浏览器来说代码编写难度太不可控了。

解决方案 -- Event Loop

JavaScript语言的设计者意识到,如果是因为IO设备导致的任务阻塞,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,带着这种设计理念,第一版的Event Loop问世了,它将js中的任务分为同步任务和异步任务,同步任务会在代码开始执行后被直接加入调用栈,然后排队一个一个执行。而异步任务则会被推送到对应的“执行者”那里去执行(比如浏览器中的setTimeout会将其中的回调函数推送到浏览器提供的计时器API那里去计时),并在相关任务执行完毕后(比如计时结束了),将回调函数压入一个叫做“任务队列”的列表中,这个列表会在js调用栈中所有的同步代码都执行完毕后,再开始按序一个个执行。

来看一段代码:

// 我在这段代码中挖了一个坑,不过我会在下面的文章中填上(笑)
setTimeout(() => {
  console.log(1);
}, 0);

setTimeout(() => {
  console.log(2);
}, 0);

console.log(3);

上面这段代码执行结果为3、1、2,您可能会疑惑:我两个计时器都设置了0毫秒延迟,按道理说不应该就是没延迟的意思吗?

结合我们上面提到的关于异步任务的执行顺序,其实结果就很明朗了:之所以0毫秒延迟依然排在同步代码的后面,是因为无论传入了多长时间的延迟,异步任务在执行结束(在这里是指计时结束)后总会被压入“任务队列”,而“任务队列”必须要等到js调用栈中的代码全部执行完才可以开始执行。

如果您觉得我的文字描述不够直观,可以结合这个工具来搭配理解,它是Philip Roberts在JS2014CONF上演讲关于Event Loop时使用的工具,能够让您结合可视化更清楚的看到Event Loop的执行过程,同时我的文章也参考了Philip Robert的演讲。

如果您认为到这里就理解Event Loop了,那就大错特错了。事实上,关于Event Loop,还有许多可以探讨的点。

宏队列与微队列

您可能会发现,我在文章的最开头提到了两种异步队列,但是到目前为止只含糊的提出了一个叫“任务队列”的玩意儿。其实在前些年的浏览器中,确实只有一种“任务队列”,它其实就是现在的宏队列。而微队列是在后续的发展中更新上的,那么,为什么需要这个微队列,它与宏队列又有什么不同?

我会在下面为您一一解答,不过在那之前,我先将两种队列所包含的异步任务列举出来:

宏队列,macrotask,也叫tasks。 一些异步任务的回调会依次进入macro task queue,等待后续被调用,这些异步任务包括:

  • setTimeout
  • setInterval
  • setImmediate (Node独有)
  • requestAnimationFrame (浏览器独有,有点特殊)
  • I/O
  • UI rendering (浏览器独有)

微队列,microtask,也叫jobs。 另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:

  • process.nextTick (Node独有)
  • Promise
  • Object.observe
  • MutationObserver(浏览器独有)

(注:这里只针对浏览器和NodeJS)

浏览器中的Event Loop

首先,展示一段代码:

document.body.appendChild(div);
div.style.display = "none";

这段代码曾经被js开发者们争议了许久,因为在它的逻辑中先为body元素增加了一个新div,同时立马又将这个div隐藏掉了。这很容易让人联想出如果浏览器卡顿的话,会不会出现这个div瞬间加入body,然后瞬间隐藏的闪烁效果。

事实上,你完全可以放心,上述的情况是一定不会发生的,因为浏览器严格规定了各部门执行任务的先后顺序,而这,全要归功于Event Loop。

图源自JSCONF2018中关于Event Loop演讲上的配图

上面这个图就是Event Loop的可视化,中间的小白点是当前正在执行的任务,左侧是执行js代码的区域,右侧是关于UI渲染的阶段。默认情况下,也就是同时没有js和UI相关任务需要执行,图中的两个阀门会闭合,Event Loop会以最节省资源的方式在中间转圈,如果有需要执行的js代码,左侧的阀门就会打开并在缺口处填上需要执行的js内容,小白点就会进入到左侧的半圆中进行js相关任务的执行。

让我们将上面的两行代码带入到图中模拟执行下看看:

  1. Event Loop检测到有需要执行的js代码;
  2. 左侧的阀门打开,并将需要执行的两行js代码填充上去;
  3. 小白点进入左侧半圆执行相关代码;

那么请问现在页面发生变化了吗?显然是没有的,因为真正控制页面的是渲染引擎,也就是右半边的半圆,这时,因为收到了对页面进行更改的请求,右边的阀门也打开了,小白点继续前进,执行UI相关的渲染,它们分别对应:

  1. S:样式计算,收集所有计算应用到元素上的样式;
  2. L:创建一个渲染树,找出页面上的所有相关内容以及元素的位置;
  3. P:创建实际的像素数据,绘制内容到页面上。

那么,这个问题也就得到解决了:因为必须要等待js执行完毕并发送渲染请求后,右侧的阀门才会打开,从而将设置的全部修改渲染完毕,所以无论这两行代码如何排列,都不会影响到UI渲染的结果。不过,我建议您最好还是将display一行放到上边,因为大多数情况下,这种代码让人看着最舒服。

浏览器中的宏队列

那么宏队列在浏览器中是如何运作的呢?

在浏览器中,Event Loop一次执行(也就是转一圈)只会从宏队列中取出一个任务来执行,这是什么意思呢?

document.onclick = function () {
  while (true) {};
};

如果你尝试在页面中加入这种代码并且触发,随后就会理所应当得得到一个完全卡住的页面,任何的渲染,包括gif,按钮,文字选中,全部都无法进行了。

这是因为while循环是同步js代码,并且它会一直的执行下去,这样就会将小白点卡在js执行阶段,无法去执行UI渲染,所以你就会觉得页面卡住了。

小白点会一直在这里卡着,直到耗尽你的资源

那么如果是这种代码呢?

document.onclick = function () {
  loop();
};

function loop() {
  setTimeout(loop, 0);
};

点击按钮,发现一切正常,这又是怎么回事呢?

这其实就是因为宏队列中的任务 Event Loop 一次只能拿一个出来执行,所以在执行完一个setTimeout后,小白点就会被释放,前往右半边去执行UI渲染,而下一个setTimeout就要等到小白点再从UI那里绕回来后再执行。

浏览器中的微队列

我猜您第一次听说微队列是在学习Promise的时候,我自己就是,所以我常常将Promise和微队列关联起来想。但是Promise并不是微队列推出的初衷,微队列推出的最初是为了解决DOM的变化监测问题:浏览器想要提供一种当DOM结构发生变化时所触发的事件,好让开发者能够监控到DOM的变化,于是w3c说老哥没问题,随后提供了DOM变化事件:

document.body.addEventListener("DOMNodeInserted", () => {
  console.log("body里面添加了新玩意儿!")
})

上面的console.log会在body的DOM结构发生改变时触发。看起来很好,对吧?但是看看下面的代码:

for (let i = 0; i < 100; i++) {
  let box = document.createElement("div");
  document.body.appendChild(box);
  box.textContent = "hi!";
}

上面的代码中,我创建了100个div,并将它们添加到了body元素中。您觉得这会产生多少个事件?1个?100个?不,都不是,正确答案是200个:100个div产生100个事件,并且还有100个事件是这行代码导致的。

box.textContent = "hi";

为div设置文本的行为会产生事件,并且冒泡,导致这段简单的代码最终会产生200个事件。

但是我们只想被通知1次而不是200次,解决方案是使用DOM变化事件的观察者(mutationObserver),它们创建了一个新队列就叫做微任务队列,并规定微任务可以在任何一个宏任务之后执行,也就是说,每当结束一个宏任务后都会检测是否有待执行的微任务。并且会一次性执行完微任务队列里的所有微任务,如果在执行的过程中又产生了新的微队列,则继续执行新的微队列,直到队列清空。

document.onclick = function () {
  loop();
};

function loop() {
  Promise.resolve().then(loop);
};

我们使用微任务创建一个无限循环会怎么样呢?像之前setTimeout做得一样,点击页面后我们发现,页面再一次卡住了,这样就印证了我们上面提到的,如果有不断的新微任务加入进来,js就会一直坚持将微任务执行下去,从而阻塞后面的UI渲染。

Promise与a标签

我们先来看一段代码,并且想一下它被触发后的执行顺序:

button.addEventListener("click", () => {
    Promise.resolve().then(() => console.log("微任务01"));
    console.log("监听器01");
});

button.addEventListener("click", () => {
    Promise.resolve().then(() => console.log("微任务02"));
    console.log("监听器02");
});

首先,毫无疑问会先打印“监听器01”,但是接下来是什么?在JSCONF2018的演讲会上,演讲人在登台之前对这段程序做过一段调查,其中有63%的人选择了接下来会打印“监听器02”。嗯,结果很显然,这是错误的,正确的输出应该是:

  1. 监听器01
  2. 微任务01
  3. 监听器02
  4. 微任务02

这是因为,当第一个输出“监听器01”执行完毕后,第一个监听器也就执行完毕,从js执行栈中抛出了,js栈清空,所以接下来是微任务时间,下面的监听器同样道理。

上面只是在用户点击触发的情况下,那么如果使用js来触发呢?

button.addEventListener("click", () => {
    Promise.resolve().then(() => console.log("微任务01"));
    console.log("监听器01");
});

button.addEventListener("click", () => {
    Promise.resolve().then(() => console.log("微任务02"));
    console.log("监听器02");
});

button.click();

我们又得到了另外一个答案:

  1. 监听器01
  2. 监听器02
  3. 微任务01
  4. 微任务02

使用js触发事件时,当第一个监听器抛出后,button.click()还没有结束,所以js调用栈没有清空,那么微任务就无法启动,只能等第二个监听器也执行完毕后,再开始执行相关的代码。

可以看出,微任务的调用和js调用栈息息相关,明白了这个道理,那么下面这段代码也就好解释了:

const nextClick = new Promise((resolve, reject) => {
    link.addEventListener("click", resolve, { once: true });
});

nextClick.then(event => {
    event.preventDefault();
})

link是一个超链接标签,我们在一个Promise中为它注册了点击事件,并且规定当这个超链接被触发后,就立即禁用掉它的默认事件,如果您理解了上面描述的现象,这里也就很明显得分为了两种情况:

  1. 当用户直接点击超链接触发时,由于不会向js调用栈中压入新任务,所以导致js执行栈为空,所以微任务中禁用默认行为的逻辑会执行,导致用户第一次点击无法跳转。(后面就不行了,因为Promise只能改变一次状态);
  2. 而当由js来触发超链接的点击事件时,由于nextClick.click()不会被释放,所以超链接会正常跳转。

浏览器执行顺序小结

浏览器大概的执行顺序如下:

  1. 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout等);
  2. 全局Script代码执行完毕后,调用栈Stack会清空;
  3. 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
  4. 继续取出位于队首的任务,放入调用栈Stack中执行,以此类推,直到把microtask queue中的所有任务都执行完毕。注意,如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行
  5. microtask queue中的所有任务都执行完毕,此时microtask queue为空队列,调用栈Stack也为空;
  6. 小白点跑去右边执行UI渲染,然后在跑回来继续执行js;
  7. 取出宏队列macrotask queue中位于队首的任务,放入Stack中执行;
  8. 执行完毕后,调用栈Stack为空;
  9. 重复第3-8个步骤;
  10. 重复第3-8个步骤;
  11. ......

可以看到,这就是浏览器的事件循环Event Loop

这里归纳3个重点:

  1. 宏队列macrotask一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务;
  2. 微任务队列中所有的任务都会被依次取出来执行,之道microtask queue为空;
  3. UI渲染是在每次执行完微任务队列后,下一个宏任务之前进行执行。

requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

在之前,我们都是使用定时器来做动画,不过定时器做出来的动画在某些低端机上会出现卡顿、抖动现象,这种现象的产生有两个原因:

  • setTimeout的执行时间并不是确定的。在Javascript中, setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,因此 setTimeout 的实际执行时间一般要比其设定的时间晚一些。
  • 刷新频率受屏幕分辨率屏幕尺寸的影响,因此不同设备的屏幕刷新频率可能会不同,而 setTimeout只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。

以上两种情况都会导致setTimeout的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。 那为什么步调不一致就会引起丢帧呢?

首先要明白,setTimeout的执行只是在内存中对图像属性进行改变,这个变化必须要等到屏幕下次刷新时才会被更新到屏幕上。如果两者的步调不一致,就可能会导致中间某一帧的操作被跨越过去,而直接更新下一帧的图像。假设屏幕每隔16.7ms刷新一次,而setTimeout每隔10ms设置图像向左移动1px, 就会出现如下绘制过程:

  • 第0ms: 屏幕未刷新,等待中,setTimeout也未执行,等待中;
  • 第10ms: 屏幕未刷新,等待中,setTimeout开始执行并设置图像属性left=1px;
  • 第16.7ms: 屏幕开始刷新,屏幕上的图像向左移动了1px, setTimeout 未执行,继续等待中;
  • 第20ms: 屏幕未刷新,等待中,setTimeout开始执行并设置left=2px;
  • 第30ms: 屏幕未刷新,等待中,setTimeout开始执行并设置left=3px;
  • 第33.4ms:屏幕开始刷新,屏幕上的图像向左移动了3px, setTimeout未执行,继续等待中;

从上面的绘制过程中可以看出,屏幕没有更新left=2px的那一帧画面,图像直接从1px的位置跳到了3px的的位置,这就是丢帧现象,这种现象就会引起动画卡顿。

因为丢帧所以跑得飞快的setTimeout

解决方法 -- requestAnimationFrame

导致问题出现的原因是我们不清楚不同设备下,浏览器会以最大多少速度的刷新率来进行UI渲染,那么requestAnimationFrame是怎么做的呢,其实很简单,浏览器在Event Loop新开辟了一段固定执行区域,用来统计本次UI渲染需要执行的DOM操作,并且会在本次UI渲染之前这些任务完成,这段区域在这:

右边黄色的那段,于UI渲染前执行

我们在上面提到过requestAnimationFrame是一种特殊的宏任务,它的特殊性主要有两点:

  1. 跟宏任务相比,它会在固定的时间段内将本次需要执行的任务全部执行完,而不是只取出一个;
  2. 跟微任务相比,如果在执行requestAnimationFrame任务过程中,又加入了新的requestAnimationFrame任务,那么浏览器会将这些后加入的任务留到下一次再执行。

与transition造成的渲染冲突问题

在2018年的JSCONF上,一名叫jake的开发者做了关于Event Loop的分享,其中他提到了一道这样的题目:

button.addEventListener("click", () => {
    box.style.transform = "translateX(1000px)";
	box.style.transition = "3s";
	box.style.transform = "translateX(500px)";
});

jake期望box能够先移动到1000px,然后再移动到500px,然而浏览器仅仅渲染了0500px的过程。这可能是许多js开发者都遇到过的问题,不过在学习了Event Loop的相关知识后,这道题就很容易解释了:因为UI渲染永远要等到js代码执行完毕,所以在UI渲染阶段,浏览器只会看到transition500px两条指令,而1000px已经被覆盖了。但是问题还没有结束,如何才能实现jake期望中的效果呢?

jake本人给出了他的答案:使用requestAnimationFrame,只需要将500px的指令放到下次一UI渲染前执行,这样就不会和1000px造成冲突。嗯,听起来很完美,jake也通过他的ppt动画展示了0 --> 1000px --> 500px的过程。

但是,问题来了,我自己的尝试完全没有效果,浏览器依旧只为我展示了0 --> 500px的渲染过程,这又是怎么回事呢?

其实是jake失误了,他提供的方案确实理论上可以完成相应的操作,但是由于他的代码中出现了这句transition = "..."指令,一切都不一样了。

那么为什么transition会导致我的1000px指令“丢失”呢,我们都知道transition可以让一个属性不同值间的转变变得更顺畅,比如由1改变到10,那么如果有transition的存在,浏览器就会根据设置的速度对这个属性进行1 --> 2 --> 3 --> ... --> 10的渐进设置,那么正因如此,这一系列操作肯定不是一次UI渲染能够完成的,所以就导致了一下情况:

比如我想将一个元素的透明度由0更改到1,并为它设置了一个指定的时间,假设浏览器根据该时间计算出每次渲染要等100ms,每次渲染属性值加0.1,最终到达规定的时间,属性的值变为1,这是正常情况。但是,如果我在设置透明度的同时请求了requestAnimationFrame,并在其中将透明度更改为了0.5,猜猜会发生什么?我想您应该已经想到了:第一个100ms时间段还没到,透明度的目标值就已经在下一次的UI渲染之前被修改为了0.5,那么浏览器也就不会再执行0.5以后的操作了。

得出结论:1000px的指令之所以无法执行,是因为transition需要多次UI渲染才能完成过渡,这个过程中可能包含多次requestAnimationFrame,那么为了证明我的结论,您可以在浏览器中尝试执行以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .box {
        width: 300px;
        height: 300px;
        background-color: red;
      }
    </style>
  </head>
  <body>
    <div class="box"></div>
  </body>
  <script>
    let box = document.querySelector(".box");

    document.addEventListener(
      "click",
      (change = () => {
        box.style.transition = "3s";
        box.style.width = "700px";

        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            requestAnimationFrame(() => {
              requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                  requestAnimationFrame(() => {
                    requestAnimationFrame(() => {
                      requestAnimationFrame(() => {
                        requestAnimationFrame(() => {
                          requestAnimationFrame(() => {
                            requestAnimationFrame(() => {
                              requestAnimationFrame(() => {
                                requestAnimationFrame(() => {
                                  requestAnimationFrame(() => {
                                    requestAnimationFrame(() => {
                                      requestAnimationFrame(() => {
                                        requestAnimationFrame(() => {
                                          requestAnimationFrame(() => {
                                            requestAnimationFrame(() => {
                                              requestAnimationFrame(() => {
                                                requestAnimationFrame(() => {
                                                  requestAnimationFrame(() => {
                                                    box.style.width = "0px";
                                                  });
                                                });
                                              });
                                            });
                                          });
                                        });
                                      });
                                    });
                                  });
                                });
                              });
                            });
                          });
                        });
                      });
                    });
                  });
                });
              });
            });
          });
        });
      })
    );
  </script>
</html>

这段代码可能会让您感到不舒服,不过它确实证实了我的结论是对的:box在被点击后width属性一开始增大了,说明box.style.width = "700px";这句代码生效了,随后马上又开始缩小了,这是因为在多次requestAnimationFrame后,box.style.width = "0px";执行了,不过由于Event Loop执行速度非常快,即使我嵌套了这么多层,也只是让box.style.width = "700px";生效了一瞬间而已。

requestAnimationFrame的其他优点

最后我想跟您聊聊requestAnimationFrame的其他优点:

  • CPU节能:使用setTimeout实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费CPU资源。而requestAnimationFrame则完全不同,当页面处理未激活的状态下,该页面的屏幕刷新任务也会被系统暂停,因此跟着系统步伐走的requestAnimationFrame也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了CPU开销。

  • 函数节流:在高频率事件(resize,scroll等)中,为了防止在一个刷新间隔内发生多次函数执行,使用requestAnimationFrame可保证每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。一个刷新间隔内函数执行多次时没有意义的,因为显示器每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来。

Node中的Event Loop

Node的Event Loop是依靠libuv实现的

在Node中,由于和浏览器的执行环境不同,做得事情也有些不同,所以Node的Event Loop和浏览器中的Event Loop也不尽相同。

Node中的宏队列与微队列

Node和浏览器不一样,浏览器只有一个宏队列,而Node有6个,它们会按照顺序不断循环执行,每个阶段的宏任务为:

  1. timers:执行setTimeout() 和 setInterval()中到期的callback。
  2. I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
  3. idle, prepare:队列的移动,仅内部使用
  4. poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  5. check:执行setImmediate的callback
  6. close callbacks:执行close事件的callback,例如socket.on("close",func)

并且Node还有2个微队列:

  1. Next Tick Queue:是放置process.nextTick(callback)的回调任务的
  2. Other Micro Queue:放置其他microtask,比如Promise等

并且在Node中会一次性将一个宏队列中的所有任务执行完,然后再执行微任务,并且NextTick微队列要优先于Other微队列,这和浏览器中一次只执行一个宏任务是不一样的。

setTimeout(() => {
  console.log(1);
  Promise.resolve(2).then((e) => console.log(e));
}, 0);

setTimeout(() => {
  console.log(3);
}, 0);

上面这段代码,如果是在浏览器中执行,结果就是1、2、3,而在Node中,结果是1、3、2

setTimeout与setImmediate

请尝试在您的Node中运行下面这段代码:

setTimeout(() => {
    console.log(1);
}, 0);

setImmediate(() => {
    console.log(2);
}, 0);

发现什么了吗?没有?那么就请继续重复执行,慢慢的,就会发现这段代码诡异的地方:结果不确定!

上面这段代码的执行结果可能为1、2,也可能为2、1,这是为什么呢?(我要填坑了)

原因有两点:

  1. setTimeout第二个参数是没有0一说的,它的取值区间大概在[1, 2^31-1],如果超过这个范围,那它就为1;
  2. 我们知道setTimeout的回调函数在timer阶段执行,setImmediate的回调函数在check阶段执行,Event Loop开始会先检查timer阶段,但是在开始之前到timer阶段会消耗一定时间,所以就会出现两种情况:
    1. timer前的准备时间超过1ms,这时setTimeout已经准备完毕,所以执行setTimeout;
    2. timer前的准备时间小于1ms,这时setTimeout还在等待计时中,所以会跳过timer阶段执行setImmediate,等下次的timer阶段再执行setTimeout。

如果想让setTimeout百分百在setImmediate之前执行,那我们可以让timer准备的时间更充足一点,从而保证在进入timer阶段时,setTimeout能够完成计时工作:

setTimeout(() => {
  console.log(1);
});

setImmediate(() => {
  console.log(2);
});

// 这段代码会让程序卡住10ms,这段时间足够setTimeout完成计时
const start = Date.now();
while (Date.now() - start < 10) {}

而如果想让setImmediate百分百在setTimeout之前执行,只需要给它们包裹一层环境,这个环境使得当前的Event Loop执行于timer阶段的下方,check阶段的上方即可,这样,只要Event Loop继续向下执行,那么肯定先执行到check阶段,这里我们那I/O阶段举例:

const fs = require("fs");

fs.readFile("./md/01.md", (error, data) => {
  setTimeout(() => {
    console.log(1);
  });

  setImmediate(() => {
    console.log(2);
  });
});

Node 11.x 新变化

在Node11及以上的版本中,Event Loop的实现又开始逐渐向浏览器靠拢:微队列不再等到执行完一整个宏队列之后再执行,而是和浏览器一样,位于一个宏任务之后执行。

setTimeout(() => {
  console.log(1);
  Promise.resolve(2).then((e) => console.log(e));
}, 0);

setTimeout(() => {
  console.log(3);
}, 0);

在Node11+的版本中,这段代码的执行结果同浏览器一样,都是1、2、3

参考

Help, I'm stuck in an event-loop -- Philip Roberts

JavaScript 运行机制详解:再谈Event Loop

Event Loop -- JSCONF 2018

带你彻底弄懂Event Loop

通杀Event Loop面试题!

requestAnimationFrame -- MDN

MutationObserver -- MDN

深入理解 requestAnimationFrame

【实践相关】Vue集成highlight.js

Vue集成highlight.js

一、安装highlight.js

npm install highlight.js --save 或 yarn add highlight.js

二、封装自定义指令

import Hljs from "highlight.js";

export const highlight = (el: any) => {
  const blocks = el.querySelectorAll("pre code");

  blocks.forEach((block: any) => {
    Hljs.highlightBlock(block);
  });
};

三、引入指令和样式

import { highlight } from "@/utils/directive";
Vue.directive("highlight", highlight);

// 样式文件在 node_modules/highlight.js/style/ 中可以找到
@import "../assets/atom-one-dark";

四丶使用指令

<div v-highlight v-html="articleDetail.content"></div>

【实践相关】textarea高度随内容变化

textarea高度随内容变化

做项目遇到了这个需求,搜罗了一下,找到了3种较为靠谱的方案。

div + contentEditable

这种方案实现起来非常简单,不是说textarea不好用吗,那就不用好了,直接使用div代替。而关键的contentEditable是DOM元素身上的一个属性,平常开发几乎不会用到,反而是在富文本领域很受欢迎,这里不做展开。在给div设置div.contentEditable = true;后,该div在页面展示时就拥有了接收输入的能力,同时因为div块状元素的特性,内容在超出宽度时就会换行导致div高度被撑开,如果内容被删除,高度还会自动回复。

缺点:我没有采用这种方案,所以不知道其他的缺陷。最明显的缺点,也是我pass掉这个方案的主要原因就是——无法使用输入框身上的特性,例如placeHolder,而靠自己模仿的话成本好像又有点太大。

下面放上方案的示例代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .box {
        width: 100px;
        height: 20px;
        background-color: #999;
      }

      .input-block {
        width: 100%;
        background-color: red;
      }
    </style>
  </head>
  <body>
    <div class="box">
      <div class="input-block"></div>
    </div>
  </body>
  <script>
    let box = document.querySelector(".input-block");

    box.contentEditable = true;
  </script>
</html>

textarea + scrollHeight

该方案的原理是将textareaoverflow-y设置为hidden,并通过监听textarea的输入变化,来动态赋值textarea的高度。

缺点:该方案需要注意的事项非常多,由于是动态赋值的textareascrollHeight,而scrollHeight又不包含元素的paddingborder,如果不添加box-sizing: border-box;就很容易导致元素高度失控。并且该方案不会监听元素高度的恢复,一旦撑开了就无法恢复了。

下面是该方案的示例代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .box {
        width: 100px;
        background-color: #999;
      }

      .input-block {
        width: 100px;
        display: block;
        overflow-y: hidden;
        resize: none;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <div class="box">
      <textarea class="input-block"></textarea>
    </div>
  </body>
  <script>
    let box = document.querySelector(".input-block");
    let boxHeight = box.scrollHeight;

    box.addEventListener("input", (e) => {
      if (Number(e.target.scrollHeight) > Number(boxHeight)) {
        boxHeight = e.target.scrollHeight;
        box.style.height = boxHeight + "px";
      }
    });
  </script>
</html>

div + textarea

那么有没有即可以使用输入框的特性,又可以自由响应内容的高度,并且坑还少的方案呢,其实是有的,该方案就是兼顾了上面两种方案的特性来实现的,原理:

  1. 在一个外层div中放置一个divtextarea
  2. 将外层div设置相对定位并且textarea绝对定位覆盖到表面,同时将内部divvisibility设置为hidden使其隐藏起来
  3. 将输入框的高度设为100%,并且为内部div添加word-break: break-all;
  4. 最后监听输入框的变化,将输入的值传递到隐藏起来的div中,这样内部的div就会因为内容的扩充而被撑开,从而导致外层div也被撑开。

缺点:还未发现什么大的缺陷,需要注意的一点是内部div要和输入框的字体大小及类型相同,以及paddingborder也相同,确保二者的内容宽一样。

下面的该方案的示例代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .box {
        width: 100px;
        background-color: #999;
        position: relative;
      }

      .hidden-block {
        font-family: monospace;
        visibility: hidden;
        word-break: break-all;
        padding: 2px;
        border: 1px solid;
        min-height: 20px;
        font-size: 16px;
      }

      .input-block {
        font-family: monospace;
        position: absolute;
        left: 0;
        top: 0;
        width: 100px;
        height: 100%;
        display: block;
        overflow-y: hidden;
        font-size: 16px;
        resize: none;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <div class="box">
      <div class="hidden-block"></div>
      <textarea class="input-block"></textarea>
    </div>
  </body>
  <script>
    let box = document.querySelector(".input-block");
    let hiddenBox = document.querySelector(".hidden-block");

    box.addEventListener("input", (e) => {
      hiddenBox.innerHTML = e.target.value;
    });
  </script>
</html>

最后

记着将输入框的keydownenter默认事件阻止掉,不然会因为换行而导致外层被错误撑开。

【实践相关】Jest处理静态资源

一般像图片、CSS、字体这种静态文件我们是不希望Jest帮我们解析测试的,配置如下:

// jest.config.js  
moduleNameMapper: {
    "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
      "<rootDir>/__mocks__/fileMock.js",
    "\\.(css|less)$": "identity-obj-proxy",
},

且需要安装identity-obj-proxy

【实践相关】返回页面顶部

返回页面顶部

以匀速运动为例,先假设几个变量

距离 c(change position)
初始位置 b(beginning position)
最终位置 p(position)
持续时间 d(duration)
时间 t(time)
速度 v(velocity)

  

上面几个变量有如下等式

  1、最终运动距离 = 最终位置 - 初始位置

c = p - b

  2、最终运动距离 = 速度 * 持续时间

c = v * d

  3、当前运动距离 = 当前位置 - 初始位置

c(当前) =  p(当前) - b

  4、当前运动距离 = 速度 * 时间

c(当前) = v * t

  最终要表示为如下函数

p(当前) = ƒ(t)

  因此,经过整理得出公式如下

p(当前) = b + c(当前) = b + v*t = b + c*t/d

  最终结果为

p = t * c / d + b

​ 因为返回顶部需要的距离为负值,所以

p = t * (-c) / d + b

完整代码

const toPageTop = (scrollTiming = 300) => {
  let timer = 0;

  return () => {
    cancelAnimationFrame(timer);

    const b = document.body.scrollTop || document.documentElement.scrollTop;
    const c = b;

    const startTime = Date.now();

    const func = () => {
      const t =
        scrollTiming - Math.max(0, startTime - Date.now() + scrollTiming);

      window.scrollTo(0, b + (-c / scrollTiming) * t);

      timer = requestAnimationFrame(func);

      if (t === scrollTiming) {
        cancelAnimationFrame(timer);
      }
    };

    timer = requestAnimationFrame(func);
  };
};

自定义终点版本

想要指定移动目的地也很简单,只需要改动其中的变量c(距离)参数值(终点) - b(起点) 即可,另外下面的-c要改为c

const slidePage = (scrollTiming = 300) => {
  let timer = 0;

  return (des = 0) => {
    cancelAnimationFrame(timer);

    const b = document.body.scrollTop || document.documentElement.scrollTop;
    const c = des - b;

    const startTime = Date.now();

    const func = () => {
      const t =
        scrollTiming - Math.max(0, startTime - Date.now() + scrollTiming);

      window.scrollTo(0, b + (c / scrollTiming) * t);

      timer = requestAnimationFrame(func);

      if (t === scrollTiming) {
        cancelAnimationFrame(timer);
      }
    };

    timer = requestAnimationFrame(func);
  };
};

【实践相关】使用nvm切换node版本

使用nvm切换node版本

本来我是不打算切换node版本的,但是在执行一段node脚本时出现了语法不识别报错,令我稍微有些惊讶,原来是不支持ES6 class语法的static属性,要知道我的node版本已经是10.x.x了,应该算是一个很高的版本了,在同学的12.16.3版本中测试正常,没想到这一特性的支持这么靠后。

于是我想反正要切换node版本,干脆下一个nvm来管理node版本好了,以后出现类似问题也不用重复卸载安装。

答案

首先从nvm的github仓库中下载并安装上nvm,然后执行指令nvm install node版本来安装想要使用node和npm,下载完成后nvm会提醒你使用nvm use 版本来切换node版本。

坑1.0

可能会出现由于网速的问题github仓库文件拉取失败的情况,可以在nvm的安装路径下找到settings.txt文件,在其中加上:

node_mirror: https://npm.taobao.org/mirrors/node/
npm_mirror: https://npm.taobao.org/mirrors/npm/

来配置淘宝镜像,从而加速下载。

坑2.0

这个坑是基于1.0的,如果你配置了淘宝镜像,你要注意了,截止到2020-08-13,也就是这篇博客编写的时候,淘宝源上的npm包只包含了6.9.2版本及其以前的版本。如果你选择的node版本偏高,可能会出现nvm提示你安装成功但是仍无法使用的情况,比如我选择的12.16.3版本node需要6.14.4版本的npm,这就导致了上述的问题。

这种情况下只通过官网或其他方式手动下载。

【实践相关】遍历与 async 结合的正确方式

遍历与 async 结合的正确方式

  1. 如果你想连续执行await调用,请使用for循环(或任何没有回调的循环)。
  2. 永远不要和forEach一起使用await,而是使用for循环(或任何没有回调的循环)。
  3. 不要在 filterreduce 中使用 await,如果需要,先用 map 进一步骤处理,然后在使用 filterreduce 进行处理。

示例代码

    result = await Promise.all(
      result.map(async (article) => {
        article = article.toObject();

        let filterStr = "usernumber pic name summary";
        article.author = await UserModel.findOne(
          { usernumber: article.author },
          filterStr
        );

        return article;
      })
    );

map会将数组中的元素转换为一个个的Promise,通过Promise.all做到连续循环await

或者直接使用for循环也是可以的。

【实践相关】通过js为css属性添加合适的prefix

通过js为css属性添加合适的prefix

一般在通过webpack进行工程代码开发时,我们无需关心在Style中书写的样式的前缀问题,那是因为有专门的prefix-loader来完成这项工作,但是如果是在js代码中动态添加的css代码就享受不到这种便利了,于是可以通过下面封装的一个工具方法来实现自动检测补全css属性前缀。

答案

其实原生js就有对css属性支持的检测能力,我们可以通过下面的方法来检测某一个css属性应该添加哪种前缀:

let elementStyle = document.createElement("div").style;

let vendor = (() => {
  // 各个浏览器厂商的前缀
  let transformNames = {
    webkit: "webkitTransform",
    Moz: "MozTransform",
    O: "OTransform",
    ms: "msTransform",
    standard: "transform",
  };

  // 不支持的前缀不会定义在dom元素的style属性上,为undefined
  for (const key in transformNames) {
    if (elementStyle[transformNames[key]] !== undefined) {
      return key;
    }
  }

  // 如果都不满足,那这个浏览器肯定有毛病
  return false;
})();

检测到css属性支持的浏览器前缀后,只需要做字符拼接就可以了:

/**
 * css3属性添加前缀
 * @export
 * @param {any} style 样式
 * @returns 前缀+style
 */
export function prefixStyle(style) {
  if (vendor === false) {
    return false;
  }

  if (vendor === "standard") {
    return style;
  }

  return vendor + style.charAt(0).toUpperCase() + style.substr(1);
}

完整代码

/**
 * 给dom元素添加类名
 * @param {dom} el dom
 * @param {string} className 类名
 */
export function addClass(el, className) {
  el.classList.add(className);
}

/**
 * 判断dom是否有这个类名
 * @param {dom} el dom对象
 * @param {string} className 类名
 */
export function hasClass(el, className) {
  return el.classlist.contains(className);
}

/**
 * 设置或者获取dom元素的data-属性
 * @param {dom} el dom
 * @param {属性} name
 * @param {*} val
 */
export function getData(el, name, val) {
  const prefix = "data-";
  name = prefix + name;
  if (val) {
    return el.setAttribute(name, val);
  } else {
    return el.getAttribute(name);
  }
}

let elementStyle = document.createElement("div").style;

let vendor = (() => {
  // 各个浏览器厂商的前缀
  let transformNames = {
    webkit: "webkitTransform",
    Moz: "MozTransform",
    O: "OTransform",
    ms: "msTransform",
    standard: "transform",
  };

  for (const key in transformNames) {
    if (elementStyle[transformNames[key]] !== undefined) {
      return key;
    }
  }

  // 如果都不满足,那这个浏览器肯定有毛病
  return false;
})();

/**
 * css3属性添加前缀
 * @export
 * @param {any} style 样式
 * @returns 前缀+style
 */
export function prefixStyle(style) {
  if (vendor === false) {
    return false;
  }

  if (vendor === "standard") {
    return style;
  }

  return vendor + style.charAt(0).toUpperCase() + style.substr(1);
}

【实践相关】Vue-cli集成 less3.x 时出现的问题

Vue-cli集成 less3.x 时出现的问题

项目背景

CSS 预处理选择的是sass,但是UI组件库选择的是以less为基础的view-design,因为有主题定制的需求,所以只能手动集成less

问题

less在升级到3.x后,在集成时可能会报出以下的错误:

解决方案

在根目录下创建vue.config.js,在其中添加如下内容:

module.exports = {
    css: {
        loaderOptions: {
            less: {
              lessOptions:{
                javascriptEnabled: true,
              }
            }
        }
    },
  }

注意:不要将它创建成一个TS文件。

模拟实现一个async

async/await可以让一段异步代码同步执行,但是这段异步代码整体还是异步的:

asyncFn(); // asyncFn是异步代码,但是它里面的内容可以同步执行
otherFn(); // 因为asyncFn是异步,所以otherFn()会先出结果(不是异步)

效果

直接上效果代码:

const runner = main(function* (param) {
  console.log(param);
  let a = yield 3;
  let b = yield 4;
  let c = yield new Promise((resolve, reject) =>
    setTimeout(resolve, 3000, [5, 6, 7])
  );
  let d = yield Promise.resolve(6);

  return [a, b, c, d];
});

runner("ree").then((res) => {
  console.log(res); // [3, 4, [5, 6, 7], 6];
});

原理

function main(fn) {
  return (...params) => {
    const gen = fn(...params);

    return new Promise((resolve, reject) => {
      function _next(...params) {
        step(gen, resolve, reject, _next, _throw, "next", params);
      }

      function _throw(err) {
        step(gen, resolve, reject, _next, _throw, "throw", [err]);
      }

      _next();
    });
  };
}

function step(gen, resolve, reject, _next, _throw, key, param) {
  const { value, done } = gen[key](...param);

  try {
    if (done) {
      resolve(value);
    } else {
      Promise.resolve(value).then(_next, _throw);
    }
  } catch (err) {
    reject(err);
  }
}

原理就是运用了generator可以中止函数执行的特性,在遇到yield时退出等待异步执行完毕时再回到函数中继续向下执行,async/await只是作为一种语法糖,将我们需要手动调用next的步骤省略了。而且还给async函数本身套上了一层Promise,使这个函数能够“自个玩自个的”,不会阻塞函数外的代码。

参考

【实践相关】 修改git的commit的注释信息

修改git的commit的注释信息

主要分为两种注释修改情况:

  1. 还没push到远程仓库,只存在于本地的commit
  2. 已经push到远程仓库的commit

两种commit的修改流程大致相似,只是第二种情况要多上一个步骤。

答案

修改最新的一次commit

这种情况最简单,只需要使用git commit --amend即可,执行指令后会弹出最新commit的修改界面,只需要修改保存即可。

修改之前的注释

如果要修改之前的注释,需要先执行git rebase -i head~[想要展示的历史commit条数],执行指令后会出现之前的commit信息,且每条commit信息前都有一个pick标识,将你想修改的commit信息前的pick修改为edit,你可以同时设置多条commit信息,设置好之后,保存退出。

接下来再执行git commit --amend,这次git会找到你设置edit的最新一条commit,弹出其的修改页面,修改保存退出,在执行git rebase continue即可完成该条commit的更新(且该commit的状态变为pick)。

如果你设置了多条edit状态的commit,则可继续执行git commit --amendgit rebase continue,git会继续向前修改。

修改已经push到远程仓库的commit

前面的流程与本地仓库无异,只不过最后提交时需要加上--force,表示强制推送且覆盖远程仓库的commit,一般情况下,不建议使用该方法。

模拟实现Promises/A+

模拟实现Promises/A+规范

Promise是ES2015中加入Javascript的一个相当受欢迎的特性,目的是为了解决js中令人厌恶的异步方式。Promise/A+是规定Promise的范文,这篇文章会通过Promise/A+规范,来模拟实现一个Promise,并借此来更深入的了解关于Promise的繁枝细节。

文章中的代码可以在这里获取。

Promise解决了什么问题?

这篇文章假定您已经有了些许的Promise使用经验。关于Promise解决的问题,大多数文章值提出了“回调地狱”这一观点,诚然,这确实是Promise解决的一大问题,但是,关于Promise的亮点,还包括但不限于以下几方面:

  • 解决回调函数的控制反转导致的信任问题:

    Promise提供了针对于第三方在调用回调函数时关于错误的调用时机,错误的调用次数等方面的解决方案,具体实现可以参考《你不知道的Javascript》中卷 第二部分--第三章--3.3部分--Promise信任问题 相关内容。

  • 指定回调函数的方式更加灵活:

    之前的异步方式必须要在启动之前指定回调函数,这是因为以前的异步方式无法保存异步状态,导致会在得到结果后就立马进入回调函数中进行处理,而Promise因为可以保存异步的执行状态和返回值,所以无论是在异步启动前,还是启动后,甚至在已经得到结果后再指定回调函数都是被允许的。

宏队列与微队列

Event Loop(事件循环)是JavaScript的执行模型,不过它并不是我们这篇文章要探讨的,我们要引用其中两个很重要的两个概念:Macrotask(宏队列)、Microtask(微队列),理解了这两个概念,可以解决我们在编写Promise中的一些疑惑。

首先先来看三段代码:

// 第一段代码
setTimeout(() => {
  console.log(1); // 我后输出
}, 0)

console.log(2); // 我先输出
// 第二段代码
Promise.resolve(1).then(resolved => console.log(resolved)); // 我后输出

console.log(2); // 我先输出
// 第三段代码
setTimeout(() => {
  console.log(1); // 我第二个输出
  Promise.resolve(3).then((e) => console.log(e)); // 我第3个输出
}, 0);

setTimeout(() => {
  console.log(2); // 我最后输出
}, 0);
console.log(4); // 我先输出

从前面两段代码中,我们不难看出,setTimeoutPromise.prototype.then都会异步执行其中的代码片段,但是第三段代码中,在最后加入异步队列的Promise.prototype.then却排在了第二个setTimeout前面。

我们直接说结论,原因是在JavaScript的执行模型中,异步队列分为宏队列和微队列两种,其中setTimeout属于宏队列,Promise.prototype.then属于微队列。二者的共同点是都会等待JS执行栈全部pop空后才执行。

它们的区别是宏队列一次只会弹出一个回调函数执行,并且每一个宏队列函数执行完毕后,都会检测当前微队列中有无待执行函数,如果有会一次性将微队列中的全部待执行函数执行完毕。

带入到第三段代码中,Promise之所以会先于第二个定时器就是因为在第一个定时器执行完毕后,检测到微队列中包含一个Promise待执行函数,所以会将微队列的函数执行完后,再返回宏队列执行接下来的代码。

通过上面的小例子,如果您依然对宏队列以及微队列抱有疑惑,无法联想出相应的执行模型,笔者推荐您可以参考一下这个2分钟了解 JavaScript Event Loop | 面试必备视频,作者通过动画的方式,可以更加通俗易懂的了解其中的运作流程。

另外,为了通俗理解,笔者只是简单的介绍了一下两种队列的概念,上面的代码也只是运行于浏览器中的结果,node中并不遵循该执行模型,我会在另一篇文章中详细探讨一下关于Event Loop。

开始实现Promise

我们先看一段基本的Promise使用代码:

let asyncCode = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(1);
  });
});

asyncCode.then(
  (resolved) => {
    console.log(resolved); // 1
  },
  (rejected) => {
    console.log(rejected);
  }
);

我们先列出从上面的代码中就可以看出的规范:

  1. Promise是一个构造函数,构造一个Promise实例需要传入一个回调函数;
  2. 传入的参数函数中包含两个参数,并且这两个参数也是函数;
  3. 构造出的Promise实例身上包含then方法;
  4. then方法中需要传入两个函数类型的参数,两个函数各有一个参数。

以上面的规则,我们就可以开始构建:

(function (window) {
  function MyPromise(executor) {
    function resolve(value) {
        
    }
     
    function reject(reason) {
        
    }
    
    executor(resolve, reject)
  }

  MyPromise.prototype.then = function (onResolved, onRejected) {

  }
})(window)

仅仅根据上面的特征,我们发现工作无法顺利开展,于是我们需要通过阅读Promise/A+规范,来进行Promise的具体实现。

术语

  • 解决(fulfill):指一个 promise 成功时进行的一系列操作,如状态的改变、回调的执行。虽然规范中用 fulfill 来表示解决,但在后世的 promise 实现多以 resolve 来指代之。
  • 拒绝(reject):指一个 promise 失败时进行的一系列操作。
  • 终值(eventual value):所谓终值,指的是 promise 被解决时传递给解决回调的值,由于 promise 有一次性的特征,因此当这个值被传递时,标志着 promise 等待态的结束,故称之终值,有时也直接简称为值(value)。
  • 据因(reason):也就是拒绝原因,指在 promise 被拒绝时传递给拒绝回调的值。

Promise构造函数编写

根据规范,一个 Promise 的当前状态必须为以下三种状态中的一种:等待态(Pending)执行态(Fulfilled)和拒绝态(Rejected)

等待态(Pending)

处于等待态时,promise 需满足以下条件:

  • 可以迁移至执行态或拒绝态

执行态(Fulfilled)

处于执行态时,promise 需满足以下条件:

  • 不能迁移至其他任何状态
  • 必须拥有一个不可变的终值

拒绝态(Rejected)

处于拒绝态时,promise 需满足以下条件:

  • 不能迁移至其他任何状态
  • 必须拥有一个不可变的拒因

这里的不可变指的是恒等(即可用 === 判断相等),而不是意味着更深层次的不可变(盖指当 value 或 reason 不是基本值时,只要求其引用地址相等,但属性值可被更改)。

另外,ES2015中并没有选择”Fulfilled“作为执行态,而是选择与”rejected“相对应的“resolved”,我们的实现也遵从ES2015的实现。

function MyPromise(executor) {
    // Promise当前的状态
    this.state = "pending";
    
    // 由于Promise只会有一种状态,所以我们利用一个属性来存储返回的终值或拒因
    this.data = undefined;
    
    // 使用数组是因为同一个Promise实例可能被多次调用then方法
    // 每个元素的结构:{onResolved() {}, onRejected() {}}
    this.callbacks = [];
    
    
    // ... other code
}

接下来我们再来实现需要传入executor中的resolvereject方法,它们的逻辑包括:

  1. 由于Promise只有一次更改状态的机会,所以只要当前的state不为"pending",直接return;
  2. resolve需要保存本次异步的终值,reject需要保存本次异步的拒因;
  3. 由于浏览器并没有开放将代码push到微队列的接口,所以我们借用官方的Promise.prototype.then方法来实现合适的回调函数的调用时机。
function MyPromise(executor) {
  // ... other code 
   
  function resolve(value) {
    if (this.state !== "pending") return;

    // 将状态改为resolved
    this.state = "resolved";

    // 保存value数据
    this.data = value;

    // 如果有待执行的回调函数,依次添加入异步队列中(此处用宏队列模拟微队列)
    if (this.callbacks.length > 0) {
      let _this = this;
      Promise.resolve(null).then((e) => {
        _this.callbacks.forEach((callbacksObj) => {
          callbacksObj.onResolved(value);
        });
      });
    }
  }

  function reject(reason) {
    if (this.state !== "pending") return;

    // 将状态改为rejected
    this.state = "rejected";

    // 保存reason数据
    this.data = reason;

    // 如果有待执行的回调函数,依次添加入异步队列中(此处用宏队列模拟微队列)
    if (this.callbacks.length > 0) {
      // setTimeout(() => {
      //   this.callbacks.forEach((callbacksObj) => {
      //     callbacksObj.onRejected(reason);
      //   });
      // });

      // 使用Promise.prototype.then来模拟为微队列效果
      Promise.resolve(null).then((e) => {
        this.callbacks.forEach((callbacksObj) => {
          callbacksObj.onRejected(reason);
        });
      });
    }
  }
}

最后因为Promise的构建是同步执行的,所以我们在构造函数中立即执行传进来的构建器:

// 立即执行executor
try {
  executor(resolve.bind(this), reject.bind(this));
} catch (error) {
  reject(error); // 如果执行器抛出异常,Promise为失败状态
}

构造函数的最终代码为:

(function (window) {
  function MyPromise(executor) {
    // Promise当前的状态
    this.state = "pending";

    // 由于Promise只会有一种状态,所以我们利用一个属性来存储返回的终值或拒因
    this.data = undefined;

    // 使用数组是因为同一个Promise实例可能被多次调用then方法
    // 每个元素的结构:{onResolved() {}, onRejected() {}}
    this.callbacks = [];

    function resolve(value) {
      if (this.state !== "pending") return;

      // 将状态改为resolved
      this.state = "resolved";

      // 保存value数据
      this.data = value;

      // 如果有待执行的回调函数,依次添加入异步队列中(此处用宏队列模拟微队列)
      if (this.callbacks.length > 0) {
        let _this = this;
        Promise.resolve(null).then((e) => {
          _this.callbacks.forEach((callbacksObj) => {
            callbacksObj.onResolved(value);
          });
        });
      }
    }

    function reject(reason) {
      if (this.state !== "pending") return;

      // 将状态改为rejected
      this.state = "rejected";

      // 保存reason数据
      this.data = reason;

      // 如果有待执行的回调函数,依次添加入异步队列中(此处用宏队列模拟微队列)
      if (this.callbacks.length > 0) {
        // setTimeout(() => {
        //   this.callbacks.forEach((callbacksObj) => {
        //     callbacksObj.onRejected(reason);
        //   });
        // });

        // 使用Promise.prototype.then来模拟为微队列效果
        Promise.resolve(null).then((e) => {
          this.callbacks.forEach((callbacksObj) => {
            callbacksObj.onRejected(reason);
          });
        });
      }
    }

    // 立即执行executor
    try {
      executor(resolve.bind(this), reject.bind(this));
    } catch (error) {
      reject(error); // 如果执行器抛出异常,Promise为失败状态
    }
  }
  
  // 用于测试,暂时假定Promise的状态为pending
  MyPromise.prototype.then = function (onResolved, onRejected) {
    this.callbacks.push({ onResolved, onRejected });
  };

  window.MyPromise = MyPromise;
})(window);

实现Promise.prototype.then

then方法是Promise中最重要同时也是较为复杂的一部分逻辑,理解了then方法的执行逻辑,后面的Promise方法就显得通俗易懂了。

那么首先我们还是先通过规范,将then方法的行为准则列举出来:

  1. promise 的 then 方法接受两个参数:

    promise.then(onFulfilled, onRejected);
  2. 如果 onFulfilled、onRejected 不是函数,其必须被忽略

  3. 如果 onFulfilled 是函数:

    • promise 执行结束后其必须被调用,其第一个参数为 promise 的终值
    • promise 执行结束前其不可被调用
    • 其调用次数不可超过一次
  4. 如果 onRejected 是函数:

    • promise 被拒绝执行后其必须被调用,其第一个参数为 promise 的据因
    • promise 被拒绝执行前其不可被调用
    • 其调用次数不可超过一次
  5. then 方法必须返回一个 promise 对象

    promise2 = promise1.then(onFulfilled, onRejected);   

    理解上面的“返回”部分非常重要,即:不论 promise1 被 reject 还是被 resolve 时 promise2 都会被 resolve,只有出现异常时才会被 rejected

前两条规则比较容易理解,我们优先实现它们:

MyPromise.prototype.then = function (onResolved, onRejected) {
  // 如果传入的参数不为函数,我们为它们规定默认行为,分别为return拿到的终值和抛出拿到的拒因
  onResolved =
    typeof onResolved === "function" ? onResolved : (value) => value;
  onRejected =
    typeof onRejected === "function" ? onRejected : (reason) => { throw reason; };
};

第三和第四条规定了Promise状态的更新以及回调函数的执行时机,相对容易理解,所以我们接着向下查看第五条规则:规定了then方法的返回值为一个新的Promise。那么我们的代码就可以这样来写:

MyPromise.prototype.then = function (onResolved, onRejected) {
  // ... other code
   
  // then()返回一个新的Promise用于链式调用
  return new MyPromise((resolve, reject) => {

  });
};

在编写里面的逻辑前,我们还需要再整理一下思绪,既然then方法的返回值是一个新的Promise,这不仅解释了Promise链式调用的原因,同时也带来的一个问题,这个Promise的状态如何决定?

首先,毋庸置疑的是这个Promise的返回值一定是根据then方法其中的onResolved, onRejected二者之一的执行结果来决定的。其次,根据onResolved, onRejected的返回值类型,要进行不同的处理,大致分为如下三种情况:

  1. 如果在执行过程中出现异常,则直接变为rejected状态
  2. 如果成功完成执行,且返回的值不为Promise类型,则直接变为resolved状态,并且将返回的值作为下一个then方法的值
  3. 如果成功完成执行,但返回的值为新的Promise,则由这个Promise的执行结果来决定下一个then方法的状态

最后,由于Promise可以在任何时刻指定回调函数,所以会有当then方法调用时,Promise状态仍为pending的情况,这种情况下,我们将onResolved, onRejected添加到Promise的callbacks中,等待Promise中的异步执行完毕后,通过构造函数中相对应的方法来将回调函数推送到微队列中进行等待。

根据上面的所有规则,then方法的最终实现如下:

MyPromise.prototype.then = function (onResolved, onRejected) {
  // 如果传入的参数不为函数,我们为它们规定默认行为,分别为return拿到的终值和抛出拿到的拒因
  onResolved =
    typeof onResolved === "function" ? onResolved : (value) => value;
  onRejected =
    typeof onRejected === "function" ? onRejected : (reason) => { throw reason; };

  const SELF = this;

  // then()返回一个新的Promise用于链式调用
  return new MyPromise((resolve, reject) => {
    // 处理函数
    function handler(callback) {
      try {
        // 执行回调函数,获取结果,并根据不同的三种结果执行相应的逻辑
        let result = callback(SELF.data);
		
        // 如果执行结果是一个新的Promise,则以该Promise的执行结果作为结果
        if (result instanceof MyPromise) {
          result.then(resolve, reject);
        } else {
          // 如果执行结果是一个非Promise值,则直接将其作为终值返回
          resolve(result);
        }
      } catch (error) {
        // 如果在执行回调函数中捕获到异常,则将Promise更改为失败态,并将error作为拒因抛出
        reject(error);
      }
    }
	
   	// 根据三种不同的Promise状态,来决定对应的逻辑
    if (this.state === "pending") {
      // 如果执行then()时,Promise中的执行器还未产生结果,就暂时将回调函数存储起来

      this.callbacks.push({
        onResolved(value) {
          handler(onResolved);
        },
        onRejected(reason) {
          handler(onRejected);
        },
      });
    } else if (this.state === "resolved") {
      Promise.resolve(null).then((e) => {
        handler(onResolved);
      });
    } else {
      // rejected同resolved原理相同,只不过调用的是onRejected()
      Promise.resolve(null).then((e) => {
        handler(onRejected);
      });
    }
  });
};

注:上面代码中所使用的Promise.resolve(null).then()用于实现将代码推送到微队列的效果,如果使用setTimeout则只能将代码推送到宏队列,这与规范中的约定相违背。

实现Promise.prototype.catch

catch方法的实现非常简单,它的作用用于捕获一段Promise程序的异常,通常我们会将它放到Promise调用链的最下方。之所以说它非常简单因为这个作用已经被then方法实现了,所以我们只需要对then方法进行下包装即可:

MyPromise.prototype.catch = function (onRejected) {
  return this.then(undefined, onRejected);
};

如果您没有理解上面的实现,可以返回到上面构造函数和then方法实现中仔细查看一下回调函数的调用过程以及出现异常时的应对方法。关于catch方法的调用机制,我们以以下规则进行处理异常:

  1. 如果Promise的执行状态为rejected,那么并不会直接决定接下来的Promise是否为rejected,而是同resolved状态一样,根据函数的执行结果决定。这条非常重要,我在这里再次重复一遍;
  2. 我们在then方法中对传入的onRejected回调方法进行了判断,如果其不为一个函数,我们会规定它的默认行为为直接抛出拿到的拒因,这也是为什么Promise的拒因可以一直穿透到最下方的catch方法中的原因。但是,正如我们上一条规则所提到的,如果您一旦指定了onRejected,下方的then方法就会根据传入的回调函数的执行结果来决定执行状态。简单得说就是,如果在catch方法的上方,有其它onRejected对拿到的拒因进行了处理,并且该过程并没有发生或者主动抛出异常,最下方的catch是不会拿到拒因的。

我的文字描述可能不能直接让您理解这个理念,您可以结合下方这段代码来进行理解:

let asyncCode = new Promise((resolve, reject) => {
  setTimeout(() => {
    // 这里的失败态会调用第一个then方法中的onRejected()
    reject(1);
  });
});

asyncCode.then(
  (value) => {
    // 不会执行
    console.log(e);
  },
  (reason) => {
    // 我们覆盖了onRejected的默认行为,但是我们主动抛出了异常
    throw reason;
  }
).then(
  (value) => {
    // 这里的代码同样不会执行
  },
  (reason) => {  // *
    // 我们在这里对拿到的拒因进行了操作,并且这段代码不会抛出异常(尽管它看起来很蠢)
    console.log(reason + "2"); // 输出:"12"
  }
).catch((e) => {
  // 所以这里的代码是不会执行的,因为上面的onRejected()执行结果(返回值)为undefined
  console.log(e + 1);
});

但是如果将标记*的失败处理函数删掉或者在其中使用throw主动抛出了错误,又或者是该函数中的逻辑代码出现了异常,下方的catch就会被执行。

实现Promise.resolve/reject

这两个方法直接挂载在Promise对象身上,用于快速指定一个带有预定状态的Promise实例,由于它们的实现简单且极其相似,我将它们直接放在这里一起带过。

不过,在实现之前,有一点需要注意的细节,请观察下方的代码,并思考执行结果:

Promise.resolve(Promise.reject(1)).then(
  (v) => {
    console.log(0);
  },
  (e) => {
    console.log(e);
  }
);

Promise.reject(Promise.resolve(1)).then(
  (v) => {
    console.log(0);
  },
  (e) => {
    console.log(e);
  }
);

正确答案是:1状态为resolved的Promise实例,看到了吗,这两个函数的执行逻辑有一些不同:如果resolve方法的参数是一个Promise实例,那么resolve方法会根据这个Promise实例的执行结果来改变状态;而reject方法则会直接将参数作为失败的拒因抛出。

其实很好理解,期待百分百的成功往往不现实,但期待百分百的失败倒是信手拈来。

MyPromise.resolve = function (value) {
  return new MyPromise((resolve, reject) => {
    if (value instanceof MyPromise) {
      value.then(resolve, reject);
    } else {
      resolve(value);
    }
  });
 };

MyPromise.reject = function (reason) {
  return new MyPromise((resolve, reject) => {
    reject(reason);
  });
}

实现Promise.all

Promise.all的实现同样不复杂,但是有一些小细节需要注意。该方法用于执行多个Promise代码,并返回一个新的Promise,这个Promise会根据所有的异步程序执行结束后返回相应的结果来改变状态:

  1. 如果全部执行成功,则resolve,并按照执行的顺序返回一个终值数组;
  2. 只要有一个执行失败,则立即reject,并返回当前失败的拒因。

根据要求,我们开始码实现:

MyPromise.all = function (promises) {
  return new MyPromise((resolve, reject) => {
    let successfulPromiseArr = [],
      successfulCount = 0; // 用于存在成功完成执行的Promise结果和数量

    promises.forEach((promise, index) => {
      if (!(promise instanceof MyPromise)) {
        successfulPromiseArr[index] = promise;

        successfulCount++;
      }

      promise.then(
        (value) => {
          successfulPromiseArr[index] = value;

          successfulCount++;

          if (successfulCount === promises.length) {
            resolve(successfulPromiseArr);
          }
        },
        (reason) => {
          reject(reason);
        }
      );
    });
  });
};

从上面的源码实现中,可以提取出几点关于实现时需要注意的小细节:

  1. 如果传入的数组中包含非Promise实例对象的数据类型,则直接将其作为终值加入返回数组;
  2. 因为要按照执行的顺序返回终值,且由于异步代码完成顺序不确定,所以不能使用push方法,而是使用脚标的方式进行设置;
  3. 原因同第二点,在判断当前完成的Promise是否为最后一个时不能通过判断数组脚标的方式,上面代码使用了一个变量来进行计数,当完成的数量达到了数组的数量时,再进行resolve。

实现Promise.race

Promise.race相比Promise.all来说,逻辑简单了许多,只需要根据第一个完成执行的Promise对象的结果来决定返回状态,同时注意处理传入非Promise实例对象类型数据的情况即可。

MyPromise.race = function (promises) {
  return new MyPromise((resolve, reject) => {
    promises.forEach((promise, index) => {
      if (!(promise instanceof MyPromise)) {
        resolve(promise);
      }

      promise.then(
        (value) => {
          resolve(value);
        },
        (reason) => {
          reject(reason);
        }
      );
    });
  });
};

实现Promise.prototype.finally

最后我们再来实现一下这个方法,这个方法实现起来是最简单的一个,它的执行逻辑也非常简单:这个方法无论上一个Promise的返回状态如何,都会执行回调函数中的内容。

MyPromise.prototype.finally = function (callback) {
  return this.then(
    value => {
      callback();
      ,kreturn value;
    },
    reason => {
      callback();
      throw reason;
    }
  );
};

Promise的缺点

  1. promise一旦新建,就会立即执行,无法取消
  2. 如果不设置回掉函数,promise内部抛出的错误就不会反应到外部
  3. 处于pending状态时,是不能知道目前进展到哪个阶段的

参考

【翻译】Promises/A+规范

Promise-MDN

为什么说Promise不能取消是一个缺点

【实践相关】JS解析文章内容生成目录结构

JS解析文章内容生成目录结构

/*
 * articleBody: 内容区的父元素
 * articleContent: 内容区
*/
const articleBodyTop = this.articleBody.offsetTop;
// 1. 获取所有内容标签
const allNodes = this.articleContent.children[0].children;

// 2. 筛选h2、h3标签
const titleList = Array.from(allNodes).filter((node: any) => {
  return node.nodeName === "H2" || node.nodeName === "H3";
});

// 3. 组件目录结构
const h2List: any = [];
let current = 0;
titleList.forEach((node: any) => {
  if (node.nodeName === "H2" && !h2List[current]) {
    h2List[current] = {
      id: articleBodyTop + node.offsetTop - 84,
      val: node.textContent,
      children: [],
    };
  } else if (node.nodeName === "H2") {
    h2List[++current] = {
      id: articleBodyTop + node.offsetTop - 84,
      val: node.textContent,
      children: [],
    };
  } else if (node.nodeName === "H3" && !h2List[current]) {
    h2List[current] = {
      id: articleBodyTop + node.offsetTop - 84,
      val: "",
      children: [
        {
          id: articleBodyTop + node.offsetTop - 84,
          val: node.textContent,
        },
      ],
    };
  } else if (node.nodeName === "H3") {
    h2List[current].children.push({
      id: articleBodyTop + node.offsetTop - 84,
      val: node.textContent,
    });
  }
});

// 4. 渲染至页面
this.navList = h2List;

点击目录标题跳转至相关位置

因为目录结构中的id就是存的目的地的top值,所以只需要在(Vue)模板中循环时将该值传给对应的移动函数即可。

当然也可以利用事件委托进行优化,将id值绑定在有特定标识(例如class)的元素上,在触发最外层点击事件后,通过利用特定标识来筛选冒泡列表,拿到指定元素身上的id值,最后进行跳转即可。

示例代码:

  <div
    class="article-right-side"
    :class="`side-r-${sideBarPos}`"
    @click="toTitleHere"
  >
    <div class="directory-container" v-for="nav in navList" :key="nav.id">
      <!-- 通过自定义属性绑定位置数值 -->
      <div class="item" :data-top="nav.id">
        <div class="circle"></div>
        <div class="title">{{ nav.val }}</div>
      </div>
      <div class="sub-directory-container">
        <!-- 通过自定义属性绑定位置数值 -->
        <div
          class="item sub-directory"
          v-for="navChild in nav.children"
          :key="navChild.id"
          :data-top="navChild.id"
        >
          <div class="circle h3"></div>
          <div class="title">{{ navChild.val }}</div>
        </div>
      </div>
    </div>
  </div>
// toTitleHere

// 1. 从冒泡路径上找到附带 data-set 的元素
const current = e.path.filter((target: any) => {
  return target.className && target.className.includes("item");
})[0];

// 2. 如果没找到则直接终止
if (!current) return;

// 3. 移动页面至 元素身上附带的自定义高度值那里
this.slider(Number(current.dataset.top));

【实践相关】Vue模板中多个 &nbsp ; 合并的问题

Vue模板中多个 &nbsp ; 合并的问题

在Vue模板文件中,我尝试使用多个&nbsp;来调整字体两侧的间距,却发现无论写多少个空格转义字符,到最后都会被合并成为一个。

原因

这篇问答中有小伙伴提出了这是Vue在compile模板编译阶段做得处理,但是没有给出这么做的原因,下面也有老哥提出了可能是个BUG。不过在v-html中,由于是不做模板编译直接通过innerHTML绑定DOM,所以&nbsp;可以正常显示。

解决方案

既然不能用也就没必要纠结下去了,毕竟咱自己也不方便为了这么一个小问题修改源码,何况调整字体间距的方案多着去了。

Node.js相互引用模块执行机制

Node.js相互引用模块执行机制

A.js:

module.exports = 'A';

const B = require('./B');
console.log( 'A中的B:', B);

module.exports = 'AA';

B.js

module.exports = 'B';

const A = require('./A'); // "A"
console.log( 'B中的A:', A);

module.exports.test = 'BB';

以为的执行流程

如果A与B存在相互依赖、相互引用关系,不就形成了一个闭环或者说死循环?那程序怎么会继续解析呢?

DwUBsU.gif

实际上的执行流程

很显然,运行结果告诉我们,nodejs引擎有自己的一套处理循环引用的机制。下面我们根据上述运行结果,来推演了两个module模块的执行顺序,以了解nodejs打破闭环的机制。

DwU0MT.gif

执行机制

DwYqJ0.png

  1. ①执行modA第一行,输出一个test接口

  2. ②执行modA第二行,要引入modB此时断点产生了,即开始执行modB里的代码, 程序开始走"breakpoint-out"路线

  3. ③执行modB第一行

  4. ④执行modB第二行,要因为modA,此步骤为打破闭环的关键,此时将A里断点之前的执行结果输出给modB,如图里的蓝色虚线框标识的部分,此时在modB中打印modA.test,打印'A'

  5. ⑤继续执行modB第三行

  6. ⑥继续执行modB第四行,对外输出test接口('BB'),此后,modB执行完毕,主程序返回至断点处(modA中在②步骤产生的断点),将modB的执行结果保存在'modB' const变量中。

  7. ⑦执行modA的第三行

  8. ⑧执行modA的第四行,打印'modB'对象里的test接口,根据中指向结果可知,'modB'返回的test接口为'BB',此,打印'BB',程序结束。

如果main.js调用的是'modB.js',分析过程完全一致,打印的结果将是'B, AA'

根据上述分析可知,nodejs中的模块互相引用形成的**“闭环”其实是用“断点”**这一方式打开的,以断点为出口去执行其他模块,也以断点为入口进行返回,之后继续执行断点之后的代码。

【实践相关】VSCode对配置alias的支持

VSCode对配置alias的支持

我们有时候会借助Webpack的alias功能来帮助我们优化相对路径的编写,但是这时就可能导致我们编辑器的提示功能失效,如果想要让VSCode识别alias,我们可以通过以下方法来进行解决。

方案

要解决这个问题,我们需要借助一个插件——Node modules resolve,可以在VSCode中的插件商店中找到它,安装之后,只需在我们的根目录下创建一个jsconfig.json文件,在其中编写以下配置即可:

{
  "compilerOptions": {
    "target": "es2017",
    "allowSyntheticDefaultImports": false,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "exclude": ["node_modules", "dist"]
}

利用微队列突破递归限制

利用微队列突破递归限制

普通的递归,大概会在10000层左右爆掉:

let index = 0;
function a() {
  index += 1;
  console.log(index);
  return a();
}

使用微队列后,可以在理论上进行无限的递归:

let index = 0;
async function a() {
  await undefined;
  index += 1;
  console.log(index);
  return await a();
}

事实上,这个递归函数永远不会停止,它会一直执行下去,也没有爆栈,这是一个神奇的优化,可以让你写出非常大深度的递归而不会出现问题,这个优化的关键就是:

async function() { await undefined }

首先将递归函数改为async函数,然后在内部最好第一行 await undefined

这个操作的原理就是:

  1. async创建微任务队列,然后执行器执行当前队列.
  2. 此时遇到await undefined,其实这个写法等同于await (async () => {})await Promise.resolve(setTimeout)这几种写法效果等同,用unedfined只是为了在实现同样效果的情况下更简洁,既然已经等同了,那就从这三个写法分析起.
  3. 此时,执行器发现第一个任务完全没有等待,马上完成了,但是执行器发现后面的任务是需要等待的,并不会马上完成.
  4. 这时候执行器为了microtask(也就是协程)调度的合理优化,不会让这个微任务队列始终占有这个execution,而是会把当前微任务队列转移到别的execution去执行(您几位走得慢,请去那边空闲的地方走).
  5. 转移execution带来的操作就是,因为没办法直接转移调用栈,所以会先将当前调用栈入堆,然后把任务队列转移到别的execution.
  6. 然后队列里面接下来的任务全部都是使用新创建的execution去执行.

这个操作的本意就是为了让当前栈入堆,而且这个写法在C#和Kotlin里面是完全通用的,因为这3个语言的异步方案都是基本类似,而这个写法来自Rust群一位群友的发现,当时我看到这种写法的时候也表示了惊奇,然后对于递归大面积使用这种写法,目前没有发现什么问题.

【实践相关】Vue + Koa2 + Socket.io

Vue + Koa2 + Socket.io

依赖库版本信息

  • vue:2.6.11

  • koa:2.7.0

  • socket.io:3.03

  • socket.io-client:3.03

Koa2集成

const Koa = require('koa');
const app = new Koa();
const server = require('http').Server(app.callback());
const io = require('socket.io')(server);
const port = 8081;

server.listen(process.env.PORT || port, () => {
     console.log(`app run at : http://127.0.0.1:${port}`);
})

io.on('connection', socket => {
     console.log('初始化成功!下面可以用socket绑定事件和触发事件了');
     socket.on('send', data => {
          console.log('客户端发送的内容:', data);
          socket.emit('getMsg', '我是返回的消息... ...');
     })

     setTimeout( () => {
         socket.emit('getMsg', '我是初始化3s后的返回消息... ...') 
     }, 3000)
})

如果是在不同源下进行socket连接,需要,在创建io实例时将配置项cors打开:

io = io(server, { cors: true });

Vue集成

如果想在组件内部使用socket实例 ,可以使用vue-socket.io,我是将socket实例绑定在vuex上,所以只需安装官网要求的socket.io-client即可。

import config from "@/config";
import { io } from "socket.io-client";

const socket = io(config.baseUrl);

socket.on("connect", () => {
  console.log("???");
});

export default socket;

【实践相关】CSS实现图片占位

CSS实现图片占位

在我们实际开发过程中,图片加载之前通常需要一个元素来进行占位,否则无法保证页面结构的正常展示,一般我们都是通过监听图片加载完毕实现该工程,这里记录一个巧妙利用CSS特性实现的方法。

代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
    <style>
      img {
        position: relative;
        height: 100%;
        width: 100%;
      }

      img::before {
        content: "";
        position: absolute;
        height: 100%;
        width: 100%;
        left: 0;
        top: 0;
        /* 此处路径为任意 本地图片路径,用于占位 */
        background: url("./xx.png") no-repeat center;
      }
    </style>
  </head>

  <body>
    <div style="width: 300px; height: 300px"><img src="" /></div>
  </body>
  <script>
    setTimeout(function () {
      // 此处路径为 实际图片路径
      document.querySelectorAll("img")[0].src =
        "https://www.baidu.com/img/flexible/logo/pc/result.png";
    }, 3000);
  </script>
</html>

实现思路

利用after伪元素无法在img标签上使用的特性,在img加载完毕后会自动使自身上的after伪元素失去效果,通过这一原理实现加载完毕前的占位。

防抖节流

防抖节流

老伙计了,做个人项目时好多地方需要用到,正好发现自己还没有整理出一篇文章,就想着产出一篇笔记来。防抖和节流网上都讲烂了,这里就直接上干货了。

防抖

防抖的意思是规定操作之间的间隔,如果两次操作的时间间隔小于指定的时间,则判定第二次操作无效。翻译成大白话就是:“让你歇一秒再操作,你非要搁那一直点个不停,那劳资就判你这些操作都无效,什么时候停下来歇完一秒后才算数”。

示例代码

const debounce = (fn, time, immediate) => {
  let timer = null;

  // 包裹函数
  const bundler = (...arg) => {
    // 是否开启立即执行
    if (immediate) {
      // 开启立即执行后为下文条目3的执行逻辑
      clearTimeout(timer);
      let flag = !timer;
      if (flag) fn(...arg);

      timer = setTimeout(() => {
        timer = null;
        // 如果想隔阂时间结束后同样执行方法,像这样在这里执行下方法即可
        fn(...arg);
      }, time);
    } else {
       // 不开启立即执行后为下文条目1的执行逻辑
      clearTimeout(timer);
      timer = setTimeout(() => {
        fn(...arg);
      }, time);
    }
  };

  // 取消本次时间隔阂
  bundler.cancel = () => {
    clearTimeout(timer);
    timer = null;
  };

  return bundler;
};

实现原理

笔者的语文功底很弱,所以下面的话一定要看着代码来进行阅读:

  1. 通过定时器延时操作需要执行的函数,并在方法最顶部清除定时器,这就导致,如果方法触发的时间小于定时器指定的时间,定时器就会在执行之前立马被关闭掉,里面的方法执行不了,本次的操作也就算做无效了。
  2. 以前实现防抖和节流还要想办法去拿被加工函数的参数this,比如通过argumentscall等,现在直接通过拓展运算符箭头函数就可以了。
  3. 条目1中提到的防抖函数每次都会在时间隔阂结束之后执行方法,如果想在触发时先立马触发一次方法,可以在防抖函数中每次判断保存定时器的变量,并在定时器中将该变量置为null,同时也要在防抖函数顶部清除定时器,这样,第一次进如防抖函数时,因为该变量默认为null,所以会立马执行一次,接下来由于变量中存储了定时器,如果不到规定时间执行定时器中的置null操作,变量会一直不变为null,这样这些操作都算是无效操作。如果想要在立即执行的前提下,在时间隔阂之后也执行一次方法,只需要在定时器中加入函数执行语句即可。
  4. 取消本次隔阂时间:这个操作也很简单,由于防抖函数使用了柯里化函数,可以在内部返回函数的身上添加cancel方法,在该方法中关闭定时器,同时将timer置为null即可。

节流

节流的意思是规定操作的频率,不管用户以多快的速度触发操作,总会以规定的时间进行响应。翻译成大白话就是:“不管你点多少次,我说一秒一次就是一秒一次”。

示例代码

关于节流的实现,最通用的版本是underscore的可配置版,不过我认为可配置版的代码可读性较差,不如直接拆分成三种方法,分别实现节流调用时间leading、ending、all

function throttle(func, wait, options) {
    var timeout, context, args, result;
    var previous = 0;
    if (!options) options = {};

    var later = function() {
        previous = options.leading === false ? 0 : new Date().getTime();
        timeout = null;
        func.apply(context, args);
        if (!timeout) context = args = null;
    };

    var throttled = function() {
        var now = new Date().getTime();
        if (!previous && options.leading === false) previous = now;
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            func.apply(context, args);
            if (!timeout) context = args = null;
        } else if (!timeout && options.trailing !== false) {
            timeout = setTimeout(later, remaining);
        }
    };
    
    throttled.cancel = function() {
      clearTimeout(timeout);
      previous = 0;
      timeout = null;
    }
    return throttled;
}

拆分为:

const throttleLeading = (fn, wait) => {
  let old = 0;

  return function (...arg) {
    let now = Date.now();

    if (now - old >= wait) {
        fn(...arg);
        old = now;
    }
  };
};
const throttleEnding = (fn, wait) => {
  let timer = null;

  return function (...arg) {
    if (!timer) {
      timer = setTimeout(() => {
        fn(...arg);
        timer = null;
      }, wait);
    }
  };
};
const throttleAll = (fn, wait) => {
  let old = 0,
    timer = null;

  return function (...arg) {
    let now = Date.now();
    let remianing = wait - (now - old);

    if (remianing <= 0) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }

      fn(...arg);
      old = now;
    } else if (!timer && ending) {
      timer = setTimeout(() => {
        fn(...arg);
        timer = null;
        old = Date.now();
      }, remianing);
    }
  };
};

实现原理

  • 时间戳版本:通过比对当前的时间戳与上一次执行的时间戳来判断是否已经过了指定了时间,会立马执行一次,停止操作后不会再执行
  • 定时器版本:通过判断定时器变量是否被清除而添加定时器,并且在定时器内部清除定时器变量,不会立马执行,停止操作后还会执行一次
  • 时间戳+定时器版本:结合时间戳和定时器的特点,会立马执行一次,停止操作后还会执行一次

【实践相关】通过 svg-captcha 制作验证码

通过 svg-captcha 制作验证码

const svgCaptcha = require("svg-captcha");

// 创建验证码
const newCaptcha = svgCaptcha.create({
  size: 4,
  ignoreChars: '0oO1ilLI',
  color: true,
  noise: Math.floor(Math.random() * 5),
  width: 150,
  height: 38
});

// 两个关键属性
newCaptcha.data // 验证码svg图片
newCaptcha.text // 图片上验证文字

关键配置属性

  • size:字符数量
  • ignoreChars:混淆字符过滤
  • color:是否开始颜色
  • noise:干扰线条
  • width:svg图片宽度
  • height:svg图片高度

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.