示教器APP· 开发实践(二)

这周给外包项目添加了坐标系和工具箱的配置页面,主要提供两者的CRUD功能。

甲方提供了坐标系和工具箱的接口。每一个坐标系或者工具箱都包含了名称和id以及一系列点,其中坐标系包含3个点,坐标系包含6个点。而每个点则具有12个属性。

甲方要求坐标系和工具箱分开进行配置,并且放在之前的安全界面。

经过和美工组同学的讨论之后,他们很快设计好了UI,交由我来制作。

这个业务最大的特点就是表单庞大,需要提交的内容很多。

但其实很明显,坐标系和工具箱之间有很多逻辑是可以复用的。

于是我构建了两个组件,一个是PointGroup,一个是 CoordinateControl

PointGroup 组件

该组件的功能就是提供某个点的配置功能,含有12个input。

该组件接收2个属性,分别代表点的类型和序号.

1
2
3
4
interface PointGroupProps {
type: number;
index: number;
}

其中type表示坐标系还是工具箱,index则表示点的序号。

关于Input的value和placeholder

这一点在之前我从来没有特意关注过,可能是因为之前的输入框主要是输入字符串。这次我们的输入框的内容都是数字。我最开始为每个输入框的value定义了类型是number的state。state的初始值设为0.此时我发现input的placeholder失效了。并且输入的时候没有办法将0删除并重新输入。

通过查阅资料,我发现input的value最好设定为string类型,然后在onchange事件中进行非法字符的判断。只有这样才不影响用户的输入体验。

CoordinateControl 组件

该组件提供了对于坐标系或者工具箱的name和point的管理功能。

接收一个属性:type,用来区分其是坐标系还是工具箱。

该组件首先使用一个下拉菜单来呈现当前数据库中所有的坐标系。并且在这个下拉菜单中提供增加坐标系的功能。

在该功能的实现上,我起初考虑了Antd rn 的Selector组件,但是在阅读文档之后,感觉其定制能力较弱。考虑再三后选择了比较熟悉的 ElementUI 的Tooltip组件。

组件下方提供了3或者6个按钮,以方便用户选择切换点。

通过Redux来实现复用

由于工具箱和坐标系是可以同时编辑的,因此我们要分别对其进行状态的维护与管理。其内部的管理大同小异,我们以坐标系为例。

坐标系包括了3个点,每个点的状态都要进行保存。

同时我们UI中有个小功能是,当用户配置完某个点时,controller中对应的按钮要变色。

因此我们设定了每个点的check flag

这样,我们用户编辑完一个点之后,需要点击PointGroup的保存按钮来保存该点的状态。此时我们对输入进行合法性检验,检验完毕之后,dispatch到store之中。并且对check flag属性也进行变更。

总结

在实现这个功能之后,我觉得我们在拿到一个业务之后,不要急于下手去干,而是要仔细分析,先在脑海里有一个大体的程序结构,再去把它们实现。

正是因为有了架构,在这次开发中我才没有犯很大的错误,功能的实现进行的比较顺利。

读博还是就业?

暑假在朱老师实验室体验生活。朱老师给我一个项目来作为夏令营的工作。这个项目其实就是在另外一个数据集上复现一篇paper

而我一个月以来,几乎是0产出。

这些天以来我对个人的未来重新进行了思考。

最近我和鸡腿的几个朋友接了个外包,开发一款工厂用的示教器app,其功能用来操控机器人的运作。技术栈选取的是React Native。这些天我偶尔也会花一些时间用在这款APP的开发上。

在开发过程中我也遇到了一些困难,例如技术选型、BUG解决等等。有一天晚上我只解决了一个很小的功能,而解决其的办法甚至仅仅是简单地更换组件库即可。

但是我发现,在开发过程中,遇到困难我从来不会去退缩,总是会想办法将其解决,哪怕会花费很多的时间与精力。

我又回想了最近在实验室的一些经历,在复现这篇论文的过程中,我从零开始入门了RGBD三维重建的理论,并且了解了点云等等的概念。并且阅读建立在DirectX框架上的代码和CUDA编程。

我同样遇到了很多困难,但我仔细想了想,这些困难面前,我的干劲好像不是太充足,尤其在与开发时候的对比。

我开始怀疑了,我是否真的喜欢科研,或者说是否真的对科研感兴趣。

我从坚定地读博,开始有了就业的想法。

其实也不是深造和就业的选择,归根结底还是开发和科研的选择。

回顾整个大学生涯,我课外的学习绝大多数时间其实是花在开发上的。不论是从鸡腿,到INA,在学长的带领下,我学到了一些前端开发的知识,也参与到了一些实践之中。

我之前坚定的读博想法到底源于什么呢?

我仔细想了想,我本科期间从未进行过科研,也鲜有对数学有过浓厚的兴趣。那么我读博的想法是源于我对科研的兴趣吗?很显然不像。我读博的想法更多的源于我对于学历的憧憬、对于科研的憧憬。认为我科班出身,不搞搞科研,是不是浪费掉了。

我之前对于前端的排斥是不是仅仅是源于我对其不太看得上呢?

但是经过最近的这段时间,我觉得我未来的科研生活可能是浑浑噩噩地过日子。可能会在实验室坐5年,每天糊弄自己糊弄导师,最后一事无成。因为我发现我对于科研似乎没有什么兴趣。我看到论文似乎没有求知的渴望。这种长时间没有反馈的工作,我好像不是特别喜欢。

我从高中就接触了编程,并且对编程很有兴趣。但我一直以来的兴趣好像是编程本身,而不是科研。

整个大学期间,凡是偏实践的编程比较多的课,我要不满绩要不4.8,那些偏理论的课程,我则大多分数不高。课余时间要不是啥都看,要不就是在搞开发。

C、C++、Python、Java、JavaScript各种语言语法我都略知一二。C++更是从高中写到现在。但我最喜欢的语言,最愿意深入了解原理的语言还是JS。

我为什么还要读博呢?

职业没有高低贵贱、学位也仅仅是人生的一部分。我觉得最重要的还是个人的提升。不管什么领域,只要做到极致,一定有自己的价值。在自己不感兴趣的领域怎么可能做到机制呢?

或许自己可以在前端开发这个领域取得比较不错的建树呢?

在与同学、学长、家长交流之后,我决定开始着手准备秋招。根据offer的结果来再做决定。

《深入浅出nodejs》阅读笔记

闲来无事,想仔细了解一下nodejs,在知乎上查了一下node的读物,很多人推荐这一本朴灵的书。于是开个坑,读一读

我的阅读笔记一向如此:摘抄为主,个人感悟为辅。主要是便于自己复习回顾。(单押有没有!)

1、Node简介

这一章主要讲了Node的起源和其特点,包括应用场景

1.1 Node的起源

Node的作者 Ryan Dahl 是一个C/C++的程序员,C语言偏底层,偏硬件。而我们的作者先生的工作主要是围绕高性能的web服务器来进行的。他总结出了高性能的Web服务器的特点为: 事件驱动和非阻塞I/O。

高性能、符号事件驱动、没有历史包袱

在选择语言的时候,JavaScript脱颖而出: C语言门槛太高(懂得都懂);Lua有很多阻塞IO库,构建非阻塞IO库阻力大;Ruby虚拟机性能差。

而JavaScript的开发门槛低,历史包袱少,后端没有市场。并且JavaScript在前端有广泛的事件驱动方面的应用。

1.2 Node的特点

  • 异步I/O

    在Node中,绝大多数的操作都以异步的方式进行调用

  • 事件与回调函数
  • 单线程

    在Node中,JavaScript与其余线程是无法共享任何状态的。单线程的最大好是不用像多线程编程那样处处在意状态同步的问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

单线程的缺点则是无法利用多核CPU、错误会引起整个应用退出,大量计算占用CPU导致无法继续调用异步I/O

  • 跨平台

1.3 Node的应用场景

  • I/O 密集型
  • CPU密集型,通过合理的调度可以胜任
  • 分布式应用

2、 模块机制

《你不知道的JavaScript(中)》阅读笔记 —— 异步

上个月看完了《你不知道的 JavaScript(上)》,上册分为两部分,第一部分讲了作用域与闭包以及 this 的指代问题,第二部分则讲了 js 的面向对象,重点分析了原型链。收获很大。

