Skip to main content

前端知识之异步和this

异步相关

promiseasync await 区别

  • Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强 大,简单地说,Promise好比容器,里面存放着一些未来才会执行完毕(异步)的事件的结果,而这些结果一旦生成是无法改变的
  • async await也是异步编程的一种解决方案,他遵循的是Generator 函数的语法糖,他拥有内置执行器, 不需要额外的调用直接会自动执行并输出结果,它返回的是一个Promise对象

区别:

  • Promise的出现解决了传统callback函数导致的“地域回调”问题,但它的语法导致了它向纵向发展行成了一个回调链,遇到复杂的业务场景,这样的语法显然也是不美观的。而async await代码看起来会简洁些,使得异步代码看起来像同步代码,await的本质是可以提供等同于”同步效果“的等待异步返回能力的语法糖,只有这一句代码执行完,才会执行下一句。
  • async awaitPromise一样,是非阻塞的
  • async await是基于Promise实现的,可以说是改良版的Promise它不能用于普通的回调函数。

script 标签上的属性 defer 和 async 区别

区别主要在于执行时间

  • defer会在文档解析完之后执行,并且多个defer会按照顺序执行
  • async则是在js加载好之后就会执行,并且多个async,哪个加载好就执行哪个

解析:

  • 在没有defer或者async的情况下:会立即执行脚本,所以通常建议把script放在body最后
  • async:有async的话,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步)。 但是多个js文件的加载顺序不会按照书写顺序进行
  • defer:有defer的话,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成,并且多个defer会按照顺序进行加载。

同步和异步

同步

  • 指在主线程上排队执行的任务,只有前一个任务执行完毕,才能继续执行下一个任务。
  • 也就是调用一旦开始,必须这个调用 返回结果才能继续往后执行。程序的执行顺序和任务排列顺序是一致的。

异步

  • 异步任务是指不进入主线程,而进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程。
  • 每一个任务有一个或多个回调函数。前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行。
  • 程序的执行顺序和任务的排列顺序是不一致的,异步的。
  • 我们常用的setTimeoutsetInterval函数,Ajax都是异步操作。

实现异步的方法

回调函数(Callback)、事件监听、发布订阅、Promise/A+、生成器Generators/ yield、async/await

JS 异步编程进化史:callback -> promise -> generator -> async + await

  • async/await 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。
  • async/await可以说是异步终极解决方案了。
  • async/await函数相对于Promise,优势体现在
    • 处理 then 的调用链,能够更清晰准确的写出代码并且也能优雅地解决回调地狱问题
    • 当然async/await函数也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低,代码没有依赖性的话,完全可以使用 Promise.all 的方式。
  • async/await函数对 Generator 函数的改进,体现在以下三点:
    • 内置执行器。 Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。
    • 更广的适用性。 co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
    • 更好的语义。 asyncawait,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

promise

手写实现自己的promise,在这里:docs/javascript/#手写实现promise

查看下面代码输出内容和顺序

console.time('start');

setTimeout(function() {
console.log(2);
}, 10);
setImmediate(function(){
console.log(1);
});

new Promise(function(resolve){
console.log(3);
resolve();
console.log(4);
}).then(function(){
console.log(5);
console.timeEnd('start')
});

console.log(6);

process.nextTick(function(){
console.log(7);
});

console.log(8);

答案: 3——>4——>6——>8——>7——>5——>start: xxxms——>1——>2

本题目,考察的就是 node 事件循环 Event Loop 我们可以简单理解Event Loop如下:

  • 所有任务都在主线程上执行,形成一个执行栈(Execution Context Stack)
  • 在主线程之外还存在一个任务队列(Task Queen),系统把异步任务放到任务队列中,然后主线程继续执行后续的任务
  • 一旦执行栈中所有的任务执行完毕,系统就会读取任务队列。如果这时异步任务已结束等待状态,就会从任务队列进入执行栈,恢复执行
  • 主线程不断重复上面的第三步

Event Loop读取任务的先后顺序,取决于任务队列中对于不同任务读取规则的限定。
任务队列中的队列分为两种类型:

  • 宏任务 Macrotask 宏任务是指Event Loop在每个阶段执行的任务
  • 微任务 Microtask 微任务是指Event Loop在每个阶段之间执行的任务

