你不知道的JS(中册)

606 阅读18分钟

本文系你不知道的JS(中册)读书笔记

第二部分 异步和性能

第1章 异步:现在与将来

事实上,程序中现在运行的部分和将来运行的部分之间的关系就是 异步编程的核心

1.1 分块的程序

function now() {
    return 21;
}

function later() {
    answer = answer * 2;//将来执行部分
    console.log( "Meaning of life:" answer );//将来执行部分
}

var answer = now();

setTimeout( later, 1000 ); //Meaning of life: 42

 console.*方法族不是JavaScript正式的一部分,而是**宿主环境**添加到JavaScript中的。
在某些条件下,某些浏览器的console.log(..)并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是JavaScript)中,I/O是非常低速的阻塞的部分。所以,(从页面/UI的角度来说)浏览器在后台异步处理控制台I/O能够提高性能。

1.2 事件循环

JavaScript引擎本身并没有时间的概念,只是一个按需执行JavaScript任意代码片段的环境。“事件”(JavaScript代码执行)调度总是由包含它的环境进行。

什么是事件循环?

//eventLoop是一个用作队列的数组
//(先进先出)
var eventLoop = [];
var event;

//“永远执行”
while (true) {
    //一次tick(循环的每一轮称为一个tick)
    if (eventLoop.length > 0) {
        event = eventLoop.shift();
        
        //现在,执行下一个事件
        try {
            event();
        }
        catch (err) {
            reportError(err);
        }
    }
}

一定要清楚,setTimeout(..)并没有把你的回调函数挂在事件循环队列中。它所做的时设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的tick会摘下并执行这个回调。

ES6从本质上改变了在哪里管理事件循环,ES6精确指定了事件循环的工作细节。------> Promise.

1.3 并行线程

异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。

并行计算最常见的工具就是进程线程。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存

与之相对的是,事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。

多线程并行共享内存地址,交错进行,结果不定,但JS不允许(单线程)。

  • 完整运行

在Javascript的特殊中,函数顺序的不确定性就是通常所说的竞态条件

1.4 并发

例子:

用户向下滚动加载更多内容至少需要连个独立的“进程”同时运行。“进程为虚拟进程,或者称为任务”,一个“进程”触发onscroll事件并响应,一个“进程”接收Ajax响应。

两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行相对。

  • 非交互

顺序不影响对代码的执行结果。

  • 交互
var res = [];
function response(data) {
    res.push( data );
}

//ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

协调相互处理竞态