最近我继续读了中册,中册同样是两部分。但是第一部分讲 js 的类型和语法,内容有些繁琐,由于事情比较多,不太有心境看进去。。。而第二部分讲异步,这一部分我还是比较感兴趣的。

这学期操作系统课上对进程线程以及线程间的同步互斥的讲解,让我对于之前不太能理解的 js 的异步有了很大的好奇,想要去一探究竟。

写在前面

《你不知道的 JavaScript(中)》的作者在同步和性能这一部分中,从异步的概念谈起,聊了 js 的单线程和并发、任务等概念,这里的逻辑和操作系统的一致的。

接着作者开始着手介绍回调函数,或者说着手介绍回调函数的缺点。并由此引出了 Promise。在对 Promise 进行深入讲解之后,作者又讲了 generator 生成器在异步中的作用。

众所周知,js 的异步经历了如下历史:

回调函数 -> promise -> generator -> async/await

第一次读到本书的 promise 部分的时候,是有一些吃力的,因为我对于 promise 的概念其实没有一个很深的理解,只知道.then 而已。于是在此之前,我首先阅读了阮一峰老师的ECMAScript6 入门的 Promise 部分。在我熟悉了 promise 之后,再来阅读《你不知道的 JavaScript(中)》就能够读懂作者所要传递的意思了。

1、单线程与异步任务

我之前很长一段时间里都对“异步任务”这个概念有着很大的误解。同时对于“js 是单线程的”这句话有着很大的误解。

js 是单线程的

我们先来解释 js 的单线程是什么意思。

js 是单线程的意思指的是,浏览器中运行 js 的引擎是单线程的。这里运行 js 的引擎也叫 JS 内核,比如 V8 引擎,其作用就是负责处理执行 JavaScript 脚本程序。

但是这不意味浏览器也是单线程的,不是说所有的任务都由 js 这个单线程来完成。(笔者曾经的困惑就是误认为 js 是单线程指的是浏览器的行为是单线程的)

比如说 GUI 渲染线程就负责渲染。我们听人常说,js 线程和 GUI 渲染线程是互斥的,因为 js 会改变 dom 元素。

所以我们首先要搞清楚,浏览器完成它的任务靠的是很多个线程共同完成。而 js 的单线程的意思就是说对于一个 tab 页,一个时刻只有一个线程在执行 js 脚本。

异步任务

接着我们再来讨论”异步任务”

这里我就不讨论大家都知道的为什么要有异步任务了。我想阐明的是,我们的异步任务到底是哪个线程来完成的,以及它和 js 单线程的关系。

举一个定时器的例子,(其实我很不想用这个例子,因为就是这个例子曾经让我很困惑)

1
2
3
setTimeout(function callback() {
console.log("我才是异步任务!");
}, 1000);

就是这个很简单的例子,让我一度十分迷惑。因为我以前认为setTimeout()这个函数本身是异步任务,而事实上,函数callback()才是我们这里说的异步任务。

换个网络请求的例子。设想一个场景,我们要向服务器发送一个请求,获取一个值data,并且将它输出。这个用代码实现很简单,比如通过 Ajax 操作来进行请求,那么我们在回调函数里放一个console.log()即可。

那么在这个例子里,什么是异步任务呢?没错,是输出data这个任务,而不是发送网络请求这件事情。

如果你没有像我这样的误解,那么恭喜你,你从一开始理解的异步任务就是正确的,如果你也曾像我一样困惑,那么以后你将对异步任务有着更深更正确的理解。

到这里,我们回顾一下异步任务和同步任务比较教条的概念。

  • 同步任务: 同步任务是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。
  • 异步任务是那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。

结合上面的例子,setTimeout例子中,我们的异步任务就是那个console.log("我才是异步任务!"),它不会立即执行,它会在未来某个时刻(2s 以后)“被引擎认为该执行了”。在网络请求的例子里,异步任务则是 console.log(data),它不会立即执行,它会在网络请求有响应以后,“被认为可以执行”

同样,我们听别人说的事件循环,循环的事件也是这些异步任务。上面的例子里,“被认为可以执行”以后,就会被放进事件队列里。

那么问题来了,之前例子里,计时 2s 和发送网络请求这两个任务谁来完成呢?要是单线程的话,它们什么时候完成?

这就是我之前困惑的地方。答案其实很简单,这两个任务由其他线程来完成。计时 2s 由 timer 线程完成,发送网络请求由负责 network 的线程来完成。

希望到这里,所有人都不会对异步任务和单线程有误解。

2、任务队列与回调函数

单线程的好处

我们可以考虑一下并行线程的行为。

1
2
3
4
5
6
7
var a = 20;
function foo() {
a = a + 1;
}
function bar() {
a = a * 2;
}

假设上述代码的 foo 和 bar 函数分别由两个线程并行执行,那么我们很容易想到,a 最终的结果是不可预测的,因为这些指令的先后顺序没办法确定。(类比操作系统中多线程的同步互斥)

而我们 js 是单线程的,我们总是可以保证一个函数完整执行,只要函数间的顺序决定好,结果就是可预测的。

事件队列

单线程处理异步任务的方式通常都是回调函数。js 处理回调函数的方式是任务队列。(请再次搞清楚,任务队列里的任务是异步任务,不是发起异步任务的东西)

js 引擎执行代码时有三个数据结构,一个是栈,一个是堆,一个是队列。

栈用来运行代码,堆用来保存对象等数据(这一点和 Java 等语言是一致的),队列则是任务队列。

js 引擎每次在栈中新建一帧,这一帧就是一个函数的运行环境。(我们可以假设所有的代码最外层包裹着一个函数)

在当前这一帧中,如果我们 new 了一个对象,就在堆里增加其数据的保存。如果我们由调用了新的函数,就在栈里再 push 一帧,如果它调用完了,就弹出。以此类推。

而在我们上面运行的过程中遇到异步任务,就把它交由对应的线程来处理,并且在满足条件时被对应的线程插入到事件队列中。

当栈空时,我们就循环一次事件队列,把它加入栈中,依次类推。

这个过程和 Java 等编译语言也是类似,只需要加入事件队列这一结构即可。并且不断的进行事件队列的循环。

回调函数的信任问题

回调函数的使用方法作者没有过多的阐述,它更多的是讲了回调函数的缺点。

提到回调函数的缺点,所有人都能想到回调地狱,以及其非直觉的代码顺序。但是相比与老生常谈的回调地狱问题,作者更多的笔触在讲解回调函数的另一个很严重的缺点:那就是信任问题。

我们回顾回调函数的使用,以 ajax 为例吧。

1
2
3
ajax("..", function() {
// C
});

我们给 ajax 传入一个回调函数,并且期望它在应该的时候被调用。

回想我们之前说过的问题,ajax 这个行为是谁来做的,回调函数何时被调用是由谁来通知的?

没错,是别人,不是你自己。

那你怎么能保证你的回调函数被调用了呢,甚至你怎么能保证你的回调函数被正确的(在正确的时机,执行了正确的次数等等)调用了呢?

我们发现我们使用回调函数的时候,把控制权交了出去,这叫控制反转。而控制反转产生了信任问题。因为你和你使用的 API 并没有明确的契约。

回调函数可能出错的方式:

  • 调用回调过早(在追踪之前)
  • 调用回调过晚(或没有调用);
  • 调用回调的次数太少或太多(就像你遇到过的问题!);
  • 没有把所需的环境/ 参数成功传给你的回调函数;
  • 吞掉可能出现的错误或异常;

Zalgo

还是之前的例子,我们有一个更加疯狂的怀疑,这个怀疑是合理的。

1
2
3
4
ajax("..", function() {
console.log("A");
});
console.log("B");

这个代码的输出顺序是什么?你会毫不犹豫的说是 B - A!

你确定吗?

我换个方式问,你确定你使用的 API,一定会异步执行你的任务吗?

我们没办法确定。

而这种因为异步和同步任务的不确定性而导致的代码结果的不确定性,我们称之为 Zalgo

解决 Zalgo 的方式是在外面包裹一个 setTimeout(..,0)来确保任务被异步执行。