我们举例来看执行顺序的规定,我们假设
宏任务队列包含任务: A1, A2 , A3
微任务队列包含任务: B1, B2 , B3
执行顺序为,首先执行宏任务队列开头的任务,也就是 A1 任务,执行完毕后,在执行微任务队列里的所有任务,也就是依次执行B1, B2 , B3,执行完后清空微任务队中的任务,接着执行宏任务中的第二个任务A2,依次循环。

在node V8中,这两种类型的真实任务顺序如下所示:
宏任务 Macrotask队列真实包含任务:

script(主程序代码), setTimeout, setInterval, setImmediate, I/O, UI rendering

微任务 Microtask队列真实包含任务:

process.nextTick, Promises, Object.observe, MutationObserver

由此我们得到的执行顺序应该为:

script(主程序代码) —> process.nextTick —>  Promises ——> setTimeout ——> setInterval ——> setImmediate ——> I/O ——> UI rendering
tip

在ES6中宏任务 Macrotask队列又称为ScriptJobs,而微任务 Microtask又称PromiseJobs

this 相关

call apply bind的作用和区别

都可以改变函数内部的this指向

  • callapply 会调用函数,并且改变函数内部this指向。
    • 经常做继承
  • callapply 传递的参数不一样,call 传递参数arg1,arg2...形式. apply 必须数组形式[arg]
    • 经常跟数组有关系,比如借助于数学对象实现数组最大值最小值
  • bind 不会调用函数,可以改变函数内部this指向。
    • bind 不调用函数,但是还想改变this指向,比如改变定时器内部的this指向。

this指向

普通函数中的this: 谁调用了函数或者方法,那么这个函数或者对象中的this就指向谁
匿名函数中的this: 匿名函数的执行具有全局性,则匿名函数中的this指向是window,而不是调用该匿名函数的对象 箭头函数中的this:

  1. 箭头函数中的this是在函数定义的时候就确定下来的,而不是在函数调用的时候确定的;
  2. 箭头函数中的this指向父级作用域的执行上下文;(技巧:因为javascript中除了全局作用域,其他作用域都是由函数创建出来的,所以如果想确定this的指向,则找到离箭头函数最近的function,与该function平级的执行上下文中的this即是箭头函数中的this)
  3. 箭头函数无法使用applycallbind方法改变this指向,因为其this值在函数定义的时候就被确定下来。

手写 call

使用示例:

const obj = {
func: 'call',
say(something) {
console.log(`手撕${this.func}, ${something}`)
}
}
obj.say('apply') // '手撕call, apply'

const anothorObj = {
func: 'bind'
}
obj.say.call(anothorObj, 'done!') // '手撕bind, done!'

其实,也可以在 anothorObj 对象里声明一个 say 函数,就可以实现想要的结果。而 call 函数就是在帮助我们在目标对象上新建一个方法!

原理:普通函数中的this: 谁调用了函数或者方法,那么这个函数或者对象中的this就指向谁。
即 使用 call 函数帮助去在目标对象中声明一个一样的函数, this 自然就转移了!

实现代码:

Function.prototype.myCall = function myCall(target, ...args) {
target = target || window; // javascript要求,当我们target传入的是一个非真值的对象时,target指向window
const symbolKey = Symbol() // 避免有重复的名字!
target[symbolKey] = this

let ans = target[symbolKey](...args); // 执行函数
delete target[symbolKey];
return ans; // 返回结果
}

obj.say.myCall(anothorObj, 'myCall!')

手写 apply

理解了call的实现,apply就很好办了,因为本质上它们只是在使用方式上有区别而已
call调用时,从第二个参数开始,是一个个传递进去的,而apply 调用的时候,第二个参数是个数组

实现代码:

Function.prototype.myApply = function myApply(target, args) {
target = target || window; // javascript要求,当我们target传入的是一个非真值的对象时,target指向window
const symbolKey = Symbol() // 避免有重复的名字!
target[symbolKey] = this

let ans = target[symbolKey](...args); // 执行函数
delete target[symbolKey];
return ans; // 返回结果
}

手写 bind