var res = [];
function response(data) {
    if( data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if(data.url == "http://some.url.2") {
        res[1] = data;
    }
}

//ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );
  • 协作

并发协作是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。

1.5 任务

在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。

事件循环队列和任务队列(插队接着玩)

console.log("A");

setTimeout( function() {
    console.log( "B" ); 
}, 0); //下一个事件循环tick

//理论上的“任务API”
schedule( function(){
    console.log( "c" );
    schedule(function(){
        console.log( "D" );
    });
});

// A C D B

1.6 语句顺序

编译器语句重排几乎就是并发和交互的微型隐喻。

第2章 回调

回调函数包裹或者说封装了程序的延续。

2.1 嵌套回调与链式回调

listen( "click", function handler(evt) {
    setTimeout( function request() {
        ajax( "http://some.url.1", function response(text) {
            if (text == "hello") {
                handler();
            }else if (text == "world") {
                request();
            }
        });
    }, 500)
});
//也被称为回调地狱或者毁灭金字塔

为了避免在函数上跳来跳去,可使用硬编码 ,但 硬编码 肯定会使代码更脆弱,因为它并没有考虑可能导致步骤执行顺序偏离的异常情况(当其中某个步骤失败报错的情况)。

2.2 信任问题

//A
ajax( "..", function(..) {
    //C
});
//B

有时候ajax(..)不是你编写的代码,也不在你的直接控制下,多数情况下。它是某个第三方提供的工具。

这时候就会出现控制反转,把自己程序一部分的执行控制交给第三方。

控制反转的修复:

var tracked = false;
 analytics.trackPurchase( purchaseData, function() {
    if(!tracked) {
        tracked = true; //只能回调一次
        chargeCreditCard();
        displayThankyouPage();
    }
 })

2.3 挽救回调(失败)--- Promise

第3章 Promise

回调表达程序异步和管理并发的两个主要缺陷:缺乏顺序性和可信任性。

不把自己的程序的continuation传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么。---> Promise

3.1 什么是Promise

Promise就是在快餐店点餐付款之后服务员给你的收据小票,这是一个承诺,你将在后续凭借这张小票拿到你的面包。

从外部看,由于Promise封装了依赖时间的状态 --- 等待底层值的完成或拒绝,所以Promise本身是与时间无关的。一旦Promise决议,它就永远保持这个状态。

使用回调的话,通知就是任务(foo(..))调用的回调。而使用Promise的话,我们把这个关系反转了过来,侦听来自Foo(..)的事件,然后在得到通知的时候,根据情况继续。

看一下伪代码

foo(x) {
    //开始做点可能耗时的工作
    //构造一个listener事件通知处理对象来返回
    return listener;
}

var evt = foo( 42 );

evt.on ("completion", function() {
    // 可以进行下一步了!
});

evt.on ( "failure", function(err) {
    // 啊,foo(..)中出错了
})

对回调模式的反转其实就是对反转的反转,或者说反控制反转 --- 把控制还给调用代码

  • 两种Promise模式
//第一种
function foo(x) {
    //开始做一些可能耗时的工作
    
    //构造并返回一个Promise
    return new Promise( function(resolve, reject){
        //最终调用resolve(..)或者reject(..)
        //这是这个promise的决议回调
    });
}

var p = foo( 42 );

bar( p );
baz( p );
//第二种
function bar() {
    // foo(..)肯定已经完成,所以执行bar(..)的任务
}

function oopsBar() {
    // 啊,foo(..)出错了,所以bar(..)没有执行
}

//对于baz()和oopsBaz()也是一样
var p = foo( 42 );
p.then( bar, oopsBar );
p.then( baz, oopsBaz );

3.2 具有then方法的鸭子类型

要确定某个值是不是真正的Promise ,用 p instance of Promise是不够的。因为Promise值可能是从其他浏览器窗口(iframe)接收到的。不同窗口/不同iframe。此外,库和框架会选择实现自己的Promise,而不是使用原生的ES6 Promise实现。

识别Promise(或者行为类似于Promise的东西)就是定义某种称为thenable的东西,将其定义为任何具有then(..)方法的对象和函数。

所有值(或其委托),不管是过去的、现存的还是未来的,都不能拥有then(..)函数,不管是有意的还是无意的;否则这个值在Promise系统中就会被误认为是一个thenable,这可能导致难以追踪的bug。

3.3 Promise的信任问题

  • 调用过早

主要是代码是否会引入类似Zalgo(同步异步混乱)这样的副作用。Promise可以通过回调总是被异步调用来解决这个问题。

  • 调用过晚

Promise基于任务“插队”

  • 回调未调用

Promise本身永远不会被决议的解决办法:--- (一种称为竞态的高级抽象机制)

// 用于超时一个Promise工具
function timeoutPromise(delay) {
    return new Promise( function(resolve, reject){
        setTimeout( function(){
            reject( "Timeout" )
        }, delay)
    })
}

//设置foo()超时
Promise.race([
    foo(); //试着开始foo()
    timeoutPromise( 3000 ); //给它3秒钟
]).then(
    function() {
        // foo(..)及时完成
    },
    function(err){
        // 或者foo()被拒绝,或者只是没能按时完成
        //查看err来了解是哪种情况
    }
)
  • 调用次数过少或过多

Promise将只会接受第一次决议,并默默地忽略任何后续调用。

  • 未能传递参数/环境值

Promise至多只能有一个决议值(完成或拒绝)

如果使用多个参数调用resolve(..)或者reject(..),第一个参数之后的所有参数都会被默默忽略。要传递多个值,则需要把它们封装在单个值中传递,比如通过一个数组或对象。

对环境来说,JavaScript中的函数总是保持其定义所在的作用域的闭包。

  • 吞掉错误或异常

Promise甚至会把JavaScript异常也变成了异步行为,进而极大降低了竞态条件出现的可能,解决潜在的Zalgo风险(同步异步混乱)。

  • 是可信任的Promise吗?(可预见/可靠)

使用Promise过滤得到可信任值。

//这么做使得foo(42)返回值可靠
Promise.resolve( foo(42) )
.then( function (){
    console.log( V ); 
} );

3.4 链式流

连石榴可以实现的关键在于以下两个Promise固有行为特性:

  • 每次你对Promise调用then(..),它都会创建并返回一个新的Promise,可以将其链接起来;
  • 不管从then(..)调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接Promise(第一点中的)完成。

Promise.resolve(..)会直接返回接收到的真正Promise,或展开接收到的thenable值,并在持续展开thenable的同时递归地前进。

如果不显式返回一个值,就会隐式返回````undefined,并且这些promise```仍然会以同样的方式链接在一起。

var p = Promise.resolve( 42 );
p.then(
    //假设的完成处理函数,如果省略或者传入任何非函数值
    //function(v) {
        return v;
    }
    null,
    function rejected(err) {
        //永远不会到达这里
    }
)

默认的完成处理函数只是吧接受到的任何传入值传递给下一个步骤(promise)而已。

then( null, function(err){...} )只处理拒绝模式 === catch( function(){...} );

如果向reject(..) 传入一个Promise/thenable值,它会把这个值原封不动的设置为拒绝理由。后续的拒绝处理函数接收到的是你实际传给reject(..)的那个Promise/thenable,而不是其底层的立即值。

3.5 错误处理

try-catch只能是同步的,无法用于异步代码模式。

Promise采用分离回调风格,一个回调用于完成情况,一个回调用于拒绝情况。

  • 绝望的陷阱(使用catch捕捉不到的错误,未被查看的错误)
  • 处理未捕获的情况(浏览器有一个特有的功能是我们的代码所没有的:它们可以跟踪并了解所有对象被丢弃以及被垃圾回收的时机)
  • 成功的坑(defer)

3.6 Promise模式

  • promise.all([..])
  • promise.race([..])
//超时竞赛
//为foo()设定超时
Promise.race([
    foo(),    //启动foo()
    timeoutPromise( 3000 )   //给它三分钟
])
.then(
    function() {
        //foo()按时完成
    },
    function(err){
        //要么foo()被拒绝,要么只是没能按时完成
        //因此要查看err了解具体原因
    }
)
//finally
var p = Promise.resolve( 42 );

p.then( something )
.finally( cleanup )
.then( another )
.finally( cleanup );

3.7 Promise的局限性

  • 顺序错误处理
  • 单一值
  • 单决议
  • 惯性
  • 无法取消的Promise

第四章 生成器

用回调表达异步控制流程的两个关键缺陷:

  1. 基于回调的异步不符合大脑对任务步骤的规划方式;
  2. 由于控制饭庄,回调并不是可信任或可组合的。

只有控制生成器的迭代器具有恢复生成器的能力。

生成器为异步代码保持了顺序,同步,阻塞的代码模式。

4.1 生成器的定义

生成器是一种特殊的函数。

yield..next(..)这一对组合起来,在生成器的执行过程中构成了一个双向消息传递系统。(启动生成器,即第一个next,不传值)

每次构建一个迭代器,实际上就隐式构建了生成器的一个实例,通过这个迭代器来控制的是这个生成器的实例。同一个生成器的多个实例可以同时运行,甚至可以彼此交互。

相互交替执行的生成器内部具有同名变量,但这些变量是彼此独立,相互之间没有联系。

4.2 生成器的产生值

生成器作为一种产生值的方式。

var something = (function(){
    var nextVal;
    
    return {
        //for...of循环需要
        //计算属性名:指定一个表达式并用这个表达式的结果作为属性的名称
        [Symbol.iterator]: function(){return this;},
        
        //标准迭代器接口方法
        next: function() {
            if(nextVal === undefined){
                nextVal = 1;
            }else{
                nextVal = (3*nextVal) + 6;
            }
            return { done: false, value: nextVal };
        }   
    };
})();

something.next().value; //1
something.next().value; //9
something.next().value; //33
something.next().value; //105

Object.keys(..)并不包含来自于[[Prototype]]链上的属性。而for..in 会包含。

从ES6开始,从一个iterable中提取迭代器的方法是: iterable必须支持一个函数,其名称是专门的ES6符号值Symbol.iterator。调用这个函数时,它会返回一个迭代器。通常每次调用会返回一个全新的迭代器,虽然这一点并不是必须的。

严格说来,生成器本身不是iterable,当你执行一个生成器,就得到了一个迭代器。

function *foo() { .. }
var it = foo()

生成器把while..true带回了JavaScript编程的世界。

生成器会在每一个yield处暂停,这意味着不需要闭包也可在调用之间保持变量状态。

生成器内有try..finally语句,它将总是运行,即使生成器已经外部结束。如果需要清理资源的话,这一点非常有用。

function *something() {
    try{
        var nextVal;
        while (true) {
            if(nextVal === undefined){
                nextVal = 1;
            }else{
                nextVal = ( 3*nextVal ) + 6
            }
            
            yield nextVal;
        }
    }
    //清理
    finally{
        console.log( "clean up!");
    }
}

4.3 异步迭代生成器

function foo(x, y) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        function (err, data) {
            if(err) {
                //向*main()抛出一个错误
                it.throw( err );
            }else{
                //用收到的data回复*main()
                it.next();
            }
        }
    );
}

function *main() {
    try{
        //这里的yield有一个暂停,会等待foo()的完成再赋值给text
        var text = yield foo(11, 31);
        console.log(text);
    }
    catch(err) {
        console.error( err );
    }
}

var it = main();

//这里启动
it.next();

把异步作为实现细节抽象了出去,使得我们可以以同步顺序的形式追踪流程控制:“发出一个Ajax请求,等它完成之后打印出响应结果。”

生成器的yield暂停的特性意味着我们不仅能够从异步函数调用得到看似同步的返回值,还可以同步捕获(try..catch)来自这些异步函数调用的错误。

function *main() {
    var x = yield "Hello World";
    
    //永远不会到达这里
    console.log( x );
}

var it = main();
it.next();

try{
    //*main()会处理这个错误吗?看看吧
    it.throw("oops");
}catch(err) {
    //不行,没有处理!
    console.log( err ); //oops
}

4.4 生成器+Promise

ES6中最完美的世界就是生成器(看似同步的异步代码)和Promise(可信任可组合)的结合。

获得Promise和生成器最大效应的最自然的方法就是yield出来一个Promise,然后通过这个Promise来控制生成器的迭代器。

function foo(x, y){
    return request{
        "http://some.url.1/?x=" + x + "&y=" + y
    };
}

function *main() {
    try {
        var text = yield foo(11, 31);
        console.log( text );
    }
    catch(err){
        console.error( err );
    }
}

var it = main();

var p = it.next().value;
//等待Promise p决议
p.then(
    function(text) {
        console.log( text);
    },
    function(err) {
        it.throw( err )
    }
)
  • 支持PromiseGenerator Runner
function run(gen) {
    //...
}

function *main() {
    //..
}

run( main );

ES7: asyncwait

function foo(x, y){
    return request{
        "http://some.url.1/?x=" + x + "&y=" + y
    };
}

//不是生成器函数,而是async函数
async function main() {
    try {
        //不再yield出Promise,而是await等待它决议
        var text = await foo( 11, 31 );
        console.log( text );
    }
    catch( err ) {
        console.error( error );
    }
}

main();
  • 生成器中的Promise并发
function *foo() {
    //var r1 = yield request( "http://some.url.1" );
    //var r2 = yield request( "http://some.url.2" );
    
    <!--并发模式-->
    //var p1 =  request( "http://some.url.1" );
    //var p2 =  request( "http://some.url.2" );
    
    //var r1 = yield p1;
    //var r2 = yield p2;
    
    var results = yield Promise.all( [
        request( "http://some.url.1" );
        request( "http://some.url.2" );
    ] );
    
    //数组结构
    var [r1, r2] = results;
    
    var r3 = yield request( "http://some.url.3?v=" + r1 + "&w=" + r2 );
    
    console.log( r3 );
}

//使用前面定义的工具run(..)
run( foo );

如果要实现一系列高级流程控制的话,那么非常有用的做法是:把你的Promise逻辑隐藏在一个只从生成器代码中调用的函数内部。如:

function bar() {
    Promise.all( [
        baz(..)
        .then(..),
        Promise.race( [..] )
    ] )
    .then(..)
}

4.5 生成器委托

yield * 暂停了迭代控制,而不是生成器控制。

  • 为什么用委托

yield委托的主要目的是组织代码,以达到与普通函数调用的对称。

  • 消息委托

yield不只用于迭代器控制工作,也用于双向信息传递工作。

...

和yield委托透明的双向传递信息的方式一样,错误和异常也是双向传递的。

  • 异步委托
  • 递归委托

4.6 生成器并发

// Request(..)是一个支持Promise的Ajax工具
var res = [];
function *reqData(url) {
    var data = yield request( url );
    
    //控制转移
    yield;
    res.push( data );
}

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next();
var p2 = it2.next();

p1.then( function(data){
    it1.next(data);
});

p2.then( function(data){
    it2.next(data);
});

Promise.all( [p1,p2] )
.then( function(){
    it1.next();
    it2.next();
} );

4.7 形实转换程序(thunk

狭义表述: thunk是指一个用于调用另外一个函数的函数,没有任何参数

换句话说,你用一个函数定义封装函数调用,包括需要的任何参数,来定义这个调用的执行,那么这个封装函数就是一个形实转换程序。

function foo(x,y) {
    return x + y;
}

function fooThunk() {
    return foo( 3, 4 );
}

//将来
console.log( fooThunk() ); //7

thunkory (thunk+factory) --- 生成thunk的工厂模式

var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory(3,4);
var fooThunk2 = fooThunkory(5,6);

//将来
fooThunk1 (function(sum){
    console.log( sum ); //7
})
fooThunk2 (function(sum){
    console.log( sum ); //11
})

Promise要比裸thunk功能更强,更值得信赖。

4.8 ES6之前的生成器

第5章 程序性能

5.1 Web Worker

JavaScript当前并没有任何支持多线程执行的功能。

但是,像浏览器这样的环境,很容易提供多个JavaScript引擎实例,各自运行在自己的线程上,这样你就可以在每个线程上运行不同的程序。程序中每一个这样独立的多线程部分被成为一个(Web)Worker。这种类型的并行化被成为任务并行,因为其重点在于把程序划分为多个块并发运行。

//专用Worker
var w1 = new Worker( "http://some/url.1/mycoolworker.js" )

除了这样指向外部文件的URL的专用Worker,还可以创建一个在想Worker,本质上就是一个存储在单个(二进制)值中的在线文件。

Worker之间以及它们和主程序直接,不会共享任何作用域资源,那会把所有多线程编程的噩梦带到前端领域,而是通过一个基本的事件消息机制相互联系。

w1侦听事件

w1.addEventListener( 'message', function(evt){
    //evt.data
})

w1发送事件

w1.postMessage( 'sth cool to say');

要在创建Worker的程序中终止Worker,可以调用Worker对象上的terminate()。突然终止Worker线程不会给它任何计划完成它的工作或者清理任何资源。类似于通过关闭浏览器标签页来关闭页面。

  • Worker环境

在Worker内部是无法访问主程序的任何资源的。这意味着你不能访问它的任何全局变量,也不能访问页面的DOM或者其他资源。但可以执行网络操作(Ajax,WebSockets)以及设定定时器。而且Worker、可以访问几个重要的全局变量和功能的本地复本。如navigator, location, JSON和applicationCache。

可以通过importScripts(..)向Worker同步(阻塞余下的Worker执行,直到文件加载和执行完成)加载额外的JS脚本:

//在Worker内部
importScripts('foo.js', 'bar.js');

Web Woker通常应用有:

处理密集型数据计算
大数据集排序
数据处理(压缩,音频分析,图形处理)
高流量网络通信
  • 数据传递

      使用解构克隆算法
      使用Transferable对象(对象所有权的转移)
    
  • 共享Worker(shareWorker,降低系统的资源使用)

  • 模拟Web Worker(兼容老式浏览器)

5.2 SIMD(单指令数据)

单指令数据是一种数据并行方式,与Web Worker的任务并行相对,因为这里的重点实际上不再是把程序逻辑分成并行的块,而是并行处理数据的多个位。

var v1 = SIMD.float32*4(3.14159, 21.0, 32.3, 55.55);
var v2 = SIMD.float32*4(2.1, 3.2, 4.3, 5.4);

SIMD.float32*4.mul(v1, v2);
//[6.597339, 67.2, 138.89, 299.97]

5.3 asm.js

asm.js这个标签是指JavaScript语言中可以高度优化的一个子集。

通过小心避免某些难以优化的机制和模式(垃圾收集、类型强制转换,等),asm.js风格的代码可以被JavaScript引擎识别并进行特别几斤的底层优化。

var a = 42;
//..
var b = a | 0;
//b应该总是被当作32位整型来处理,这样就可以省略强制类型转换追踪。

对JavaScript性能影响最大的因素是内存分配,垃圾收集和作用域访问。