3、promise

关于 promise 的细节,建议去看阮一峰老师的教程,这里只是谈 promise 的理念,建立在对于 promise 有个基本概念的基础上。

promise 的理念

铺垫了这么多,promise 的概念应运而生。

顾名思义,promise 就是承诺的意思。它的本质就是由 api 提供一个承诺给我们,通过承诺来给告诉我们异步任务是否可以开始进行了,由我们来调用异步任务。

我借用作者的例子,并且稍加改动。

假如我们在肯德基就餐,我们想吃汉堡,很明显这是个异步任务(因为汉堡的制作需要时间)。我们怎么发起这个异步任务呢?那就是向餐馆点餐。(类似于我们调用了 ajax 来要求 network 线程发送网络请求)

如果是回调函数模式: 我们点餐之后回到座位上等待。肯德基做好汉堡之后,让服务员将汉堡送到我们桌子上(加入事件队列),我们拿到汉堡开始就餐(执行异步任务)。这里就有一个信任问题,要是店铺不给我们汉堡(没有执行回调函数),那我们就会一直空腹等待。

现在 Promise 的理念是这样的: 我们点餐以后跟吧台要了一个电子凭据。这个电子评据可以显示我们的汉堡现在的状态,是正在制作,还是已经做完了,或者运气比较差,汉堡卖完了。我们可以根据这个电子评据来决定我们的行为: 汉堡做好了,我们去取餐(执行异步任务)

如果你之前在项目中使用过回调函数和 promise,那么你类比上面的例子就会发现,这个电子评据就是 promise。

这种理念让控制再次反转了回来,我们不把回调函数给 API,要求 API 调用。而是我们要求 API 提供给我们一个 promise,通过 promise 来告诉我们它提供的服务的状态,我们自己根据状态来执行异步任务。

promise 的信任问题

而这个理念最大的作用不是提供给我们链式调用 then 来避免回调地狱。而是提供给我们解决信任问题的方式。promise 就是解决了之前说的一系列信任问题。

调用过早

promise 通过规则来避免 Zalgo:那就是把所有操作都异步处理(微任务队列),尽管它可以同步完成。

1
2
3
4
5
6
new Promise(function(resolve) {
resolve(2);
}).then(v => {
console.log(v);
});
console.log(1);

这里的代码执行结果是 1 - 2,上面的 Promise 中,立即执行了 resolve,但是它的回调还是异步执行了。

调用过晚

1
2
3
4
5
6
7
8
9
10
p.then(function() {
p.then(function() {
console.log("C");
});
console.log("A");
});
p.then(function() {
console.log("B");
});
// A B C

上面的例子中,p 是一个 promise 对象。C 被放入了下一个异步队列,不会抢占 B。也就是说,一个 Promise 决议后,这个 Promise 上所有的通过 then(..) 注册的回调都会在下一个异步时机点上依次被立即调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let p3 = new Promise(function(resolve, reject) {
resolve("B");
});
let p1 = new Promise(function(resolve, reject) {
resolve(p3);
});
let p2 = new Promise(function(resolve, reject) {
resolve("A");
});
p1.then(function(v) {
console.log(v);
});
p2.then(function(v) {
console.log(v);
});

这个例子的结果是 A - B 这里的规则是 p1 resolve 了一个 promise 对象,那么它会异步展开它并且根据后者的决议状态进行决议。

我们具体分析代码,p3 立即 resolve 了’B’。

  • p1 中 resolve 了 p3,那么这个时候它异步展开,那么 p1 就还是 pending,因为异步的意思就是把展开这个行为放在在微任务队列的末尾。
  • p2 立即 resolve 了’A’.
  • p1.then 订阅回调函数,但是 p1 还是 pending,不执行。
  • p2.then 订阅回调函数,p2 已经 resolve,立即执行。
  • 开始微任务队列循环,p1 展开 p3, p3 resolve’B’,p1 也 resolve ‘B’,回调函数加入微任务队列
  • 执行回调函数

回调未调用

其一我们有 reject 回调。

如果 promise 永远是 pending 的话,我们可以用Promise.race()来加入一个定时异步任务,这样来防止超时。

调用次数过多或过少

promise 只能被决议一次。如果调用 resolve 多次,只接受第一次决议。

吞掉异常或者错误

promise 会把异常在 then() 链中传递。(因为默认的 onReject 函数就是接受错误并且传递错误)

Promise API

Promise.resolve() 把参数包装成 promise。如果参数是 promise,直接传递;如果参数是 thenable 对象,则将其转为 promise 并且立即执行 then()方法;如果参数是其他的,就返回新的 promise 对象,状态为 resolved

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};

let p1 = Promise.resolve(thenable);
p1.then(function(value) {
console.log(value); // 42
});

const p = Promise.resolve("Hello");

p.then(function(s) {
console.log(s);
});
// Hello

Promise.all() 接收一个 promise 数组,所有的 promise 对象都 resolve 的话,它 resolve 一个数组。只要有一个 reject,它就 reject。空数组传进去会立即完成

Promise.race() 接收一个 promise 数组,一旦有一个 promise 对象决议,它就决议。空数组传进去会永远不会决议

4、generator

Promise 有很多的.then,这在视觉上还是不够优雅。

协程

协程,又称微线程,纤程。英文名 Coroutine。

子程序,或者称为函数,在所有语言中都是层级调用,比如 A 调用 B,B 在执行过程中又调用了 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕。

所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。

子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。

协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似 CPU 的中断。

generator 和 Iterator

生成器 generator 就是协程的一种实现。它允许我们在函数执行过程中进行上下文的切换。

generator 的语法和运行过程不赘述,详情可以看阮一峰老师的 es6 教程。

我们调用 generator 函数,会返回一个迭代器,使用这个迭代器可以操控生成器的执行。

我们还可以换个视角来看待 generator 和 iterator 的关系,从数据传递的角度。

迭代器和生成器互相提问。首先迭代器用 next()来向生成器提问:“你给我什么值?”,然后迭代器运行到 yield,通过 yield 的参数回答这个问题,把结果给迭代器。与此同时,生成器也向迭代器提了问题,“你要给我什么值”。迭代器通过下次 next 携带参数传递给生成器,生成器通过 yield 的返回值获取答案。以此类推。

因此 next 的数量往往是大于 yield 的。

generator 与异步

所谓异步就是这个任务不是连续的,被分为两部分,第一部分是现在执行,第二部分则是未来执行。

比如之前的网络请求,第一部分就是通知 network 线程发送网络请求,第二部分就是拿到请求的结果。

这和生成器有着很自然的适配。我们在生成器中执行第一部分任务,然后暂停,把线程的控制权交给别的任务继续同步执行,然后等待第二部分的任务可以执行时,再拿回控制权,继续执行。

generator 和 iterator 的信息交互帮助我们很容易能够实现这样的过程。

最重要的是在生成器内部,除了 yield 之外,代码的组织方式很像同步的组织方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let it;
function* asyncTask() {
const data = yield myAjax("www.baidu.com"); //开始执行第一部分的任务
console.log(data); //执行第二部分的任务,但是看上去像同步,其实是异步的。
}
function myAjax(url) {
ajax(url, (err, data) => {
if (err) {
} else {
it.next(data); //拿到数据后,再次把控制权转给生成器
}
});
}
it = asyncTask();
it.next(); //开始执行
console.log(123);

上面这个例子中,我们用生成器作为一个异步任务的“容器”,它的内部可以用同步的代码组织形式来组织代码。

基于 thunk 函数的流程控制

但是我们发现上面这个例子多写了很多多余的代码来进行异步任务的流程控制,但是它们是可以复用的,我们可以写一个自动执行器。

在此之前,我们先介绍一下 thunk 函数,这个概念和函数柯里化(可以参考我之前的博客:this 与函数柯里化)是类似的,

thunk 函数就是把一个函数柯里化,只不过 thunk 函数总是把一个函数的参数分两次传入,其中第二次传入的是它的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// es5
function thunk(fn) {
return function() {
const args = Array.from(arguments);
return function(callback) {
args.push(callback);
return fn.apply(this, args);
};
};
}

//es6
const thunk = fn => (...args) => callback => fn.call(this, ...args, callback);