首先,需要弄清楚 bind 方法的用法才能掌握实现的思路。

const start = {
process : 'start~',
log() {
console.log(`手撕bind, ${this.process}`)
}
}
start.log() // 手撕bind, start~

const doing = {
process: 'on doing~'
}

start.log = start.log.bind(doing)
start.log() // 手撕bind, on doing~

使用过程中,发现: bind方法返回的是一个函数,其this绑定到目标对象(接收的第一个参数)上;按照逻辑可写出代码:

Function.prototype.myBind = function myBind(target) {
target = target || {}
const symbolKey = Symbol()
target[symbolKey] = this;
return () => {
target[symbolKey]();
delete target[symbolKey];
}
}
// test
const done = {
process: "done?"
}

start.log = start.log.myBind(done)
start.log() // 手撕bind, done?

还没完

实际上 bind() 是可以传多个参数的

首先,修改我们的测试代码:

const start = {
process : 'start',
log(symbol1, symbol2) {
console.log(`手撕bind, ${this.process}${symbol1}${symbol2}`)
}
}
start.log('~','~') // 手撕bind, start~~

const doing = {
process: 'on doing'
}

start.log = start.log.bind(doing, '~')
start.log('~') // 手撕bind, on doing~~

可以发现无论是在调用bind函数时给出的参数还是后面执行的时候给的参数,到最后参数都会集中传给绑定后的函数:

Function.prototype.myBind = function myBind(target, ...outerArgs) {
target = target || {}
const symbolKey = Symbol()
target[symbolKey] = this;
return (...innerArgs) => {
target[symbolKey](...outerArgs, ...innerArgs);
delete target[symbolKey];
}
}

// test code
const done = {
process: "done"
}
start.log = start.log.myBind(done, '~')
start.log('?') // 手撕bind, done?

有返回值?!

上面的代码都是根据测试案例写的,其中全是 console.log 直接打印,没有考虑到返回值的问题,多少有点一叶障目。

修改后的:

Function.prototype.myBind = function myBind(target, ...outerArgs) {
target = target || {}
const symbolKey = Symbol()
target[symbolKey] = this;
return (...innerArgs) => {
let ans = target[symbolKey](...outerArgs, ...innerArgs);
delete target[symbolKey];
return ans;
}
}

最后思考

  • 保留原型链?
  • 判断是否使用了 new 操作符

手撕 new

new的使用:

function log(text, symbol) { 
this.text = text;
this.symbol = symbol;
}
log.prototype.print = () => {
console.log(`${this.text}${symbol}`)
return this.text;
}

const a = new log('手撕new', '!');

console.log(a)
/*
log {
text: '手撕new',
symbol: '!',
__proto__: { constructor: ƒ log(), print: ƒ () }
}
*/
console.log(a instanceof log) // true

实现代码:

function _new(constructor, ...args) {
if(typeof constructor !== 'function') {
throw new Error(`${constructor} must be a function!`)
}
const ans = {}; // 帮助创建新对象
Object.setPrototypeOf(ans, constructor.prototype); // 设定ans的原型链

// 判断传入的函数是否有返回值
// 如果调用的函数有返回值,并且是对象,就要返回其所返回的对象。
let res = constructor.apply(ans, args);
return res instanceof Object ? res : ans;
}

// test code
const b = _new(log, '手撕new', '!')
console.log(b)
/*
log {
text: '手撕new',
symbol: '!',
__proto__: { constructor: ƒ log(), print: ƒ () }
}
*/
console.log(b instanceof log) // true

箭头函数能否当构造函数

箭头函数表达式的语法比函数表达式更简洁,但是没有自己的thisargumentssupernew.target
箭头函数表达式更适用于那些本来需要匿名函数的地方,它不能用作构造函数

继承的优缺点

继承的好处:

  • 提高了代码的复用性
  • 提高了代码的维护性
  • 让类与类之间产生了关系,是多态的前提

继承的弊端:类的耦合性增强了,违反了开发的原则 高内聚,低耦合

js继承的方法和优缺点

1. 原型链继承

即 将子类的原型链指向父类的对象实例

示例代码:

function Vegetables() {
this.type = "vegetable";
}
Vegetables.prototype.log = () => {
// console.log(this.type);
console.log("vegetable");
}
function Potato(){}

Potato.prototype = new Vegetables();
const a = new Potato();

// ===
a.log() // 'vegetable'
a.type // 'vegetable'

解析:

子类实例a_proto_指向Potato的原型链prototype,而Potato.prototype指向Vegetables类的对象实例,该父类对象实例的_proto_ 指向Vegetables.prototype,所以a可继承Vegetables的构造函数属性、方法和原型链属性、方法

优点:可继承构造函数的属性,父类构造函数的属性,父类原型的属性
缺点:无法向父类构造函数传参;且所有实例共享父类实例的属性,若父类共有属性为引用类型,一个子类实例更改父类构造函数共有属性时会导致继承的共有属性发生变化;

2、构造函数继承

即 在子类构造函数中使用call或者apply劫持父类构造函数方法,并传入参数

示例:

function Vegetables(type) {
this.type = type;
this.innerFnc = function() {
console.log(this.type)
}
}
Vegetables.prototype.log = () => {
// console.log(this.type);
console.log(this.type);
}
function Potato(type){
Vegetables.call(this, type)
// or
// Vegetables.apply(this, arguments)
}

const protato = new Potato("potato");

// ===
protato.type; // 'potato'
protato.innerFnc(); // 'potato'
protato.log() // TypeError: protato.log is not a function

使用call或者apply更改子类函数的作用域,使this执行父类构造函数,子类因此可以继承父类共有属性

优点:可解决原型链继承的缺点 缺点:不可继承父类的原型链方法,构造函数不可复用

3、组合继承

构造函数继承和原型链继承一起使用

function Vegetables(id, type) {
this.type = type;
this.id = id;
this.arr = [1,2]
this.innerFnc = function() {
console.log(this.id + ',' + this.type)
}
}
Vegetables.prototype.log = () => {
console.log(this.id + ',' + this.type)
}
function Potato(id, type){
Vegetables.call(this, id, type);
}

Potato.prototype = new Vegetables();
const potato = new Potato(1,'potato');

// ===
potato.type; // 'potato'
potato.innerFnc(); // '1,potato'

let a = new Potato(2,'a');
let b = new Potato(3,'b');
a.arr.push(3)
b.arr // [ 1, 2 ]

可继承父类原型上的属性,且可传参;每个新实例引入的构造函数是私有的 缺点:会执行两次父类的构造函数,消耗较大内存,子类的构造函数会代替原型上的那个父类构造函数

4、原型式继承

类似Object.create,用一个函数包装一个对象,然后返回这个函数的调用,这个函数就变成了个可以随意增添属性的实例或对象,结果是将子对象的proto指向父对象

let Fruit = {
type: ['apple', 'grape']
}
function _create(obj) {
function newObj () {}
newObj.prototype = obj;
return new newObj();
}

const a = _create(Fruit);

a.type // [ 'apple', 'grape' ]

缺点:共享引用类型

5、寄生式继承

二次封装原型式继承,并拓展

function _create_obj(obj) {
let o = _create(obj);
o.getType = () => {
console.log(this.type)
}
return o;
}

6、寄生组合式继承

改进组合继承,利用寄生式继承的思想继承原型

function copy(object) {
function F() {}
F.prototype = object;
return new F();
}
function inheritPrototype(subClass, superClass) {
var p = copy(superClass.prototype);
p.constructor = subClass;
subClass.prototype = p;
}

// ===
function Parent(name, id){
this.id = id;
this.name = name;
this.list = ['a'];
this.printName = function(){
console.log(this.name);
}
}
Parent.prototype.sayName = function(){
console.log(this.name);
};
function Child(name, id){
Parent.call(this, name, id);
}

inheritPrototype(Child, Parent);

new会发生什么

  1. 创建空对象; var obj = {};
  2. 设置新对象的constructor属性为构造函数的名称,设置新对象的proto属性指向构造函数的prototype对象; obj.proto = ClassA.prototype; 扩展了新对象的原型链。
  3. 使用新对象调用函数,函数中的this被指向新实例对象: ClassA.call(obj)
  4. 返回this指针。当存在显式的返回时,返回return后面的内容。新建的空对象作废。