利用这个 thunk 函数,我们重新之前的 ajax 的例子

1
2
3
4
5
6
7
8
9
ajaxThunk = thunk(ajax);
function* asyncTask() {
const data = yield ajaxThunk("www.baidu.com");
console.log(data);
}

const it = asyncTask();
const next = it.next();
next.value(data => it.next(data));

这里重点观察next.value的含义,由于我们 yield 的是一个 thunk 函数,这个 next.value 就是提供给外界一个函数,它接收一个回调函数。

利用 thunk 函数我们可以自动实现流程控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const run = gen => {
const g = gen();
const next = data => {
let result = g.next(data); //执行生成器中的任务
if (result.done) return result.value;
result.value(next); //把next作为回调函数传给thunk函数,实现自动流程控制
};
next();
};

function* asyncTasks() {
const data = yield ajaxThunk("www.baidu.com");
console.log(data);
const data1 = yield ajaxThunk(data.url);
console.log(data1);
}

run(asyncTasks);

基于 promise 的流程控制

在上面的例子里,我们 yield 了一个 thunk 函数,其他流程拿到这个函数之后要做的仅仅是给他传一个回调函数。

相信你会觉得这种行为似曾相识,没错我们的 promise 的.then()就是传一个回调函数。

因此我们自然而然地会想试试 promise 与 generator 的结合。

1
2
3
4
5
6
7
8
9
//request api返回一个promise对象

function* asyncTask() {
const data = yield request("wwww.baidu.com");
console.log(data);
}

const it = asyncTask();
it.next().value.then(data => it.next(data));

和上面 thunk 函数的代码几乎是一模一样的,只不过我们 yield 了一个 promise 对象,并且把回调函数传递给 then()方法。并且这种模式要比 thunk 函数更容易理解。

同样的 promise 的自动流程控制如下

1
2
3
4
5
6
7
8
9
10
11
const run = gen => {
const g = gen();
const next = data => {
let result = g.next(data);
if (result.done) return result.value;
result.value.then(data => {
next(data);
});
next();
};
};

5、 async/await

终于到了我们现在习以为常的async函数。

其实async函数就是上面generator+promise的语法糖。把 星号 换成async, yield换成await。我们不用自己写流程控制,async函数为我们写好了流程控制。

async的实现也就是使用我们上面分析过程中的实现方式,只不过它更加严谨。

6、 总结

js的异步解决方案已经从最初的回调函数发展到了现在的async函数。

但是正如糖吃多了也有害一样,我们不能只沉浸在async函数这种对回调函数层层封装提供的语法糖的喜悦之中。只有对它产生的意义和如何产生的搞清楚,我们才能够安心地使用这些。

《你不知道的JavaScript(上)》阅读笔记(三)- prototype

这一篇博客不做读书笔记了,结合之前qc做的内训,直接谈谈我对于prototype的理解。直到今天我才真正地理解了js的原型、原型链。

比较嘲讽的是qc的内训时间是2018年的12月15日,现在是2020年,过去了几乎两年的时间。还记得当时内训的时候,我对于C++的面向对象模式都还是一知半解。那个时候听不懂也可以理解。

1、面向对象和面向类

借用《你不知道的JavaScript》作者对于面向对象的说法。js才是真正的面向对象,而Java和C++则是面向类的。

怎样来理解这句话呢。首先js没有类,或者说没有真正的类(es6有了class的语法糖)

其实确实是这样的,C++的面向对象模式中,所谓的面向对象3个设计理念:封装、继承、多态的实现都是基于类的。我们在C++中写面向对象的代码都是在想法设法地设计类。它的对象只是对类进行实例化。

而js中,我们的封装、继承、多态的实现则都是基于对象的。我们直接面向对象去进行设计,而不是面向类。

2、prototype和__proto__

声明一下: __proto__就是书中的 [[prototype]]链,也就是原型链

先放个图,这个图里p表示prototype[p] 表示 __proto__

prototype

prototype是函数的属性,它是一个对象。叫做函数的原型(对象)。
__proto__ 是对象的属性,它指向一个对象。 叫做对象的原型链。我们可以把它理解为一个”指针”

1
2
3
4
5
6
7
8
9
10
11
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() { alert(this.name) }
}
var person1 = new Person('Zaxlct', 28, 'Software Engineer');
var person2 = new Person('Mick', 23, 'Doctor');

person1.constructor == Person
Person.prototype.constructor == Person

prototype

当我们创建一个函数时,同时会为这个函数创建一个原型对象,并且这个原型对象的constructor属性指向该函数。

当我们用上述函数创建(new)一个实例对象时,我们会将该实例对象的 __proto__指向上述函数的原型对象,即prototype属性

这个__proto__的作用就是继承。为什么我们所有的对象都能使用一些共有的方法,比如isOwnProperty。其实这些方法都定义在Object.prototype中,它也是所有对象的原型链的终点(这个对象的原型链指向null)。我们对象的属性就包括了自己的属性和原型链上的属性。这个原型链就通过__proto__不断向上连接,最终连接到终点。

3、构造器

我们可以使用一些内置的构造器来创建对象,诸如: Object()Function()Array()Date()等等

1
2
3
4
5
6
7
8
9
10
11
var b = new Array();
b.constructor === Array;
b.__proto__ === Array.prototype;

var c = new Date();
c.constructor === Date;
c.__proto__ === Date.prototype;

var d = new Function();
d.constructor === Function;
d.__proto__ === Function.prototype;

其实这些构造器本身就是个函数而已,只不过我们通常使用new来调用它,进而创建对象。关于js的new,我们在上一篇笔记中有所介绍。

4、 函数对象

我们知道 “JavaScript中万物都是对象”(当然这句话是错的,我们当然有基本类型,不过它们可以转化为相应的对象)

那么函数它也是一个对象。

1
2
3
4
function fun() {

}
fun.__proto__ === Function.prototype

这个例子可以看到,我们的函数对象,是通过构造器Function来构造的。也就是说,每当我们声明一个function,它其实就是调用了Function()函数new了一个对象。

那么我们之前知道prototype是一个对象。Function的prototype是什么呢? 它是一个空函数,如下图所示.

因此typeof Function.prototype的结果是function,这也是唯一一个原型是函数的函数。

所有函数对象的__proto__都指向Function.prototype,包括那些构造器。也包括Function()自己,毕竟它自己也是一个函数对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Number.__proto__ === Function.prototype  // true
Number.constructor == Function //true

Boolean.__proto__ === Function.prototype // true
Boolean.constructor == Function //true

String.__proto__ === Function.prototype // true
String.constructor == Function //true

// 所有的构造器都来自于Function.prototype,甚至包括根构造器Object及Function自身
Object.__proto__ === Function.prototype // true
Object.constructor == Function // true

// 所有的构造器都来自于Function.prototype,甚至包括根构造器Object及Function自身
Function.__proto__ === Function.prototype // true
Function.constructor == Function //true

Array.__proto__ === Function.prototype // true
Array.constructor == Function //true

RegExp.__proto__ === Function.prototype // true
RegExp.constructor == Function //true

Error.__proto__ === Function.prototype // true
Error.constructor == Function //true

Date.__proto__ === Function.prototype // true
Date.constructor == Function //true

所有构造器都继承了·Function.prototype·的属性及方法。如length、call、apply、bind

所有的构造器也都是一个普通 JS 对象,可以给构造器添加/删除属性等。同时它也继承了Object.prototype上的所有方法:toString、valueOf、hasOwnProperty等。

[[ProtoType]]链

这个原型链就是我们之前提过的 __proto__,它通过链的形式,一层一层网上指,使得所有的对象都继承了Object.prototype的属性

同样它也使得所有的数组继承了 Array.prototype 的属性。

等等等

因此,js中的继承靠的就是原型链(而不是原型prototype)。

到这里建议你回到第一章,好好看看那张图,和我们的说过的话。然后再读一遍本文。

《你不知道的JavaScript(上)》阅读笔记(二)—— this与函数柯里化

this的指代问题是JavaScript常见的坑。当然它也是面试的时候常见的问题。

这里对于《你不知道的JavaScript(上)》的第二部分前几章关于this的讲解进行一些摘录,并且谈谈自己的想法。

1、关于this

首先搞清楚this存在的价值: 复用。很多时候我们会为不同的对象抽象相同的方法,那么我们用this来进行指代,就可以方便这些逻辑的复用。

关于js的this有两个误解:

  • this指向函数对象自己
  • this指向函数的作用域

上面两种理解都是错误的。

this 是在运行时绑定的,不是在编写时绑定的。它的上下文取决于函数调用时的各种条件。this的绑定与函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。

2、this的绑定规则

2.1 默认绑定

函数独立执行的时候。在无法使用其他绑定方法的时候使用默认绑定。默认绑定会将this绑定到全局对象即windows. 注意严格模式下全局对象无法使用默认绑定

2.2 隐式绑定

考虑调用的位置是否有上下文对象。如果有的话this会绑定到调用函数的对象上。

例如

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42

对象属性引用链中只有最顶层或者说最后一层会影响调用位置

再此注意,这里提到的上下文是调用的时候,而不是在声明的时候。

隐式丢失:

如果对于上面“调用”的理解不够深刻,会出现很多意想不到的事情。一个常见的问题就是隐式绑定的函数可能会丢失绑定的对象,从而使用默认绑定。

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

在这里,我们可以发现bar()在调用的时候,this并没有指向对象obj,而是采用了默认绑定,绑定到了全局对象上。

这并不是一个错误,我们考虑一下js的赋值语句,我们知道对于非简单类型的值,赋值的时候都是对引用的赋值,并且这个引用不会传递。在上面这个例子中这个不可传递的意思也就是barobj.foo都直接引用了函数foo. bar的调用就是普通的调用,而不是对于obj.foo的调用。

上面的这个错误更容易发生在函数作为参数的函数调用环节。如下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

这里函数传参的方式和上面的例子是一样的,但是它更加隐蔽,容易被忽略。这个问题也导致我们的回调函数有很大的麻烦。因为回调函数会在未来被执行,它总是会丢失自己的this。

2.3 显式绑定

如果我们想要强制的将函数与某个对象绑定,我们有一些显式绑定的方法。比如call和apply。每个函数都有call和apply方法,它接收的第一个参数就是this要绑定的对象,后面跟随一些参数。这两个方法的区别也仅仅是后面参数的形式。

apply和call语法比较

1
2
Function.call(obj[,param1,[param2[,[...paramN]]]]);
Function.apply(obj[,argArray]);

如上所示,call的参数是一个一个参数单独放入,而apply则是一个参数的列表。

由于我们显式声明了一个对象,因此称之为显式绑定。

硬绑定

显式绑定同样没有办法解决丢失绑定的问题。但是显示绑定的一个变种可以解决该问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的bar 不可能再修改它的this
bar.call( window ); // 2

实际的操作方法也很简单,其实就是在绑定外包裹一个函数,通过调用包裹在外层的函数来避免绑定的丢失。这样我们外层函数的赋值、绑定,都不会影响其内部写死的绑定。

硬绑定是一个常用的手法,因此我们可以写一个通用的硬绑定生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

上面的bind函数接收一个函数和一个对象,返回这个函数将this绑定到该对象上的版本。事实上这里bind建立了一个闭包,使用闭包返回了绑定后的函数。

由于硬绑定是一种非常常用的模式,所以在ES5 中提供了内置的方法Function.prototype.bind

2.4 new 绑定

JavaScript 中new 的机制实际上和面向类的语言完全不同。

首先我们重新定义一下JavaScript 中的“构造函数”。在JavaScript 中,构造函数只是一些使用new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new 操作符调用的普通函数而已。

作者的这段话有助于我们对于js的new和构造函数进行深入的理解。实际上不存在构造函数,只存在函数的构造调用

使用new来调用函数会执行以下流程:

  • 创建一个全新的对象
  • 新对象被执行 [[prototype]] 连接
  • 新对象会绑定到函数调用的this
  • 如果函数没有返回其他对象那么new表达式中的函数调用会自动返回这个新对象。

这里的绑定也就是new 绑定

3、this绑定的优先级

先说结论,new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。

可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

  • 函数是否在new 中调用(new 绑定)?如果是的话this 绑定的是新创建的对象。 var bar = new foo()
  • 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。var bar = foo.call(obj2)
  • 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。var bar = obj1.foo()
  • 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。var bar = foo()

显式绑定和隐式绑定以及默认绑定的优先级可以很容易的用代码来验证。

而new绑定和显式绑定的优先级的验证方式可以用硬绑定bind来验证。

1
2
3
4
5
6
7
8
9
10
function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2
var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3

如上的结果,new成功改变了bind的绑定。(注意这个bind是es5的bind,不是我们之前写过的那个简单的bind)

我们更应该关注的是为什么要把bind和new结合起来使用。这里就是我们本文的第二个知识点: 函数柯里化。 我们在后续章节进行具体介绍。容我先把this讲完。

4、绑定例外

如果你把null 或者undefined 作为this 的绑定对象传入call、apply 或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则

另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

箭头函数: es6中的箭头函数this绑定永远是外层作用域的this

bind与函数柯里化

1、从new与bind开始谈起

在上文验证new与bind的优先级时提到过new与bind同时使用的场景。

其实这个场景是有意义的,并非是我们为了验证而特意编写出来的。

举个例子:

1
2
3
4
5
6
7
8
function adder(p1,p2) {
this.val = p1 + p2;
}
// 之所以使用null 是因为在本例中我们并不关心硬绑定的this 是什么
// 反正使用new 时this 会被修改
var adder5 = adder.bind( null, 5 );
var baz = new adder5( 1 );
baz.val; // 6

在上面的例子中,我们分两步才向adder中传完两个参数。首先传了一个5,返回了一个函数,其次向上面返回的函数传入1,得到了结果1+5

函数柯里化做的就是这种事情。

2、函数柯里化

柯里化,英语:Currying(果然是满满的英译中的既视感),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 普通的add函数
function add(x, y) {
return x + y
}

// Currying后
function curryingAdd(x) {
return function (y) {
return x + y
}
}

add(1, 2) // 3
curryingAdd(1)(2) // 3

这个例子和之前我们的bind是类似的。

函数柯里化的主要作用就是参数复用。

我们可以自己封装一个函数柯里化的方法:

1
2
3
4
5
6
7
8
function curry(fn) {
var args = Array.prototype.slice.call(arguments,1);
return function() {
var innerArgs = Array.prototype.slice.call(arguments);
var finalArgs = args.concat(innerArgs);
return fn.apply(null,finalArgs);
}
}

首先获取第一次的参数,然后返回一个闭包,闭包中获取剩下的参数,然后利用apply方法执行fn

es5的bind实现了函数的柯里化。拿红宝书的一个例子

1
2
3
4
5
6
7
8
var handler = {
message: "Event handled",
handleClick: function(name,event) {
alert(this.message+":"+name+":"+event.type);
}
};
var btn = document.getElementById('my-btn');
EventUtil.addHandler(btn,'click',handler.handleClick.bind(handler,'my-btn'));

这个例子中回调函数有两个参数:name和event,我们在bind的时候先把name传入,当监听的事件触发时,回调函数被调用,第二个参数event被传入。

最后参考 详解JS函数柯里化[https://www.jianshu.com/p/2975c25e4d71]
实现一道有名的面试题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 实现一个add方法,使计算结果能够满足如下预期:
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

function add() {
// 第一次执行时,定义一个数组专门用来存储所有的参数
var _args = Array.prototype.slice.call(arguments);

// 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
var _adder = function() {
_args.push(...arguments);
return _adder;
};

// 利用toString隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
_adder.toString = function () {
return _args.reduce(function (a, b) {
return a + b;
});
}
return _adder;
}

add(1)(2)(3) // 6
add(1, 2, 3)(4) // 10
add(1)(2)(3)(4)(5) // 15
add(2, 6)(1) // 9

闭包到底是什么?

这篇文章主要讲讲什么是闭包。

写在前面的碎碎念

笔者最近在阅读书籍《你不知道的JavaScript(上)》,该书的第一部分即是作用域与闭包,而正是这一章节,让我坐下来好好重新了解一下“闭包”这个词语的含义。

有幸的是,笔者在经过计算机组成、体系结构和操作系统等底层原理课程的学习之后,对于作用域等概念有了比较深刻的理解。得益于这些基础知识,让我对于曾经比较畏惧和排斥的概念重新拾起了兴趣。

所以学校教的东西虽然看上去没有立竿见影的效果,不像前端开发那样,今天学习了React,明天就可以搭建一个网站。但是其实它们对于我们有着潜移默化的影响,这个影响很深远。

1、什么是闭包

闭包它其实并没有特别神秘,相反,它时常出现在我们写下的代码之中。只要我们对于作用域的理解足够深刻,那么闭包的概念就显得十分自然。事实上闭包是函数作用域下的必然产物。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

这句话也可以这样理解,函数与其所在的词法作用域共同构成了闭包。

“记住”这个词的含义需要仔细揣摩。所谓“记住”就是:某个函数的作用域其外层是不能访问的,但是通过闭包,我们可以在其外部对其进行访问,尽管看上去这个函数的空间已经被销毁了。

并且闭包的使用场景就是去“记住”某个作用域。

2、从函数作用域谈起

借用《你不知道的JavaScript》作者举的例子

1
2
3
4
5
6
7
8
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();

我们关注一下bar()这个函数,根据函数作用域的规则,函数bar()定义于函数foo()之中,那么其内对于变量 a 的引用(这里是RHS引用查询),就会自然而然地去访问其外层作用域即foo()函数的作用域。因为函数 bar() 中没有变量a,而函数作用域是允许嵌套的,并且在进行RHS引用访问时,会进行递归查找。

上面的描述是自然而然的,任何一个了解JavaScript语法的人都能够理解,并且认为我在说废话。事实上也确实是这样。

那么这个是闭包吗?

或许是,但不完全是。

上面的这个东西虽然不完全是闭包, 但是它是闭包规则的一部分,也是最重要的一部分。希望读者能够注意这句话,并且再去想一下上面那段看上去是废话的解释。

3、一个完整的真正的闭包

再此借用作者的例子,当然这个例子也是几乎所有认识闭包的程序员见过的第一个闭包的例子。事实上,它只是对上面的代码进行了一个很简单的改动。

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2

我们来解释一下这段代码。函数bar() 的词法作用域能够访问foo() 的内部作用域。然后我们将bar() 函数本身当作一个值类型当作返回值进行传递。在foo()执行之后,其内部的函数bar()赋值给了baz。那么bar()这个函数(现在这个函数被baz引用)很明显能够正常执行,并且输出了a的值2。函数bar()在自己定义的作用域(函数foo())外部被调用并且执行,同时它还能访问a这个本不能在全局作用域被访问到的变量

foo() 执行后,我们通常会期待foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

但是这件事情被bar()给阻止了,因为它拥有了涵盖foo()内部作用域的闭包,使得该作用域仍然存在,以供bar在之后的任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

到这里,闭包的概念已经很明了了。正如文章开头所说的,闭包没有那么神奇,它就是一件很自然的事情。

4、闭包的作用

我们学会一项技术就要学会怎么去使用它。

4.1 回调函数与IIFE(立即执行函数表达式)

1
2
3
4
5
6
7
8
9

function setupBot(name, selector) {
$( selector ).click( function activator() {
console.log( "Activating: " + name );
} );
}
setupBot( "Closure Bot 1", "#bot_1" );
setupBot( "Closure Bot 2", "#bot_2" );

上面这个jQuery框架的例子中,我们为click事件绑定了一个回调函数。了解过一些事件循环概念的人应该会知道这个回调函数的执行是由系统调用的。那么上面代码中,这个回调函数在未来执行的时候仍然能够使用name这个变量的值,这就是闭包。因为它“记住”了其所在的作用域,而name作为函数参数也包含在函数作用域之中。

再来一个例子

1
2
3
4
5
6
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

这是个老生常谈的例子,相信每一个学习JavaScript的人都在很多博客、很多场景中看到过这个例子。如果你还是天真的认为它的输入是 1-5,那么我建议你重新去学习一下作用域等等很多的基础知识。

没错,这段代码的输出结果是5个6,具体原因我就不解释了,我们来看看我们如何通过闭包来将其更改成我们想要的结果。

1
2
3
4
5
6
7
8
for (var i=1; i<=5; i++) {
(function(){
var j = i;
setTimeout( function timer() {
console.log( j );
}, i*1000 );
})()
}

通过这种方式,我们在匿名函数中新建了一个作用域,在这个作用域中用j来保存了i这个变量的值。这个时候函数timer就“记住”了j的值,这同样是闭包。

这也揭示了IIFE的作用: 创建一个作用域来保存我们想要的值.

es6以后,我们有了块作用域,上面的问题可以很容易的得到解决了。

4.2 模块化

学习过C++或者Java的同学一定知道类可以有私有成员。而私有成员的主要作用就是防止外部代码不小心对其进行更改。举一个C++的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person(){
private:
string name = "Tom";
public:
void setName(string name){
this.name = name;
}
string getName(){
return this.name;
}
}
Person person = new Person();
person.setName("Ydream");
std::cout<<person.getName();

这个简单的C++代码就是面向对象的“封装”理念的体现。

有了闭包,我们JavaScript也可以进行封装,我们用js写一下上面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {
var name = 'Tom';
function setName(str) {
name = str;
}
function getName() {
return name;
}
return {setName,getName}
}
var person = Person();
person.setName('Ydream');
console.log(person.getName());//Ydream

5、总结

一个函数和其对于声明它的作用域的引用就是闭包。

闭包产生的原因就是函数作用域的规则,根据函数作用域的规则,闭包的产生显得自然而然。

闭包无处不在,我们平时也时常在使用闭包。

《你不知道的JavaScript(上)》阅读笔记(一)

知乎上无意间看到了这本书,扫了眼内容以后觉得这本书写的很好,因此抽空读一遍。

这个系列的读书笔记主要是我对书中学到的知识进行的摘抄。偶尔会有一些个人想法,

1、作用域是什么

对于 var a = 2 的理解

编译器将 var a 当作一句声明,会查看作用域内是否有a,有的话忽略,没有的话在作用域内新增变量a

引擎将 a = 2 当作一句运行语句,去查找 a

(这里也让我明白了为什么 js会有变量的声明提升,因为他的编译和运行是分开来的两个过程)

LHS 和 RHS

LHS:Left-Hand-Side

RHS:Right-Hand-Side

即赋值的左侧和右侧:当出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。

LHS找引用(地址),RHS获取值即可。前者递归寻找失败会在全局作用域声明该变量(严格模式下会报ReferenceError),后者递归寻找失败也会报ReferenceError,如果对该变量进行不合理操作报TypeError

ReferenceError 同作用域判别失败相关,而TypeError 则代表作用域判别成功了,但是对
结果的操作是非法或不合理的。

2、词法作用域

词法阶段

全局变量会自动成为全局对象(比如浏览器中的window 对象)的属性,因此
可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引
用来对其进行访问。
window.a
通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量
如果被遮蔽了,无论如何都无法被访问到。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处
的位置决定。

欺骗词法

这一节讲了改变词法作用域的一些方式,主要是 eval 和 with

JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是
什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能
下降。

之前早就在知乎上看到人说 eval 和 with 会严重影响性能。现在了解到了 WHY

另外 with 这个语法 我以前是从来没有了解过的。

eval

默认情况下,如果eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函
数),就会对eval(..) 所处的词法作用域进行修改。

在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其
中的声明无法修改所在的作用域。

with

作者举了两个例子,并且对它们进行详解,进而解释了with的用法及缺点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}

上面的例子就是with的大多数用法,这也是大多数入门教程上讲解(没错,看到这里我突然想起来我在菜鸟教程上看到过这个玩意,只不过忘了)

接下来作者举了另外一个例子,这个例子讲揭示 with 的真正作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!

重点关注o2的部分

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对
象的属性也会被处理为定义在这个作用域中的词法标识符。

尽管with 块可以将一个对象处理为词法作用域,但是这个块内部正常的var
声明并不会被限制在这个块的作用域中,而是被添加到with 所处的函数作
用域中。

eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而
with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

进行正常的 LHS 标识符查找

o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符a,因此当a=2 执行
时,自动创建了一个全局变量(因为是非严格模式)。

这个例子的讲解非常明确

性能

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的
词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到
标识符。

最悲观的情况是如果出现了eval(..) 或with,所有的优化可能都是无意义的,因此最简
单的做法就是完全不做任何优化。

3、函数作用域与块作用域

函数作用域

es5 中函数会创建一个新的作用域,这个作用域包括函数的参数、函数内部声明的各个变量。并且值得注意的是嵌套在函数内部定义的函数,是可以访问其外层的作用域的。

声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来。

块作用域

es6 中let和const声明的变量支持块作用域

es5中 with是块作用域的一个例子

另一个不常被人注意的例子是 try/catch

1
2
3
4
5
6
7
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found

catch语句中是一个块作用域。 (这也是es6语法转化为es5的一个解决方案)

4、提升

变量的声明提升其实按照先前提过的 编译器和引擎 两步走的方式是很容易理解的,这里可以重申一次:

我们的编译器先对代码进行一次执行,它会把所有的变量声明都进行处理。在此之后,引擎再从头开始执行代码,这样我们就会感受到后面声明的变量被提升。

而且变量的声明可以覆盖。

另一个原则是: 函数的声明会被优先提升

1
2
3
4
5
6
7
8
9
10
foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}

foo进行了两次函数声明,一次变量声明,这里被提升为了函数,输出了3。而如果第三个函数没有声明的话,输出的结果应当是1,尽管在第一次函数声明之后又再一次对foo进行了变量声明。

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:

1
2
3
4
5
6
7
8
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}

很明显,因为我们的编译器不会执行代码,它没有办法判断a到底是true还是false

5、作用域闭包

下面这一章的内容十分重要。它让我重新审视闭包,也刷新了我之前对于闭包的粗浅的认识。正如作者说的,闭包它其实并没有特别神秘,相反,它时常出现在我们写下的代码之中。只要我们对于作用域的理解足够深刻,那么闭包的概念就显得十分自然。事实上闭包是作用域下的必然产物。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

这句话也可以这样理解,函数与其所在的词法作用域共同构成了闭包。(这个词法作用域会被函数记住,并且可以访问)。

写到这里,我决定单独为闭包写一篇博客:闭包到底是什么?,来体现其重要性。

本书的第一部分到这里也就结束了,第二部分讲解了this和对象原型。

Linux-Lab3

shell程序设计

课程名称: Linux应用技术基础

实验类型:综合型

实验项目名称: shell程序设计

一、实验环境

操作系统:Windows 10 家庭中文版 64位操作系统,基于x64的处理器

处理器: Intel(R) Core(TM)i7-8750H CPU 2.20GHz 2.21 GHz

内存(RAM ) : 16.0GB

Linux版本: Ubuntu-1904

二、实验内容和结果及分析

1、编写一个shell脚本程序,它带一个命令行参数,这个参数是一个文件名。如果这个文件是一个普通文件,则打印文件所有者的名字和最后的修改日期。如果程序带有多个参数,则输出出错信息。

code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
if [ $2 ];then #more than one parameter
echo "Too many parmeters, please input exactly one parameter!"
elif [ $1 ];then
if test -f "$1";then #get input
ownername=$(ls -l $1 | awk '{print $3}') #get name
month=$(ls -l $1 | awk '{print $6}') #get month
day=$(ls -l $1 | awk '{print $7}') #get day
time=$(ls -l $1 | awk '{print $8}') #get time
#output
echo "Filename:$1"
echo "Owner's name:$ownername"
echo "Modify time:$month $day $time"
else #not a file
echo "$1 is not a ordinary file"
fi
else
echo "You should input one parameter!"
fi

test:

创建test文件和dir文件夹

image-20200617210417017

以依次通过没有参数、一个文件参数、一个目录参数、两个参数来测试sh

image-20200617211223892

如图 获得了预期的结果。

2、 编写shell程序,统计指定目录下的普通文件、子目录及可执行文件的数目,统计该目录下所有普通文件字节数总和,目录的路径名字由参数传入。

code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh
echo "normal files:" `find $1 -type f | wc -l` #normal files
echo "subdirectory:"`find $1 -type d | wc -l` #subdirectory
echo "executable files:" `find $1 -type f -executable | wc -l` #executable files
num=0
for file_name in `ls $1` #for each file
do
file=$1"/"$file_name #get the path
if [ -f $file ] #if it is an ordinary file
then
ch=$(cat $file | wc -c) #get the number of chars
num=$(($num+$ch))
fi
done
echo "total char num: $num"

image-20200617213350746

在lab3中实验,可以看到结果正确。

3、编写一个shell 脚本,输入一个字符串,忽略(删除)非字母后,检测该字符串是否为回文(palindrome)。对于一个字符串,如果从前向后读和从后向前读都是同一个字符串,则称之为回文串。例如,单词“mom”,“dad”和“noon”都是回文串。

1
2
3
4
5
6
7
8
9
10
11
#!/bin/sh
echo -n "Please input the string:"
read line ##get input
str=`echo $line | tr -c -d [:alpha:] ` ##delete other chars
reverse=`echo $str | rev`
if [ $str = $reverse ]; ##check if int the same
then
echo "$str is palindorme."
else
echo "$str isn't palindorme"
fi

image-20200617214722800

尝试了几组测试结果,成功忽略了非字母,并且判断回文结果正确。

4、编写一个shell脚本,把当前目录下文件大小大于100K的文件全部移动到~/tmp/ 目录下。

1
2
#!/bin/sh
find ./ -type f -size +100k -exec mv {} ~/tmp/ \; ##find语句来实现

image-20200617215559497

创建好tmp后,我们执行shell后发现两个大于100k的文件被移动到了/home/ydream/tmp下

####5、编写一个实现文件备份和同步的shell脚本程序dirsync。程序的参数是两个需要备份同步的目录,如:

$dirsync /dir1 ~/dir2$ # $/dir1$为源目录,$~/dir2$为目标目录​

dirsync程序实现两个目录内的所有文件和子目录(递归所有的子目录)内容保持一致。程序基本功能如下。

1) 备份功能:目标目录将使用来自源目录的最新文件,新文件和新子目录进行升级,源目录将保持不变。dirsync程序能够实现增量备份。

2) 同步功能:两个方向上的旧文件都将被最新文件替换,新文件都将被双向复制。源目录被删除的文件和子目录,目标目录也要对应删除。

代码1

代码1通过比较文件更改时间来进行,子目录使用递归进行更新备份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
#!/bin/bash

# dirsync.sh
# Two modes:
# 1. back-up
# 2. synchronize

# Judge the input parameter
if [ $# -ne 2 ] #judge the number of parameter
then
echo "program: $0 needs one prameter."
exit 1
fi
if [[ ! -d $1 || ! -d $2 ]] #check if they are dictionary
then
echo "parameters must be dirctory"
exit 1
fi

# The definitions of functions used

# To update file.
# @parameters : source file, destination path and source path

function update_file {
new_file=$2\/$1
source_file=$3\/$1
if [`! -f $new_file`] && [ `stat -c %Y $new_file` -gt `stat -c %Y $source_file` ]
then
cp -fp $source_file $new_file
echo "File "$source_file" has been copied"
cnt_update=$cnt_update+1 # cnt the number
return 1
fi
return 0
}

# To delete the file that doesn't exist in the given source file.
# @Parameters : source file, destination path and source path
# Use a dictionary /tmp to replace rm
function delete_file {
if test -f $2\/$1
then
:
else
mv $3\/$1 \/tmp
echo "File "$3\/$1" has been deleted"
cnt_delete=$cnt_delete+1 # cnt the number
return 1
fi
return 0
}

# To update a directory.
# @Parameters : source dir, destination path and source path

function update_dir {
cnt=0
if test -d $2\/$1
then
for item in $(ls $3\/$1)
do
if test -d $3\/$1\/$item
then
update_dir $item $2\/$item $3\/$1 # recursively update
elif test -f $3\/$1\/$item
then
update_file $item $2\/$1 $3\/$1 # check the file
if (( $? == 1 ))
then
cnt=$cnt+1
fi
fi
done
else
cp -fpr $3\/$1 $2\/$1
echo "Directory "$3\/$1" has been copied"
fi
}

# To delete the directory that doesn't exist in the given source file.
# @Parameters : source dir($1), destination path($2) and source path($3)
# Use a dictionary /tmp to replace rm
function delete_dir {
cnt=0
if test -d $2\/$1
then
for item in $(ls $3\/$1)
do
if test -d $3\/$1\/$item # check if it is a dir
then
delete_dir $item $2\/$item $3\/$1 # recursively delete
continue
fi
if test -f $3\/$1\/$item # check if it is a file
then
delete_file $item $2\/$1 $3\/$1 # delete a file
if (( $? == 1 ))
then
cnt=$cnt+1
fi
continue
fi
done
else
mv $3\/$1 \/tmp # safely remove
echo "Directory "$3\/$1" has been deleted"
fi
}

# Main

source_dir=${1:-./}
destination_dir=${2:-./}
declare -i cnt_update=0 # counter of update
declare -i cnt_delete=0 # counter of deletion
echo "---------------------------------Welcome To Dirsync---------------------------------"
echo -e "Source directory : \033[33m ${source_dir} \033[0m"
echo -e "Destination directory : \033[33m ${destination_dir} \033[0m"
echo "mode1 Backup : ${destination_dir} will be updated with the file from ${source_dir}"
echo "mode2 Synchronize: all the old files will be updated."
read -p "Please input mode and press Enter (1/2) : " mode


if [ $mode = 1 ]
then
# back-up
echo -e "\033[31mbackup... \033[0m"
# Note that the '/' is removed
source_dir=${source_dir%/}
destination_dir=${destination_dir%/}
for item in $(ls ${source_dir}\/)
do
tmp_path=$source_dir\/$item
if test -d $tmp_path
then
update_dir $item $destination_dir $source_dir
elif test -f $tmp_path
then
update_file $item $destination_dir $source_dir
fi
done

echo -e "\033[32mBackup done! \033[0m"
echo "Totally updated ${cnt_update} files."
elif [ $mode = 2 ]
then
# Synchronize

echo -e "\033[31mSynchronize... \033[0m"
source_dir=${source_dir%/}
destination_dir=${destination_dir%/}

tmp=$source_dir
source_dir=$destination_dir
destination_dir=$tmp

for item in $(ls ${source_dir}\/)
do
tmp_path=$source_dir\/$item
if test -d $tmp_path
then
delete_dir $item $destination_dir $source_dir
elif test -f $tmp_path
then
delete_file $item $destination_dir $source_dir
fi
done

for item in $(ls ${source_dir}\/)
do
tmp_path=$source_dir\/$item
if test -d $tmp_path
then
update_dir $item $destination_dir $source_dir
elif test -f $tmp_path
then
update_file $item $destination_dir $source_dir
fi
done

tmp=$source_dir
source_dir=$destination_dir
destination_dir=$tmp

for item in $(ls ${source_dir}\/)
do
tmp_path=$source_dir\/$item
if test -d $tmp_path
then
update_dir $item $destination_dir $source_dir
elif test -f $tmp_path
then
update_file $item $destination_dir $source_dir
fi
done
# echo some relavant information
echo -e "\033[32mSynchronize done!\033[0m"
echo "Totally updated ${cnt_update} files."
echo "Totally deleted ${cnt_delete} files."
else
echo "Invalid input!"
fi

  • 1)备份功能

    image-20200621224325527

    image-20200621230030247

    如图1

    dir为源目录,newDir为新目录

    dir中包含1 2 3 三个文件,newDir中没有文件

    执行shell,选择模式1(备份模式),可以看到更新了1 2 3三个文件出现在了newDir中

    然后我们在dir目录下更改文件3,并且新建文件4

    再次执行shell,选择模式1(备份模式), 根据log,我们发现只有3 4被更新了,查看发现1 2 3 4 均出现在newDir中

    图2展示了子目录的情况。

  • 2)同步功能

    image-20200621225539926

    首先对新目录进行备份,使用模式1

    接着删除dir/1 更改dir/2 更改newDir/3 新建dir/4

    运行shell,使用模式2

    根据log,可以看到,

    newDir1 被删除,说明源目录中文件被删除,目标目录下文件也被删除

    newDir3 被复制到dir3 中 dir2被复制到newDir2中,说明双向可以同步文件

    dir中新建的文件4 被复制到newDir中,说明双向可以更新文件

代码2

代码2使用了rsync命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
!#/bin/bash
mkdir tmp ##使用临时文件夹
rsync -av $2 tmp ##使用rsync来同步
for item in `ls tmp/$2` ##遍历移动
do
mv -f tmp/$2/$item tmp
done
rm -rf tmp/$2
rm -f $2 ##删除

rsync -av $1 $2 ##以下内容同上,通过一个临时文件夹来实现同步

for item in `ls $2/$1`
do
mv -f $2/$1/$item $2
done
rm -rf $2/$1
rsync -av tmp $1

for item in `ls $1/tmp`
do
mv -f $1/tmp/$item $1
done
rm -rf $1/tmp
rm -rf tmp

展示

image-20200621230926295

image-20200621230937698

过程同代码1,不赘述

##三、感想和讨论

这个实验中,从简单到复杂,一共写了5个shell脚本,让我对于shell编程有了更深的理解,从简单的几个命令拼凑,到分支结构,到循环结构。在最后一个题目中更是使用了递归函数。在编程的过程中,我拿shell编程和c语言编程做类比,在逻辑上并没有遇到很大的困难,不过shell的语法十分严格,并且没有较好的编辑器来及时检查错误,或者代码补全,以致于经常写完以后没有办法运行。对于我的细心有很大的磨砺。

遇到的问题:

1)在windows环境下编写shell以后通过ftp保存到linux,运行会报错/bin/bash^M: bad interpreter: No such file or directory

查询后发现,原因在于windows下shell是dos格式,linux是unix格式,换行符存在差异。解决方案是用vim打开shell,输入set ff=unix 命令就可以解决

2)在linux直接运行shell会报错 Permission denied 这是因为sh没有权限访问 /bin/bash

只需要通过chmod为shell增加权限即可

3)在实验五中,理解题意花了挺长的时间。由于之前接触过rsync用来同步windows和远程linux服务器的文件,因此首先想到了直接使用rsync命令来完成实验。但是又觉得老师出题的目的应该是想要我们自己去写一个shell来完成这些功能。想了很久,并且与同学讨论之后,想到了使用时间来判断先后,通过直接复制文件来进行同步。当然,最后实现的功能是远远不及直接使用rsync的。并且我写的shell,只能粗暴的根据时间来进行复制,如果文件都有更新,则没办法正确的同步。最好还是使用git等源代码管理工具或者rsync等来进行多端同步。

安全编程原理-LAB4-Format-String-Vulnerability

#Lab 2.4 Format String Vulnerability

1、Crash the program named “vul_prog.c”.

连续输入 %s 即可,经过实验 最少3个就可以完成crash

image-20200617160839651

如图,出现段错误

2、Print out the secret[1] value.

image-20200617170452971

调试代码,在printf设置断点,运行到printf(user_input)

查看堆栈如上,可以看到user_input的参数地址为 0xffffd668 而看1的位置,可以知道user_input和int_input地址差了16.

因此我们可以将secret1的值输入到int_input. 然后通过8个 .%08x 来控制字符串。 输入 “.%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x|%s”

image-20200617174550605

|后面输出了 U 即为secret 并且前面的地址和debug的结果一致

3、 Modify the secret[1] value.

只需使用%hn即可

image-20200617175026933

如图 新的密码变成了0x48

###4、 Modify the secret[1] value to a pre-determined value.

我们把secret1 改为 0x1111

同理,但是我们前面控制长度的字符需要进行改变

0x1111 十进制为 4369

8个.08x 输出长度 8*9=72,远远小于4369

改为7个 .08x 7*9=63

4369-63+1=4307

故输入

%.08x.%08x.%08x.%08x.%08x.%08x.%08x%.4307d%hn

image-20200617180620725

如图 得到了0x1111的正确结果