first commit
This commit is contained in:
176
专栏/JavaScript进阶实战课/00开篇词JavaScript的进阶之路.md
Normal file
176
专栏/JavaScript进阶实战课/00开篇词JavaScript的进阶之路.md
Normal file
@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 JavaScript的进阶之路
|
||||
你好,我是石川,欢迎和我一起学习JavaScript。
|
||||
|
||||
JavaScript的前身Mocha,是布兰登·艾克(Brendan Eich)在1995年用10天时间设计出来的。我第一次接触JavaScript,是从它诞生算起的10年后,在学校学习的,那时的网站更多是静态的信息展示类网页。所以当时,JavaScript和它刚发明出来时类似,更多是一门脚本语言。可以说,整个前端在那个年代和之前的很长时间,都被认为是一个很业余的技能。
|
||||
|
||||
但随着AJAX在2005年诞生,接着W3C在2006年第一次发布了XHR(XMLHttpRequest)规范草案后,这种情况就改变了。基于AJAX,我们不仅可以开发静态展示的页面,也可以开发动态交互的应用。到这时候,JS才逐渐被重视起来,之后AJAX相关技术也被纳入了网络标准。我第一次开发AJAX相关的应用,是2007年毕业后给雅虎开发的一个集合各种Web API (Flickr, Yahoo Answers,类似知乎)的应用门户。所以,我完美地赶上了它的转折期。
|
||||
|
||||
在此之后,JavaScript就从一门简单的脚本语言,发展成了可以用来开发复杂应用的语言。但即使如此,当时的JavaScript本身并没有太大变化,仍然无法像Java或C++一样,拥有强大的标准库,因此开发者们需要自己想办法去解决很多问题,比如当时最大的问题,就是要解决系统兼容性,这也导致当年涌现了很多第三方库。
|
||||
|
||||
而我也是在同一时间,接触了开源,把平时自己踩过的坑以及解决方案写成了小工具。再后来,我有幸和Google、Twitter的一些工程师合作,创建了 HTML5 Boilerplate开源项目,为的就是解决一些前端经常遇到的兼容性问题。之后,我在英国出版了一本《HTML5 移动Web开发实战》,以及继续从事一些开源工作,后续推出了开源项目 JavaScript Patterns & Anti-Patterns。
|
||||
|
||||
而随着Web应用的进一步发展,我在工作中也面临了更大的挑战。最近几年,我和腾讯、阿里合作了一些大型前端项目。在做项目的过程中,我发现随着应用使用者的大规模增加,很多问题产生的副作用也会呈现指数级上升,性能、安全等非功能性的问题也会不断显露。
|
||||
|
||||
举个例子,我曾在项目中负责一个大型订票应用的开发,由于业务流程复杂、业务需求也瞬息万变,因此一个很小的副作用就会造成大量订单问题,这时就要用到纯函数思想中的幂等,来保证任意多次执行结果与一次执行结果相同,避免订单的重复提交。
|
||||
|
||||
除了由于业务复杂度、高并发引起的副作用外,在大量访问的情况下,一个很小的资源加载对资源的消耗就是指数级的,那在强调降本增效的今天,能够实现对资源的有效控制,对我们做业务来说是十分关键的一点。比如说,如果通过函数式+响应式编程,我们就可以通过用户的实时需求动态加载资源,从而能够节省不必要的资源预加载成本。
|
||||
|
||||
这样算下来,我从接触前端,再到使用JavaScript进行开发工作,已经有十余年的时间。在这个过程中,我遇到过太多前端/JavaScript方面的疑难杂症,因此在接下来的时间里,我会将我的JavaScript实践经验一一分享给你,希望能帮助你建立起系统解决问题的思维、掌握实际处理问题的方法。
|
||||
|
||||
为什么要学这门课?
|
||||
|
||||
现在,JavaScript早已不是当年的一个脚本语言,随着Web应用和Node.js的兴起、函数式编程的复兴,以及响应式编程开始进入人们视野,让JavaScript看上去更“专业”了。虽然和十几年前相比较,JavaScript也加入了很多功能和语法糖,但是它的核心原理并没有太大变化。
|
||||
|
||||
可即使没有太多本质上的变化,JavaScript也仍然具有容易入门但难以进阶的问题。
|
||||
|
||||
我认为造成这个问题的原因主要有两个:一是早期写JavaScript的很多程序员的编程基础并不扎实,知识点很杂,导致大家对JS的理解不够深入;二是后来入局的一些大咖,很多都是从其它语言转来的,他们认为一些知识没法分享和讲解。
|
||||
|
||||
|
||||
拿函数式编程中的Monad举例,道格拉斯·克罗克福特(Douglas Crockford,他帮助改良了JavaScript)就曾经说过:Once someone understands Monads, they lose the ability to explain it to anybody else.-
|
||||
大意是你一旦会用Monad,你就不知道怎么给别人解释它了。
|
||||
|
||||
|
||||
这就使得JavaScript的开发者两极分化很严重,一部分一直停留在入门级,一部分出道即巅峰。
|
||||
|
||||
所以这门课的初衷,就是让学习JavaScript的你,能够对这个开始比较不那么“专业”的语言,有一个系统的专业理解。帮助你一步一个脚印,把点连成线,把线连成面,把面搭建起一座空间立体的“思维大厦”。
|
||||
|
||||
|
||||
|
||||
看到这座“大厦”,你可能也会望而却步,觉得知识体系十分庞杂和分散,学习起来很可怕。不过不用担心,万丈高楼平地起,只要你结合实际,按照我分享的方法掌握了知识的脉络,理解起来、用起来就并不复杂。
|
||||
|
||||
那么我的方法是什么呢?下面就跟你具体说道说道。
|
||||
|
||||
怎样学才能少走弯路?
|
||||
|
||||
很多人认为,编程模式、数据结构和算法可以脱离语言本身,因为无论用什么语言都会用到这些模式。
|
||||
|
||||
在我看来,它们确实可以抽象出来,但是基于语言的特点,我们能更好地理解这些模式的应用。毕竟,无论是读别人的代码,还是让机器有效执行程序,“语言”才是我们交流的工具。
|
||||
|
||||
函数式编程
|
||||
|
||||
打个比方,函数式编程和响应式编程之所以崛起或者说复兴,和实际应用是分不开的。因为在前端开发中,我们的应用面对的是UI客户端,所以应用背后的程序,就需要处理大量的用户和网络触发的事件,并根据事件的状态来做出响应。
|
||||
|
||||
而函数式和响应式编程的很多思想,正好可以帮助这一目的的实现,开发者和用户是用脚投票的,所以才出现了复兴。而不是单单基于某个理论,或者要和面向对象比个高下,才得到广泛应用的。
|
||||
|
||||
但是我也发现,如果脱离了实际的语言和它解决的问题,来解释编程模式,就会高度抽象,显得比较形而上,还是拿函数式编程中很重要的概念Monad来说明,其官方解释是这样的:
|
||||
|
||||
|
||||
All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.-
|
||||
总而言之,X 中的单子只是 X 的内函子类别中的一个monoid,乘积 × 被内函子的组合替换,单位由恒等函子设置。
|
||||
|
||||
|
||||
这样的解释,相信会让想要了解函数式编程的你头皮发麻,更不要说去掌握它的强大功能了。虽然它的解释并没有错,因为这就是一个数学上的概念,需要严谨且客观。但问题是,它和我们做开发有什么关系呢?
|
||||
|
||||
那么,要我来说的话,对于“为什么需要函数式和响应式编程”这个问题,用一个很简单的生活例子其实就能解释清楚了。
|
||||
|
||||
你应该也有感触,这两年全球范围内黑天鹅事件频发,我们感到世界的秩序在重置,过去我们面对繁杂和复杂,还可以“摸着石头过河”;可是现在,连石头都没有了,我们感到的就是“混乱”。
|
||||
|
||||
威尔士学者戴夫·斯诺登(Dave Snowden),在1999年供职于IBM期间,提出过一个Cynefin框架,就是来解决这种“混乱”的。他认为,在面对混乱时,应该按这三步走:行动、感知、响应。
|
||||
|
||||
|
||||
|
||||
这和我们的应用有什么关系?
|
||||
|
||||
我们其实可以结合JS语言对函数式编程的支持,以及它相关领域的前端应用来理解。在前端开发中,人机交互是很复杂的。比如我们加载了一个界面后,根本不知道用户会做出什么反应,或者点击哪个按钮。那我们怎么办呢?其实就是先把初始页面加载下来,也就是第一步,行动。
|
||||
|
||||
那么,当用户点击了一个按钮,比如一个日历的时候,我们就进行到了第二步,即感知到用户想要选择一个日期。所以,我们就可以给这个按钮加上事件处理器(event handler),完成相关的日历组件或模块加载,通过之前“记住”的用户需求,展示加载后的日历,这也就是第三步,响应。
|
||||
|
||||
|
||||
|
||||
在这个过程当中,我们要通过工具来处理很多事,比如事件处理、状态管理。函数就是这个工具,它的核心就是IO输入输出。我们给函数一个输入值(数据结构),它进行处理(算法),然后给我们一个输出值(返回值)。
|
||||
|
||||
而Monad的核心,就是围绕一个“值”来组织各种方法。所以无论是JavaScript本身带的 array.map,还是我们常用的React.js里面的observer、reducer, 其实万变不离其宗,都是在复杂系统、不确定性和混沌中,帮助我们去行动、感知和响应,解决各种副作用,提高性能的方法和工具。
|
||||
|
||||
所以,函数式、响应式编程不是远在天边的概念,而是近在眼前的生产力,如果你没有看到,可能只是缺少一双发现它们的眼睛。
|
||||
|
||||
面向对象编程
|
||||
|
||||
如果说在使用函数式编程的时候,我们考虑的是“生产力”,那在使用面向对象的时候,我们考虑的更多是“生产关系”。我想你在学习JavaScript之前可能也有其它的语言背景,会习惯性地将class等概念照搬到JavaScript,而忽略JavaScript本身的一些特点。
|
||||
|
||||
比如JavaScript中的继承,是基于原型链的继承,更偏向delegation而不是inheritance。即使在面向对象设计本身,也是追求多用组合少用继承。所以,我们可以看看JavaScript在面向对象中,有哪些自己的特点,来组织生产关系。
|
||||
|
||||
在课程中,我们并不会用二极管的思维去说哪一种模式就是银弹,而是会根据实际情况,找到合适的方法。
|
||||
|
||||
数据结构和算法
|
||||
|
||||
说到数据结构和算法,相信无论是刷Leetcode还是看极客时间上的课程,你都可以掌握,而且虽然不能说绝对,但算法确实相对独立于语言。有朋友曾经问过我,学习数据结构和算法应不应该刷题,我其实认为应该,有的时候哪怕是应试,也是学习的一种方法。
|
||||
|
||||
但是在讲这部分知识的时候,我并不会应试地讲,因为你完全可以在市面上找到类似的内容。在课程里,我希望能带你在学习JavaScript的引擎、浏览器和编译原理的过程中,来理解这些数据结构和算法,这样你更能理解这门语言的运行机制和原理,起到和专门讲算法的课程相辅相成的作用。比如你如果不明白stack、queue、heap,就几乎很难理解哪怕比较基础的闭包和事件循环,更不要说基于这些机制来做程序开发时的考量。
|
||||
|
||||
这门课的设计思路
|
||||
|
||||
那么,基于以上思考,我也总结了学习JavaScript的几大痛点。
|
||||
|
||||
首先,如前面所说,正是因为JavaScript是一门学习门槛不高的语言,虽然上手快,但是容易缺乏对底层逻辑的理解,这会从很大程度上限制我们的代码质量和解决更复杂问题的能力。
|
||||
|
||||
其次,也会导致很多理论知识,我们很难学以致用。
|
||||
|
||||
最后,就是由于前端涉及的知识点非常广,比如你要理解JavaScript的运行机制,就要了解计算机组成、引擎、浏览器和编译,如果每个知识点都跳来跳去地看,消耗的无效精力可能会是几倍、几十倍。
|
||||
|
||||
|
||||
|
||||
所以在设计这门课的时候,我主要划分了五个模块来解决这些问题。
|
||||
|
||||
|
||||
JavaScript之道
|
||||
|
||||
|
||||
第一个模块,我们先来讲讲函数式和面向对象的编程模式,毕竟其中的一些核心概念或元认知(metacognition)即使不是恒久不变,也至少是到目前为止,经受住了时间考验的核心理论和实践。因此,当你理解和掌握了这两类核心编程范式之后,你就知道要如何结合JavaScript的特性进行取长补短了,也能够因地制宜地解决实际问题了。
|
||||
|
||||
|
||||
JavaScript之法
|
||||
|
||||
|
||||
接下来,我会带你来学习JavaScript的底层逻辑和所用到的数据结构与算法,以此帮助你写出更高效的代码。我会从大量的开源项目等案例出发,带你了解、学习和掌握JS引擎及浏览器在编译和运行时的一些特点,帮助你达成对这些知识点的真正理解,最后能够融会贯通。这样你在使用JS的一些功能,如排序或者做代码优化的时候,就能够更好地抓住重点,管理预期。
|
||||
|
||||
|
||||
JavaScript之术
|
||||
|
||||
|
||||
在理解了JavaScript的数据结构与算法之后,我们还要一起来看看它用到的设计模式。前面我也强调过JS编程模式(函数式+响应式及面向对象)的重要性,而设计模式其实是前面模块的延续。这个部分,我会结合一些三方的库,来帮助你理解和掌握如何通过设计模式进一步提高“生产力”,优化“生产关系”。
|
||||
|
||||
|
||||
JavaScript之器
|
||||
|
||||
|
||||
我们知道,通过工具的有效使用,可以减少重复的工作,帮助我们提高开发质量和效率。因此在这个模块中,我们依然是从案例出发,来了解、学习JavaScript中的常用工具及其背后的使用原理、使用场景,让你能够通过对原理和实践经验的理解,更好地为开发赋能。
|
||||
|
||||
|
||||
JavaScript之势
|
||||
|
||||
|
||||
我们说唯一不变的就是变化本身,通过前面对JavaScript知识体系的系统性理解,最后,我们也来看看前端一些新的技术趋势,了解下这些变化和趋势会对我们产生哪些影响,以此进一步巩固知识体系,进阶为一名JavaScript语言应用强者。
|
||||
|
||||
|
||||
|
||||
写在最后
|
||||
|
||||
最后我想说,学习是一件需要长期坚持做的事。我希望在接下来的更新时间里,你能坚持每讲都学完,并在评论区参与讨论。不过我也知道,这说起来容易,做起来其实很难,毕竟学习本身就是一件反人性的事儿。
|
||||
|
||||
那如果这么难,如何才能真正坚持学习课程呢?我没有标准答案,但是我想结合自己的一些经验,来给你分享一些可行的方法,希望能对你有所帮助。
|
||||
|
||||
|
||||
第一就是在学习一节知识时,尽量一鼓作气,有些概念即使模糊,你硬着头皮看下去也比停顿去深入了解某个点强。实在遇到困难的地方,你可以简单查下资料,但只要查到刚刚能让你继续往下读的内容即可,千万不要偏离了每节课的主题。
|
||||
第二是可以反馈意见,我们承诺会帮助;如果你对某个知识点有更简单的描述、更独特的见解,也欢迎你在评论区多分享。
|
||||
第三是可以让你的好朋友也一起学习课程,一起成长。读万卷书,不如行万里路,行万里路有时不如沟通和交流,这就像是编程中的Peer programming。
|
||||
|
||||
|
||||
另外,为了让你学起来没那么难,我也借用函数式和面向对象编程模式中常用的两个思想,“声明式”和“基于接口而非实现”来设计这门课。也就是把这个过程当做一个对话,尽量用平实的语言、易读的代码和直观的图片,来帮助你理解一些生涩难懂的概念。
|
||||
|
||||
最后,也感谢你的信任,希望通过这门课,我们可以一起和时间做朋友,共同成长!通过对话,我们彼此也能成为终身学习之路上的好朋友。
|
||||
|
||||
延伸阅读
|
||||
|
||||
|
||||
Cynefin framework
|
||||
Import on Interaction
|
||||
|
||||
|
||||
|
||||
|
||||
|
312
专栏/JavaScript进阶实战课/01函数式vs.面向对象:响应未知和不确定.md
Normal file
312
专栏/JavaScript进阶实战课/01函数式vs.面向对象:响应未知和不确定.md
Normal file
@ -0,0 +1,312 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 函数式vs.面向对象:响应未知和不确定
|
||||
你好,我是石川。
|
||||
|
||||
编程模式(programming paradigm)可以说是编程语言的元认知。从编程模式的角度看JavaScript,它是结构化的、事件驱动的动态语言,且支持声明式和指令式两种模式。所以我们说,JavaScript是一个多模式(multi-paradigm)的语言,也是一门“丰富”的语言。
|
||||
|
||||
|
||||
|
||||
在JavaScript所支持的编程模式中,用得最多的是面向对象(OOP object oriented programming)和函数式(FP functional programming)两种,其中又以面向对象的普及率最高。现在介绍面向对象的书已经很多了,函数式因为受众相对小一些,支持的语言少一些,所以被提及的也相对比较少。
|
||||
|
||||
我猜你也许已经对这两种编程模式有所了解,甚至会比较熟悉,但我之所以还是要在第一节课去强调这个话题,是因为你在学习JavaScript时,可能会面对以下至少 1个核心痛点:
|
||||
|
||||
|
||||
如果你已经学过传统的面向对象语言,那么在学JavaScript的时候,很可能对函数式的理解和运用不够深入;
|
||||
反之,如果你一开始就学习JavaScript,只是停留在开发一些简单应用上,可以说你对它的面向对象的理解和运用,也不会很深入。
|
||||
|
||||
|
||||
这两种学习困扰,很多时候会导致我们刚知道了点概念,就碰上了千奇百怪的副作用,然后我们发现还需要去学习解决它的办法,最后往往很容易就放弃了。
|
||||
|
||||
补充:在开篇词里,我提到函数式+响应式编程可以对抗不确定性。这个概念不只是在编程中,它也是一个跨学科的研究。比如在AI、机械和航空航天工程这些硬科技的领域,以及很多知名的大学(如伯克利、麻省理工)和政府机构(如NASA),都对System Dynamics and Controls开展了很深入的研究。其核心就是研究在动态情况下如何做到系统控制,其中很重要的一点就是处理波动和干扰。
|
||||
|
||||
而在函数式编程中,我们通常会把各种干扰,就叫做副作用(Side effect)。
|
||||
|
||||
所以接下来,我会先带你从“思维大厦”的顶层开始,来了解JavaScript语言的核心思想,然后再带你看看如何因地制宜地使用这两种编程模式来解决问题。这样一来,你在日后面对已知和未知问题,做复杂系统开发的时候,也能找到一套行之有效的方法了。
|
||||
|
||||
|
||||
|
||||
函数式编程
|
||||
|
||||
首先,我们一起来看看函数式编程,了解下函数是什么、它是做什么用的、在编程中可能会有哪些副作用,以及如何利用JavaScript的核心设计思想和工具,解决这些副作用。
|
||||
|
||||
函数是什么、如何使用?
|
||||
|
||||
一个函数由输入、函数和输出组成,这和我们在初中就学过的函数一样,函数是数据集到目标的一种关系,它所做的就是把行为封装起来,从而达到目标。
|
||||
|
||||
|
||||
|
||||
举一个最简单的例子:我们要实现一个“计算消费税”的工具,目标是通过产品价格计算出消费税。
|
||||
|
||||
以下代码中的productPrice是输入的形参(parameter),产品价格100元是传入的数据实参(argument),而calculateGST这个功能就是封装算法的函数本身;代码中输出的5,就是返回值(returned value),也就是消费税5元。
|
||||
|
||||
function calculateGST( productPrice ) {
|
||||
return productPrice * 0.05;
|
||||
}
|
||||
calculateGST(100); // return 5
|
||||
|
||||
|
||||
其实,很多开发者常用的jQuery就是一个工具集。我们打开jQuery在GitHub的源代码,可以看到里面有大大小小的工具助手。比如下面这个isArrayLike函数,就是一个帮助我们判断一个对象是不是类似数组的功能。这个功能也可以独立于jQuery库存在,这就是函数式编程最基本的使用。
|
||||
|
||||
function isArrayLike( obj ) {
|
||||
var length = !!obj && obj.length,
|
||||
type = toType( obj );
|
||||
|
||||
if ( typeof obj === "function" || isWindow( obj ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return type === "array" || length === 0 ||
|
||||
typeof length === "number" && length > 0 && ( length - 1 ) in obj;
|
||||
}
|
||||
|
||||
|
||||
所以,通过isArrayLike可接受的参数可见,函数的输入值不仅可以是一个基础类型数据(primitive type),比如前面例子中的数字或者字符串;也可以是一个相对复杂些的对象类型数据(object type),包括对象本身和数组。甚至,函数本身作为对象,也可以是输入或输出值,我们把这种函数就叫做高阶函数(higher order functions)。
|
||||
|
||||
|
||||
|
||||
函数中都有哪些副作用?
|
||||
|
||||
前面我们说过,函数已经把算法封装了起来,那么函数里相对就是可控的,而比较不可控的是外部环境。这里,我们可以把不可控的外部环境分为三大类。
|
||||
|
||||
第一类,函数中最常见的副作用,就是全局变量(global variable)。比如下面的例子里,我们首先定义了一个全局变量x,之后每次在log它的值之前,都执行了不同的函数,但我们没法保证这些函数没有改变这个变量的值,也没法保证每次输出的结果是1。所以从输入开始,这种不确定性就存在了。
|
||||
|
||||
var x = 1;
|
||||
foo();
|
||||
console.log( x );
|
||||
bar();
|
||||
console.log( x );
|
||||
baz();
|
||||
console.log( x );
|
||||
|
||||
|
||||
除了全局变量以外,另一个比较明显的问题就是 IO影响(IO effects)。这里的IO说的不是前面函数里的参数和返回值,而是类似前端浏览器中的用户行为,比如鼠标和键盘的输入,或者如果是服务器端的Node的话,就是文件系统、网络连接以及stream的stdin(标准输入)和stdout(标准输出)。
|
||||
|
||||
第三种比较常见的副作用是与网络请求(HTTP request)相关,比如我们要针对一个用户下单的动作发起一个网络请求,需要先获得用户ID,再连着用户的ID一起发送。如果我们还没获取到用户ID,就发起下单请求,可能就会收到报错。
|
||||
|
||||
减少副作用:纯函数和不可变
|
||||
|
||||
那么我们要如何减少以上这些副作用呢?在函数式编程中,有两个核心概念:纯函数(pure function)和不可变(immutability)。
|
||||
|
||||
这是一个“双循环”,纯函数更多解决的是“内循环”;而不可变更多考虑的是“外循环”。
|
||||
|
||||
|
||||
|
||||
纯函数的意思是说,一个函数的返回结果的变化只依赖其参数,并且执行过程中没有副作用。也就是说打铁还需自身硬,面对外界的复杂多变,我们要先保证函数封装的部分本身是稳固的。比如前面消费税计算器的例子,当输入的产品价格参数为100时,输出的结果永远是5。无论有什么干扰,它都不会返回一个不是5的数字,除非你换一个参数。
|
||||
|
||||
我们再来看下面这个例子,当把税率从函数中抽离出来,放在函数外作为变量时,它就不是一个纯函数了,因为随着这个变量的变化,计算结果会有所不同。所以,纯函数就可以通过减少对外界不确定因素的依赖,来减少副作用。
|
||||
|
||||
var rate = 0.05;
|
||||
function calculateGST( productPrice ) {
|
||||
return productPrice * rate;
|
||||
}
|
||||
calculateGST(100); // return 5
|
||||
|
||||
|
||||
除了纯函数,函数式编程解决副作用的另一个核心思想,就是不可变。这个如何理解呢?我们可以通过JavaScript中自带的splice和slice来举例。
|
||||
|
||||
const beforeList = [1,2,3,4]
|
||||
console.log(beforeList.splice(0,2))
|
||||
console.log(beforeList.splice(0,2))
|
||||
//[ 1, 2 ]
|
||||
//[ 3, 4 ]
|
||||
|
||||
const beforeList = [1,2,3,4]
|
||||
console.log(beforeList.slice(0,2))
|
||||
console.log(beforeList.slice(0,2))
|
||||
//[ 1, 2 ]
|
||||
//[ 1, 2 ]
|
||||
|
||||
|
||||
可以看到,数组中的splice方法,在对数据进行了处理后,改变了全局中的beforeList的值,所以是可变的。而slice在执行之后的结果,没有影响全局中的beforeList的值,所以它是不可变的。也是因为这样,在开发中,如果要保证不可变,我们就不能用splice,而用slice。
|
||||
|
||||
所以,不可变就是在减少程序被外界影响的同时,也减少对外界的影响。因为如果你把一个外部变量作为参数作为输入,在函数里做了改变,作为输出返回。那么这个过程中,你可能不知道这种变化会对整个系统造成什么样的结果。
|
||||
|
||||
而且在数组中,你还可以看到更多类似splice和slice这种纯函数、非纯函数,以及可变与不可变的例子。
|
||||
|
||||
|
||||
|
||||
另外,从纯函数和不可变的设计思想中,我们还可以抽象出一个概念。
|
||||
|
||||
因为“副作用”首先是一个作用(effect),而作用遵循的是一个因果(cause and effect)关系。那么,从值的角度来看,“纯函数”对值只影响一次,而“不可变”完全不影响。
|
||||
|
||||
如何理解“纯函数”对值只影响一次呢?这里有一个幂等(idempotence)的概念。如果你做过大型的交易类应用的话,应该对这个概念不陌生。比如说,有时用户对同一个订单多次重复发出更新请求,这时返回的结果不应该有差别。
|
||||
|
||||
在数学中,幂等的意思是不管我们把一个函数嵌套多少次来执行,它的结果都应该是一样的。比如在这个Math.round四舍五入的例子里,无论你嵌套执行几次,结果都是一样的。
|
||||
|
||||
//数学幂等
|
||||
Math.round(((0.5)))
|
||||
|
||||
|
||||
在计算机中,幂等的意思是一个程序执行多次结果是一样的。比如,假设我们有一个adder函数,3和4相加永远返回7。所以,你其实可以把数学里的概念迁移过来。
|
||||
|
||||
//计算机幂等
|
||||
adder (3, 4) // 返回 7
|
||||
adder (3, 4) // 返回 7
|
||||
|
||||
|
||||
好,然后我们再来看看如何理解“不可变”对值完全不影响。
|
||||
|
||||
通过前面array slice和splice的例子,你应该能感觉到,splice更像是一块橡皮泥,一开始它可能是个方块儿,你可以捏出腿和脑袋,它就成了一个小人儿,也就是说它本身发生了变化。而slice在处理完后是形成了一个新的数组,但原始的数组完好无损,它是把值当成乐高积木,而不是橡皮泥。把这种思想用到状态管理里,你就会记录状态的变化,而不会篡改状态。
|
||||
|
||||
|
||||
|
||||
总之,我们可以看到函数式编程最核心的地方,就是输入输出和中间的算法,我们要解决的核心问题就是副作用。而为了解决副作用,我们需要掌握两个重要的概念,一个是纯函数,一个是不可变。纯函数强调的是自身的稳定性,对结果只影响一次;而不可变强调的是和外界的交互中,尽量减少相互间负面的影响。
|
||||
|
||||
|
||||
|
||||
面向对象编程
|
||||
|
||||
我们再来看看面向对象。如前面所说,如果我们用函数来做一个税率计算工具,判断一个数是不是类数组的对象,是没问题的,而且我们可以放心,如果希望它“纯粹”,那么它运行的结果就可以基于我们定义的法则,没有惊喜,没有意外。那这样不就足够了?为什么我们还需要对象?下面我们就来看看。
|
||||
|
||||
对象是什么、如何创建?
|
||||
|
||||
开篇词里,我说过一个“摸着石头过河”的例子,首先得有站在岸边的“你”,这个“你”就是对象,如果没有对象,就算是有一个工具(function),比如有快艇在岸边,它也只能停靠在那儿;或者你有游泳这个方法(method),但它也只有在你身上才能发挥作用。
|
||||
|
||||
这其实就是对象的意义。我们在做业务系统开发的时候,会面对各种各样的业务对象,比如“表单”“购物车”“订单”,这些都可以看做是对象。所以我们说,工具和方法通常是服务于对象的。
|
||||
|
||||
举个例子,假设我们有一个微件对象,我们想定义一个属性是它的微件名称widgetName,并给它定义一个identify的功能来识别自己的名称,那么在JavaScript中,其实就可以通过以下代码来实现:
|
||||
|
||||
var widget = {
|
||||
widgetName : "微件",
|
||||
identify : function(){
|
||||
return "这是" + this.widgetName;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(widget.widgetName); // 返回 "微件"
|
||||
console.log(widget.identify()); // 返回 "这是微件"
|
||||
|
||||
|
||||
为什么需要封装、重用和继承?
|
||||
|
||||
实际上,如果说函数加对象组成了生产力,那么封装、重用和继承则可以用来组成生产关系。
|
||||
|
||||
|
||||
|
||||
封装最常见的使用就是在我们做组件化设计的时候,比如在一个旅行网站页面中,我们看到的筛选器、日历、结果区域都可以看做是不同的模块(module)或组件( component),这些组件是通过封装导入加载到页面的。
|
||||
|
||||
重用就是把可以重复使用的功能抽象到一个类里,每次只是创建一个它的实例对象来使用。比如我们的页面上有很多按钮,它们的功能大同小异,这时我们就可以把它抽象出来成为一个类(class),每一个按钮都是一个按钮类中的实例(instance)。
|
||||
|
||||
当然,上面我们说的按钮可能虽然功能上大同小异,但还是有具体差别。这时,我们可以把通用功能放到抽象类;而一些特定的行为或属性,我们可以通过继承放到实现类中,这样在继承了基础的父类(parent class)功能的基础上(extend),我们能够在子类(child class)中作一些改动。
|
||||
|
||||
但是如果一个程序中,父子的层级过于复杂,也会变得“官僚化”,如果父类有了问题,就会牵一发动全身,而且抽象的层级过多,也会让代码难以理解。
|
||||
|
||||
实际上,在面向对象中,也有组合的概念,就是一个子类不是继承的某个父类,而是通过组合多个类,来形成一个类。这也很像我们如今的职场,公司为了应付外界竞争压力,内部会有一个个的敏捷团队,通过每个成员自身价值和团队“组合”产生1+1>2的价值,而不是强调依靠某种从属关系。
|
||||
|
||||
所以,在面向对象的编程中,也有“组合”优于“继承”的概念。不过在实际情况下,继承也不是完全不可取的,在开发中,我们使用哪种思想还是要根据情况具体分析。
|
||||
|
||||
什么是基于原型的继承?
|
||||
|
||||
好,既然说到了继承,那我们还需要明确一个问题,什么是基于原型的继承?
|
||||
|
||||
这里我们首先要搞清楚一点:JavaScript中的类和其它面向对象的语言,究竟有什么不同?
|
||||
|
||||
对于传统的面向对象的编程语言来说,比如Java,一个对象是基于一个类的“蓝图”来创建的。但是在JavaScript中,就没有这种类和对象的拷贝从属关系。实际上,JS里的对象和“类”,也就是构建函数之间是原型链接关系。
|
||||
|
||||
比如,在下图左边基于类的例子中,以一个类作为蓝图,可以创建两个实例。而右边基于原型的例子里,我们可以看到通过一个构建函数构建出的两个对象,是通过原型链和构建函数的原型相连接的,它们并不是基于一个蓝图的复制拷贝和从属关系。
|
||||
|
||||
|
||||
|
||||
虽然后来JavaScript在ES6之后也加入了类,但实际上它只是运用了语法糖,在底层逻辑中,JavaScript使用的仍然是基于原型的面向对象。
|
||||
|
||||
在ES6+中,class的语法糖用法基本和之前的类似,只是把function变成了class:
|
||||
|
||||
class Widget {
|
||||
constructor (){
|
||||
// specify here
|
||||
}
|
||||
notice(){
|
||||
console.log ("notice me");
|
||||
}
|
||||
|
||||
display(){
|
||||
console.log ("diaplay me");
|
||||
}
|
||||
}
|
||||
|
||||
var widget1 = new Widget();
|
||||
|
||||
widget1.notice();
|
||||
widget1.display();
|
||||
|
||||
|
||||
好,我们再通过一个例子来实际观察下原型链。下面的代码中,我们是通过函数自带的call()方法和对象自带的Object.create()方法,让Notice作为子类继承了Widget父类的属性和方法,然后我们创建了两个实例notice1和notice2。
|
||||
|
||||
而这时,我们如果用getPrototypeOf来获取notice1和notice2的原型,会发现它们是等于Notice原型。当我们用display方法调用这个方法时,实际调用的是原型链里Notice的原型中的方法。
|
||||
|
||||
function Widget(widgetName) {
|
||||
this.widgetName= widgetName;
|
||||
}
|
||||
|
||||
Widget.prototype.identify = function() {
|
||||
return "这是" + this.widgetName;
|
||||
};
|
||||
|
||||
function Notice(widgetName) {
|
||||
Widget.call(this, widgetName);
|
||||
}
|
||||
|
||||
Notice.prototype = Object.create(Widget.prototype);
|
||||
|
||||
Notice.prototype.display= function() {
|
||||
console.log("你好, " + this.identify() + ".");
|
||||
};
|
||||
|
||||
var notice1 = new Notice("应用A");
|
||||
var notice2 = new Notice("应用B");
|
||||
|
||||
Object.getPrototypeOf(notice1) === Notice.prototype //true
|
||||
Object.getPrototypeOf(notice2) === Notice.prototype //true
|
||||
|
||||
notice1.display(); // "你好,这是应用A"
|
||||
notice2.display(); // "你好,这是应用B"
|
||||
|
||||
|
||||
而这就印证了前面所说的,在传统的面向对象语言,比如Java里,当我们用到继承时,一个类的属性和功能是可以被基于这个类创建的对象“拷贝”过去的。但是在JavaScript里,虽然我们用Notice创建了notice1和notice2,但是它俩并没有将其属性和功能拷贝过来,而是默认通过原型链来寻找原型中的功能,然后利用“链接”而不是“拷贝”来。
|
||||
|
||||
for (var method in Notice.prototype) {
|
||||
console.log("found: " + method);
|
||||
}
|
||||
// found: display
|
||||
// found: identify
|
||||
|
||||
|
||||
所以,我们通过上面的for in,就可以找出所有原型链上的功能,而如果我们想要看Notice函数的原型对象都有哪些功能的话,可以看到返回的是display和identify。这就证明,除了Notice自己的原型对象和自己的display功能之外,它也链接了Widget里的identify功能。
|
||||
|
||||
|
||||
|
||||
好,现在我们知道了,面向对象编程最核心的点就是服务业务对象,最需要解决的问题就是封装、重用和继承。在JavaScript中,面向对象的特殊性是基于原型链的继承,这种继承更像是“授权”,而不是传统意义的“父子”继承。而且为了解决继承的层级过多的情况,在面向对象中,也有组合优于继承的思想。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们主要是了解了函数式编程和面向对象编程的核心概念,它们一个是管理和解决副作用,一个是服务于业务对象。
|
||||
|
||||
而理解这部分内容,对于我们接下来要深入了解的这两种编程模式,以及后面会学习的各种数据结构和算法、JavaScript的各种设计模式等等,都有很强的指导意义,它能为我们学好并更好地应用JavaScript这门语言,提供扎实的理论基础。
|
||||
|
||||
思考题
|
||||
|
||||
我们提到函数式编程的时候,说到为了解决副作用,因此有了不可变和纯函数的概念,那么你觉得JavaScript中的常量(const,constant)算不算不可变呢?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
延伸阅读
|
||||
|
||||
|
||||
Beginning Functional JavaScript
|
||||
Function Light JS
|
||||
JavaScript世界的一等公民——函数
|
||||
Mastering JavaScript Functional Programming
|
||||
You Don’t Know JS: this & Object Prototypes
|
||||
JavaScript Patterns
|
||||
Learning JavaScript Design Patterns
|
||||
|
||||
|
||||
|
||||
|
||||
|
269
专栏/JavaScript进阶实战课/02如何通过闭包对象管理程序中状态的变化?.md
Normal file
269
专栏/JavaScript进阶实战课/02如何通过闭包对象管理程序中状态的变化?.md
Normal file
@ -0,0 +1,269 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 如何通过闭包对象管理程序中状态的变化?
|
||||
你好,我是石川。
|
||||
|
||||
通过上节课的学习,现在我们知道,函数式编程中存在副作用(side effect),而纯函数和不可变就是减少副作用的两个核心思想。那么按理说,我们要想把副作用降低到接近为零,就可以用纯函数,同时不接受任何参数。但是这样完全自我封闭的函数,也就几乎没有什么使用意义了。
|
||||
|
||||
所以,作为一个函数,还是要有输入、计算和输出,才能和外界有互动往来,我们的系统也才能“活”起来。而一个活的系统,它的状态肯定是在不停变化的,那么我们如何才能在不可变的原则下,来管理这种变化呢?
|
||||
|
||||
今天这节课,我们就一起来看看在函数式编程中,有哪些值是可变的、哪些不可变,以及如何能在状态更新的同时做到不可变。
|
||||
|
||||
值的(不)可变
|
||||
|
||||
首先,我们要搞清楚一个问题,值到底是不是可变的?在JavaScript中,值一般被分为两种:原始类型和对象类型。
|
||||
|
||||
先来看原始类型。像字符串或数字这种数据类型,都是属于原始类型,而它们本身是不可变的。举个例子:在console.log中输入2 = 2.5,得到的结果会是invalid,这就证明了我们不可能改变一个原始类型的值。
|
||||
|
||||
2 = 2.5 // invalid
|
||||
|
||||
|
||||
然后是对象类型。在JavaScript中,像数组、对象这类数据类型就叫做对象类型,这类数据更像是一种数据结构或容器。那这样的“值”是否可变?其实通过上节课数组的例子,你能看到这类值是可变的,比如通过splice这种方法。
|
||||
|
||||
所以下面,我们就来看看在使用对象类型的值来存储状态的时候,要如何在更新状态的同时做到不可变。
|
||||
|
||||
React.js中的props和state
|
||||
|
||||
这里,我们就以React.js为例,来观察下它是用什么类型的值作为状态的。
|
||||
|
||||
说到状态,React中有两个很重要的概念经常容易被混淆,分别是props和state。props通常是作为一个外部参数传入到函数里,然后作为静态元素输出在UI中渲染;state则是一个内部变量,它会作为动态元素输出在UI中渲染,并根据行为更新状态。
|
||||
|
||||
|
||||
|
||||
在上面这个图例中,有一个静态的文案和一个动态的计数器。其中,props就是“点击增加:”这个文案,它在页面上基本是不应该有什么变化的,就是一句固定的提示语,所以它就是props,一个静态的“属性”。
|
||||
|
||||
而在计数按钮中,它的值是基于每次点击加一的,也就是基于点击动态变化的,所以我们说它就是state,一个动态“状态”。
|
||||
|
||||
// 属性 props
|
||||
class Instruction extends React.Component {
|
||||
render() {
|
||||
return <span>提示 - {this.props.message}</span>;
|
||||
}
|
||||
}
|
||||
const element = <Instruction message="点击以下按钮增加:" />;
|
||||
|
||||
// 状态 state
|
||||
class Button extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
updateCount() {}
|
||||
render() {
|
||||
return (<button onClick={() => this.updateCount()}> 点击了 {this.state.count} 次</button>);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
那么回到刚才的问题,在React.js里,props和state是什么类型的值呢?答案是对象,props和state都是用对象来储存状态的。
|
||||
|
||||
可是,React为什么用对象做属性和状态存储值类型呢?它还有没有其它选择呢?下面我们就来看看。
|
||||
|
||||
结构型值的不可变
|
||||
|
||||
我们先来思考一个问题:props和state是不是必须的?
|
||||
|
||||
答案是,props是必须的,而state不是。因为假设我们的页面是一个静态页面,那么我们渲染内容就好了,但这种纯静态的应用也完全无法和用户互动。
|
||||
|
||||
所以在前端,纯静态的应用被叫做dumb as f*ck。当然这是一个不太文明的词,不过话糙理不糙,而且它的意思也很明显,就是说这样的应用也太“笨”了。
|
||||
|
||||
我们的应用肯定需要和用户交互,而一旦有交互,我们就需要管理值的状态(state) 和围绕值设计一系列行为(behavior)。在这个过程中,我们需要考虑的就是一个值的结构性不可变的问题。
|
||||
|
||||
所以接下来,我们就一起看看围绕值的结构性操作,都有哪些数据类型可以选择。
|
||||
|
||||
闭包和对象
|
||||
|
||||
首先是闭包(closure)和对象(object),这二者都可以对一个状态值进行封装和创建行为。
|
||||
|
||||
闭包最大的特点是可以突破生命周期和作用域的限制,也就是时间和空间的控制。
|
||||
|
||||
这里的突破生命周期的限制是指,当一个外部函数内嵌一个内部函数时,如果内嵌函数引用了外部函数的变量,这个变量就会突破生命周期的限制,在函数结束执行后,仍然存在。比如在下面闭包的例子中,我们创建了一个计数器,每次加1,可以记住上一次的值。
|
||||
|
||||
而突破作用域的限制是指,我们可以把一个内部函数返回成一个方法在外部调用。比如以下代码中,counter返回的counting方法,我们可以通过counter1来执行这个方法,从而就突破了counter作用域的限制。
|
||||
|
||||
function counter() {
|
||||
let name = "计数";
|
||||
let curVal = 0;
|
||||
function counting() {
|
||||
curVal++;
|
||||
}
|
||||
function getCount() {
|
||||
console.log(
|
||||
`${name}是${curVal}`
|
||||
);
|
||||
}
|
||||
return {counting,getCount}
|
||||
}
|
||||
|
||||
var counter1 = counter();
|
||||
|
||||
counter1.counting();
|
||||
counter1.counting();
|
||||
counter1.counting();
|
||||
counter1.getCount(); // 计数是3
|
||||
|
||||
|
||||
同样地,我们也可以通过对象来封装一个状态,并且创建一个方法来作用于这个状态值。
|
||||
|
||||
var counter = {
|
||||
name: "计数",
|
||||
curVal: 0,
|
||||
counting() {
|
||||
this.curVal++;
|
||||
console.log(
|
||||
`${this.name}是${this.curVal}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
counter.counting(); // 计数是1
|
||||
counter.counting(); // 计数是2
|
||||
counter.counting(); // 计数是3
|
||||
|
||||
|
||||
所以,单纯从值的状态管理和围绕它的一系列行为的角度来看,我们可以说闭包和对象是同形态的(isomorphic),也就是说可以起到异曲同工的作用。比如上面闭包例子中的状态,就是对象中的属性,我们在闭包中创建的针对值的行为,也可以在对象中通过方法来实现。
|
||||
|
||||
|
||||
|
||||
你可能要问,我们对比它们的意义是什么呢?其实是因为它们在隐私(privacy)、状态拷贝(state cloning)和性能(performance)上。还是有差别的,而这些差别在结构性地处理值的问题上,具有不同的优劣势。
|
||||
|
||||
下面,我们就从这几个维度的不同点展开来看下。
|
||||
|
||||
|
||||
|
||||
属性的查改
|
||||
|
||||
实际上,你通过闭包的例子可以发现,除非是通过接口,也就是在外部函数中返回内部函数的方法,比如用来获取值的getCount方法,或者重新赋值的counting方法,不然内部的值是对外不可见的。
|
||||
|
||||
所以,它其实可以细粒度地控制我们想要暴露或隐藏的属性,以及相关的操作。
|
||||
|
||||
counter1.counting();
|
||||
counter1.getCount();
|
||||
|
||||
|
||||
而对象则不同,我们不需要特殊的方式,就可以获取对象中的属性和重新赋值。如果想要遵循不可变的原则,有一个 Object.freeze() 的方法,可以把所有的对象设置成只读 writable: false。
|
||||
|
||||
但这里有一点需要注意,通过freeze会让对象所有的属性变得只读,而且不可逆。当然,它的好处就是严格遵守了不可变原则。
|
||||
|
||||
counter.name;
|
||||
counter.initVal;
|
||||
counter.counting();
|
||||
|
||||
|
||||
状态的拷贝
|
||||
|
||||
所以到这里,我们可以发现,针对原始类型的数据,无需过度担忧值的不可变。
|
||||
|
||||
不过,既然应用是“活”的,就可能会有“一系列”状态。我们通常需要通过诸如数组、对象类的数据结构,来保存“一系列”状态,那么在面对这一类的数据时,我们如何做到遵循不可变的原则呢?
|
||||
|
||||
|
||||
如何通过拷贝管理状态?
|
||||
|
||||
|
||||
要解决这个问题,我们可以通过拷贝+更新的方式。也就是说,我们不对原始的对象和数组值做改变,而是拷贝之后,在拷贝的版本上做变更。
|
||||
|
||||
比如在下面的例子中,我们通过使用spread,来展开数组和对象中的元素,然后再把元素赋值给新的变量,通过这种方式,我们完成了浅拷贝。之后,我们再看数组和对象的时候,会发现原始的数据不会有变化。
|
||||
|
||||
// 数组浅拷贝
|
||||
var a = [ 1, 2 ];
|
||||
var b = [ ...a ];
|
||||
b.push( 3 );
|
||||
a; // [1,2]
|
||||
b; // [1,2,3]
|
||||
|
||||
// 对象浅拷贝
|
||||
var o = {
|
||||
x: 1,
|
||||
y: 2
|
||||
};
|
||||
var p = { ...o };
|
||||
p.y = 3;
|
||||
o.y; // 2
|
||||
p.y; // 3
|
||||
|
||||
|
||||
所以可见,数组和对象都是很容易拷贝的,而闭包则相对更难拷贝。
|
||||
|
||||
|
||||
如何解决拷贝性能问题?
|
||||
|
||||
|
||||
从上面的例子中,我们可以看到通过对状态的拷贝,是可以做到不可变,不过随之而来的就是性能问题。
|
||||
|
||||
如果这个值只改变一两次,那就没问题。但假设我们的系统中有值不停在改变,如果每次都拷贝的话,就会占据大量内存。这样一来,我们应该如何处理呢?
|
||||
|
||||
实际上,在这种情况下,有一个解决方案就是用到一个类似链表的结构,当中有一组对象记录改变的指数和相关的值。
|
||||
|
||||
比如下面的 [3, 1, 0, 7] 这组数组中,我们把第0个值变成2,第3个值变成6,第4个值添加1,形成了 [2, 1, 0, 6, 1]。那么如果我们只记录变化的话,就是0:2、3:6和4:1这三组对象,是不是就减少了很多内存占用?
|
||||
|
||||
|
||||
|
||||
其实目前在市面上,已经有很多成熟的三方库比如immutable.js,它们会有自己的数据结构,比如array list和object map,以及相关算法来解决类似的问题了。
|
||||
|
||||
性能的考虑
|
||||
|
||||
我们接着再来看看性能上的考虑。
|
||||
|
||||
从性能的角度来讲,对象的内存和运算通常要优于闭包。比如,在下面第一个闭包的例子中,我们每次使用都会创建一个新的函数表达。
|
||||
|
||||
而第二个对象的例子中,我们通过bind将this绑定到greetings2上,这样一来,PrintMessageB就会引用greetings2.name来作为this.name,从而达到和闭包一样的效果。但我们不需要创建一个闭包,只需要将this指向引用的对象即可。
|
||||
|
||||
// 闭包
|
||||
function PrintMessageA(name) {
|
||||
return function printName(){
|
||||
return `${name}, 你好!`;
|
||||
};
|
||||
}
|
||||
var greetings1 = PrintMessageA( "先生" );
|
||||
greetings1(); // 先生,你好!
|
||||
|
||||
// 对象
|
||||
function PrintMessageB(){
|
||||
return `${this.name}, 你好!`;
|
||||
}
|
||||
var greetings2 = PrintMessageB.bind( {
|
||||
name: "先生"
|
||||
} );
|
||||
greetings2(); // 先生,你好!
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们一起深入理解了函数式编程中的不可变。我们需要重点关注的,就是对象和闭包在处理不可变问题上的不同优势。
|
||||
|
||||
|
||||
在属性和方法的隐私方面,闭包天然对属性有保护作用,同时它也可以按需暴露接口,来更细粒度地获取或重新给状态赋值。但是它和我们要解决的问题,似乎关系不大。
|
||||
而对象不仅可以轻松做到 props整体不可变,而且在需要state变化时,在拷贝上也更有优势。不过从性能的角度来看,如果拷贝的量不大,也许它们的性能差不多,但如果是一个高频交互的界面,微小的差别可能就会被放大。
|
||||
|
||||
|
||||
所以总结起来,在React.js中,它选择使用对象作为props和state的值类型,能更容易保证属性和状态值的整体不可变;而且面对状态的变化,它也更容易拷贝;在处理高频交互时,它的性能也会更好。
|
||||
|
||||
而闭包虽然有隐私上的优势和更细粒度的操作,可是在应用交互和状态管理这个场景下,它并没有什么实际的作用。所以,有利于使用对象的条件会相对多一些。
|
||||
|
||||
最后,你也可以再来复习下这两种方式的优劣势。其实,基于React.js的例子,你可以发现,不同的数据类型和处理变与不变的方式,并没有绝对的好与不好,而是需要根据具体情况,来确定哪种方式更适合你的程序和应用所需要支持的场景。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
我们在提到值的状态拷贝时,说spread做到的是浅拷贝,那么你是否了解与之对应的深度拷贝?它会不会影响状态的管理?
|
||||
|
||||
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
延伸阅读
|
||||
|
||||
|
||||
Component State - React
|
||||
Props vs State
|
||||
ReactJS: Props vs. State
|
||||
Closure vs. Object
|
||||
|
||||
|
||||
|
||||
|
||||
|
245
专栏/JavaScript进阶实战课/03如何通过部分应用和柯里化让函数具象化?.md
Normal file
245
专栏/JavaScript进阶实战课/03如何通过部分应用和柯里化让函数具象化?.md
Normal file
@ -0,0 +1,245 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 如何通过部分应用和柯里化让函数具象化?
|
||||
你好,我是石川。
|
||||
|
||||
在前面两节课里,我说过函数式编程的核心就是把数据作为输入,通过算法进行计算,最后输出结果。同时我也提到,在函数式+响应式编程中,面对未知、动态和不可控时,可以通过纯函数和不可变等手段减少副作用、增加确定性,及时地适应和调整。
|
||||
|
||||
那么现在你来想想,在输入、计算和输出这个过程中,什么地方是最难控制的呢?对,就是输入。因为它来自外界,而计算是在相对封闭的环境中,至于输出只是一个结果。
|
||||
|
||||
所以今天这节课,我们就来说说输入的控制。
|
||||
|
||||
部分应用和柯里化
|
||||
|
||||
在前面课程里也讲过,函数的输入来自参数,其中包括了函数定义时的形参和实际执行时的实参。另外,我们也通过React.js中的props和state以及JavaScript中的对象和闭包,具体了解了如何通过不可变,做到对运行时的未知状态变化的管理。
|
||||
|
||||
那今天,我们就从另外一个角度理解下对编程时“未知”的处理,即如果我们在编写一个函数时,需要传入多个实参,其中一部分实参是先明确的,另一部分是后明确的,那么该如何处理呢?
|
||||
|
||||
其实就是部分应用(partial application)和柯里化(currying)。下面我们就一起来看看函数式编程中,如何突破在调用点(call-site)传参的限制,做到部分传参和后续执行。
|
||||
|
||||
通过部分应用延迟实参传入
|
||||
|
||||
我们知道,函数式编程重在声明式和可读性,而且强调每个函数尽量解决一个单一问题。假设有一个orderEventHandler函数,它比较抽象,因此缺少可读性;又或者假设下面这个函数需要url、data和callback三个参数的输入,才能执行,我们预先知道它的url,却不知道它的data和callback。这时该怎么办呢?
|
||||
|
||||
function orderEventHandler(url,data,callback) {
|
||||
// ..
|
||||
}
|
||||
|
||||
|
||||
要解决这些问题,我们就可以通过部分应用。下面是它的一个执行流程图。
|
||||
|
||||
|
||||
|
||||
也就是说,我们可以通过orderEventHandler函数,具象出一个专门的fetchOrder函数。通过这种方式,我们就提前预置了已知参数url,减少了后面需要传入的参数数量,同时也增加了代码的可读性。
|
||||
|
||||
function fetchOrder(data,cb) {
|
||||
orderEventHandler( "http://some.api/order", data, cb );
|
||||
}
|
||||
|
||||
|
||||
可是如果我们想进一步具象化,预制一些参数怎么办?比如下面的getCurrentOrder,如果我们想把前面fetchOrder里的data,也内置成order: CURRENT_ORDER_ID,这样会大量增加代码结构的复杂性。
|
||||
|
||||
function getCurrentOrder(cb) {
|
||||
getCurrentOrder( { order: CURRENT_ORDER_ID }, cb );
|
||||
}
|
||||
|
||||
|
||||
所以在函数式编程中,我们通常会使用部分应用。它所做的就是抽象一个partial工具,在先预制部分参数的情况下,后续再传入剩余的参数值。如以下代码所示:
|
||||
|
||||
var fetchOrder = partial( orderEventHandler, "http://some.api/order" );
|
||||
var getCurrentOrder = partial( fetchOrder, { order: CURRENT_ORDER_ID } );
|
||||
|
||||
|
||||
partial工具可以借助我们在上节课提到过的闭包,以及ES6中引入的…延展操作符(spread operator)这两个函数式编程中的利器来实现。
|
||||
|
||||
我先来说一下延展操作符,它的强大之处就是可以在函数调用或数组构造时,将数组表达式或者string在语法层面展开。在这里,我们可以用它来处理预置的和后置的实参。而闭包在这里再次发挥了记忆的功能,它会记住前置的参数,并在下一次收到后置的参数时,可以和前面记住的前置参数一起执行。
|
||||
|
||||
var partial =
|
||||
(fn,...presetArgs) =>
|
||||
(...laterArgs) =>
|
||||
fn( ...presetArgs, ...laterArgs );
|
||||
|
||||
|
||||
除此之外,我们在上一讲里提到的bind也可以起到类似的作用。但 bind通常是在面向对象中用来绑定this的,用作部分应用的话会相对比较奇怪,因为这里我们不绑定this,所以第一个参数我们会设置为null。
|
||||
|
||||
当然,这么用确实不会有什么问题,但是一般来说,为了不混淆bind的使用场景,我们最好还是用自己定义的partial工具。
|
||||
|
||||
var fetchOrder = httpEvntHandler.bind( null, "http://some.api/order" );
|
||||
|
||||
|
||||
通过柯里化每次传一个参数
|
||||
|
||||
我们接着来看看柯里化。
|
||||
|
||||
可以看到,在下面的例子中,我们把之前的httpEventHandler做了柯里化处理之后,就不需要一次输入3个参数了,而是每次只传入一个参数。第一次,我们传入了url来获取订单;之后,我们传入了当前订单的id;最后,我们获得了当前订单后,传入一个订单修改的参数来做相关修改。
|
||||
|
||||
var curriedOrderEvntHandler = curry( orderEventHandler );
|
||||
|
||||
var fetchOrder = curriedHttpEvntHandler( "http://some.api/order" );
|
||||
|
||||
var getCurrentOrder = fetchOrder( { order: CURRENT_ORDER_ID } );
|
||||
|
||||
getCurrentOrder( function editOrder(order){ /* .. */ } );
|
||||
|
||||
|
||||
你同样可以来看一下它的一个执行流程图,看看柯里化是如何实现的。
|
||||
|
||||
|
||||
|
||||
实际上,和部分应用类似,这里我们也用到了闭包和…延展操作符。
|
||||
|
||||
在柯里化中,延展操作符可以在函数调用链中起到承上启下的作用。当然,市面上实现部分应用和柯里化的方式有很多,这里我选了一个“可读性”比较高的。因为和部分应用一样,它有效说明了参数前后的关系。
|
||||
|
||||
function curry(fn,arity = fn.length) {
|
||||
return (function nextCurried(prevArgs){
|
||||
return function curried(nextArg){
|
||||
var args = [ ...prevArgs, nextArg ];
|
||||
if (args.length >= arity) {
|
||||
return fn( ...args );
|
||||
}
|
||||
else {
|
||||
return nextCurried( args );
|
||||
}
|
||||
};
|
||||
})( [] );
|
||||
}
|
||||
|
||||
|
||||
好了,通过部分应用和柯里化的例子,我们能够发现函数式编程处理未知的能力。但这里我还想强调一点,这个未知,跟我们说的应用在运行时的未知是不同的。这里的未知指的是编程时的未知,比如有些参数是我们提前知道的,而有一些是后面加入的。
|
||||
|
||||
要知道,一个普通的函数通常是在调用点执行时传入参数的,而通过部分应用和柯里化,我们做到了可以先传入部分已知参数,再在之后的某个时间传入部分参数,这样从时间和空间上,就将一个函数分开了。
|
||||
|
||||
而这样做除了一些实际的好处,比如处理未知,让函数从抽象变具体、让具体的函数每次只专心做好一件事、减少参数数量之外,还有一个更抽象的好处,就是体现了函数式底层的声明式思想。
|
||||
|
||||
在这里,我们让代码变得更可读。
|
||||
|
||||
还有哪些常用的参数处理工具?
|
||||
|
||||
在函数式编程中,我们把参数的数量叫做 arity。从上面的例子中,我们可以看到,部分应用可以减少每次函数调用时需要传入的参数,而柯里化更是把函数调用时需要传入的参数数量,降到了1。它们实际上都起到了控制参数数量的作用。
|
||||
|
||||
而在函数式编程中,其实还有很多可以帮助我们处理参数输入的工具。下面,我们就通过unary、constant和identity这几个简单的例子来一起看看。
|
||||
|
||||
|
||||
|
||||
改造接口unary
|
||||
|
||||
我们先来看看改造函数的工具。其中,最简单的工具就是一元参数(unary)了,它的作用是把一个接收多个参数的函数,变成一个只接收一个参数的函数。其实现也很简单:
|
||||
|
||||
function unary(fn) {
|
||||
return function oneArg(arg){
|
||||
return fn( arg );
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
你可能会问它有什么用?我来举个例子。
|
||||
|
||||
当你想通过parseInt,把一组字符串通过map来映射成整数,但是parseInt会接收两个参数,而如果你直接输入parseInt的话,那么“2”就会成为它的第二个参数,这肯定不是你期待的结果吧。
|
||||
|
||||
所以这时候,unary就派上用场了,它可以让parseInt只接收一个参数,从而就可以正确地打出你想要的结果。
|
||||
|
||||
["1","2","3","4","5"].map( unary( parseInt ) ); // [1,2,3,4,5]
|
||||
|
||||
|
||||
看到这里,聪明的你可能会问:除了一元,会不会有二元、三元?答案是有的。二元就是binary,或是函数式中的“黑话”dyadic;三元就是tenary。顾名思义,它们分别代表的就是把一个函数的参数数量控制在2个和3个。
|
||||
|
||||
改造参数constant
|
||||
|
||||
如果你用过JavaScript promise的话,应该对then不陌生。从函数签名的角度来看,它只接收函数,而不接收其他值类型作为参数。比如下面例子中,34这个值就是不能被接收的。
|
||||
|
||||
promise1.then( action1 ).then( 34 ).then( action3 );
|
||||
|
||||
|
||||
这里你可能会问,什么是函数签名?函数签名一般包含了参数及其类型返回值,还有类型可能引发或传回的异常,以及相关的方法在面向对象中的可用性信息(如关键字public、static或prototype)。你可以看到在C或C++中,会有类似这样的签名,如下所示:
|
||||
|
||||
// C
|
||||
int main (int arga, char *argb[]) {}
|
||||
|
||||
// C++
|
||||
int main (int argc, char **argv) {/** ... **/ }
|
||||
|
||||
|
||||
而在JavaScript当中,基于它“放荡不羁”的特性,就没有那么多条条框框了,甚至连命名函数本身都不是必须的,就更不用说签名了。那么遇到then这种情况怎么办呢?
|
||||
|
||||
在这种情况下,我们其实可以编写一个只返回值的constant函数,这样就解决了接收的问题。由此也能看出,JavaScript在面对各种条条框框的时候,总是上有政策下有对策。
|
||||
|
||||
function constant(v) {
|
||||
return function value(){
|
||||
return v;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
然后,我们就可以把值包装在constant函数里,通过这样的方式,就可以把值作为函数参数传入了。
|
||||
|
||||
promise1.then( action1 ).then( constant( 34 ) ).then( action3 );
|
||||
|
||||
|
||||
不做改造identity
|
||||
|
||||
还有一个函数式编程中常用的工具,也就是identity,它既不改变函数,也不改变参数。它的功能就是输入一个值,返回一个同样的值。你可能会觉着,这有啥用?
|
||||
|
||||
function identity(v) {
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
其实它的作用也很多。比如在下面的例子中,它可以作为断言(predicate), 来过滤掉空值。在函数式编程中,断言是一个可以用来做判断条件的函数,在这个例子里,identity就作为判断一个值是否为空的断言。
|
||||
|
||||
var words = " hello world ".split( /\s|\b/ );
|
||||
words; // ['', '', '', 'hello', 'world', '', '']
|
||||
|
||||
words.filter( identity ); // ['hello', 'world']
|
||||
|
||||
|
||||
当然,identity的功能也不止于此,它也可以用来做默认的转化工具。比如以下例子中,我们创建了一个transLogger函数,可以传入一个实际的数据和相关的lower功能函数,来将文字转化成小写。
|
||||
|
||||
function transLogger (msg,formatFn = identity) {
|
||||
msg = formatFn( msg );
|
||||
console.log( msg );
|
||||
}
|
||||
|
||||
function lower(txt) {
|
||||
return txt.toLowerCase();
|
||||
}
|
||||
|
||||
transLogger( "Hello World" ); // Hello World
|
||||
transLogger( "Hello World", lower ); // hello world
|
||||
|
||||
|
||||
除了以上这些工具以外,还有更复杂一些的工具来解决参数问题。比如在讲部分应用和柯里化的时候,提到它在给我们带来一些灵活性的同时,也仍然会有一些限制,即参数的顺序问题,我们必须按照一个顺序来执行。而有些三方库提供的一些工具,就可以将参数倒排或重新排序。
|
||||
|
||||
重新排序的方式有很多,可以通过解构(destructure),从数组和对象参数中提取值,对变量进行赋值时重新排序;或通过延展操作符把一个对象中的一组值,“延展”成单独的参数来处理;又或者通过 .toString() 和正则表达式解析函数中的参数做处理。
|
||||
|
||||
但是,有时我们在灵活变通中也要适度,不能让它一不小心变成了“奇技淫巧”,所以对于类似“重新排序”这样的技巧,在课程中我就不展开了,感兴趣的话你可以在延伸阅读部分去深入了解。
|
||||
|
||||
总结
|
||||
|
||||
通过今天这节课,我们能看到在面对未知、动态和不可控时,函数式编程很重要的一点就是控制好输入。
|
||||
|
||||
在课程里,我们一起重点了解了函数的输入中的参数,知道部分应用和柯里化,可以让代码更好地处理编程中的未知,让函数从抽象变具体,让具体的函数每次只专心做好一件事,以及可以在减少参数的数量之外,还能够增加可读性。
|
||||
|
||||
另外,我们也学习了更多“个子小,功能大”的工具,我们不仅可以通过这些工具,比如unary和constant来改造函数和参数,从而解决适配问题;同时,哪怕是看上去似乎只是在“透传”值的identity,实际上都可以用于断言和转化。而这样做的好处,就是可以尽量提高接口的适应性和适配性,增加过滤和转化的能力,以及增加代码的可读性。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们主要学习了柯里化,而与它相反的就是反柯里化(uncurry),那么你知道反柯里化的用途和实现吗?
|
||||
|
||||
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
延伸阅读
|
||||
|
||||
|
||||
JS 函数签名
|
||||
C/C++ 函数签名与名字修饰(符号修饰)
|
||||
JS 中的解构
|
||||
Functional Light JS
|
||||
JavaScript Patterns - Chapter 4 Functions
|
||||
|
||||
|
||||
|
||||
|
||||
|
231
专栏/JavaScript进阶实战课/04如何通过组合、管道和reducer让函数抽象化?.md
Normal file
231
专栏/JavaScript进阶实战课/04如何通过组合、管道和reducer让函数抽象化?.md
Normal file
@ -0,0 +1,231 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 如何通过组合、管道和reducer让函数抽象化?
|
||||
你好,我是石川。
|
||||
|
||||
上节课我们讲到,通过部分应用和柯里化,我们做到了从抽象到具象化。那么,今天我们要讲的组合和管道,就是反过来帮助我们把函数从具象化变到抽象化的过程。它相当于是系统化地把不同的组件函数,封装在了只有一个入口和出口的函数当中。
|
||||
|
||||
其实,我们在上节课讲处理函数输入问题的时候,在介绍unary的相关例子中,已经看到了组合的雏形。在函数式编程里,组合(Composition)的概念就是把组件函数组合起来,形成一个新的函数。
|
||||
|
||||
|
||||
|
||||
我们可以先来看个简单的组合函数例子,比如要创建一个“判断一个数是否为奇数”的isOdd函数,可以先写一个“计算目标数值除以2的余数”的函数,然后再写一个“看结果是不是等于1”的函数。这样,isOdd函数就是建立在两个组件函数的基础上。
|
||||
|
||||
var isOdd = compose(equalsToOne, remainderOfTwo);
|
||||
|
||||
|
||||
不过,你会看到这个组合的顺序是反直觉的,因为如果按照正常的顺序,应该是先把remainderByTwo放在前面来计算余数,然后再执行后面的equalsToOne, 看结果是不是等于1。
|
||||
|
||||
那么,这里为什么会有一个反直觉的设计呢?今天这节课,我们就通过回答这个问题,来看看组合和管道要如何做到抽象化,而reducer又是如何在一系列的操作中,提高针对值的处理性能的。
|
||||
|
||||
组合Compose
|
||||
|
||||
在讲组合前,我想先带你来看看Point-Free和函数组件。这里,我们还是用刚刚提到的“判断一个值是不是奇数”的isOdd函数,来一步步看下它的实现。
|
||||
|
||||
Point-Free
|
||||
|
||||
那么首先,什么是Point-Free呢?实际上,Point-Pree是函数式编程中的一种编程风格,其中的Point是指参数,free是指没有。加在一起,Point-Free的意思就是没有参数的函数。
|
||||
|
||||
而这样做的目的是什么呢?其实通过这种方式,就可以将一个函数和另外一个函数结合起来,形成一个新函数。比如,为了要创建isOdd函数,通过这种方式,我们就可以把这两个函数“组合”在一起,得到isOdd。
|
||||
|
||||
var isOdd = (x) => equalsToOne(remainderOfTwo(x));
|
||||
|
||||
|
||||
函数组件
|
||||
|
||||
接着,我们再来看函数组件。
|
||||
|
||||
在以下的代码示例当中,我们先定义了两个函数:第一个是dividedBy,它的作用是计算x除以y的余数;第二个是equalsTo,它是用来看余数是否等于1。
|
||||
|
||||
这两个函数其实就是我们用到的组件函数。你可以发现,这两个组件的特点都是努力专注做好一件小事。
|
||||
|
||||
var dividedBy = (y) => {
|
||||
return function forX(x) {
|
||||
return x % y;
|
||||
}
|
||||
}
|
||||
var equalsTo = (y) => {
|
||||
return function forX(x) {
|
||||
return x === y;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
然后,在dividedBy和equalsToOne的基础上,我们就可以创建两个Point-Free的函数,remainderOfTwo和equalsToOne。
|
||||
|
||||
var remainderOfTwo = dividedBy(2);
|
||||
var equalsToOne = equalsTo(1);
|
||||
|
||||
|
||||
最后,我们只需要传入参数 x,就可以计算相应的isOdd的结果了。
|
||||
|
||||
var isOdd = (x) => equalsToOne(remainderOfTwo(x));
|
||||
|
||||
|
||||
好了,现在我们知道了,函数是可以通过写成组件来应用的。这里其实就是用到了函数式编程声明式的思想,equalsToOne和remainderByTwo,不仅把过程进行了封装,而且把参数也去掉了,暴露给使用者的就是功能本身。所以,我们只需要把这两个函数组件的功能结合起来,就可以实现isOdd函数了。
|
||||
|
||||
独立的组合函数
|
||||
|
||||
下面我们再来看看独立的组合函数。
|
||||
|
||||
其实从上面的例子里,我们已经看到了组合的影子。那么更进一步地,我们就可以把组合抽象成一个独立的函数,如下所示:
|
||||
|
||||
function compose(...fns) {
|
||||
return fns.reverse().reduce( function reducer(fn1,fn2){
|
||||
return function composed(...args){
|
||||
return fn2( fn1( ...args ) );
|
||||
};
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
也就是说,基于这里抽象出来的compose功能,我们可以把之前的组件函数组合起来。
|
||||
|
||||
var isOdd = compose(equalsToOne, remainderOfTwo);
|
||||
|
||||
|
||||
所以,回到课程一开始提到的问题:为什么组合是反直觉的?因为它是按照传参顺序来排列的。
|
||||
|
||||
前面讲的这个组合,其实就是 equalsToOne(remainderOfTwo(x))。在数学中,组合写成 fog,意思就是一个函数接收一个参数x,并返回成一个 f(g(x))。
|
||||
|
||||
好,不过看到这里,你可能还是觉得,即使自己理解了它的概念,但是仍然觉得它反直觉,因此想要一种更直观的顺序来完成一系列操作。这个也有相应的解决方案,那就是用函数式编程中的管道。
|
||||
|
||||
管道Pipeline
|
||||
|
||||
函数式编程中的管道,是另外一种函数的创建方式。这样创建出来的函数的特点是:一个函数的输出会作为下一个函数的输入,然后按顺序执行。
|
||||
|
||||
所以,管道就是以组合反过来的方式来处理的。
|
||||
|
||||
Unix/Linux中的管道
|
||||
|
||||
其实管道的概念最早是源于Unix/Linux,这个概念的创始人道格拉斯·麦克罗伊(Douglas McIlroy)在贝尔实验室的文章中,曾经提到过两个很重要的点:
|
||||
|
||||
|
||||
一是让每个程序只专注做好一件事。如果有其它新的任务,那么应该重新构建,而不是通过添加新功能使旧程序复杂化。
|
||||
二是让每个程序的输出,可以成为另一个程序的输入。
|
||||
|
||||
|
||||
感兴趣的话你也可以读一下这篇杂志文章,虽然这是1978年的文章,但是它的设计思想到现在都不算过时。
|
||||
|
||||
|
||||
|
||||
好,那么现在,我们就来看一个简单的管道例子,在这个例子里,我们可以找到当前目录下面所有的JavaScript文件。
|
||||
|
||||
$ ls -1 | grep "js$" | wc -l
|
||||
|
||||
|
||||
你能发现,这个管道有竖线“ | ”隔开的三个部分。第一个部分 ls -1,列出并返回了当前目录下所有的文件,这个结果作为了第二步 grep "js$" 的输入;第二个部分会过滤出所有的以 js 结尾的文件;然后第二步的结果会作为第三部分的输入,在第三步,我们会看到最后计算的结果。
|
||||
|
||||
JavaScript中的管道
|
||||
|
||||
回到JavaScript中,我们也可以用isOdd的例子,来看看同样的功能要如何通过管道来实现。
|
||||
|
||||
其实也很简单,我们只需要通过一个reverseArgs函数,将compose中接收参数的顺序反过来即可。
|
||||
|
||||
你可能会想到我们在上节课讲unary的时候,是把函数的输入参数减少到1,而这里是把参数做倒序处理,生成一个新的函数。在函数式编程中,这算是一个比较经典的高阶函数的例子。
|
||||
|
||||
function reverseArgs(fn) {
|
||||
return function argsReversed(...args){
|
||||
return fn( ...args.reverse() );
|
||||
};
|
||||
}
|
||||
|
||||
var pipe = reverseArgs( compose );
|
||||
|
||||
|
||||
然后我们可以测试下管道是否“畅通”。这次,我们把remainderOfTwo和equalsToOne按照比较直观的方式进行排序。
|
||||
|
||||
可以看到,isOdd(1)返回的结果是true,isOdd(2)返回的结果是false,和我们预期的结果是一样的。
|
||||
|
||||
const isOdd = pipe(remainderOfTwo, equalsToOne);
|
||||
|
||||
isOdd(1); // 返回 true
|
||||
isOdd(2); // 返回 false
|
||||
|
||||
|
||||
Transduction
|
||||
|
||||
讲完了组合和管道之后,还有一个地方想再跟你强调下。
|
||||
|
||||
我一再说过,函数式编程中的很多概念,都来自于对复杂、动力系统研究与控制等领域。而通过组合和管道,我们可以再延伸来看一下转导(transducing)。
|
||||
|
||||
转导主要用于控制系统(Control System),比如声波作为输入,通过麦克风进入到一个功放,然后功放进行能量转换,最后通过喇叭传出声音的这样一个系统,就可以成为转导。
|
||||
|
||||
|
||||
|
||||
当然,单独看这个词,你或许并没有什么印象,但是如果说React.js,你应该知道这是一个很著名的前端框架。在这里面的reducer的概念,就用到了transducing。
|
||||
|
||||
在后面的课程中,我们讲到响应式编程和观察者模式的时候,还会更深入了解reducer。这里,我们就先来看看transduce和reducer的作用以及原理。
|
||||
|
||||
那么,reducer是做什么用的呢?它最主要的作用其实是解决在使用多个map、filter、reduce操作大型数组时,可能会发生的性能问题。
|
||||
|
||||
而通过使用transducer和reducer,我们就可以优化一系列map、filter、reduce操作,使得输入数组只被处理一次并直接产生输出结果,而不需要创建任何中间数组。
|
||||
|
||||
可能我这么讲,你还是不太好理解,这里我们先来举一个不用tansducer或reducer例子吧。
|
||||
|
||||
var oldArray = [36, 29, 18, 7, 46, 53];
|
||||
var newArray = oldArray
|
||||
.filter(isEven)
|
||||
.map(double)
|
||||
.filter(passSixty)
|
||||
.map(addFive);
|
||||
|
||||
console.log (newArray); // 返回:[77,97]
|
||||
|
||||
|
||||
在这个例子里,我们对一组数组进行了一系列的操作,先是筛选出奇数,再乘以二,之后筛出大于六十的值,最后加上五。在这个过程中,会不断生成中间数组。
|
||||
|
||||
这个实际发生的过程如下图左半部分所示。
|
||||
|
||||
|
||||
|
||||
而如果使用reducer的话,我们对每个值只需要操作一次,就可产出最终的结果。如上图的右半部分所示。
|
||||
|
||||
那么它是如何实现的呢?在这里,我们是先将一个函数,比如isEven作为输入,放到了一个transducer里,然后作为输出,我们得到的是一个isEvenR的reducer函数。
|
||||
|
||||
是的,这里的transducer其实也是一个经典的高阶函数(即输入一个函数,得到一个新的函数)的例子!
|
||||
|
||||
实际上,像 double和addFive都具有映射类的功能,所以我们可以通过一个类似mapReducer这样的一个transducer,来把它们转换成reducer。而像 isEven和passSixty都是筛选类的功能,所以我们可以通过一个类似filterReducer这样的一个transducer,来把它们转换成 reducer。
|
||||
|
||||
如果我们抽象化来看,其代码大致如下。它的具体实现这里我卖个关子,你可以先自己思考下,我们下节课再探讨。
|
||||
|
||||
var oldArray = [36, 29, 18, 7, 46, 53];
|
||||
|
||||
var newArray = composeReducer(oldArray, [
|
||||
filterTR(isEven),
|
||||
mapTR(double),
|
||||
filterTR(passSixty),
|
||||
mapTR(addFive),
|
||||
]);
|
||||
|
||||
console.log (newArray); // 返回:[77,97]
|
||||
|
||||
|
||||
总而言之,从上面的例子中,我们可以看出来composeReducer用的就是一个类似组合的功能。
|
||||
|
||||
总结
|
||||
|
||||
这节课通过对组合和管道的了解,相信你可以看出来,它们和上节课我们讲到的部分应用和柯里化正好相反,一个是从具象走向抽象,一个是从抽象走向具象。
|
||||
|
||||
不过,虽然说它们的方向是相反的,但有一条原则是一致的,那就是每个函数尽量有一个单一职责,只专注做好一件事。
|
||||
|
||||
值得注意的是,这里的方向不同,并不是指我们要用抽象取代具象,或者是用具象取代抽象。而是说它们都是为了单一职责函数的原则,相辅相成地去具象化或抽象化。
|
||||
|
||||
|
||||
|
||||
另外,通过reducer的例子,我们也知道了如何通过reducer的组合,做到普通的组合达不到的性能提升。
|
||||
|
||||
在这节课里,我们是先从一个抽象层面理解了reducer,不过你可能仍然对map、filter、reduce等概念和具体实现感到有些陌生。不用担心,下节课我就带你来进一步了解这一系列针对值的操作工具的机制,以及functor和monad。
|
||||
|
||||
思考题
|
||||
|
||||
我们讲到reduce可以用来实现map和filter ,那么你知道这背后的原理吗?欢迎在留言区分享你的答案,或者你如果对此并不十分了解,也希望你能找找资料,作为下节课的预习内容。
|
||||
|
||||
当然,你也可以在评论区交流下自己的疑问,我们一起讨论、共同进步。
|
||||
|
||||
|
||||
|
||||
|
255
专栏/JavaScript进阶实战课/05map、reduce和monad如何围绕值进行操作?.md
Normal file
255
专栏/JavaScript进阶实战课/05map、reduce和monad如何围绕值进行操作?.md
Normal file
@ -0,0 +1,255 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 map、reduce和monad如何围绕值进行操作?
|
||||
你好,我是石川。
|
||||
|
||||
上节课里,我们在学习组合和管道的工作机制的时候,第一次认识了reducer,同时在讲到transduce的时候,也接触到了map、filter和reduce这些概念。那么今天这节课,我们就通过JS中数组自带的功能方法,来进一步了解下transduce的原理,做到既知其然,又知其所以然。
|
||||
|
||||
另外,我们也看看由map作为functor可以引申出的monad的概念,看看它是如何让函数间更好地进行交互的。
|
||||
|
||||
数据的核心操作
|
||||
|
||||
那在正式开始之前,我先来说明下这节课你要关注的重点。课程中,我会先带你通过JavaScript本身自带的映射(map)、过滤(filter)和reduce方法,来了解下这几种方法对值的核心操作。同时呢,我也给你解答下上节课提出的问题,即如何通过映射和过滤来做到reduce。作为我们后面讲到functor和monad的基础。
|
||||
|
||||
好,下面我们就从map开始讲起。
|
||||
|
||||
map映射和函子
|
||||
|
||||
我们经常听说,array.map就是一个函子(functor),那什么是一个函子呢?
|
||||
|
||||
实际上,函子是一个带运算工具的数据类型或数据结构值。举个常用的例子,在JavaScript中,字符串(string)就是一个数据类型,而数组(array)既是一个数据类型也是一个数据结构,我们可以用字符串来表达一个单词或句子。而如果我们想让下图中的每个字母都变成大写,那么就是一个转换和映射的过程。
|
||||
|
||||
|
||||
|
||||
我们再用一段抽象的代码来表示一个字符串的映射函子stringMap。可以看到,stringMap可以把字符串Hello World!作为输入,然后通过一个uppercaseLetter工具函数的转换,对应返回大写的HELLO WORLD!。
|
||||
|
||||
stringMap( uppercaseLetter, "Hello World!" ); // HELLO WORLD!
|
||||
|
||||
|
||||
类似地,如果我们有一个数组的映射函子arrayMap,也可以把数组 [“1”,“2”,“3”] 中每个字符串的元素转化成整数,然后再对应输出一个整数数组 [1, 2, 3]。
|
||||
|
||||
["1","2","3","4","5"].map( unary( parseInt ) ); // [1,2,3,4,5]
|
||||
|
||||
|
||||
filter过滤和筛选
|
||||
|
||||
说完了函子,我们再来看看过滤器(filter)和断言(predicate)。
|
||||
|
||||
filter顾名思义,就是过滤的意思。但要注意一点,filter可以是双向的,我们可以过滤掉(filter out)不想要的东西,也可以筛选出(filter in)出想要的东西。
|
||||
|
||||
然后再来看断言。我们上节课在说到处理输入参数的工具的时候,也接触过断言,比如identity就可以看作是断言。在函数式编程中,断言就是一个个的筛选条件,所以在过滤器中,我们经常会使用断言函数。
|
||||
|
||||
|
||||
|
||||
举个例子,假如有一个用来判断“一个值是不是奇数”的isOdd函数,它是一个断言,而它的筛选条件就是筛选出数组中的单数。所以,如果用它来筛选 [1,2,3,4,5],得到的结果就是 [1,3,5]。
|
||||
|
||||
[1,2,3,4,5].filter( isOdd ); // [1,3,5]
|
||||
|
||||
|
||||
在Javascript中也有自带的 some() 和 every() 断言方法。它们的作用就是可以判断数组中的一组元素是不是都符合判断条件。
|
||||
|
||||
比如在下面一列包含了 [1,2,3,4,5] 这几个数字的数组中,如果我们要判断它的每一个元素是不是都小于6,结果就是true。如果我们要判断它们是不是都是奇数,结果就是false,因为这里面既有奇数,也有偶数。
|
||||
|
||||
let arr = [1,2,3,4,5];
|
||||
arr.every(x => x < 6) // => true,所有的值都小于6
|
||||
arr.every(x => x % 2 === 1) // => false,不是所有的数都是奇数
|
||||
|
||||
|
||||
类似地,some()可以帮助我们判断这组数组中有没有一些小于6的数字或者奇数。这时,这两个判断返回的结果都是true。
|
||||
|
||||
let arr = [1,2,3,4,5];
|
||||
arr.some(x => x < 6) // => true,a里面有小于6的数字
|
||||
arr.some(x => x % 2 === 1) // => true,数组a里面有一些奇数
|
||||
|
||||
|
||||
虽然some() 和 every() 都是 JavaScript自带的断言方法,但是对比 filter() ,它们就显得没有那么“函数式”了,因为它们的返回值只是一个 true 或 false,而没有像 filter 一样返回一组数据作为输出,继续用来进行后续一系列的函数式的操作。
|
||||
|
||||
reduce和缩减器
|
||||
|
||||
最后我们再来说说reduce。实际上,缩减(reduce)主要的作用就是把列表中的值合成一个值。如下图所示:
|
||||
|
||||
|
||||
|
||||
在reduce当中,有一个缩减器(reducer)函数和一个初始值。比如在下面的例子中,初始值是3,reducer函数会计算3乘以5的结果,再乘以10,得出的结果再乘以15,最后归为一个结果2250。
|
||||
|
||||
[5,10,15].reduce( (arr,val) => arr * val, 3 ); // 2250
|
||||
|
||||
|
||||
而缩减reduce除了能独立来实现以外,也可以用映射map和过滤filter的方法来实现。这是因为 reduce的初始值可以是一个空数组[],这样我们就可以把迭代的结果当成另一个数组了。
|
||||
|
||||
我们来看一个例子:
|
||||
|
||||
var half = v => v / 2;
|
||||
[2,4,6,8,10].map( half ); // [1,2,3,4,5]
|
||||
|
||||
[2,4,6,8,10].reduce(
|
||||
(list,v) => (
|
||||
list.push( half( v ) ),
|
||||
list
|
||||
), []
|
||||
); // [1,2,3,4,5]
|
||||
|
||||
|
||||
|
||||
var isEven = v => v % 2 == 0;
|
||||
[1,2,3,4,5].filter( isEven ); // [2,4]
|
||||
|
||||
[1,2,3,4,5].reduce(
|
||||
(list,v) => (
|
||||
isEven( v ) ? list.push( v ) : undefined,
|
||||
list
|
||||
), []
|
||||
); // [2,4]
|
||||
|
||||
|
||||
可以发现,这里我故意利用了一个副作用。通过第一节课的学习,我们知道array.push是一个非纯函数的方法,它改变了原数组,而不是复制后修改。而如果我们想完全避免副作用,可以用concat。但是,我们也知道concat虽然遵循的是纯函数、不可变的原则,但是有一点是我们需要注意的,就是它在面对大量的复制和修改时会产生性能上的问题。所以估计到这里,你也猜到了在上节课中,我们提到的transducer的原理了。
|
||||
|
||||
是的,这里我们就是故意利用了副作用来提高性能!
|
||||
|
||||
你或许会认为,这样是不是就违背了纯函数和不可变的原则?实际上是也不是,因为在原则上,我们做的这些变化都是在函数内部的,而我在前面说过,需要注意的副作用一般多是来自外部。
|
||||
|
||||
所以在这个例子中,我们没有必要为了几乎没有负面影响的副作用而牺牲性能。而transducer正是利用了副作用,才做到的性能提升。
|
||||
|
||||
单子monad
|
||||
|
||||
好,现在让我们回到课程一开始提到的问题:monad和functor有什么区别呢?
|
||||
|
||||
在开篇词我们也提到过,函子(functor)其实就是一个值和围绕值的一些功能。所以我们知道,array.map可以被看做是一个functor,它有一组值,而如map这样的方法可以作用于数组里面的每一个值,提供了一个映射的功能。而monad就是在functor的基础上,又增加了一些特殊功能,其中最常见的就是 chain和应用函子(applicative)。下面我就带你具体看看。
|
||||
|
||||
array作为functor
|
||||
|
||||
前面我们说过,array.map就是一个函子,它有一个自带的包装对象,这个对象有类似map这样的映射功能。那么同样地,我们也可以自己写一个带有映射方法的Just Monad,用它来包装一个值(val)。这个时候,monad相当于是一个基于值形成的新的数据结构,这个数据结构里有map的方法函数。
|
||||
|
||||
function Just(val) {
|
||||
return { map };
|
||||
|
||||
function map(fn) { return Just( fn( val ) ); }
|
||||
|
||||
}
|
||||
|
||||
|
||||
可见,它的使用方式就类似于我们之前看到的array.map映射。比如在下面的例子里,我们用map将一个函数 v => v * 2 运用到了Just monad封装的值10上,它返回的就是20。
|
||||
|
||||
var A = Just( 10 );
|
||||
var B = A.map( v => v * 2 ); // 20
|
||||
|
||||
|
||||
chain作为bind、flatMap
|
||||
|
||||
再来说说chain。
|
||||
|
||||
chain通常又叫做flatMap或bind,它的作用是flatten或unwrap,也就是说它可以展开被Just封装的值val。你可以使用chain将一个函数作用到一个包装的值上,返回一个结果值。如下代码所示:
|
||||
|
||||
function Just(val) {
|
||||
return { map, chain };
|
||||
|
||||
function map(fn) { return Just( fn( val ) ); }
|
||||
|
||||
// aka: bind, flatMap
|
||||
function chain(fn) { return fn( val ); }
|
||||
}
|
||||
|
||||
|
||||
我再举个例子,我们用chain方法函数把一个加一的函数作为参数运用到monad A上,得到了一个 15+1=16 的结果,那么之后返回的就是一个flatten或unwrap展开的16了。
|
||||
|
||||
var A = Just( 15 );
|
||||
var B = A.chain( v => v + 1 );
|
||||
|
||||
B; // 16
|
||||
typeof B; // "number"
|
||||
|
||||
|
||||
monoid
|
||||
|
||||
OK,既然说到了chain,我们也可以看一下monoid。
|
||||
|
||||
在上节课我们说过函数组合compostion。而在组合里,有一个概念就是签名一致性的问题。举个例子如果前一个函数返回了一个字符串,后一个函数接收的输入是数字,那么它们是没办法组合的。所以,compose函数接收的函数都要符合一致性的 fn :: v -> v 函数签名,也就是说函数接收的参数和返回的值类型要一样。
|
||||
|
||||
那么,满足这些类型签名的函数就组成了 monoid。看到这个公式你是不是觉得很眼熟?没错,它的概念就是基于我们之前说到过的 identity函数。在TypeScript中,identity也是泛型使用中的一个例子。比如在C#和Java这样的语言中,泛型可以用来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。它的基本原理也是基于这样的一个identity函数。
|
||||
|
||||
function identity<T>(arg: T): T {
|
||||
return arg;
|
||||
}
|
||||
|
||||
|
||||
identity在monad中有一个用处,就是如果把identity作为一个参数,可以起到观察inspect的作用。比如,我们先用Just来封装 15 这个值,然后调用chain的方法时,把identity作为参数,返回的就是一个flatten或unwrap展开的15。所以我们可以看出,它也这里也起到了一个log的作用。
|
||||
|
||||
var A = Just( 15 );
|
||||
A.chain (identity) // 返回 15
|
||||
|
||||
|
||||
applicative
|
||||
|
||||
最后,我们再来看应用函子(applicative),简称ap。
|
||||
|
||||
ap的作用其实也很简单。应用函子,顾名思义,它的作用是可以把一个封装过的函数应用到一个包装过的值上。
|
||||
|
||||
function Just(val) {
|
||||
return { map, ap };
|
||||
|
||||
function map(fn) { return Just( fn( val ) ); }
|
||||
|
||||
function ap(anotherMonad) { return anotherMonad.map( val ); }
|
||||
|
||||
}
|
||||
|
||||
|
||||
再来看一个例子,可以看到,ap把monad B里的值取出来,通过monad A的映射把它应用到了monad A上。因为映射接受的值类型是函数,所以这里我们传入的是柯里化的add函数,它先通过闭包的记忆功能,记住第一个参数6,之后再加上传入的10,最后输出的结果就是16。
|
||||
|
||||
var A = Just( 6 );
|
||||
var B = Just( 10 );
|
||||
|
||||
function add(x,y) { return x + y; }
|
||||
|
||||
var C = A.map( curry( add ) ).ap( B );
|
||||
|
||||
C.chain(identity); // 返回 16
|
||||
|
||||
|
||||
如果我们把上面几个功能加在一起,其大致实现就如下所示:
|
||||
|
||||
function Just(val) {
|
||||
return { map, chain, ap, log };
|
||||
|
||||
// *********************
|
||||
|
||||
function map(fn) { return Just( fn( val ) ); }
|
||||
|
||||
// aka: bind, flatMap
|
||||
function chain(fn) { return fn( val ); }
|
||||
|
||||
function ap(anotherMonad) { return anotherMonad.map( val ); }
|
||||
|
||||
function log() {
|
||||
return `simpleMonad(${ val })`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
说到函子和应用函子,我们也可以看一下,在数组中,有一个array.of的工厂方法,它的作用是接收一组参数,形成一个新数组。
|
||||
|
||||
var arr = Array.of(1,2,3,4,5); // 返回:[1,2,3,4,5]
|
||||
|
||||
|
||||
在函数式编程中,我们称实现了of工厂方法的函子是pointed函子。通过pointed函子,我们可以把一组值放到了一个数组的容器中,之后还可以通过映射函子对每个值做映射。而应用函子,(applicative functor)就是实现了应用方法的pointed函子。
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们学习了函数式编程中针对数组的几个核心操作,解答了上节课中的如何通过映射和过滤做到reduce的问题,同时也更深入地理解了reducer和transduce的原理。
|
||||
|
||||
并且现在我们知道,array.map其实就是一个functor,它包含了map功能,可以围绕一个数组中的每个值进行操作,返回一个新的数组。而monad可以说是基于函子增加了一些特殊的功能。当然了,不同的monad也可以相互组合,比如just加上nothing,也就是一个空值单子,可以组成maybe monad来处理空值的异常。
|
||||
|
||||
另外说到了函子和单子,在函数式编程当中其实还有either、IO之类的概念。其中either是用来代替比如if else或者是try catch的条件运算,它的value里保存的是一个值;而IO可以用来延迟函数的执行,它的value里面存储的是一个函数。这里我就不多说了,感兴趣的话,你也可以去深入了解下。
|
||||
|
||||
思考题
|
||||
|
||||
从函数式编程的思维视角来看,你觉得JavaScript中的promise算是monad吗?
|
||||
|
||||
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
173
专栏/JavaScript进阶实战课/06如何通过模块化、异步和观察做到动态加载?.md
Normal file
173
专栏/JavaScript进阶实战课/06如何通过模块化、异步和观察做到动态加载?.md
Normal file
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 如何通过模块化、异步和观察做到动态加载?
|
||||
你好,我是石川。
|
||||
|
||||
在前面几节讲函数式编程的课程里,我们了解了在函数式编程中副作用通常是来自于函数外部,多在输入的过程中会出现副作用。这实际上是从空间的角度来看的。
|
||||
|
||||
而今天这节课,我们会从时间的角度来看看异步中的事件如何能引起副作用,以及要如何管理这种副作用。
|
||||
|
||||
如何处理异步事件中的时间状态?
|
||||
|
||||
实际上,在函数式编程中我们在讨论异步的时候,经常会说到信任(trustable)和承诺(promise)。这个其实是源自于合同或者是契约法中的一个概念,而且它不只限于经典的合同,我们说的智能合约之类的概念中,底层逻辑也都源于契约和共识。
|
||||
|
||||
那么,为什么我们在处理异步时需要用到这个概念呢?下面我就先带你来看看在异步时,程序都会遇到哪些问题。
|
||||
|
||||
|
||||
|
||||
假设,我们有以下getUser和getOrders两个函数,分别通过用户ID来获取用户信息和订单信息。如果getUser先得到响应的话,那么它就没法获得订单信息。同样地,如果getOrders先得到响应的话,那么它就没办法获得用户信息。
|
||||
|
||||
这样一来,我们说这两个函数就形成了一个竞争条件(Race Condition)。
|
||||
|
||||
var user;
|
||||
|
||||
getUser( userId, function onUser(userProfile){
|
||||
var orders = user ? user.orders : null;
|
||||
user = userProfile;
|
||||
if (orders) {
|
||||
user.orders = orders;
|
||||
}
|
||||
} );
|
||||
|
||||
getOrders( userId, function onOrders(userOrders){
|
||||
if (!user) {
|
||||
user = {};
|
||||
}
|
||||
user.orders = userOrders;
|
||||
} );
|
||||
|
||||
|
||||
从下图也可以看出,无论是谁先到达都会出现问题。那么你可能会说,把它们一前一后分开处理不就行了吗?但是这样的话就没有办法做到并行,只能串行,而串行所花的总时间一般会高于并行。
|
||||
|
||||
|
||||
|
||||
在这里,时间就是状态。在同步操作中我们不需要考虑时间;而在异步中时间就产生了。时间不仅仅是状态,也是最难管理的状态。
|
||||
|
||||
这和信任和承诺又有什么关系呢?因为信任和承诺之间隔着的就是时间!还是以合同交易举例,如果是同步的话,相当于一手交钱、一手交货。而在异步中,则只能靠时间证明是否遵守了承诺。所以在JavaScript里,解决异步问题的工具就叫承诺(promise)。
|
||||
|
||||
|
||||
|
||||
可是,凭什么我们要相信一个承诺呢?在生活中的交易,大家通常会走个合同,剩下的交给时间来证明。而在JavaScript函数式编程当中,解决方案就是不用合同这么麻烦了,为了让你相信我的承诺,咱们干脆直接把时间给干掉。
|
||||
|
||||
是的,就是这么霸气。把时间干掉的方式就是按照同步的方式来处理异步,下面是一个例子:
|
||||
|
||||
var userPromise = getUser( userId );
|
||||
var ordersPromise = getOrders( userId );
|
||||
|
||||
userPromise.then( function onUser(user){
|
||||
ordersPromise.then( function onOrders(orders){
|
||||
user.orders = orders;
|
||||
} );
|
||||
} );
|
||||
|
||||
|
||||
这样,即使是并行获取的用户和订单信息,在处理的时候我们也可以通过then按照同步处理时的先后顺序,来更新订单信息到用户对象上。
|
||||
|
||||
如何处理循环事件中的时间状态?
|
||||
|
||||
在函数式+响应式编程中,除了网络事件,还有更多的例子是通过去掉时间,比如循环或用户事件,都可以用类似同步的方式来处理异步事件。
|
||||
|
||||
举个例子,比如我们有生产者和消费者两个对象,消费者希望在生产者发生改变的时候,能随之映射出改变。这时候如果我们的生产者很“勤奋”,实时地在生产,消费者也可以实时地来消费。
|
||||
|
||||
// 勤奋生产者
|
||||
var producer = [1,2,3,4,5];
|
||||
|
||||
// 消费者
|
||||
var consumer = producer.map( function triple(v){
|
||||
return v * 3;
|
||||
} ); // [3,6,9,12,15];
|
||||
|
||||
|
||||
|
||||
但是如果有一个懒惰的生产者,消费者不知道在未来哪个时间该生产者会发生变化,那么要怎么办呢?
|
||||
|
||||
这时,我们就需要把懒惰的生产者当做一个被观察对象,每当它发生变化时,就随之做出反应,这就是“函数式中的异步模式”和“响应式中的观察者模式”的结合。
|
||||
|
||||
|
||||
|
||||
下面是一个相对抽象的异步循环事件的例子。不过在现实当中,我们遇到的用户输入,比如鼠标的点击、键盘的输入等等的DOM事件也是异步的。所以这个时候,我们就可以用到“懒”这个概念,根据用户反应来“懒加载”一些内容。
|
||||
|
||||
/* 例如使用RxJS,一个响应式JS的扩展 */
|
||||
|
||||
// 懒惰生产者
|
||||
var producer = Rx.Observable.create( function onObserve(observer){
|
||||
setInterval( function everySecond(){
|
||||
observer.next( Math.random() );
|
||||
}, 1000 );
|
||||
} );
|
||||
|
||||
// 消费者
|
||||
var consumer = producer.map( function triple(v){
|
||||
return v * 3;
|
||||
} );
|
||||
consumer.subscribe( function onValue(v){
|
||||
console.log( v );
|
||||
} );
|
||||
|
||||
|
||||
如何处理用户事件中的时间状态?
|
||||
|
||||
接着我们再从响应式和观察者模式延伸,来看看前端在处理页面上内容的动态加载时使用的一些方法。这就要说到动态导入了。
|
||||
|
||||
我们先来看看网页上的一个模块从加载到执行的顺序。可以看到,这个顺序大致分成了4个步骤,第一是加载,之后是解析、编译,最后是执行。如果是动态加载,就是在初始化之后,根据需求再继续加载。
|
||||
|
||||
|
||||
|
||||
而说到动态导入,基本可以分成两类,一类是可视时加载(load on visibility),一种是交互时加载(load on interaction)。
|
||||
|
||||
可视时加载就是我们经常说的懒加载(Lazy loading),这种方式经常用在长页面当中。比如产品详情页,一般都是用讲故事的方式一步步介绍产品卖点,用图说话,最后再展示参数、一键购买以及加购物车的部分。所以也就是说我们不需要一上来就加载整个页面,而是当用户滑动到了某个部分的时候,再加载相关的内容。
|
||||
|
||||
交互时加载就是当用户和页面进行交互时,比如点击了某个按钮后,可能产生的加载。举个例子,有些应用中的日历,只有用户在进行特定操作的时候才会显示并且和用户交互。这样的模块,我们就可以考虑动态加载。
|
||||
|
||||
注意,这里有几个重要的指标。在初始化的加载中,我们关注的通常是首次渲染时间(FCP,First Contentful Paint)和最大内容渲染时间(LCP,Largest Contentful Paint),也就是页面首次加载的时候。在后续的动态加载中,我们关注的是首次交互时间(TTI,Time to Interactive),也就是当用户开始从首屏开始往下滑动,或者点击了某个按钮开启了日历弹窗的时候。
|
||||
|
||||
|
||||
|
||||
你可能会觉得,这样的优化只能省下一两百KB或几个MB,是否值得?但是苍蝇腿也是肉,而且积少成多,当你在开发一个复杂的Web应用需要不断扩充模块的时候,这样的操作可能就会产生质和量上的变化。
|
||||
|
||||
然后在这个时候,我们通常会通过一些打包工具,比如用Webpack先加载核心的组件,渲染主程序,之后根据交互的需要,按需加载某个模块。
|
||||
|
||||
另外,对于动态的加载,其实也有很多三方的库可以支持,其中一个例子就是React中的Suspense。如果是Node服务器端的加载渲染的话,也有Loadable Components这样的库可以用来参考。当然,如果你不使用这些三方的库,自己开发也可以,但是原理始终是类似的。
|
||||
|
||||
|
||||
|
||||
不过这里我还想说下,在使用动态导入前,一般应该先考虑预加载(pre-load)或预获取(pre-fetch)。
|
||||
|
||||
它们两个的区别是,前者是在页面开始加载时就提前开始加载后面需要用到的元素;后者是在页面的主要功能都加载完成后,再提前加载后面需要用到的素材。除非没法做到预加载和预获取,或者你加载的是三方的内容,不可控,不然的话,这些方式都可以带来比动态加载更好的用户体验。
|
||||
|
||||
那么,有没有没法儿做到、或者不适合做预加载的例子呢?也是有的,比如要预加载的内容过大,而且用户不一定会使用预加载的内容的时候。
|
||||
|
||||
这个时候如果你事先加载,一是会使用到用户的网络,占用了用户手机的存储空间;二是也会增加自己的CDN和服务器的资源消耗。这种情况下,就要用到动态加载了。
|
||||
|
||||
另外在进一步看动态加载前,我们还要了解两个基础概念,就是页面渲染的两种基础渲染模式。一种是浏览器渲染,一种是服务器端渲染。
|
||||
|
||||
首先,在客户端渲染(CSR,client side rendering)模式下,我们是先下载HTML、JS和CSS,包括数据,所有的内容都下载完成,然后再开始渲染。
|
||||
|
||||
而SSR服务器端渲染(SSR,server side rendering)模式下,我们是先让用户能看到一个完整的页面,但是无法交互。只有等相关数据从服务器端加载和hydrate后,比如说一个按钮加上了的相关事件处理之后,才能交互。
|
||||
|
||||
这个方案看上去比CSR会好一些,但它也不是没有问题的。比如说,我们作为用户使用一些应用有时候,也会遇到类似的问题,就是我们在加载和hydrate前点击某个按钮的时候,就会发现某个组件没反应。
|
||||
|
||||
|
||||
|
||||
那么在交互驱动的动态加载中,上面这种问题怎么解决呢?比如Google,他们会使用并且开源了一个叫JSAction 的小工具,它的作用就是先加载一部分轻代码(tiny code),这部分代码可以“记住”用户的行为,然后根据用户的交互来加载组件,等加载完成再让组件执行之前“记住”的用户请求。这样就完美解决了上述问题。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过今天的学习,我们理解了函数式编程+响应式编程中,时间是一个状态,而且是一个最难管理的状态。而通过promise的概念我们可以消除时间,并且可以通过同步的方式来处理异步事件。
|
||||
|
||||
另外,通过观察者模式我们也可以更好地应对未知,通过行动来感知和响应,这样的加载方式,在应用的使用者达到一定规模化的时候,可以减少不必要和大量的资源浪费。
|
||||
|
||||
思考题
|
||||
|
||||
我们说根据事件的动态加载可以起到降本增效的作用,那么你能说说你在前端开发中做资源加载设计、分析和优化的经验吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
264
专栏/JavaScript进阶实战课/07深入理解对象的私有和静态属性.md
Normal file
264
专栏/JavaScript进阶实战课/07深入理解对象的私有和静态属性.md
Normal file
@ -0,0 +1,264 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 深入理解对象的私有和静态属性
|
||||
你好,我是石川。
|
||||
|
||||
在前面几讲里,我们围绕着函数式编程,从基础的输入、计算、输出讲起,到过程中可能产生的副作用,再到如何通过纯函数和不可变作为解决思路来管理副作用等等,都有了系统的了解。之后,我们又通过响应式编程和函数式编程的结合,了解了这种模式下面,如何面对未知以及基于事件变化做出响应。
|
||||
|
||||
从这节课开始,我们再来深入了解下 JavaScript的对象构建和面向对象的编程模式。
|
||||
|
||||
在第1讲里,我们第一次介绍到了对象和面向对象。对象其实就好比一个人,生来都是带有属性和功能的,比如肤色、身高等等就是我们的属性,会哭会笑这些就是我们的功能。我们作为对象和别的对象之间要产生交互,这就是面向对象的设计。那今天我们就从一个对象的创建讲起。
|
||||
|
||||
|
||||
|
||||
在面向对象的设计中,一个对象的属性是至关重要的,因为它也决定了对象是什么、能做什么。一个对象可以有对外分享的、别人可以获取的公开属性,也有不对外暴露的、别人不可以随便获取的私有属性。
|
||||
|
||||
除了公开和私有属性,还有静态属性。静态属性是属于类,而不是单独属于对象的。这么解释听上去可能有些绕口,我们可以打个比方,比如说中国有14亿人口,那么“14亿人口”就是属于中国这个国家类的属性,但是我们不能说具体每一个中国人具有“14亿人口”这个属性。
|
||||
|
||||
同样地,静态属性也包含公开属性和私有属性。就好比一个公司作为一个组织类,通常为了体现规模,会公开自己有多少员工,这就是公司的公开静态属性;但是公司不会公开一些运营数据,因为这个是敏感话题,只有审计的时候或特定场合才提供,这个运营数据可能就属于私有静态属性。
|
||||
|
||||
目前用于创建私有属性和静态属性的支持,都是在2022年6月后,也就是在JavaScript出现后的25年,才纳入ECMAScript规范中的(但其实除了已经退役的IE浏览器外,几乎大多数主流浏览器之前都支持了这两个功能)。但是在此之前,人们就通过其它方式,试着实现类似的功能了。
|
||||
|
||||
今天这节课,我们就来看看它们实现的底层逻辑和应用。
|
||||
|
||||
如何创建私有属性?
|
||||
|
||||
在面向对象中,有个很重要的概念就是创建私有属性。
|
||||
|
||||
我们可以看到,和Java不一样,当用JavaScript创建一个Widget对象时,无论是使用class、对象字面量还是函数构造式,一般的情况下,在定义了属性和方法后就可以公开调用,并没有任何限制。
|
||||
|
||||
// 示例1:类class
|
||||
class WidgetA {
|
||||
constructor() {
|
||||
this.appName = "天气应用"
|
||||
}
|
||||
getName(){
|
||||
return this.appName;
|
||||
}
|
||||
}
|
||||
var widget1 = new WidgetA();
|
||||
console.log(widget1.appName); // 返回 “天气应用”
|
||||
console.log(widget1.getName()); // 返回 “天气应用”
|
||||
|
||||
// 示例2:对象字面量
|
||||
var WidgetB = {
|
||||
appName : "天气应用",
|
||||
getName : function (){
|
||||
return this.appName;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(WidgetB.appName); // 返回 “天气应用”
|
||||
console.log(WidgetB.getName()); // 返回 “天气应用”
|
||||
|
||||
// 示例3:函数构造式
|
||||
function WidgetC(){
|
||||
this.appName = "天气应用";
|
||||
this.getName = function (){
|
||||
return "天气应用";
|
||||
};
|
||||
}
|
||||
|
||||
var widget3 = new WidgetC();
|
||||
console.log(widget3.appName); // 返回 “天气应用”
|
||||
console.log(widget3.getName()); // 返回 “天气应用”
|
||||
|
||||
|
||||
用#符号创建私有属性
|
||||
|
||||
那怎么才能在对象中创建私有属性呢?根据最新的ES13 规范,我们可以通过#符号,来定义一个私有的属性。
|
||||
|
||||
首先,我们声明了一个#appName,在构建者constructor里,我们给它赋值为“天气应用”。这时,当我们直接调取appName时,会看到返回的结果就是未定义的。但如果我们通过getName方法,就可以获取appName的值。
|
||||
|
||||
class WidgetD {
|
||||
#appName;
|
||||
constructor(){
|
||||
this.#appName = "天气应用";
|
||||
}
|
||||
getName(){
|
||||
return this.#appName;
|
||||
}
|
||||
}
|
||||
|
||||
var widget4 = new WidgetD();
|
||||
console.log(widget4.appName); // 返回 undefined
|
||||
console.log(widget4.getName()); // 返回 “天气应用”
|
||||
|
||||
|
||||
所以下面,我们就一起来看看在#问世之前,工程师们是怎么实现私有属性的。主要有闭包、WeakMap和Symbol这三种方式。
|
||||
|
||||
用闭包和IIFE创建私有属性
|
||||
|
||||
我们先来看看如何在对象字面量中创建私有属性。是的,我们在前面讲函数式编程时,提到过的闭包在这里派上用场了。
|
||||
|
||||
首先,我们声明一个WidgetE的变量,然后再来创建一个立即被调用的函数式表达(IIFE),在这个表达里面,我们先给内部的appName变量赋值为“天气应用”。
|
||||
|
||||
之后,在函数中我们再给WidgetE赋值,这里赋值的是一个对象,里面我们定义了getName的方法,它返回的就是外部函数的appName。
|
||||
|
||||
这个时候,当我们试图获取WedgetE.appName时,会发现无法获取嵌套函数内部声明的变量。但是当我们通过getName的方法,利用嵌套函数中内嵌函数可以访问外部函数的变量的特点,就可以获取相应的返回值。
|
||||
|
||||
// 对象字面量
|
||||
var WidgetE;
|
||||
(function(){
|
||||
var appName = "天气应用";
|
||||
WidgetE = {
|
||||
getName: function(){
|
||||
return appName;
|
||||
}
|
||||
};
|
||||
}());
|
||||
WidgetE.appName; // 返回 undefined
|
||||
WidgetE.getName(); // 返回 “天气应用”
|
||||
|
||||
|
||||
好,下面我们再来看看如何通过构造函数的方式,构造私有属性。
|
||||
|
||||
这里也可以通过我们学过的闭包,直接上代码。这个例子其实看上去要比上面的例子简单,我们先定义一个函数,在里面声明一个变量appName,然后创建一个getName的表达式函数,返回appName。
|
||||
|
||||
// 构造函数
|
||||
function WidgetF() {
|
||||
var appName = "天气应用";
|
||||
this.getName = function(){
|
||||
return appName;
|
||||
}
|
||||
}
|
||||
var widget6 = new WidgetF();
|
||||
console.log(widget6.appName); // 返回 undefined
|
||||
console.log(widget6.getName()); // 返回 “天气应用”
|
||||
|
||||
|
||||
这时候,我们通过函数构造可以创建一个新的函数widget6,但是通过这个新构建的对象来获取appName是没有结果的,因为在这里,appName是封装在WidgetF内部。不过widget6可以通过getName来获取appName,同样,这里是利用闭包的特点,来获取函数之外的变量。
|
||||
|
||||
可是这个例子中还有一个问题,就是我们每次在创建一个新对象的时候,私有属性都会被重新创建一次,这样就会造成重复工作和冗余内存。解决这个问题的办法就是把通用的属性和功能赋值给prototype,这样通过同一个构建者创建的对象,可以共享这些隐藏的属性。
|
||||
|
||||
比如我们来看下面的例子,我们给WidgetG的原型赋值了一个函数返回的对象,函数中包含了私有属性,返回的对象中包含了获取属性的方法。这样我们在创建一个widget7的对象之后,就能看到它可以获取天气应用支持的机型了。
|
||||
|
||||
function WidgetG() {
|
||||
var appName = "天气应用";
|
||||
this.getName = function(){
|
||||
return appName;
|
||||
}
|
||||
}
|
||||
WidgetG.prototype = (function(){
|
||||
var model = "支持安卓";
|
||||
return {
|
||||
getModel: function(){
|
||||
return model;
|
||||
}
|
||||
}
|
||||
}());
|
||||
var widget7 = new WidgetG();
|
||||
console.log(widget7.getName()); // 返回 “天气应用”
|
||||
console.log(widget7.getModel()); // 返回 “支持安卓”
|
||||
|
||||
|
||||
用WeakMap创建私有属性
|
||||
|
||||
在ES6 中,JavaScript引入了Set和Map的数据结构。Set和Map主要用于数据重组和数据储存。Set用的是集合的数据结构,Map用的是字典的数据结构。Map具有极快的查找速度,后面课程中我们在讲数据结构和算法的时候,还会详细介绍。在这里,我们主要先看WeakMap,它的特点是只接受对象作为键名,键名是弱引用,键值可以是任意的。
|
||||
|
||||
在下面的例子中,我们首先声明了一个WidgetG变量。接下来,建立一个块级作用域,在这个作用域里,我们再声明一个privateProps的WeakMap变量。然后我们给WidgetG赋值一个函数声明,在里面给WeakMap的键名设置为this,键值里面的appName为“天气应用”。下一步,我们基于WidgetF的prototype来创建一个getName方法,里面返回了appName的值。
|
||||
|
||||
利用这样的方式,就可以同时达到对appName的封装和通过getName在外部对私有属性值的获取了。
|
||||
|
||||
var WidgetH;
|
||||
{
|
||||
let privateProps = new WeakMap();
|
||||
|
||||
WidgetH = function(){
|
||||
privateProps.set(this,{appName : "天气应用"});
|
||||
}
|
||||
|
||||
WidgetH.prototype.getName = function(){
|
||||
return privateProps.get(this).appName;
|
||||
}
|
||||
}
|
||||
|
||||
var widget8 = new WidgetH();
|
||||
console.log(widget8.appName); // 返回 undefined
|
||||
console.log(widget8.getName()); // 返回 “天气应用”
|
||||
|
||||
|
||||
用Symbol创建私有属性
|
||||
|
||||
Symbol也是在ES6引入的一个新的数据类型,我们可以用它给对象的属性的键名命名。
|
||||
|
||||
同样我们来看一个例子。和上个例子相似,这里我们建立了一个块级作用域,但区别是我们把privateProps从WeakMap换成了Symbol来实现私有属性。
|
||||
|
||||
var WidgetI;
|
||||
{
|
||||
let privateProps = Symbol();
|
||||
|
||||
WidgetI = function(){
|
||||
this[privateProps] = {appName : "天气应用"};
|
||||
}
|
||||
|
||||
WidgetI.prototype.getName = function(){
|
||||
return this[privateProps].appName;
|
||||
}
|
||||
}
|
||||
|
||||
var widget9 = new WidgetI();
|
||||
console.log(widget9.getName()); // 返回 “天气应用”
|
||||
|
||||
|
||||
如何创建静态属性?
|
||||
|
||||
前面我们提到了,静态的属性是属于构造函数的属性,而不是构造对象实例的属性。下面我们就来看看,如何通过JavaScript来实现静态属性。
|
||||
|
||||
创建公开静态属性
|
||||
|
||||
我们先看看如何通过static这个关键词来创建公开的静态属性。如以下代码所示,当我们直接在WidgetJ上面获取appName和getName的时候,可以看到结果是返回“天气应用”。而如果我们用WidgetJ构建一个widget10,看到返回的是未定义。这就说明,静态属性只能作用于class本身。
|
||||
|
||||
class WidgetJ {
|
||||
static appName = "天气应用";
|
||||
static getName(){
|
||||
return this.appName;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(WidgetJ.appName); // 返回 “天气应用”
|
||||
console.log(WidgetJ.getName()); // 返回 “天气应用”
|
||||
|
||||
var widget10 = new WidgetJ();
|
||||
console.log(widget10.appName); // 返回 undefined
|
||||
console.log(widget10.getName()); // 返回 undefined
|
||||
|
||||
|
||||
创建私有静态属性
|
||||
|
||||
好,说完了公有静态属性,我们再来看看私有静态属性。私有的静态属性,顾名思义就是它不只是供构造者使用的,同时也是被封装在构建者之内的。
|
||||
|
||||
我们来看看它要如何实现,其实就是把 #符号和static关键词相加来使用。
|
||||
|
||||
class WidgetM {
|
||||
static #appName = "天气应用";
|
||||
static staticGetName(){
|
||||
return WidgetM.#appName;
|
||||
}
|
||||
instanceGetName(){
|
||||
return WidgetM.#appName;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(WidgetM.staticGetName()); // 返回 “天气应用”
|
||||
|
||||
var widget13 = new WidgetM();
|
||||
console.log(widget13.instanceGetName()); // 返回 “天气应用”
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课我们通过对象内部的私有和静态属性,在第一讲的基础上,进一步地了解了对象的构建。同时,更重要的是,我们通过去掉私有属性的语法糖,也了解了如何通过函数式中的闭包、对象中的prototype、值类型中的Map和Symbol,这些更底层的方式实现同样的功能。在后面的两节课里,我们会继续从单个对象延伸到对象间的“生产关系”,来进一步理解面向对象的编程模式。
|
||||
|
||||
思考题
|
||||
|
||||
我们今天尝试通过去掉语法糖,用更底层的方式实现了对象中的私有属性,那么你能不能自己动手试试去掉静态属性的语法糖,来实现类似的功能?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
343
专栏/JavaScript进阶实战课/08深入理解继承、Delegation和组合.md
Normal file
343
专栏/JavaScript进阶实战课/08深入理解继承、Delegation和组合.md
Normal file
@ -0,0 +1,343 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 深入理解继承、Delegation和组合
|
||||
你好,我是石川。
|
||||
|
||||
关于面向对象编程,最著名的一本书就数GoF(Gang of Four)写的《设计模式:可复用面向对象软件的基础》了。这本书里一共提供了23种不同的设计模式,不过今天我们不会去展开了解这些细节,而是会把重点放在其中一个面向对象的核心思想上,也就是组合优于继承。
|
||||
|
||||
在JS圈,有不少继承和组合的争论。其实无论是继承还是组合,我们都不能忘了要批判性地思考。批判性思考的核心不是批判,而是通过深度思考核心问题,让我们对事物能有自己的判断。
|
||||
|
||||
所以,无论是继承还是组合,都只是方式、方法,它们要解决的核心问题就是如何让代码更加容易复用。
|
||||
|
||||
|
||||
|
||||
那么接下来,我们就根据这个思路,看看JavaScript中是通过哪些方法来解决代码复用这个问题的,以及在使用不同的方法时它们各自解决了什么问题、又引起了什么问题。这样我们在实际的业务场景中,就知道如何判断和选择最适合的解决方式了。
|
||||
|
||||
继承
|
||||
|
||||
在传统的OOP里面,我们通常会提到继承(Inheritance)和多态(Polymorphism)。继承是用来在父类的基础上创建一个子类,来继承父类的属性和方法。多态则允许我们在子类里面调用父类的构建者,并且覆盖父类里的方法。
|
||||
|
||||
那么下面,我们就先来看下在JavaScript里,要如何通过构建函数来做继承。
|
||||
|
||||
如何通过继承多态重用?
|
||||
|
||||
实际上,从ES6开始,我们就可以通过extends的方式来做继承。具体如下所示:
|
||||
|
||||
class Widget {
|
||||
appName = "核心微件";
|
||||
getName () {
|
||||
return this.appName;
|
||||
}
|
||||
}
|
||||
|
||||
class Calendar extends Widget {}
|
||||
|
||||
var calendar = new Calendar();
|
||||
console.log(calendar.hasOwnProperty("appName")); // 返回 true
|
||||
console.log(calendar.getName()); // 返回 "核心微件"
|
||||
|
||||
calendar.appName = "日历应用"
|
||||
console.log(typeof calendar.getName); // 返回 function
|
||||
console.log(calendar.getName()); // 返回 “日历应用”
|
||||
|
||||
|
||||
接着来看多态。从ES6开始,我们可以通过super在子类构建者里面调用父类的构建者,并且覆盖父类里的属性。可以看到在下面的例子里,我们是通过super将Calendar的appName属性从“核心微件”改成了“日历应用”。
|
||||
|
||||
class Widget {
|
||||
constructor() {
|
||||
this.appName = "核心微件";
|
||||
}
|
||||
|
||||
getName () {
|
||||
return this.appName;
|
||||
}
|
||||
}
|
||||
|
||||
class Calendar extends Widget {
|
||||
constructor(){
|
||||
super();
|
||||
this.appName = "日历应用";
|
||||
}
|
||||
}
|
||||
|
||||
var calendar = new Calendar();
|
||||
console.log(calendar.hasOwnProperty("appName")); // 返回 true
|
||||
console.log(calendar.getName()); // 返回 "日历应用"
|
||||
console.log(typeof calendar.getName); // 返回 function
|
||||
console.log(calendar.getName()); // 返回 “日历应用”
|
||||
|
||||
|
||||
在一些实际的例子,如React这样的三方库里,我们也经常可以看到一些继承的例子,比如我们可以通过继承React.Component来创建一个WelcomeMessage的子类。
|
||||
|
||||
class WelcomeMessage extends React.Component {
|
||||
render() {
|
||||
return <h1>Hello, {this.props.name}</h1>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
授权
|
||||
|
||||
说完了继承,我们再来看授权这个方法。
|
||||
|
||||
什么是授权(Delegation)呢?我打个比方,这里的授权不是我们理解的作为领导(父类)给下属(子类)授权,而是作为个体对象可以授权给一个平台或者他人来一起做一件事。
|
||||
|
||||
就好像我和极客时间合作,我的个人精力和专业能力只允许我尽量做好内容,但是我没有精力和经验去做编辑、后期和推广等等,这时就授权给极客时间相关的老师来一起做,我在这件事、这个过程中只是专心把内容的部分做好。
|
||||
|
||||
如何通过授权做到重用?
|
||||
|
||||
在前面的例子中,结合我们在第1讲里提到的基于原型链的继承,我们会发现使用JavaScript无论是通过函数构建也好,还是加了语法糖的类也好,来模拟一般的面向对象语言,比如Java的类和继承,对于有些开发者来说是比较反直觉的。在使用的时候需要大量的思想转换,才能把JavaScript的底层逻辑转换成实际呈现出来的实现。
|
||||
|
||||
那么有没有一种方式可以让代码更直观呢?这种方式其实就是通过原型本身来做授权会更符合直觉。从ES5开始,JavaScript就支持了Object.create()的方法。下面我们来看一个例子:
|
||||
|
||||
var Widget = {
|
||||
setCity : function(City) {this.city = City; },
|
||||
outputCity : function() {return this.city;}
|
||||
};
|
||||
|
||||
var Weather = Object.create(Widget);
|
||||
|
||||
Weather.setWeather = function (City, Tempreture) {
|
||||
this.setCity(City);
|
||||
this.tempreture = Tempreture;
|
||||
};
|
||||
|
||||
Weather.outputWeather = function() {
|
||||
console.log(this.outputCity()+ ", " + this.tempreture);
|
||||
}
|
||||
|
||||
var weatherApp1 = Object.create(Weather);
|
||||
var weatherApp2 = Object.create(Weather);
|
||||
|
||||
weatherApp1.setWeather("北京","26度");
|
||||
weatherApp2.setWeather("南京","28度");
|
||||
|
||||
weatherApp1.outputWeather(); // 北京, 26度
|
||||
weatherApp2.outputWeather(); // 南京, 28度
|
||||
|
||||
|
||||
可见,我们创建的Weather天气预报这个对象,授权给了Widget,让Widget在得到授权的情况下,帮助Weather来设定城市和返回城市。Widget对象在这里更像是一个平台,它在得到Weather的授权后为Weather赋能。而Weather对象可以在这个基础上,专注于实现自己的属性和方法,并且产出weatherApp1和weatherApp2的实例。
|
||||
|
||||
当然也有开发者认为class的方式没有什么反直觉的,那授权同样可以通过class来实现。比如我们如果想在上一讲提到过的集合(Set)和字典(Map)的基础上,加上计数的功能,可以通过继承Set来实现。但是我们也可以反之,在把部分功能授权给Map的基础上,自己专注实现一些类似Set的API接口。
|
||||
|
||||
class SetLikeMap {
|
||||
// 初始化字典
|
||||
constructor() { this.map = new Map(); }
|
||||
// 自定义集合接口
|
||||
count(key) { /*...*/ }
|
||||
add(key) { /*...*/ }
|
||||
delete(key) { /*...*/ }
|
||||
// 迭代返回字典中的键
|
||||
[Symbol.iterator]() { return this.map.keys(); }
|
||||
// 部分功能授权给字典
|
||||
keys() { return this.map.keys(); }
|
||||
values() { return this.map.values(); }
|
||||
entries() { return this.map.entries(); }
|
||||
}
|
||||
|
||||
|
||||
组合
|
||||
|
||||
说完了授权,我们再来看看组合。当然上面我们说的授权,广义上其实就是一种组合。但是这种组合更像是“个体和平台的合作”;而另一种组合更像是“团队内部的合作”,它也有很多的应用和实现方式,我们可以来了解一下。
|
||||
|
||||
|
||||
|
||||
如何通过借用做到重用?
|
||||
|
||||
在JavaScript中,函数有自带的apply和call功能。我们可以通过apply或call来“借用”一个功能。这种方式,也叫隐性混入(Implicit mixin)。比如在数组中,有一个原生的slice的方法,我们就可以通过call来借用这个原生方法。
|
||||
|
||||
如下代码示例,我们就是通过借用这个功能,把函数的实参当做数组来slice。
|
||||
|
||||
function argumentSlice() {
|
||||
var args = [].slice.call(arguments, 1, 3);
|
||||
return args;
|
||||
}
|
||||
// example
|
||||
argumentSlice(1, 2, 3, 4, 5, 6); // returns [2,3]
|
||||
|
||||
|
||||
如何通过拷贝赋予重用?
|
||||
|
||||
除了“借力”以外,我们还能通过什么组合方式来替代继承呢?这就要说到“拷贝”了。这个方法顾名思义,就是把别人的属性和方法拷贝到自己的身上。这种方式也叫显性混入(Explicit mixin)。
|
||||
|
||||
在ES6之前,人们通常要偷偷摸摸地“抄袭”。在ES6之后,JavaScript里才增加了“赋予”,也就是Object.assign()的功能,从而可以名正言顺地当做是某个对象“赋予”给另外一个对象它的“特质和能力”。
|
||||
|
||||
那么下面,我们就先看看在ES6之后,JavaScript是如何名正言顺地来做拷贝的。
|
||||
|
||||
首先,通过对象自带的assign(),我们可以把Widget的属性赋予calendar,当然在calendar里,我们也可以保存自己本身的属性。和借用一样,借用和赋予都不会产生原型链。如以下代码所示:
|
||||
|
||||
var widget = {
|
||||
appName : "核心微件"
|
||||
}
|
||||
|
||||
var calendar = Object.assign({
|
||||
appVersion: "1.0.9"
|
||||
}, widget);
|
||||
|
||||
console.log(calendar.hasOwnProperty("appName")); // 返回 true
|
||||
console.log(calendar.appName); // 返回 “核心微件”
|
||||
console.log(calendar.hasOwnProperty("appVersion")); // 返回 true
|
||||
console.log(calendar.appVersion); // 返回 “1.0.9”
|
||||
|
||||
|
||||
好,接着我们再来看看在ES6之前,人们是怎么通过“抄袭”来拷贝的。
|
||||
|
||||
这里实际上分为“浅度拷贝”和“深度拷贝”两个概念。“浅度拷贝”类似于上面提到的赋予assign这个方法,它所做的就是遍历父类里面的属性,然后拷贝到子类。我们可以通过JavaScript中专有的for in循环,来遍历对象中的属性。
|
||||
|
||||
细心的同学可能会发现,我们在第2讲中说到用拷贝来做到不可变时,就了解过通过延展操作符来实现浅拷贝的方法了。
|
||||
|
||||
// 数组浅拷贝
|
||||
var a = [ 1, 2 ];
|
||||
var b = [ ...a ];
|
||||
b.push( 3 );
|
||||
a; // [1,2]
|
||||
b; // [1,2,3]
|
||||
|
||||
// 对象浅拷贝
|
||||
var o = {
|
||||
x: 1,
|
||||
y: 2
|
||||
};
|
||||
var p = { ...o };
|
||||
p.y = 3;
|
||||
o.y; // 2
|
||||
p.y; // 3
|
||||
|
||||
|
||||
而在延展操作符出现之前,人们大概可以通过这样一个for in循环做到类似的浅拷贝。
|
||||
|
||||
function shallowCopy(parent, child) {
|
||||
var i;
|
||||
child = child || {};
|
||||
for (i in parent) {
|
||||
if (parent.hasOwnProperty(i)) {
|
||||
child[i] = parent[i];
|
||||
}
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
|
||||
至于深度拷贝,是指当一个对象里面存在嵌入的对象就会深入遍历。但这样会引起一个问题:如果这个对象有多层嵌套的话,是每一层都要遍历吗?究竟多深算深?还有就是如果一个对象也引用了其它对象的属性,我们要不要也拷贝过来?
|
||||
|
||||
所以相对于深度拷贝,浅度拷贝的问题会少一些。但是在第2讲的留言互动区,我们也说过,如果我们想要保证一个对象的深度不可变,还是需要深度拷贝的。深度拷贝的一个相对简单的实现方案是用JSON.stringify。当然这个方案的前提是这个对象必须是JSON-safe的。
|
||||
|
||||
function deepCopy(o) { return JSON.parse(JSON.stringify(o)); }
|
||||
|
||||
|
||||
同时,在第2讲的留言区中,也有同学提到过另外一种递归的实现方式,所以我们也大致可以通过这样一个递归来实现:
|
||||
|
||||
function deepCopy(parent, child) {
|
||||
var i,
|
||||
toStr = Object.prototype.toString,
|
||||
astr = "[object Array]";
|
||||
child = child || {};
|
||||
for (i in parent) {
|
||||
if (parent.hasOwnProperty(i)) {
|
||||
if (typeof parent[i] === "object") {
|
||||
child[i] = (toStr.call(parent[i]) === astr) ? [] : {};
|
||||
deepCopy(parent[i], child[i]);
|
||||
} else {
|
||||
child[i] = parent[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
|
||||
如何通过组合做到重用?
|
||||
|
||||
上面我们说的无论是借用、赋予,深度还是浅度拷贝,都是一对一的关系。最后我们再来看看,如何通过ES6当中的assign来做到组合混入,也就是说把几个对象的属性都混入在一起。其实方法很简单,以下是参考:
|
||||
|
||||
var touchScreen = {
|
||||
hasTouchScreen : () => true
|
||||
};
|
||||
|
||||
var button = {
|
||||
hasButton: () => true
|
||||
};
|
||||
var speaker = {
|
||||
hasSpeaker: () => true
|
||||
};
|
||||
|
||||
const Phone = Object.assign({}, touchScreen, button, speaker);
|
||||
|
||||
console.log(
|
||||
hasTouchScreen: ${ Phone.hasChocolate() }
|
||||
hasButton: ${ Phone.hasCaramelSwirl() }
|
||||
hasSpeaker: ${ Phone.hasPecans() }
|
||||
);
|
||||
|
||||
|
||||
React中的组合优于继承
|
||||
|
||||
在React当中,我们也可以看到组合优于继承的无处不在,并且它同样体现在我们前面讲过的两个方面,一个是“团队内部的合作”,另一个是“个体与平台合作”。下面,我们先看看“团队内部的合作”的例子,在下面的例子里,WelcomeDialog就是嵌入在FancyBorder中的一个团队成员。
|
||||
|
||||
function FancyBorder(props) {
|
||||
return (
|
||||
<div className={'FancyBorder FancyBorder-' + props.color}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeDialog() {
|
||||
return (
|
||||
<FancyBorder color="blue">
|
||||
<h1 className="Dialog-title">
|
||||
Welcome
|
||||
</h1>
|
||||
<p className="Dialog-message">
|
||||
Thank you for visiting our spacecraft!
|
||||
</p>
|
||||
</FancyBorder>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
另外,我们也可以看到“个体与平台合作”的影子。在这里,WelcomeDialog是一个“专业”的Dialog,它授权给Dialog这个平台,借助平台的功能,实现自己的title和message。这里就是用到了组合。
|
||||
|
||||
function Dialog(props) {
|
||||
return (
|
||||
<FancyBorder color="blue">
|
||||
<h1 className="Dialog-title">
|
||||
{props.title}
|
||||
</h1>
|
||||
<p className="Dialog-message">
|
||||
{props.message}
|
||||
</p>
|
||||
</FancyBorder>
|
||||
);
|
||||
}
|
||||
function WelcomeDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
title="Welcome"
|
||||
message="Thank you for visiting our spacecraft!" />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们了解了通过JavaScript做到代码复用的几种核心思想和方法,从传统的继承,到JavaScript特色的授权以及组合等方式都有分析。虽然我说授权和组合优于继承,但实际上它们之间的关系不是非黑即白的。
|
||||
|
||||
我们看到在前端圈,有很多大佬比如道格拉斯·克罗克福德(Douglas Crockford)和凯尔·辛普森(Kyle Simpson),都是基于授权的对象创建的积极拥护者;而像阿克塞尔·劳施迈尔博士(Dr. Axel Rauschmayer)则是基于类的对象构建的捍卫者。
|
||||
|
||||
我们作为程序员,如果对对象和面向对象的理解不深入,可能很容易在不同的论战和观点面前左摇右摆。而实际的情况是,真理本来就不止一个。我们要的“真理”,只不过是通过一个观察角度,形成的一个观点。这样,才能分析哪种方式适合我们当下要解决的问题。这个方式,只有在当下,才是“真理”。而我们通过这个单元整理的方法,目的就是帮助我们做到这样的观测。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
在前面一讲中,我们试着通过去掉对象私有属性的语法糖,来看如何用更底层的语言能力来实现类似的功能。那么,今天你能尝试着实现下JS中的类和继承中的super,以及原型和授权中的Object.create()吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
269
专栏/JavaScript进阶实战课/09面向对象:通过词法作用域和调用点理解this绑定.md
Normal file
269
专栏/JavaScript进阶实战课/09面向对象:通过词法作用域和调用点理解this绑定.md
Normal file
@ -0,0 +1,269 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 面向对象:通过词法作用域和调用点理解this绑定
|
||||
你好,我是石川。
|
||||
|
||||
今天,我们来讲讲JavaScript中的this。其实讲this的资料有很多,其中不少已经把这个概念讲的很清楚了。但是为了课程的系统性,我今天也从这个单元咱们讲到的对象和面向对象的角度来说一说。
|
||||
|
||||
因为现在正好赶上国庆假期,咱们这节课的内容不是很长,所以你学起来也不会很辛苦。但是字少事大,this的概念还是很重要的。所以如果你之前没有具体了解过,还是希望这节课能帮助你更好地理解this。
|
||||
|
||||
从直观的印象来看,你可能觉得 this 指的是函数本身或它所在的范围,其实这样的理解都是不对。在JavaScript中,this 是在运行时而不是编写时绑定的。所以要正确地使用它,需要考虑到函数调用时的执行上下文。
|
||||
|
||||
默认绑定
|
||||
|
||||
我们来看一个简单的例子,在下面的例子中,a 是在全局定义的,aLogger的函数是在全局被调用的,所以返回的this就是全局上下文,所以a的值自然就是2。
|
||||
|
||||
function aLogger() {
|
||||
console.log( this.a );
|
||||
}
|
||||
var a = 2;
|
||||
aLogger(); // 2
|
||||
|
||||
|
||||
这种默认的绑定只在非strict mode的情况下是可以的。所以如果在strict mode下,这种默认的的绑定是不可以的,则会返回 TypeError: this is undefined。
|
||||
|
||||
隐式绑定
|
||||
|
||||
下面,我们再来看看,如果我们在一个对象 obj 里给 a 赋值为 3,然后我们通过调用 aLogger 来获取 a 的值,这个时候,aLogger 被调用时的上下文是在 obj 中,所以它的值就是 3。
|
||||
|
||||
function aLogger() {
|
||||
console.log( this.a );
|
||||
}
|
||||
|
||||
var obj = {
|
||||
a: 3,
|
||||
logger: aLogger
|
||||
};
|
||||
|
||||
var a = 2;
|
||||
|
||||
obj.logger(); // 3
|
||||
|
||||
|
||||
但是隐式绑定也有它的问题,就是当我们把对象里的方法赋值给一个全局变量时,这种绑定就消失了。比如下面的例子中,我们给 objLogger 赋值 obj.logger,结果 this 引用的就是全局中 a 的值。
|
||||
|
||||
function logger() {
|
||||
console.log( this.a );
|
||||
}
|
||||
|
||||
var obj = {
|
||||
a: 3,
|
||||
logger: logger
|
||||
};
|
||||
|
||||
var a = 2;
|
||||
|
||||
var objLogger = obj.logger;
|
||||
|
||||
objLogger(); // 2
|
||||
|
||||
|
||||
显式绑定
|
||||
|
||||
下面,我们再来看看显式绑定。在这种情况下,我们使用的是 call 或者 apply。通过这种方式,我们可以强行使 this 等于 obj。
|
||||
|
||||
function logger() {
|
||||
console.log( this.a );
|
||||
}
|
||||
|
||||
var obj = {
|
||||
a: 3
|
||||
};
|
||||
|
||||
logger.call( obj ); // 3
|
||||
|
||||
|
||||
这种显式绑定也不能完全解决问题,它也会产生一些副作用,比如在通过 wrapper 包装的 new String,new Boolean 或 new Number 的时候,这种绑定就会消失。
|
||||
|
||||
硬性绑定
|
||||
|
||||
下面,我们再来看看一种硬性绑定的方式。这里,我们使用从ES5开始支持的 bind 来绑定,通过这种方式,无论后续我们怎么调用 hardBinding 函数,logger 都会把 obj 当做 this 来获取它的 a 属性的值。
|
||||
|
||||
function logger() {
|
||||
console.log( this.a );
|
||||
}
|
||||
|
||||
var obj = {
|
||||
a: 3
|
||||
};
|
||||
|
||||
var hardBinding = logger.bind( obj );
|
||||
|
||||
setTimeout( hardBinding, 1000 ); // 3
|
||||
|
||||
hardBinding.call( window ); // 3
|
||||
|
||||
|
||||
new绑定
|
||||
|
||||
最后,我们再来看看new 绑定,当我们使用 new 创建一个新的实例的时候,这个新的对象就是 this,所以我们可以看到在新的实例中我们传入的 2,就可以给 loggerA 实例的属性 a 赋值为 a,所以返回的结果是 2。
|
||||
|
||||
function logger(a) {
|
||||
this.a = a;
|
||||
console.log( this.a );
|
||||
}
|
||||
|
||||
var loggerA = new logger( 2 ); // 2
|
||||
|
||||
|
||||
下面我们来看一个“硬碰硬”的较量,我们来试试用hard binding 来对决 new binding,看看谁拥有绝对的实力。下面,我们先将 logger 里的 this 硬性绑定到obj 1上,这时我们输出的结果是2。然后,我们用 new 来创建一个新的 logger 实例,在这个实例中,我们可以看到 obj 2 作为新的 logger 实例,它的 this 是可以不受 obj 1 影响的。所以new是强于hard binding的。
|
||||
|
||||
function logger(a) {
|
||||
this.a = a;
|
||||
}
|
||||
|
||||
var obj1 = {};
|
||||
|
||||
var hardBinding = logger.bind( obj1 );
|
||||
|
||||
hardBinding( 2 );
|
||||
|
||||
console.log( obj1.a ); // 2
|
||||
|
||||
var obj2 = new logger( 3 );
|
||||
|
||||
console.log( obj1.a ); // 2
|
||||
console.log( obj2.a ); // 3
|
||||
|
||||
|
||||
之前在评论区也有朋友提到过谋智,也就是开发了火狐浏览器的公司,运营的一个MDN网站是一个不错的辅助了解JavaScript的平台。通过在MDN上的 bind polyfill 的代码,我们大概可以看到在 bind 中是有一个逻辑判断的,它会看新的实例是不是通过 new 来创建的,如果是,那么 this 就绑定到新的实例上。
|
||||
|
||||
this instanceof fNOP &&
|
||||
oThis ? this : oThis
|
||||
|
||||
// ... and:
|
||||
|
||||
fNOP.prototype = this.prototype;
|
||||
fBound.prototype = new fNOP();
|
||||
|
||||
|
||||
那么我们对比 new 和 bind 各有什么好处呢?用 new 的好处是可以帮助我们忽略 hard binding,同时可以预设函数的实参。用 bind 的好处是任何 this 之后的实参,都可以当做是默认的实参。这样就可以用来创建我们之前第3讲说过的柯理式中的部分应用。比如在下面的例子中,1 和 2 就作为默认实参,在 partialFunc 中我们只要输入 9,就可以得到3个数字相加的结果。
|
||||
|
||||
function fullFunc (x, y, z) {
|
||||
return x + y + z;
|
||||
}
|
||||
|
||||
const partialFunc = fullFunc.bind(this, 1, 2);
|
||||
partialFunc(9); // 12
|
||||
|
||||
|
||||
除了硬性绑定外,还有一个软性绑定的方式,它可以在 global 或 undefined 的情况下,将 this 绑定到一个默认的 obj 上。
|
||||
|
||||
if (!Function.prototype.softBind) {
|
||||
Function.prototype.softBind = function(obj) {
|
||||
var fn = this,
|
||||
curried = [].slice.call( arguments, 1 ),
|
||||
bound = function bound() {
|
||||
return fn.apply(
|
||||
(!this ||
|
||||
(typeof window !== "undefined" &&
|
||||
this === window) ||
|
||||
(typeof global !== "undefined" &&
|
||||
this === global)
|
||||
) ? obj : this,
|
||||
curried.concat.apply( curried, arguments )
|
||||
);
|
||||
};
|
||||
bound.prototype = Object.create( fn.prototype );
|
||||
return bound;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
在下面的例子当中,我们可以看到,除隐式、显式和软性绑定外,obj2 在 timeout 全局作用域下,返回的默认绑定结果。
|
||||
|
||||
function logger() {
|
||||
console.log("name: " + this.name);
|
||||
}
|
||||
var obj1 = { name: "obj1" },
|
||||
obj2 = { name: "obj2" },
|
||||
obj3 = { name: "obj3" };
|
||||
|
||||
var logger1 = logger.softBind( obj1 );
|
||||
logger1(); // name: obj1
|
||||
|
||||
obj2.logger = logger.softBind( obj1 );
|
||||
obj2.logger(); // name: obj2
|
||||
|
||||
logger1.call( obj3 ); // name: obj3
|
||||
|
||||
setTimeout( obj2.logger, 1000 ); // name: obj1
|
||||
|
||||
|
||||
同样地,这样的软性绑定也支持我们前面说的柯理式中的部分应用。
|
||||
|
||||
function fullFunc (x, y, z) {
|
||||
return x + y + z;
|
||||
}
|
||||
|
||||
const partialFunc = fullFunc.softBind(this, 1, 2);
|
||||
partialFunc(9); // 12
|
||||
|
||||
|
||||
延伸:箭头函数
|
||||
|
||||
在 this 的绑定中,有一点是需要我们注意的,那就是当我们使用箭头函数的时候,this 是在词法域里面的,而不是根据函数执行时的上下文。比如在下面的例子中,我们看到返回的结果就是 2 而不是3。
|
||||
|
||||
function logger() {
|
||||
return (a) => {
|
||||
console.log( this.a );
|
||||
};
|
||||
}
|
||||
var obj1 = {
|
||||
a: 2
|
||||
};
|
||||
|
||||
var obj2 = {
|
||||
a: 3
|
||||
};
|
||||
|
||||
var logger1 = logger.call( obj1 );
|
||||
|
||||
logger1.call( obj2 ); // 2
|
||||
|
||||
|
||||
通过箭头函数来做 this 绑定的一个比较常用的场景就是setTimeout。在这个函数中的 this 就会绑定在 logger 的函数词法域里。
|
||||
|
||||
function logger() {
|
||||
setTimeout(() => {
|
||||
console.log( this.a );
|
||||
},1000);
|
||||
}
|
||||
var obj = {
|
||||
a: 2
|
||||
};
|
||||
logger.call( obj ); // 2
|
||||
|
||||
|
||||
如果我们不用箭头函数的话,也可以通过 self = this 这样的方式将 this 绑定在词法域里。
|
||||
|
||||
function logger() {
|
||||
var self = this;
|
||||
setTimeout( function(){
|
||||
console.log( self.a );
|
||||
}, 1000 );
|
||||
}
|
||||
var obj = {
|
||||
a: 2
|
||||
};
|
||||
logger.call( obj ); // 2
|
||||
|
||||
|
||||
但是通常为了代码的可读性和可维护性,在同一个函数中,应该一以贯之,要么尽量使用词法域,干脆不要有 this;或者要用 this,就通过 bind 等来绑定,而不是通过箭头函数或者 self = this 这样的“奇技淫巧”来做绑定。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们学习了 this 的绑定,它可以说是和函数式中的 closure 有着同等重要性的概念。如果说函数式编程离不开对 closure 的理解,那么不理解 this,在 JavaScript 中用面向对象编程也会一头雾水。这两个概念虽然理解起来比较绕脑,但是一旦理解,你就会发现它们的无处不在。
|
||||
|
||||
思考题
|
||||
|
||||
我们今天在讲 this 的绑定时,用到了 call 和 bind,我们知道 JavaScript 中和 call 类似的还有 apply,那么你觉得在处理绑定时,它和 call 效果一样吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
341
专栏/JavaScript进阶实战课/10JS有哪8种数据类型,你需要注意什么?.md
Normal file
341
专栏/JavaScript进阶实战课/10JS有哪8种数据类型,你需要注意什么?.md
Normal file
@ -0,0 +1,341 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 JS有哪8种数据类型,你需要注意什么?
|
||||
你好,我是石川。
|
||||
|
||||
JavaScript的数据类型看上去不难理解,但是你确定真的理解并且会用它们吗?实际上,如果不系统地理解数据类型的概念,很可能会导致一些隐藏问题时不时地冒出来,给我们写的程序挖坑。比如简单的加法计算可能就会带来意想不到的结果值;或者没有很好地利用到JS的一些长处,让开发更便捷,比如说通过内置的包装对象快速获取值的属性。
|
||||
|
||||
在正式开始之前,我还是要先说明一下,虽然现在JS介绍数据类型的书和资料不在少数,我们也不会在这里做理论赘述。但我还是会先带你快速建立起对值的基本认识框架,然后通过对问题的深入了解,以此达成扬长避短的目标。
|
||||
|
||||
那么,JavaScript当中有几种类型的值呢?答案是8种。如果再归归类,我们还可以把它们分为两大类,分别是原始类型(Primitive Type)和对象类型(Object Type)。
|
||||
|
||||
其中,原始数据类型包含了数字、布尔、字符串、BigInt、null、undefined,以及后来新增的symbol,这个数据类型的值都是不可变的(immutable)。
|
||||
|
||||
对象数据类型则包含我们经常说的对象,对象的值是可变的(mutable)。它是一个大类,如果再细分,它又包含了我们常用的数组(array)、函数(function)、Date、RegExp,以及后来新增的Map和Set。没错,我们经常用的数组、函数作为值都属于对象数据类型。
|
||||
|
||||
|
||||
|
||||
好,在了解了数据类型的划分后,接下来,我们会通过不同类型在实际应用时会出现的各种问题,来进一步了解它们的原理和使用方法,打下底层基础,为后面的学习铺平道路。
|
||||
|
||||
下面,我们就从原始类型数据开始说起。
|
||||
|
||||
原始类型
|
||||
|
||||
原始类型是我们经常用到的数据类型,这里有基础的类型,比如数字、字符串和布尔,也有特殊的类型,比如null和undefined。相比对象类型,原始类型数据从值的角度来看是理论和实际偏差最大的部分,另外因为它看上去比较简单,所以也同时是很容易被忽略的部分。
|
||||
|
||||
所以,我们这节课的重点,就是要把这些问题用放大镜放大,深入浅出地了解它的核心原理和解决方案。
|
||||
|
||||
number数字:为什么0.1+0.2不等于0.3?
|
||||
|
||||
我们先来做个小实验:在Chrome开发者工具中输入0.1+0.2,得到的结果是什么?惊不惊喜,意不意外?最后的结果竟然不是0.3,后面还有\(4 \\times 10^{-17}\)。
|
||||
|
||||
|
||||
|
||||
为什么会出现这样的情况?我们可以先回头看下这个数据类型脑图,JavaScript中的数字类型包括了两类:浮点数和整数。
|
||||
|
||||
|
||||
|
||||
而以上运算使用的就是浮点数。那和浮点数对应的是什么呢?是定点数。
|
||||
|
||||
定点数的好处是可以满足日常的小额计算。但它也有很大的缺点,就是在进行很小或很大的数字计算时,会浪费很大的空间。比如我们想表达中国有14亿人口, 如果写起来,就是1,400,000,000,14后面8个零。所以要解决这个问题,就需要用到科学计数法。
|
||||
|
||||
浮点数是采用科学计数法来表示的,由尾数(significand mantissa)、基数(base)和指数(exponent)三部分组成。上面的数字如果是用科学计数法表示,只需要写成\(1.4 \\times 10^{9}\)即可。对于小数,就是反之,拿0.002举例,数字是2,那么小数就是10的负3次方。
|
||||
|
||||
|
||||
|
||||
上面我们看的是十进制的例子,而JavaScript所采用的IEEE 754 是二进制浮点数算术标准。这个标准里规定了4种浮点数算术方式:单精确度、双精确度、延伸单精确度与延伸双精确度。JavaScript在这里选择的又是双精确度(64位)这种方式,通常也叫double或者float64类型。
|
||||
|
||||
这种方式顾名思义,有64位比特。其中包含了1个比特的符号位(sign)、11个比特的有偏指数(exponent)、还有52个比特的小数部分(fraction)。
|
||||
|
||||
|
||||
|
||||
因为把十进制转化为二进制的算法是用十进制的小数乘以2直到没有了小数为止,所以十进制下的有些小数无法被精确地表示成二进制小数。而既然这里的浮点数是二进制,因此小数就会存在精度丢失的问题。
|
||||
|
||||
而且当我们使用加减法的时候,由于需要先对齐(也就是把指数对齐,过程中产生移位),再计算,所以这个精度会进一步丢失。并且根据JavaScript引擎实际返回的小数点后的位数,可能会出现第三次丢失。这样下来,最后的结果就和实际相加减的数有偏离。
|
||||
|
||||
现在,我们就了解了精度丢失的问题,这并不是一个bug,而是符合标准设计的。同时,我们也了解了出现这种情况的原因和实际运算的步骤,那我们需要做什么才能解决由它引起的精度问题呢?
|
||||
|
||||
通常对于这个问题的处理,是通过按比例放大再缩小。我们来看个例子,假如要设置一个19.99元的商品,我们可以把它先变成1999,这样就可以做加法计算,之后再缩小。
|
||||
|
||||
var priceBigInt = 1999n;
|
||||
var priceStr = String(priceBigInt);
|
||||
var priceYuan = `¥${priceStr.slice(0,-2)}.${priceStr.slice(-2)}`;
|
||||
console.log(priceYuan);
|
||||
|
||||
|
||||
NaN:如何判断一个值是不是数字?
|
||||
|
||||
如果说浮点数给我们带来了意料之外、情理之中的惊喜,那么NaN带给我们的则是意料之外且情理之外的惊喜了。
|
||||
|
||||
|
||||
惊喜1-
|
||||
在IEEE 754中,NaN虽然代表的是“不是数字”的意思,但是如果我们用typeof NaN来获取,会发现它返回的是number。
|
||||
|
||||
惊喜2-
|
||||
原始类型有个特点,就是两个数据的数值一样,会被当做是等同的。而对象类型则相反,即使两个数据的数值一样,也会被当做是不同的数值,每一个数值都有一个唯一的身份。
|
||||
|
||||
|
||||
我们可以通过下面的例子来看一下。当我们严格比较两个数字时,返回的就是true;当我们严格比较两个对象字面量时,返回的结果就是false。
|
||||
|
||||
123 === 123 // 返回 true
|
||||
{} === {} // 返回 false
|
||||
|
||||
|
||||
按照这样的原则,既然NaN是数字的值,如果我们输入NaN严格比较NaN,原则上应该返回的是true,可实际NaN返回的却是false。
|
||||
|
||||
NaN === NaN // 返回 false
|
||||
|
||||
|
||||
|
||||
惊喜3-
|
||||
JavaScript中会通过isNaN来判断一个值是不是数字,但是当我们输入一个字符串,它也会被当做是一个数字。因为在这个过程中,“0”这个字符串被转换成了数字。
|
||||
|
||||
|
||||
isNaN("0") // 返回 false
|
||||
|
||||
|
||||
所以,从这些惊喜中我们可以发现,想通过NaN和isNaN来判断一个值是不是数字的做法,是很不靠谱的。
|
||||
|
||||
那么,如何才能更正确地判断一个值是不是数字呢?
|
||||
|
||||
我们可以通过判断值的类型,并加上一个isFinite这种方式来判断。isFinite是JavaScript中的一个内置函数,通过它,我们可以过滤掉NaN和Infinity。
|
||||
|
||||
但是要注意,和惊喜3一样,它会把括号中的值比如字符串转化成数字,所以我们需要再通过typeof来确保这种被转换的问题不会被漏掉。
|
||||
|
||||
var isNum = function isNum(value){
|
||||
return typeof value === 'number' && isFinite(value);
|
||||
}
|
||||
|
||||
|
||||
string字符串:一串字符有多长?
|
||||
|
||||
我们知道,原始类型的数据除了undefined和null以外,都有内置的包装对象(object wrapper)。那么下面就让我们通过字符串,来看一下它是怎么工作的。
|
||||
|
||||
可以看到在这个例子中,我们是用new String()这样的constructor的方式创建一个字符串。而当我们想要获取它的长度时,就可以采用str.length方法来获取。
|
||||
|
||||
var str = new String("hello");
|
||||
str.length // 返回 5;
|
||||
typeof str // 返回 'object'
|
||||
|
||||
|
||||
但是,即使你不用constructor这种方式,也仍然可以用字面量的方式来获取字符串的长度(length)。在这个过程中,你同样可以看到长度结果的返回。而且,当你再用typeof来获取它的类型时,收到的结果仍然是字符串,而不是对象。
|
||||
|
||||
这是因为在你使用length这个方法的时候,JavaScript引擎临时创建了一个字符串的包装对象。当这个字符串计算出长度后,这个对象也就消失了。所以当你再回过头来看str的类型时,返回的是字符串而不是对象。
|
||||
|
||||
var str = "hello";
|
||||
str.length // 返回 5
|
||||
typeof str // 返回 'string'
|
||||
|
||||
|
||||
boolean布尔:你分得清真假吗?
|
||||
|
||||
在Java中,布尔类型的数据包含了真值true和假值false两个值。但需要注意的是,在JavaScript中,除了false以外,undefined、null、0、NaN和“‘’”也都是假值。
|
||||
|
||||
这里你一定会问,那么真值有哪些呢?其实你可以使用排除法,除了假值以外的,都可以认为是真值。为了方便查询,你可以参考下面这个列表。-
|
||||
|
||||
|
||||
null:什么,你是个对象?
|
||||
|
||||
我们前面说过,null是六种原始数据类型中独立的一种。可当我们用typeof来获取null的种类时,返回的结果是’object’,也就是说它是属于对象类型。
|
||||
|
||||
这是一个bug,但是也并非完全没有逻辑的bug,因为null实际是一个空的对象指针。
|
||||
|
||||
那我们要如何判断一个值是不是null呢?解决这个问题方法,其实就是不用typeof,而是直接将值和null做严格比较。
|
||||
|
||||
除了null以外,另外一个和它类似的是undefined。如果说null代表值是空对象,undefined代表的就是没有值。但是当我们对比它们的值时,它们却是相等的;另外严格比较它们的数据类型的时候,又会发现它们是不同的。
|
||||
|
||||
null == undefined // 返回 true
|
||||
null === undefined // 返回 false
|
||||
|
||||
|
||||
所以,我们判断值是否为空,可以是 if (x === undefined || x === null) {},也可以是 if (!x) {…}。
|
||||
|
||||
那么我们什么时候用undefined,什么时候用null呢?通常我们是不用undefined的,而是把它作为系统的返回值或系统异常。比如当我们声明了一个变量,但是没有赋值的情况下,结果就是undefined。而当我们想特意定义一个空的对象的时候,可以用null。
|
||||
|
||||
var a;
|
||||
a // undefined
|
||||
var b = null;
|
||||
|
||||
if (b != null){
|
||||
// do something!
|
||||
}
|
||||
|
||||
|
||||
对象类型
|
||||
|
||||
说完了原始类型,我们再来看看对象类型。原始类型的问题都是非黑即白的,比如我们前面看到的问题都是由于JavaScript设计中的某种限制、缺陷或bug造成的。
|
||||
|
||||
而对象类型的问题,更多是在不同场景下、不同的使用方式所体现出的优劣势。
|
||||
|
||||
为什么基于对象创建的实例instanceOf返回错误?
|
||||
|
||||
你要创建一个对象,既可以通过字面量也可以通过constructor的模式(我们在后面讲设计范式的时候还会更具体地提到这一点,这里我们只需要了解它的不同使用方式)。但这里你需要注意的问题是,如果你进一步基于一个对象创建实例,并且用到我们之前讲面向对象编程模式中提到的Object.create()的话,这样的情况下,你没法用instanceOf来判断新的实例属于哪个对象。
|
||||
|
||||
因为这里的两个对象间更像是授权而不是继承的关系,之间没有从属关系,所以返回的是错误。而通过经典的基于原型的继承方式创建的实例,则可以通过instanceOf获取这种从属关系。
|
||||
|
||||
这里我们可以来对比下字面量,constructor以及基于原型的继承的使用,具体你可以参考以下代码示例:
|
||||
|
||||
// 方式1:字面量
|
||||
var objA = {name: "Object A"};
|
||||
var objB = Object.create(objA);
|
||||
console.log(objB instanceof objA); // 返回 类型错误
|
||||
|
||||
// 方式2:constructor
|
||||
var objA = new Object();
|
||||
objA.name = "Object A";
|
||||
var objB = Object.create(objA);
|
||||
console.log(objB instanceof objA); // 返回 类型错误
|
||||
|
||||
// 经典的基于原型的继承
|
||||
var objA = function() {
|
||||
/* more code here */
|
||||
}
|
||||
objB = new objA();
|
||||
console.log(objB instanceof objA); // 返回 true
|
||||
|
||||
|
||||
其实,不光是对象,数组和函数也都可以不用constructor,而是通过字面量的方式创建。反之,我们说的数字、字符串和布尔除了字面量,也都可以通过constructor创建。
|
||||
|
||||
|
||||
|
||||
我们知道,原始类型是不可改变的,而对象类型则是可变的。比如下面这个例子,当定义一个字符串,通过 toUpperCase() 把它变成大写后,再次获取它的值时,会发现其仍然是小写。
|
||||
|
||||
var str = "hello";
|
||||
str.toUpperCase(); // 返回 HELLO
|
||||
str; // 返回 hello
|
||||
|
||||
|
||||
而如果我们尝试给一个对象增加一个属性,那么再次获取它的属性的时候,其属性就会改变。
|
||||
|
||||
var obj = { vehicle: "car" };
|
||||
obj.vehicle= "bus";
|
||||
console.log (obj.vehicle); // 返回 bus
|
||||
|
||||
|
||||
另外一点需要注意的是,对象数据在栈中只被引用,而实际存放在堆中。举个例子,假如我们有两个变量,变量personA赋值为{name: “John”, age:25},当我们将personB赋值为personA,然后修改personA的名称,那么personB的名字也会改。因为它们引用的都是堆中的同一个对象。
|
||||
|
||||
var personA = {
|
||||
name: "John",
|
||||
age:25
|
||||
};
|
||||
var personB = personA;
|
||||
|
||||
personB.name = "Jack";
|
||||
|
||||
personA.name; // 返回 Jack
|
||||
personB.name; // 返回 Jack
|
||||
|
||||
|
||||
|
||||
|
||||
如何识别一个数组?
|
||||
|
||||
前面我们说过,数组其实是对象,那我们怎么能判断一个值是数组还是对象呢?其实就是我刚刚提到过很多次的typeof,它可以帮我们了解一个“值”的种类。当我们用typeof来获取对象和数组的种类时,返回的都是object。
|
||||
|
||||
但是,前面我们也说过,null返回的也是object,这样我们用typeof是没有办法来实际判断一个值是不是对象的。不过,因为null是一个假值,所以我们只要加一个真假判断,和typeof结合起来,就可以排除null,来判断是否一个值实际是不是对象。
|
||||
|
||||
if (myVal && typeof myVal == 'object') {
|
||||
// myVal 是一个对象或数组
|
||||
}
|
||||
|
||||
|
||||
好,上面的例子能筛选对象了,可我们仍然没法判断一个对象是不是数组。
|
||||
|
||||
其实,在ES5以后,JavaScript就有了isArray的内置功能,在此之前,人们都是通过手写的方式来判断一个值是不是数组。但在这儿,为了更好地理解原理,我们可以在上面例子的基础上,做一个isArray的功能。
|
||||
|
||||
这里我们用到了数组的特性,数组虽然从数据类型上看也是一种对象,但是它和对象值相比,区别在于是否可以计算长度。
|
||||
|
||||
if (myVal && typeof myVal === "object" &&
|
||||
typeof myVal.length === "number"
|
||||
&& !(myVal.propertyIsEnumerable("length"))) {
|
||||
console.log("yes");
|
||||
}
|
||||
|
||||
|
||||
除此之外,我们也可以用在前面讲到面向对象时,讲到的“基于原型的继承”中学过的原型来判断。
|
||||
|
||||
if (typeof Array.isArray === 'undefined') {
|
||||
Array.isArray = function (arg) {
|
||||
return Object.prototype.toString.call(arg) === "[object Array]";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function函数字面量:是声明还是表达?
|
||||
|
||||
函数在JavaScript中主要有两种写法,表达式和声明式。
|
||||
|
||||
|
||||
声明式函数
|
||||
|
||||
|
||||
我们先来看看在JavaScript中,什么是声明,都有哪些形式的声明。
|
||||
|
||||
如下图所示,我们大致把声明分为了变量、常量、函数和类这四种类型。从中可以看出,这四大类声明几乎都是我们接触过的常用的语句,比如变量、常量和函数都是我们在讲到函数式和不可变时有提到过的,类则是我们在面向对象编程时讲过的。
|
||||
|
||||
|
||||
|
||||
说完声明,我们再看看声明式函数。声明式函数最大的好处就是可以用于函数提升hoisting。可以说,除了有特殊需求会用到表达式以外,声明式就是默认的写法,下面是一个声明式函数的抽象语法树AST(abstract syntax tree)。
|
||||
|
||||
|
||||
|
||||
function a = {} // 声明式
|
||||
|
||||
|
||||
|
||||
表达式函数
|
||||
|
||||
|
||||
下面我们再来看看表达式函数。首先同样的问题,什么是表达式?一个语句里面可以有很多的表达,而表达式函数其实就是把函数作为这样的一个表达。在表达式函数中,又可以分为两种写法:
|
||||
|
||||
第一种是把函数当一个字面量的值来写,它的好处是可以自我引用。
|
||||
|
||||
var a = function() {} // 表达式
|
||||
|
||||
|
||||
第二种是在ES6中,函数表达也可以通过箭头函数的方式来写。它的好处是可以用于this、参数和super相关的绑定。
|
||||
|
||||
var a = () => {}; // 表达式-箭头函数
|
||||
|
||||
|
||||
延伸:类型之间如何转换?
|
||||
|
||||
另外,在JavaScript中,我们还会经常遇到的一个问题是:如何将一个值从一种类型转换到另外一种类型呢?
|
||||
|
||||
其实这里可以用到强制多态coercion。强制多态一般分为两种方式:一种是通过显性explicit的;另外一种是通过隐性implicit的。比如说,我们可以通过显式explicit,将一个字符串转化成为一个数字。
|
||||
|
||||
var a = 42;
|
||||
var b = a + ""; // implicit coercion
|
||||
var c = String(a); // explicit coercion
|
||||
|
||||
|
||||
以上面这段代码为例,当a = 42时,它是一个数字,b通过隐性的强制多态,把它变成了一个字符串。c则通过显性的强制多态,把它变成了一个字符串。在ECMAScript 的官方文档中,有相关的运行时的判断条件,实际浏览器会通过类似以下的算法来判断对应的处理。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过这一讲,希望你对JavaScript的数据类型有了更系统的了解,也对不同数据类型的相关问题有了更好的解决方式,从而扬长避短。
|
||||
|
||||
但除了解决执行层面的问题,也更希望你能发现JavaScript之美和不足之处。如果你之前看过《黄金大镖客》,可能知道它的英文名字叫_The Good, the bad, the ugly_。在JavaScript里,通过对数据类型的认识,我们同样可以看到它的美和不足之处,这同时也才是它的精彩之处。
|
||||
|
||||
它的美在于简单、灵活。我们可以看到数字、字符串虽然简单,但又可以灵活地使用内置的包装对象,来返回属性和使用强大的调用功能;对象、函数、数组虽然是复杂的数据类型,但又都可以通过字面量来表示。
|
||||
|
||||
同时,JS也有着不足之处,特别是原始数据的一些缺陷和bug,但正是它的灵活和没有那么多条条框框,当初才吸引了很多开发者。除了语言本身的进化外,工程师们通过各种方式克服了这些缺陷。
|
||||
|
||||
最后,我们再来丰富下开篇中的脑图,一起总结下今天所学到的内容。让我们在记忆宫殿中将这些关键的信息通过这张脑图,放到我们信息大厦里的“JS之法”这一层吧!
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
课程中我们总结过有些类型的数据既可以用字面量也可以用constructor来创建,那你觉得哪种方式在不同场景下更适合呢?
|
||||
|
||||
欢迎在留言区分享你的答案和见解,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
238
专栏/JavaScript进阶实战课/11通过JS引擎的堆栈了解闭包原理.md
Normal file
238
专栏/JavaScript进阶实战课/11通过JS引擎的堆栈了解闭包原理.md
Normal file
@ -0,0 +1,238 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 通过JS引擎的堆栈了解闭包原理
|
||||
你好,我是石川。
|
||||
|
||||
在前面讲到编程模式的时候,我们就提到过闭包。
|
||||
|
||||
如果说一个函数“出生”的地方是作用域,从出生到被回收的“一生”是它的生命周期,那么闭包则可以突破这种空间和时间上的限制,那它是怎么做到这种突破的呢?
|
||||
|
||||
这节课,我们就从JavaScript编译原理以及其中的栈和堆的数据结构开始,来看看闭包的原理。
|
||||
|
||||
静态和动态作用域
|
||||
|
||||
我们先从作用域的空间概念来说。作用域可以分为静态作用域(static scope)和动态作用域(dynamic scope)。
|
||||
|
||||
静态作用域,取决于变量和函数在何处声明,这里你可以想象成它的“出生地”,并在它执行之前就已经确定了。所以静态作用域又被称为词法作用域(lexical scope),因为函数的“出生地”是在词法分析时“登记”的。
|
||||
|
||||
动态作用域则相反。动态作用域下,函数的作用域是在函数调用的时候才决定的。所以取决于在何处调用,这里你可以想象成它的“居住地”,这个是可以后天修改的。
|
||||
|
||||
我们所写的JavaScript代码,通常是通过前端浏览器编译后运行的,这个过程是先编译、后执行。所以JavaScript代码的作用域是在编译过程中通过分析它在何处声明来确定的,属于静态(词法)作用域。
|
||||
|
||||
下面我们来看看函数的作用域在代码编译的阶段是如何定义的,以及它在执行阶段的生命周期。
|
||||
|
||||
作用域:代码编译
|
||||
|
||||
我们先从作用域说起。下面以V8为例,我们先来看一段代码从编译到执行的过程总览。当我们打开一个页面,执行一段代码时,会经历从环境初始化到代码分析,从编译到执行的过程。具体你可以参考下方的图示:
|
||||
|
||||
|
||||
|
||||
我们在第10讲里面介绍过JavaScript的数据类型。这里我们重点关注上图红色虚线框部分所示的存放和处理数据的栈空间和堆空间。
|
||||
|
||||
|
||||
|
||||
栈是线性连续的数据结构的存储空间,里面主要存有JavaScript原始数据类型以及对象等复杂数据类型的地址。除此之外还有函数的执行状态和this值。堆是树形非连续的数据结构的存储空间,里面存储了对象、数组、函数等复杂数据类型,还有系统内置的window和document对象。
|
||||
|
||||
说完存储,下面通过一段代码,我们再来看下从词法到语法分析的过程。
|
||||
|
||||
var base = 0;
|
||||
var scope = "global";
|
||||
function addOne () {
|
||||
var base = 1;
|
||||
return base +1;
|
||||
}
|
||||
|
||||
function displayVal () {
|
||||
var base = 2;
|
||||
var scope = "local"
|
||||
increment = addOne();
|
||||
return base + increment;
|
||||
}
|
||||
|
||||
|
||||
当我们输入上面这段代码后,代码像字符串一样被拆分成段,这个是叫做分词或词法分析(tokenizing/lexing)的过程。在这个过程中,比如var base = 0会被分为var变量、base、赋值表达、数字常量0。 词法作用域指的就是拆分成词法标记时这段代码所在的作用域。如下图红色虚线框部分所示:
|
||||
|
||||
|
||||
|
||||
在词法拆分之后,在下一步的解析(parsing)动作中,上面一段段的代码会被转换成一个抽象语法树(AST, Abstract Syntax Tree),这就到了语法分析。在这个语法树的顶端,我们可以看到一个父节点,它就是var这个变量的声明。在这个父节点的下面,有两个子节点:一个子节点是标识符count;另外一个子节点就是等号的赋值表达。在等号赋值表达的节点下面,还有一个子节点就是数字表面量0。如下图红色虚线框部分所示:
|
||||
|
||||
|
||||
|
||||
根据流程图中的红色虚线框部分所示,在词法分析后,JavaScript引擎会在做语法分析的同时,更新全局作用域和创建局部作用域。在这个代码例子中,全局作用域里增加了base和scope变量,displayVal里有base、scope和increment变量,而addOne里有base变量。
|
||||
|
||||
|
||||
|
||||
在作用域创建后,上面的代码就会变为中间代码,V8 会混合使用编译器和解释器技术的双轮驱动设计实时编译(JIT Just in Time),这个双轮的一个轮子是直接执行,另一个发现热点代码会优化成机器码再执行,这样做的目的是为了性能的权衡和提升。这个我们在这一讲不需要深入学习,我们只需要知道在这之后就是编译的结束,我们的代码接下来要到执行过程了。
|
||||
|
||||
是不是有点晕,没关系,我们抽象总结一下。这里我们从空间角度了解到,函数在创建伊始是存放在堆空间中的,并且通过栈空间中的地址来查找。我们通过编译的过程,了解了作用域在代码未执行的解析阶段就完成了。
|
||||
|
||||
生命周期:代码执行
|
||||
|
||||
如果说作用域是从“空间”维度思考问题,那么生命周期就是从“时间”维度来看问题。接下来,咱们就来看看在代码执行的阶段,一个函数从调用到结束的过程,也就是它的生命周期。
|
||||
|
||||
|
||||
|
||||
函数的生命周期
|
||||
|
||||
上面我们提到过堆和栈的概念。在JavaScript执行的时候,全局执行上下文会在一个类似栈的数据结构里面,根据函数调用链依次执行,所以又称为调用栈。下面我们看看根据上面的代码,按步骤会生成的栈。
|
||||
|
||||
一开始,base、scope、addOne、displayVal 都会被记录在变量环境。可执行的代码包含了base和scope的赋值,还有displayVal()函数的调用。当赋值结束就会执行displayVal函数。
|
||||
|
||||
|
||||
|
||||
在执行displayVal函数的时候,displayVal函数相关的全局上下文就会被压入栈内,因为base和scope都有函数内声明,所以它们在函数内也会有变量提升到increment的上面。作为可执行代码,完成base和scope的赋值。下面执行addOne函数的调用。
|
||||
|
||||
|
||||
|
||||
再后面,需要继续将addOne压入栈内,base变量再次赋值,然后执行返回base+1的结果。在此以后,函数addOne的上下文会从栈里弹出,作为值返回到displayVal函数。
|
||||
|
||||
|
||||
|
||||
在最后的运行步骤里,displayVal 的increment会被赋值为2,之后函数会返回2+2的值,为4。之后displayVal的函数执行上下文也会被弹出,栈中将只剩下全局的执行上下文。addOne和displayVal这两个函数的生命周期就随着执行的结束而结束了,并且会在之后的垃圾回收过程中被回收。
|
||||
|
||||
|
||||
|
||||
执行时变量查找
|
||||
|
||||
前面我们说过,JavaScript的作用域是在编译过程中的词法分析时决定的,下面我们就来看看从编译到执行的过程中,引擎是如何与作用域互动的。
|
||||
|
||||
还是以var base = 0为例,在下图的左边,我们可以看到当编译器遇到var base的时候,会问作用域这个base是否已经存在,如果是的话,会忽略这个声明;如果base不存在,则会让作用域创建一个新变量base,之后会让引擎处理base=2的赋值。
|
||||
|
||||
这个时候引擎又会回过头来问作用域,在当前执行的作用域当中有没有base这个变量,如果有的话,执行赋值,否则会继续寻找,一直到找到为止。
|
||||
|
||||
|
||||
|
||||
这里还有一个问题值得思考:上面例子中执行的最后一步,我们提到如果引擎在当前执行作用域找不到相关变量,会一直找或返回报错,那么这个“一直找”的顺序是什么呢?答案是它会从内往外地找。我们可以通过下面一个经典的嵌套的作用域例子来看。
|
||||
|
||||
在这个例子里,我们可以看到第一层是全局作用域,里面只有一个标识符为outer的函数声明。中间第二层是一个函数作用域,里面有a,也就是函数outer的形参;b是一个变量声明; inner是一个嵌套函数。然后最里面第三层的函数作用域里有a、b和c。当我们执行outer(1)的时候,引擎会从内到外,先从最里边的第三层找起,然后在第二层inner的作用域里找,如果找不到,就会在第一层outer的作用域里找,在这里可以找到a。
|
||||
|
||||
|
||||
|
||||
IIFE:利用作用域封装
|
||||
|
||||
通过例子延伸,我们可知,作用域的层级可以分为块儿级、函数和全局这样的嵌套关系。块级作用域和函数级作用域都可以帮助我们对代码进行封装,控制代码的可见性。虽然常见的声明式函数可以帮助我们达到这个目的,但是它有两个问题:
|
||||
|
||||
|
||||
第一个是如果我们以声明式函数为目的来做封装的话,它会间接地创建foo这个函数,会对全局作用域造成污染;
|
||||
第二个问题是我们需要通过一个foo()来对它进行调用,解决这个问题的办法就是使用一个立刻调用的函数表达 (IIFE,immediately invoked function expression)。
|
||||
|
||||
|
||||
在下面的例子中,我们可以看到,在使用这种表达方式的时候,我们可以使用第一组括号将函数封装起来,并且通过最后一组括号立刻调用这个函数。
|
||||
|
||||
var a = 2;
|
||||
(function foo(){
|
||||
var a = 3;
|
||||
console.log( a ); // 3
|
||||
})();
|
||||
console.log( a ); // 2
|
||||
|
||||
|
||||
闭包:突破作用域限制
|
||||
|
||||
前面我们系统地了解了函数的作用域和生命周期,提到了变量和函数在栈中的调用和执行后的销毁、垃圾回收,我将它归之“守正”。那么接下来,我们就看看如何突破这种限制,也可以谓之“出奇”。
|
||||
|
||||
在下面的代码中,我们运用了函数的3个特点:
|
||||
|
||||
|
||||
在函数内部生成了一个局部的变量i;
|
||||
嵌入了increment和getValue这两个函数方法;
|
||||
把函数作为返回值用return来返回。
|
||||
|
||||
|
||||
function createCounter(){
|
||||
let i=0;
|
||||
function increment(){
|
||||
i++;
|
||||
}
|
||||
|
||||
function getValue(){
|
||||
return i;
|
||||
}
|
||||
return {increment,getValue}
|
||||
}
|
||||
|
||||
const counter = createCounter();
|
||||
|
||||
|
||||
|
||||
通过上述操作,我们可以看到,当我们执行下面的一段代码时,会发现即使在createCounter执行完成后,按道理相关变量 i 应该被销毁了,并完成相关的垃圾回收(garbage collection)。我们仍然可以访问它内部的变量 i,并可以继续调用increment和getValue方法。当我们尝试增加 i 的值的时候,会看到返回不断增加的结果。
|
||||
|
||||
counter.increment();
|
||||
counter.getValue(); // 返回1
|
||||
counter.increment();
|
||||
counter.getValue(); // 返回2
|
||||
|
||||
|
||||
它的原理是什么呢?这要回到我们较早前说到的解析或语法分析步骤。
|
||||
|
||||
在这个过程中,当JavaScript引擎解析函数的时候,用的是延迟解析,而不是及时解析。这样做的目的是减少解析时间和减少内存使用。所以在语法解析的时候,只会解析到createCounter函数这一层,而不会继续解析嵌入的increment和getValue函数。但是引擎同时又有一个预解析的功能,可以看到increment和getValue会引用一个外部的变量 i,所以会把这个变量从栈移到堆中,就用了更长的记忆来记录 i 的值。通过这种方式,闭包就做到了守正出奇,以此突破了作用域和生命周期的限制。
|
||||
|
||||
但是有一点要注意的是,考虑到性能、内存和执行速度,当使用闭包的时候,我们就要注意尽量使用本地而不要用全局变量。
|
||||
|
||||
延伸:提升问题和解决方法
|
||||
|
||||
变量和函数提升
|
||||
|
||||
下面我们再延伸看一下变量和函数声明的提升。我们先来看一个例子,我们把上面的var base = 2 这段代码拆开,可以看到第1行base=2是一个赋值的表达,第2行var base是一个变量声明。常识会告诉我们,这可能造成base返回的是undefined,因为base在还没声明的时候就被赋值了。可当你执行console.log(base) 时看到返回的结果是2。
|
||||
|
||||
base = 2;
|
||||
var base;
|
||||
console.log(base); // 2
|
||||
|
||||
|
||||
为什么会这样呢?因为在代码的编译执行过程中,var base这个声明会先被提升,base=2这个赋值的动作才被执行。
|
||||
|
||||
|
||||
|
||||
在下面的例子,我们再来看看如果我们先通过console.log试图获取base的值,然后再写一句var base = 3 这样的变量声明和赋值,结果是不是按照变量提升原则应该返回3呢?答案是undefined!
|
||||
|
||||
console.log(base); // undefined
|
||||
var base = 3;
|
||||
|
||||
|
||||
在这个编译执行过程中,var base=3 这个声明和赋值会被拆成两个部分,一个是声明 var base,一个是赋值 base=3。变量提升,提升的只是变量base的声明;变量base的赋值是不会被提升的,而仍然是提升后执行的。下面显示的是它的词法拆分和提升后的顺序。
|
||||
|
||||
|
||||
|
||||
和变量一样,函数声明也会被提升到顶部,而且如果函数和变量的提升同时发生,函数会被提到变量的前面。另外一点值得注意的是,如我们在前一讲所说,函数提升的只是声明式函数,而表达式函数则和变量赋值一样,不会被提升。
|
||||
|
||||
ES6块级作用域
|
||||
|
||||
不过关于变量和函数的提升特点,其实还存在着一定的问题,就是会造成变量的覆盖和污染。从ES6开始,除了全局作用域和函数作用域外,还有一个就是块级作用域被引进了JavaScript,所以在变量声明中,除了var以外,加入了let和const,这两个变量和常量就是块级作用域的变量,它们不会被提升。
|
||||
|
||||
我们可以尝试下,当我们输入 console.log(base),然后再用let声明 base=0 会发现报错。
|
||||
|
||||
{
|
||||
console.log(base); // ReferenceError!
|
||||
let base = 0;
|
||||
}
|
||||
|
||||
|
||||
同样地,在下面的例子里,我们也可以看到在if esle大括号里的 count 不会污染到全局作用域。
|
||||
|
||||
var base = 1;
|
||||
if (base) {
|
||||
let count = base * 2;
|
||||
console.log( count );
|
||||
}
|
||||
console.log( count ); // ReferenceError
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课我们用了较多的篇幅讲了“守正”,也就是一般情况下一个函数的作用域和生命周期;较少的篇幅讲了“出奇”,也就是闭包突破限制的原理。因为只有当我们对函数的实际编译和执行过程有所了解,站在一个函数的角度,和它一起走过一遍生命旅程,从它是怎么创建的,一步步到它的词法和语法分析,编译优化到执行,调用到销毁到回收,我们才能更清楚地了解如何利用规则,或者更近一步突破规则的限制。
|
||||
|
||||
同时我们也看到了JavaScript本身的变量和函数提升具有一定的反直觉性,虽然我们不能说这是一个bug,但在一些开发者看来是一种缺陷。所以在后面的ES6开始,引进了块级作用域。希望你通过对原理的了解,能够更加清楚它们的使用方法。
|
||||
|
||||
思考题
|
||||
|
||||
在讲到函数式编程时,我们说到了闭包可以作为和对象相比的数据存储结构,在讲到面向对象编程模式时,我们也说到了它可以用来创建对象的私有属性。那么除了这些例子外,你还能举例说明它的其它作用吗?
|
||||
|
||||
欢迎在留言区分享你的积累,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
219
专栏/JavaScript进阶实战课/12JS语义分析该用迭代还是递归?.md
Normal file
219
专栏/JavaScript进阶实战课/12JS语义分析该用迭代还是递归?.md
Normal file
@ -0,0 +1,219 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 JS语义分析该用迭代还是递归?
|
||||
你好,我是石川。
|
||||
|
||||
在前面两讲中,我们学习了JavaScript语言的数据类型,通过堆栈的数据结构了解了闭包的原理。这一讲,我们来聊聊算法。前面我们在讲到编程模式时提到,如果说函数式编程是输入、计算和输出,那中间的计算部分就可能用到算法了。而迭代和递归可以说是非常基础的算法。
|
||||
|
||||
迭代相对比较好理解,只要用过for loop的话,你对它就不会太陌生,而递归比较难理解。但和闭包一样,一旦你掌握了它的运用技巧,就会体会到它的强大之处。
|
||||
|
||||
我们在讲到函数式编程的时候,也说过一个很重要的思想就是“副作用”,而递归就自带很多副作用,相应地也出现了很多的解决方案。今天,我们就来看看它都有哪些副作用,以及解决这些副作用我们可以用哪些方法。
|
||||
|
||||
那在这之前,我们先来看看迭代和递归分别是什么。
|
||||
|
||||
迭代和递归的区别
|
||||
|
||||
首先我们得搞清楚迭代和递归有什么区别?
|
||||
|
||||
先说迭代,举个例子。做过软件工程的同学都经历过迭代吧,如果是一个敏捷的项目,它的过程就是一个小步快跑的过程,功能不是一下子都做出来的,而是根据优先级,先做优先级高的,再做优先级低的。这就是一个通过循环往复不断完善的过程。
|
||||
|
||||
而递归呢,就好比我们寄快递。寄出去的过程是递,收到签收的回执就是归,但是这个过程中间可不只一个人,而是在寄出去的过程,是一个人先运到中转站,再有人运到联络处,最后才到我们手中。哪怕回去的回执是电子的,但在网络世界里,信息的传递也是需要经过每个节点。这样的一去一回就是递归。
|
||||
|
||||
|
||||
|
||||
而同样的一个问题,我们可能既可以用迭代的方式来做,也可以用递归的方式来做。
|
||||
|
||||
比如我们要计算阶乘。7的阶乘是“7!”,等于 7 * 6 * 5 * 4 * 3 * 2 * 1,结果是5040。如果用一个迭代函数来计算,大概是如下的方式。在每一次的迭代循环过程中,我们都用之前的乘积乘以下一个要迭代的 n 递减的数字。
|
||||
|
||||
function factorialIterative(number) {
|
||||
if (number < 0) return undefined;
|
||||
let total = 1;
|
||||
for (let n = number; n > 1; n--) {
|
||||
total = total * n;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
console.log(factorialIterative(7)); // 5040
|
||||
|
||||
|
||||
如果我们用递归的方式来解决会是什么样子呢?
|
||||
|
||||
在递归里面,通常有两个基本元素,一个是基本条件(base case),也叫做终止条件(stop point);另外一个是递归本身。
|
||||
|
||||
现在我们可以看到,如果我们把上面的例子转变成递归的形式,即如下。在递归中调用的函数一般就是函数本身。
|
||||
|
||||
function factorialRecursive(n) {
|
||||
// 基本条件
|
||||
if (n === 1 || n === 0) {
|
||||
return 1;
|
||||
}
|
||||
// 递归调用
|
||||
return n * factorialRecursive(n - 1);
|
||||
}
|
||||
console.log(factorialRecursive(7)); // 5040
|
||||
|
||||
|
||||
上面这段代码在执行中如下。我们可以看到前面7步,都是递的过程。在碰到基本条件后,开始了归的过程。
|
||||
|
||||
|
||||
|
||||
递归中用到的分治
|
||||
|
||||
下面我们再来用经典的斐波那契(Fibonacci Sequence)数列对比下迭代和递归。斐波那契数列的特点就是这个数列从第3项开始,每一项都等于前两项之和。按照这样的规则,到第10项的时候,相加的值应该是55。
|
||||
|
||||
1,1,2,3,5,8,13,21,34,55
|
||||
|
||||
|
||||
如果用迭代的方式来写计算第n项的斐波那契数的函数的话,大致如下:
|
||||
|
||||
function fibIterative(n) {
|
||||
if (n < 1) return 0;
|
||||
if (n <= 2) return 1;
|
||||
let fibNMinus2 = 0;
|
||||
let fibNMinus1 = 1;
|
||||
let fibN = n;
|
||||
// n >= 2
|
||||
for (let i = 2; i <= n; i++) {
|
||||
// f(n-1) + f(n-2)
|
||||
fibN = fibNMinus1 + fibNMinus2;
|
||||
fibNMinus2 = fibNMinus1;
|
||||
fibNMinus1 = fibN;
|
||||
}
|
||||
return fibN;
|
||||
}
|
||||
console.log(fibIterative(10)); // 55
|
||||
|
||||
|
||||
如果我们使用的是递归的话,从代码上看就简洁多了。在这里我们也使用了另外一个核心算法思想,就是分治(divide and conquer)。分治的意思就是分而治之。因为前面我们说了,斐波那契数列的特点就是这个数列从第3项开始,每一项都等于前两项之和。所以在这里,我们就先分别调用fibRecursive(n - 1)和fibRecursive(n - 2)这两个递归函数来分别计算前两项,之后我们再把它们相加,得到最终的结果。
|
||||
|
||||
function fibRecursive(n){
|
||||
// 基本条件
|
||||
if (n < 1) return 0;
|
||||
// 基本条件
|
||||
if (n <= 2) return 1;
|
||||
// 递归+分治
|
||||
return fibRecursive(n - 1) + fibRecursive(n - 2);
|
||||
}
|
||||
console.log(fibRecursive(10)); // 55
|
||||
|
||||
|
||||
但是这里有一个问题,当我们在计算fibRecursive(5)的时候,fibRecursive(3)被计算了两次。
|
||||
|
||||
|
||||
|
||||
那么有没有什么办法,能够记住之前计算的结果,来避免这种重复的计算呢?
|
||||
|
||||
递归中的记忆函数
|
||||
|
||||
对了,我们可以利用上节课学到的作用域和闭包,在这里它又一次展示了它的强大。我们可以用闭包把递归函数中加入记忆(memoization)。在这里,fibonacci是一个递归,但是我们让它调用了一个外部的memo参数,这样一来memo就带有了“记忆”。我们可以用它来存储上一次计算的值,就可以避免重复计算了。所以记忆函数经常和递归结合起来使用。这里解决的重复计算问题,在算法中也被称为重叠子问题,而记忆函数就是一个备忘录。
|
||||
|
||||
function fibMemo(n, memo = [0, 1, 1]) {
|
||||
if (memo[n]) {
|
||||
return memo[n];
|
||||
}
|
||||
// 递归+分治+闭包
|
||||
memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
|
||||
return memo[n];
|
||||
}
|
||||
console.log(fibMemo(10)); // 55
|
||||
|
||||
|
||||
递归中的尾递归
|
||||
|
||||
在上面的例子里,我们可以看到它的时间复杂度是\(O(2^{n})\)。那有没有办法能够把时间和空间复杂度都降低到\(O(n)\)呢?这里面我们可以看看尾递归。尾递归的意思就是在函数的尾部执行递归的调用。通过用尾递归的方式,最多递归n次,因为在每次递归的过程中都会n-1。
|
||||
|
||||
function fibTailRecursive(n, lastlast, last){
|
||||
if (n == 0) {
|
||||
return lastlast;
|
||||
}
|
||||
if (n == 1) {
|
||||
return last;
|
||||
}
|
||||
return fibTailRecursive(n-1, last, lastlast + last);
|
||||
}
|
||||
console.log(fibTailRecursive(10, 0, 1)); // 55
|
||||
|
||||
|
||||
递归中的内存管理
|
||||
|
||||
在上一讲中,我们在了解闭包的原理的过程中也了解了函数调用栈的概念。在国外,有一个很有名的技术问答网站叫做stack overflow,翻译成中文就是栈溢出。在我们用递归的时候,如果没有很好的控制,就会遇到这个性能问题。所以下面,我们再来看看递归中的内存管理。
|
||||
|
||||
在前面的例子中,我们看到在用递归来代替迭代的方案中,虽然它的写法比迭代要简便,但付出的是性能上的代价。因为这是一个函数不断自己调用自己的过程,会占用大量栈的空间,所以除了时间复杂度,它会有较高的空间复杂度需要考虑。而且稍有不慎,当它不能停止被调用的时候,可能会引起栈溢出。
|
||||
|
||||
比如在前面乘阶的例子中,我们可以在调用栈(call stack)中看到函数每次被调用的过程。
|
||||
|
||||
|
||||
|
||||
JavaScript从ES6版本的标准开始,定义了尾调用优化。里面提到,如果一个函数是一个函数里的最后一个动作,它会被当做跳转而不是子程序来处理。也就是说这个代码会不停地被重复,所以这也是为什么要有一个基本条件的重要性。在实际操作中,绝大多数浏览器都会自己定义一个防止栈溢出的限制,比如Chrome在下面的一个无限循环的例子中调用了13952次之后,就出现了一个超出最大栈范围的错误消息,并且停止了递归。
|
||||
|
||||
let i = 0;
|
||||
function recursiveFn() {
|
||||
i++;
|
||||
recursiveFn();
|
||||
}
|
||||
try {
|
||||
recursiveFn();
|
||||
} catch (ex) {
|
||||
console.log('i = ' + i + ' error: ' + ex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
延伸:递归复杂度计算
|
||||
|
||||
在迭代中,Big-O的分析相对简单,因为循环可以清楚地定义什么时候增加、减少或者停止。但是在分析递归的时候,我们就需要分析两个部分了,一个是基础条件,一个是递归。所以在做递归的复杂度计算,通常会用到主定理(master theorem)。我们先来看看这个定理的组成部分。
|
||||
|
||||
在这里面,n是问题规模的大小,a是子问题个数,n/b是每个子问题的大小,\(O(n^{c})\)是将原问题分解和将子问题的解合并的时间。
|
||||
\[T(n) = aT(n/b)+O(n^{c})\]基于\(c\)和\(log\_{b}(a)\)的对比,会有三种结果。\(log\_{b}(a)\)代表了\(aT(n/b)\),即解决当前层问题所需要的时间复杂度;\(c\)代表了\(O(n^{c})\),即将原问题分解和将子问题的解合并的时间。
|
||||
|
||||
|
||||
|
||||
当然我们说并不是所有递归问题都符合主定理公式的形式,那么遇到这种问题该怎么办呢?在这种情况下,我们也可以尝试使用递推公式和递归树。
|
||||
|
||||
下面,我们先来看看使用递推公式的方式。递推公式可以帮助我们在写递归的时候,设计递归函数。同时,它还有另外一个作用就是计算复杂度。如果我们用递推公式来计算斐波那契的时间复杂度的话,要先提炼出它的递推公式及时间复杂度的推导过程。这里,你可以看到,每一次函数调用会产生两次额外的调用,计算呈指数级增加。
|
||||
|
||||
// 斐波那契递推公式
|
||||
T (n) = T (n − 1) + T (n − 2)
|
||||
|
||||
// 时间复杂度的推导
|
||||
T(n) = T(n − 1) + T(n − 2) + O(1);
|
||||
T(n − 1) = T(n − 2) + T(n − 3) + O(1);
|
||||
T(n − 2) = T(n − 3) + T(n − 4) + O(1);
|
||||
|
||||
// 每次指数级的增加
|
||||
f(6) * <-- 一次
|
||||
f(5) *
|
||||
f(4) **
|
||||
f(3) ****
|
||||
f(2) ********
|
||||
f(1) **************** <-- 16
|
||||
f(0) ******************************** <-- 32
|
||||
|
||||
|
||||
上面通过递推公式计算复杂度的方式还是很复杂的,那么还有没有更简单直观的计算方式呢?我们来看看递归树。递归树的方式,我们可以更直观地看出结果。这里当长路径为n的话,对应的耗时为\(2^{n}-1\),所以最高的时间复杂就是\(2^{n}\)。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课我们学习了算法中最核心的两种方法,迭代和递归。就和我们讲JavaScript编程范式的时候,讲到函数中的闭包和对象中的this一样,你会发现,我们后面讲到的80%算法都离不开它的影子。请记住这个二八定律,只要把迭代和递归的概念吃透搞明白,对算法的学习,可以说是有着事半功倍的效果。
|
||||
|
||||
如果要对比迭代和递归的话,从整体的性能来说,迭代是优于递归的。而如果从代码的整洁性来看,递归看起来更简洁。而且递归和作用域、闭包结合起来形成的记忆函数和尾递归,都能从一定程度上减少其“副作用”。下面我们就结合这张图,总结下针对这些副作用的解决方法吧。
|
||||
|
||||
|
||||
|
||||
所以在算法中我们应该用迭代还是递归呢?这里同样没有绝对,应用上,你可以根据它们的优劣势,结合实际情况来应用。我个人认为,我们写的代码主要还是给人读的,而不是最终的机器码。所以我的建议是以代码的“简洁可读性”为先,然后再针对机器无法替我们优化的“副作用”的部分所产生的问题做手动的优化。
|
||||
|
||||
思考题
|
||||
|
||||
前面我们讲到了针对栈溢出的尾调用优化,你知道尾递归调用优化是如何实现的吗?
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
105
专栏/JavaScript进阶实战课/13JS引擎如何实现数组的稳定排序?.md
Normal file
105
专栏/JavaScript进阶实战课/13JS引擎如何实现数组的稳定排序?.md
Normal file
@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 JS引擎如何实现数组的稳定排序?
|
||||
你好,我是石川。
|
||||
|
||||
我们经常将数据结构和算法这两个词连起来说,可是你有没有想过,这两者是什么关系?
|
||||
|
||||
可以说数据结构是服务于算法的。这么说比较概括。下面我们就找一个切入点来讲解两者的关系,我们先从排序算法说起。提到排序,我们就要说到常用的数据结构数组,比如在JS中我们一般都用过它的排序方法,可是你知不知道排序方法背后用的是哪种算法呢?这一讲,就通过数组了解排序算法,再来看JS 引擎是如何实现数组的排序的。
|
||||
|
||||
在数据结构中,我们大体可以分为两类。第一类是线性表,第二类是非线性表。这里的线性和我们生活中理解的线性没有本质上的区别,意思就是“按顺序的”。数组可以说是一种连续存储的线性表,并且可以算是一种比较基础的线性数据结构了。通过数组,我们可以实现比如之前说过的栈、队列等线性结构。你也可以用它来创建非常经典的非线性结构,比如堆和图。
|
||||
|
||||
除了数组以外,另外一种既基础又经典的数据结构就是对象了。对象的一个特点是含有属性,很多时候可以用来作为节点(node),通过节点相连的方式,就可以创建链式结构,比如链表;对象的另外一个特点是它支持键值对(key-value pair),通过这个特点可以用来实现散列表和字典。
|
||||
|
||||
回到今天的话题,我们来重点说说数组。
|
||||
|
||||
数组的优劣势是什么
|
||||
|
||||
数组作为一种线性数据结构最大的特点是连续性,带来的优势是可随机访问性,但是它的劣势是高效的插入和删除。几乎所有的语言都把数组当做一种内置的数据类型提供,在这一点上,JavaScript也不例外。作为JavaScript数据类型之一,我们在前面介绍过数组。但是作为数据结构,我们可以从另一个角度来理解它。
|
||||
|
||||
我们前面说,数组的劣势是插入和删除,这是什么意思呢?我们假设在排队的时候,有人插队,那么整个组的人都会往后移动。这个时候,时间复杂度为\(O(n)\)。
|
||||
|
||||
那么使用数组的时候如何尽量克服这个劣势问题呢?和排队一样,方法就是按规矩让这个人排到队尾。在数组中,如果把数据元素插到队尾的话,那么整个时间复杂度仅仅为\(O(1)\)。所以通常,如果我们不在乎增加一个新的数据到哪儿,那么最好就是把它加到队尾。在JavaScript中,如何将一条信息加到数组的队尾呢?我们知道数组也是一种对象,那么它也有着一些自带的属性和方法用来对数组进行操作。在JavaScript当中,实现插入最简单的方式就是在数组的长度基础上算出下一个空位,赋值为我们想加入的数据元素。
|
||||
|
||||
var week = [1,2,3,4,5,6];
|
||||
week[week.length] = 7;
|
||||
console.log(week) // 返回 [1,2,3,4,5,6,7]
|
||||
|
||||
|
||||
在这里,你可以看到它和C或者Java相同和不同的影子。和C或者Java相同的是,我们的直觉会告诉我们开始数组长度是6,那在第6个位置上赋值,怎么会是空位,这样不会覆盖到第6个数字吗?不会。这是因为数组通常是从0而不是1开始计算的。和C或者Java不同的是,在JavaScript里,数组的长度是可以动态扩容的,或者用我们之前讲的函数式思想来看,内容也是可变的,这点从上面的例子就可以看出。而在C和Java里,数组的长度是一开始就决定的,如果这时候增加一个新的数据元素,就需要创建一个新的数组。
|
||||
|
||||
除了上面的方法,还有另外一种方法可以快速地增加一个值到数组的尾部,这种方式就是使用JavaScript中数组自带的push方法。从这里,我们大概可以看出队列和栈的影子。当我们用上面排队举例子时,其实就有队列的概念。下面的push在英文里,其实就是“压栈”的意思。
|
||||
|
||||
var week = [1,2,3,4,5,6];
|
||||
week.push(7);
|
||||
console.log(week) // 返回 [1,2,3,4,5,6,7]
|
||||
|
||||
|
||||
JS如何实现数组的排序
|
||||
|
||||
说完了数组的优劣势,我们来看看它的排序实现。数组是如何实现排序的呢,通过下面的例子,我们可以来看看几种排序方式。其中前2种冒泡和选择排序是偏理论型的排序方式,后面3种,插入、快排和归并属于应用型的算法。Chrome用的V8引擎就是快排和插入排序的结合,火狐用的SpiderMonkey则是基于归并来实现排序的。为什么浏览器会使用这几种排序方式呢?我们可以通过它们的原理和特点来了解。
|
||||
|
||||
|
||||
|
||||
理论类排序法
|
||||
|
||||
首先,我们先来看看理论类的冒泡、选择这两种排序算法。这两种方式用的都是一种比较和交换的方法(compare and swap)。我们上面说过,对于数组来说,如果我们在非队尾的位置插入的话,就容易造成队列中插入的元素以后的一系列元素的移动。但是如果使用交换的方式,就巧妙地避免了这个问题。
|
||||
|
||||
冒泡排序(bubble sort)的核心思想就是通过比较两个相邻的数据元素,如果前面的大于后面的,就交换它们的位置。
|
||||
|
||||
|
||||
|
||||
选择排序(selection sort)用的是一种原地比较的算法(in-place compare),它的核心思想就是找到最小的数据元素,把它放到第一位,然后再从剩下的数组中找到最小的数据元素,然后放到第二位,以此类推。
|
||||
|
||||
|
||||
|
||||
之所以说这两类排序算法更多是理论层面,是因为它们的时间复杂度都是\(O(n^{2})\)。下面我们来看看有没有时间复杂度更低的方式来处理数组的排序。
|
||||
|
||||
常用类排序法
|
||||
|
||||
下面我们再来看看一些非理论类,实际使用中常用的排序算法。
|
||||
|
||||
插入排序(insertion sort)和之前的冒泡和选择排序法不同,它不是用比较和交换,而是通过位移来做插入。它假设数组中第1个元素已经排序了。从第2个和第1个比较,如果小于第1个值,就插入到前面,如果大于,就原地不动;第3个值同理和前面的2个比较,小于就左移插入,大于就不动;后面的操作都以此类推。虽然插入排序虽然从复杂度上来看,和冒泡及选择算法类似,都是\(O(n^{2})\),但是实际上,对于一些较短的数组来说,它的性能是明显更好的。
|
||||
|
||||
|
||||
|
||||
归并排序(merge sort)可以算是比较常见的应用型排序算法了,它的复杂度是\(O(n log n)\)。下面,我们就来看看它的实现。归并算法用到的是我们前面介绍过的递归和分治的算法思想。这种方法的核心就是先把一个大的数组拆分成只包含一个元素的不可再分的数组,然后两两比较,最后再合并成一个数组。
|
||||
|
||||
|
||||
|
||||
下面,我们再来看看快排算法(quick sort),和归并类似,快排也用到了递归和分治,同样,它的时间复杂度是\(O(n log n)\)。说它也用了分治,是因为快排也会对数组“分开来”分析;但是和归并不同的是,它不会把元素真的“拆出来”再组合。那么它是怎么实现排序的呢?
|
||||
|
||||
在快排中,用到的是区分点(pivot)和指针(pointer)。在数组中,我们会在最前和最后一个元素的位置各加一个指针,然后在中间的位置加一个区分点。我们会不断移动左指针,直到我们找到一个元素大于区分点;同时,我们也会不断移动右指针,直到我们找到一个小于区分点的数,然后将它和左指针指向的数据交换。这里类似我们在冒泡排序中提到的比较和交换的算法。这个过程可以使得所有区分点左边的值都小于右边的数,这个过程叫做分区(partition)。这个方式会按照递归的方式不断重复,直到最后整个数组排序完成。
|
||||
|
||||
|
||||
|
||||
关于快排有一点需要注意,就是分区的实现。关于分区实现中的区分点,我们需要注意的是为了避免分区后两个区域的数据量相差很大,我们可以默认在两个指针之间选一个平均值,但更好的办法是通过随机的方式来选择,虽然这样不能保证最好的结果,但从概率角度,也不会太差。
|
||||
|
||||
排序的稳定性问题
|
||||
|
||||
前面我们说了那么多,那么回到我们之前的问题,为什么V8用的是插入加快排的方式,SpiderMonkey用的是归并的方式来做排序呢?这就要从排序的稳定性说起了。在ECAMScript的官方文档中,虽然并不指定JS引擎对sort的具体实现方式,但是有一条准则,就是要确保数组的排序必须是稳定排序(stable sort)。
|
||||
|
||||
从理论上讲,虽然归并和快排的复杂度都是相同的,但是前面我们忽略了两个重要因素,第一个是时间复杂度的平均和极端情况,第二个是空间复杂度的问题。快排在平均情况下的空间复杂度是\(O(n log n)\),但是在极端情况下是\(O(n^{2})\)。
|
||||
|
||||
那你会说,这样为什么不直接用归并排序,因为归并排序也不是没有问题,在使用归并排序的时候,虽然它是稳定的,但它不是原地排序法,这就造成了它的空间复杂度是\(O(n)\),必然要高于其它的算法类型。所以这就是为什么V8和SpiderMonkey各自选择不同算法的原因。对于V8而言,为了既解决稳定性问题,又解决复杂度问题,就使用了快排加插入的方式,在数据量比较小的情况下,使用插入;在数据量比较大的时候,则切换到快排。而对于SpiderMonkey来说,就通过归并来满足稳定性的需求。
|
||||
|
||||
延伸:线性类排序法
|
||||
|
||||
还有一类的排序法我们称之为线性排序法,为什么这么叫呢。因为他们使用的都是非比较类的排序方法,这里包括了计数、桶和基数排序,因为这几种排序算法都是用于特殊场景,我们就不在这里讲它们的具体实现了,但是我也会把源码放到GitHub上。如果你想做延伸了解,可以通过代码注释来了解。
|
||||
|
||||
总结
|
||||
|
||||
今天我们通过数组这种最简单的数据结构了解了并不简单的排序算法。你应该学到了数组这种数据结构的优劣性,同时我们也看到了V8和SpiderMoney使用的排序方式。这一讲最核心的并不是我们学到了这些逻辑和应用,更重要的是我们看到了全面分析的思维方式。比如我们看到的时间复杂度只是一个方面,它不能直接代替性能。当我们把它和稳定性及空间复杂度的思考维度相加的时候,才能对解决方案作出更全面和客观的判断。同时,这也告诉我们我们所写的程序基于不同的引擎和数据量可能会有不同的结果。
|
||||
|
||||
思考题
|
||||
|
||||
今天留给你的思考题是,通过今天学到的知识,你能不能讲讲你知道的其它的JS引擎用的是什么算法?
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
100
专栏/JavaScript进阶实战课/14通过SparkPlug深入了解调用栈.md
Normal file
100
专栏/JavaScript进阶实战课/14通过SparkPlug深入了解调用栈.md
Normal file
@ -0,0 +1,100 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 通过SparkPlug深入了解调用栈
|
||||
你好,我是石川。
|
||||
|
||||
在第11讲的时候,我们通过函数中的闭包了解了栈和堆这两种数据结构。在12讲中,我们通过递归了解了函数在调用栈中的循环执行。那么今天,我们再通过V8的Sparkplug编译器来深入的了解下JavaScript引擎中的调用栈,其中栈帧(stack frame),栈指针(stack pointer)和帧指针(frame pointer)的概念。
|
||||
|
||||
|
||||
|
||||
这里,可能有不太了解V8的同学,我们来简单看一下Sparkplug的前世今生。最开始的时候啊,V8用的是一个相对比较快的Full-codegen编译器生成未优化的代码,然后通过Crankshaft这个及时(JIT)编译器对代码进行优化和反优化。但是随着更多人浏览网页的习惯从PC端转向移动端,性能问题变得更加重要了。而这个流水线既没能对ES6之前的,也没能对ES6之后版本的JS做到理想的优化。
|
||||
|
||||
|
||||
|
||||
所以随之而来的,V8就引入了Ignition解释器和TurboFan的优化编译器以移动优先为目的的流水线。它最大的特点呢,就是通过Ignition最终生成字节码,之后再通过TurboFan生成优化后的代码,和做相关的反优化。
|
||||
|
||||
|
||||
|
||||
可是这个时候,问题又来了,TurboFan和Crankshaft比起来,有时也有不足。所以为了解决这个问题,V8又创建了一个把Full-codegen,Crankshaft,Ignition和TurboFan整合起来的“全家桶”的流水线。这显然让问题显得更复杂了。
|
||||
|
||||
|
||||
|
||||
还有就是从2016年开始,V8团队也从理论往实际转变,包括自研的Octane测试引擎都被真实场景的测试所取代了。并且努力在优化编译器外寻求性能优化方向,包括解析器、流、对象模型、垃圾回收器、编译代码缓存等方面的优化。然而在这个过程中,他们开始发现在优化解释器时遇到了比如字节码解码或分派这些成本的限制。基于V8的双编译器的模型,是没办法实现更快的分层代码优化的。如果想要提速,就只能删除一些优化关卡,但这样会降低峰值性能。运行的初始阶段在还没有稳定的对象隐藏类反馈的情况下,也没有办法开始优化。基于上面的种种原因和问题,Sparkplug就诞生了。作为非优化编译器,它存在于Ingition解释器和TurboFan优化编译器之间。
|
||||
|
||||
|
||||
|
||||
为什么多了一个编译器
|
||||
|
||||
既然Sparkplug的目的是快速编译,那么为了达到这个目的,它就用到了两个重要的方法。
|
||||
|
||||
首先,它编译的函数已经被编译为字节码了。字节码编译器已经完成了大部分复杂的工作,比如变量解析、判断括号是否是箭头函数、去掉语法糖和解构语句等等。Sparkplug的特点是从字节码而不是从JavaScript源代码编译,因此不必担心这些问题。这也是它和Full-codegen的区别。因为在Full-codegen的场景里,Crankshaft需要将源码重新解析到AST语法树来编译;并且为了反优化到Full-codegen,它需要重复Full-codegen编译来搞定栈帧。
|
||||
|
||||
第二个方法是Sparkplug不会像TurboFan那样生成任何中间码 (IR)。什么是IR呢?它是一种从抽象到具象的分层结构。在具象中可以对具体内容比如新的ES标准,或特定机器比如IBM、ARM或Intel做特殊的机器码生成。TurboFan用的是基于节点海思想的IR。TurboFan在接受到Ignition的指示后,会进行优化处理,并生成针对于平台的机器代码。相反,Sparkplug在字节码上的单次线性传递中直接编译为机器码,产出与该字节码执行相匹配的代码。所以事实上,整个Sparkplug编译器是一个for循环内的switch语句,分配到基于每个固定的字节码的机器码的生成函数上。
|
||||
|
||||
// Sparkplug 编译器的部分代码
|
||||
for (; !iterator.done(); iterator.Advance()) {
|
||||
VisitSingleBytecode();
|
||||
}
|
||||
|
||||
|
||||
IR的缺失意味着除了非常有限的窥孔优化外,Sparkplug的优化机会有限。这就是说因为它没有独立于架构的中间阶段,所以必须将整个实现分别移植到支持的每个架构中。但是实际上呢,这些都不是问题,因为Sparkplug是一个简单快速的编译器,所以代码很容易移植;而且因为在工作流中还是有TurboFan的,所以不需要进行大量优化。并且我们看到TurboFan的反优化会回到SparkPlug,而不是Ignition。
|
||||
|
||||
栈指针和帧指针的使用
|
||||
|
||||
下面我们就来看看栈帧的原理,还有栈指针和帧指针的使用。在成熟的JavaScript虚机中添加新的编译器其实很难。因为除了Sparkplug自身的功能,它还必须支持例如调试器、遍历堆栈的CPU分析器、栈的异常跟踪、分层的集成,及热循环到优化代码的栈替换(OSR,on-stack replacement)等工作。
|
||||
|
||||
那Sparkplug则用了一个比较聪明的方法,简化了大多数问题,就是它维护了“与解释器兼容的栈帧”。我们知道调用栈是代码执行存储函数状态的方式,而每当我们调用一个新函数时,它都会为该函数的本地变量创建一个新的栈帧。 栈帧由帧指针(标记其开始)和栈指针(标记其结束)定义。
|
||||
|
||||
|
||||
|
||||
当一个函数被调用时,返回地址也会被压入栈内的。返回地址在返回时由函数弹出,以便知道返回到哪里。当该函数创建一个新栈帧时,也会将旧的帧指针保存在栈中,并将新的帧指针设置为它自己栈帧的开头。因此,栈有一系列的帧指针 ,每个都标记指向前一个栈帧的开始。
|
||||
|
||||
|
||||
|
||||
除了函数的本地变量和回调地址外,栈中还会有传参和储值。参数(包括接收者)在调用函数之前以相反的顺序压入栈内,帧指针前面的几个栈槽是当前正在被调用的函数、上下文,以及传递的参数数量。这是“标准” JS 框架布局:
|
||||
|
||||
|
||||
|
||||
为了使我们在性能分析时,以最小的成本遍历栈,这种 JS 调用约定在优化和解释栈帧之间是共享的。
|
||||
|
||||
Ignition解释器会进一步让调用约定变得更加明确。Ignition是基于寄存器的解释器,和机器寄存器的不同在于它是一个虚拟寄存器。它的作用是存储解释器的当前状态,包括JavaScript函数局部变量(var/let/const 声明)和临时值。这些寄存器存储在解释器的栈帧中。除此以外,栈帧中还有一个指向正在执行的字节码数组的指针,以及当前字节码在该数组中的偏移量。
|
||||
|
||||
后来,V8团队对解释器栈帧做了一个小改动,就是Sparkplug在代码执行期间不再保留最新的字节码偏移量,改为了存储从Sparkplug代码地址范围到相应字节码偏移量的双向映射。因为Sparkplug代码是直接从字节码的线性遍历中发出的,所以这是一个相对简单的编码映射。每当栈帧访问并想知道Sparkplug栈帧的“字节码偏移量”时,都会在映射中查找当前正在执行的指令并返回相应的字节码偏移量。同样,每当它想从解释器到Sparkplug进行栈替换(OSR,on-stack replacement)时,都可以在映射中查找当前字节码偏移量,并跳转到相应的Sparkplug指令。
|
||||
|
||||
|
||||
|
||||
Sparkplug特意创建并维护与解释器相匹配的栈帧布局;每当解释器存储一个寄存器值时,Sparkplug也会存储一个。它这样做有几点好处,一是简化了Sparkplug的编译, Sparkplug可以只镜像解释器的行为,而不必保留从解释器寄存器到Sparkplug状态的映射。二是它还加快了编译速度,因为字节码编译器已经完成了寄存器分配的繁琐的工作。三是它与系统其余部分(如调试器、分析器)的集成是基本适配的。四是任何适用于解释器的栈替换(OSR,on-stack replacement)的逻辑都适用于Sparkplug;并且解释器和Sparkplug代码之间交换的栈帧转换成本几乎为零。
|
||||
|
||||
之前字节码偏移量空出来的位置,在栈帧上形成了一个未使用的插槽,这个栈槽被重新定义了目的,来缓存当前正在执行的函数的“反馈向量”;来存储在大多数操作中都需要被加载的对象结构的数据。因此Sparkplug栈帧最终是这个样子的:
|
||||
|
||||
|
||||
|
||||
Sparkplug实际的代码很少,基本工作就是内置模块调用和控制流。为什么会这样呢?因为JavaScript语义很复杂,即使是最简单的操作也需要大量代码。强制Sparkplug在每次编译时内联重新生成代码会明显增加编译时间,而且这样也会增加Sparkplug代码的内存消耗,并且V8必须为Sparkplug的一堆JavaScript功能重新实现代码生成,这也可能引起更多的错误和造成更大的安全暴露。因此,大多数Sparkplug代码是调用“内置模块”的,即嵌入在二进制文件中的小段的机器码,来完成实际的脏活累活儿。这些内置函数基本与解释器使用相同,或至少大部分共享。
|
||||
|
||||
这时你可能会产生一个疑问就是Sparkplug存在的意义,感觉它和解释器做几乎同样的工作。在许多方面,Sparkplug的确只是解释器执行的序列化,调用相同的内置功能并维护相同的栈帧。但尽管如此,它也是有价值的,因为它消除了或更严谨地说,预编译了那些无法消除的解释器成本,因为实际上,解释器影响了许多CPU优化。
|
||||
|
||||
例如操作符解码和下一个字节码调度。解释器从内存中动态读取静态操作符,导致CPU要么停止,要么推测值可能是什么;分派到下一个字节码需要成功的分支预测才能保持性能,即使推测和预测是正确的,仍然必须执行所有解码和分派代码,结果依然是用尽了各种缓冲区中的宝贵空间和缓存。尽管CPU用于机器码,但本身实际上就是一个解释器。从这个角度看,Sparkplug其实是一个从Ignition到CPU字节码的“转换器”,将函数从“模拟器”中转移到“本机”运行。
|
||||
|
||||
总结
|
||||
|
||||
通过今天对Sparkplug的讲解,我们在之前对栈这种数据结构的了解基础上,更多的了解了栈帧、栈针和帧指针的概念,同时也更多的了解了JS的编译流水线。感兴趣的同学也可以看看我在参考中提供的V8博客中的相关文章的链接,同时,这个网站也有很多的语言和编译原理的解释,从中我们也可以看到V8对性能极致优化的追求,也是很值得学习的资料。同时如果你对编译原理很感兴趣的话,也可以看看隔壁班宫文学老师的编译原理课。
|
||||
|
||||
思考题
|
||||
|
||||
在前面我们说过,Sparkplug并不是完全没有优化,它也会做窥孔优化。你知道这个优化的原理吗?
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
参考
|
||||
|
||||
|
||||
V8 博客:https://v8.dev/blog/sparkplug
|
||||
隔壁班宫文学老师的课程:《编译原理之美》
|
||||
|
||||
|
||||
|
||||
|
||||
|
146
专栏/JavaScript进阶实战课/15如何通过哈希查找JS对象内存地址?.md
Normal file
146
专栏/JavaScript进阶实战课/15如何通过哈希查找JS对象内存地址?.md
Normal file
@ -0,0 +1,146 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 如何通过哈希查找JS对象内存地址?
|
||||
你好,我是石川。
|
||||
|
||||
我们曾经讲过,在Javascript中,对象在调用栈中只做引用不做存储,实际的存储是在堆里面实现的。那么我们如何查找对象在堆里的实际存储地址呢?通过我们对字典的了解,这个问题就迎刃而解了。字典也被称作映射、符号表或关联数组,这么说可能比较抽象,所以我们先来说说字典的一种实现:散列表。
|
||||
|
||||
散列表:如何检查单词是否存在
|
||||
|
||||
如果你用过一些文档编辑的软件,应该很常用的一个功能就是拼写检查,这个检查是怎么做到的呢?从它的最底层逻辑来说,就是看一个单词存在与否。那么一个单词是否存在是如何判断的呢?这里就需要用到散列表。散列表的实现逻辑就是基于每个单词都生成一个唯一的哈希值,把这些值存放在一个数组中。当我们想查询一个词是否有效,就看这个词的哈希值在数组中是否存在即可。
|
||||
|
||||
|
||||
|
||||
假设我们有上图中这样的一组城市的键值对组成的对象,我们可以看出,在哈希的过程中,一个城市的键名,通过一个哈希函数,生成一个对应的唯一的哈希值,这个值被放到数组中,形成一个哈希列表。下次,当我们想要访问其中数据的时候,就会通过对这个列表的遍历来查询相关的值。
|
||||
|
||||
这里我们可以看到,图中间位置的是哈希函数,我们需要一个哈希函数来生成哈希值,那么哈希值是怎么生成的呢?生成散列表中的哈希值有很多种方式,比如素数哈希、ASCII哈希,还有djb2等方式。
|
||||
|
||||
在哈希算法当中,最基础的就是素数(prime number)哈希。这里我们把一个素数作为模数(modulus number),来给你举一个例子,在这个例子里,我们把11这个素数作为了模数,用下面的一组键值对中的键除以模数,所获得的余数,放到一个数组中。就形成了一个散列表。这样可以获得一个统一的索引。
|
||||
|
||||
{key:7, value: "南昌"}
|
||||
{key:24, value: "太原"}
|
||||
{key:42, value: "郑州"}
|
||||
Prime number: 11
|
||||
7 % 11 = 7 // 余数为7
|
||||
24 % 11 = 2 // 余数为2
|
||||
42 % 11 = 9 // 余数为9
|
||||
|
||||
|
||||
这个方式看似可以用来生成哈希值,但是也存在一个问题。在将余数放入数组的过程中,我们会发现,如果处理的数据数量足够多,那么就会出现冲突的情况,比如下图中标红的两个对象的键除以素数11的余数是相同的,7和51的余数都是7,这样就会造成冲突。一个完美的哈希表是不应该存在冲突的,可是这样完美的哈希表其实在现实中并不存在,所以我们只能尽量减少这种情况。
|
||||
|
||||
|
||||
|
||||
为了尽量减少这种冲突,业界也在尝试其他办法,比如使用ASCII code和素数结合来生成哈希,但这种方式和上面的素数哈希一样,即使结合了ASCII,哈希值也不能完全避免碰撞的产生,只能减少冲突。
|
||||
|
||||
asciiHashCode(key) {
|
||||
if (typeof key === 'number') {
|
||||
return key;
|
||||
}
|
||||
const tableKey = this.toStrFn(key);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < tableKey.length; i++) {
|
||||
hash += tableKey.charCodeAt(i);
|
||||
}
|
||||
return hash % 37;
|
||||
}
|
||||
|
||||
|
||||
除此之外,还有一种经典的djb2的算法,可以用来进一步减少这种问题的发生。它的做法是先用一个长质数5381作为哈希数,然后根据字符串长度循环,将哈希数乘以33,再加上字符的ASCII码迭代。结果和模数1013的余数结果就是最后的哈希值。
|
||||
|
||||
这里你可能会问,33和5381这两个数字是什么意思?这里乘以33呢,是因为更易于移位和加法计算。使用33可以复制累加器中的大多数输入位,然后将这些位分散开来。5的移位和32是互素的,这有助于雪崩。ASCII可以看做是2个4位字符类型选择器,比如说,数字的前四位都是0x3。所以2、4、8位都可能导致相似的位之间交互,而5位可以让一个字符中许多的4个低位与4个高位强烈交互。所以这就是选择33的原因。那么至于原则5381作为质数呢,则更多是一种习惯,也可以由其它大的质数代替。
|
||||
|
||||
djb2HashCode(key) {
|
||||
const tableKey = this.toStrFn(key);
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < tableKey.length; i++) {
|
||||
hash = (hash * 33) + tableKey.charCodeAt(i);
|
||||
}
|
||||
return hash % 1013;
|
||||
}
|
||||
|
||||
|
||||
这几种方式只是给你一个概念,实际上哈希函数的实现方法还有很多,可能专门一本书都不一定能讲完,但是在这里,我们只是为了了解它的原理和概念。
|
||||
|
||||
通过哈希函数,我们基本可以让一个单词在哈希表中找到自己的存在了。那么解决了存在的问题后,单词就该问“我的意义是什么?”,这个时候,我们就需要字典的出场了。
|
||||
|
||||
字典:如何查找对象的内存地址
|
||||
|
||||
散列表可以只有值,没有键,可以说是数组延伸出来的索引。说完散列表,我们再来看看字典(dictionary)。顾名思义,这种数据结构和我们平时用的字典类似,它和索引的主要的作用都是快速地查询和搜索。但是我们查字典的时候,关心的不光是有没有这个词,更重要的是,我们要知道这个单词对应的意思。所以我们需要通过一组的键值对来表明它们的关系。
|
||||
|
||||
我们设想一个很初级的字典,就是每个字都有一个哈希作为键名,它的意思作为键值。这样一条条地放到每一页,最后形成一个字典。所以通常字典是有键值对的。所以我们前面说过,字典作为一种数据结构,又叫做映射(map)、符号表(symbol table)或者关联数组(associative array)。在JavaScript中,我们其实可以把对象看做是一种可以用来构建字典的一种散列表,因为对象里就包含key-value的属性。
|
||||
|
||||
回到我们开篇的问题,在前端,最常见的字典就是我们使用的对象引用了。我们用的浏览器和JS引擎会在调用栈中引用对象,那么对象在堆中的实际位置如何寻找呢?这里,对象在栈的引用和它在堆中的实际存储间的关联就是通过地址映射来实现的。这种映射关系就是通过字典来存储的。我们可以用浏览器打开任何一个页面,然后打开开发者工具,在工具里,我们进入内存的标签页,然后选择获取一个堆的快照,之后我们便可以看到对象旁边的@后面的一串数字,这串数字就是对象在内存的地址。所以这里的字典就是一部地址簿。
|
||||
|
||||
|
||||
|
||||
Map和Set:各解决什么问题
|
||||
|
||||
在ES6之前,JavaScript中只有数组和对象,并没有字典这种数据结构。在ES6之后才引进了Map和Set的概念。
|
||||
|
||||
JavaScript中的Map就是字典的结构,它里面包含的就是键值对。那你可能会问,它和对象有什么区别?我们说过对象就是一个可以用来实现字典的支持键值对的散列表。Map和对象最大的区别就是Map里的键可以是字符串以外的其它数据结构,比如对象也可以是一个键名。
|
||||
|
||||
JavaScript中的Set就是集合的结构,它里面包含值,没有键。这里你也可能会问,那这种结构和数组有什么区别?它的区别主要在于JS中的集合属于无序集合,并且里面不能有相同的元素。
|
||||
|
||||
JavaScript同时还提供了WeakMap或WeakSet,用它们主要有2个原因。第一,它们都是弱类型,代表没有键的强引用。所以JavaScript在垃圾回收时可以清理掉整条记录。第二个原因,也是它的特点,在于既然WeakMap里没有键值的迭代,只能通过钥匙才能取到相关的值,所以保证了内部的封装私有属性。这也是为什么我们前面07讲说到对象的私有属性的时候,可以用WeakMap来存储。
|
||||
|
||||
散列冲突:解决哈希碰撞的方式
|
||||
|
||||
其实解决哈希碰撞的几种方式也值得了解。上面我们已经介绍了几种通过哈希函数算法角度解决哈希碰撞的方式。下面,我们再来看看通过数据结构的方式是如何解决哈希冲突的。这里有几种基础方式,包含了线性探查法、平方探测法和二度哈希法。
|
||||
|
||||
线性探查法
|
||||
|
||||
我们先来说说线性探查法。用这种方式的话,当一个散列碰撞发生时,程序会继续往下去找下一个空位置,比如在之前例子中,7被南昌占用了,北京就会顺移到8。这样在存储的时候问题也许不大,但是在查找的时候会有一定的问题,比如当我们想要查找某个数据的时候,则需要在集群中迭代寻找。
|
||||
|
||||
|
||||
|
||||
平方探测法
|
||||
|
||||
另外一种方式就是平方探测法。平方探测法用平方值来代替线性探查法中的往后顺移一位的方式,这样就可以做到基于有效的指数做更平均的分布。
|
||||
|
||||
|
||||
|
||||
二度哈希法
|
||||
|
||||
第三种方式是二度哈希(Rehashing/Double-Hashing),也就是在第一次的哈希的基础上再次哈希。在下面公式里,x是第一次哈希的结果,R小于哈希表。假设每次迭代序列号是i,每次哈希碰撞通过i * hash2(x)来解决。
|
||||
|
||||
hash2(x) = R − (x % R)
|
||||
|
||||
|
||||
|
||||
|
||||
HashMap:Java是如何解决散列冲突的?
|
||||
|
||||
先把JS放在一边,其实我们也可以通过Java语言里一些更高阶的链式数据结构,来更深入了解下哈希碰撞的解决方式。如果你学过Java,可能有用到过HashMap、LinkedHashMap和TreeMap。那么Java中的HashMap和LinkedHashMap,那么Java中的这些数据结构有什么优势,分别是如何实现的?下面,我们可以来看看。
|
||||
|
||||
HashMap的底层逻辑是通过链表和红黑树实现的。它最主要解决的问题就是哈希碰撞。我们先来说说链表。它的规则是,当哈希函数生成的哈希值有冲突的时候,就把有冲突的数据放到一个链表中,以此来解决哈希碰撞。那你可能会问,既然链表已经解决了这个问题,为什么还需要用到红黑树?这是因为当链表中元素的长度比较小的时候,链表性能还是可以的,但是当冲突的数据过多的时候,它就会产生性能上的问题,这个时候用增删改查的红黑树来代替会更合适。
|
||||
|
||||
|
||||
|
||||
散列加链表:基于双链表存值排序
|
||||
|
||||
了解完HashMap,再来看看LinkedHashMap。LinkedHashMap是在HashMap的基础上,内部维持了一个双向链表(Doubly Linked List),它利用了双向链表的性能特点,可以起到另外一个非常重要的作用,就是可以保持插入的数据元素的顺序。
|
||||
|
||||
|
||||
|
||||
TreeMap:基于红黑树的键值排序
|
||||
|
||||
除了HashMap和LinkedHashMap,TreeMap也是Java一种基于红黑树实现的字典,但是它和HashMap有着本质的不同,它完全不是基于散列表的。而是基于红黑树来实现的。相比HashMap的无序和LinkedHashMap的存值有序,TreeMap实现的是键值有序。它的查询效率不如HashMap和LinkedHashMap,但是相比前两者,它是线程安全的。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过这节课,你应该了解了如何通过哈希查找JS对象的内存地址。不过,更重要的是希望通过今天的学习,你也能更好地理解哈希、散列表、字典,这些初学者都比较容易混淆的概念。最后我们再来总结下吧。我们说字典(dictionary)也被称为映射、符号表或关联数组,哈希表(hash table)是它的一种实现方式。在ES6之后,随着字典(Map)这种数据结构的引入,可以用来实现字典。集合(Set)和映射类似,但是区别是集合只保存值,不保存键。举个例子,这就好比一个只有单词,没有解释的字典。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题是,我们知道Map是在ES6之后才引入的,在此之前,人们如果想实现类似字典的数据结构和功能会通过对象数据类型,那你能不能用对象手动实现一个字典的数据结构和相关的方法呢?来动手试试吧。
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节再见!
|
||||
|
||||
|
||||
|
||||
|
105
专栏/JavaScript进阶实战课/16为什么环形队列适合做Node数据流缓存?.md
Normal file
105
专栏/JavaScript进阶实战课/16为什么环形队列适合做Node数据流缓存?.md
Normal file
@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 为什么环形队列适合做Node数据流缓存?
|
||||
你好,我是石川。
|
||||
|
||||
前面几讲讲完了栈这种数据结构,我们再来看看队列(queue)。队列对于你来说,可能不算是一种陌生的数据结构,和栈相反,列队通常遵循的是先进先出(FIFO,First In, First Out)的原则。你可以把它想象成在咖啡厅买咖啡时要排的队,基本是先到先得,最后来的最后买到,不能插队。如果你强行插队的话,那每个人的排队时长都会打乱。
|
||||
|
||||
|
||||
|
||||
只是实现一个队列并不复杂,重要的是,你要理解队列在编程中的应用。既然我们在讲JS,就举几个身边的例子,比如我们的浏览器就是通过引擎来做线程和任务的管理。在使用Node的应用中,环形队列可以用来做数据流的缓存。这一讲,首先我们快速地了解下队列的核心,然后通过它在JS引擎了解它们的使用场景,最后我们通过学习一种特殊的环形队列来解答开篇的问题,就是为什么环形队列适合用来缓存数据流。
|
||||
|
||||
如何实现队列和双队列
|
||||
|
||||
首先,我们来看看如何实现一个简单的队列。数据结构中队列的核心思想和我们排队买票看电影一样,关键是谁排在前面,谁就可以先买到票。和排队一样,在数据结构中,我们可以有入队、出队的概念。入队(enqeue),顾名思义就是在队伍后面加了一个人排队。这里的实现和我们看的栈里面的入栈是类似的,所以也可以用push来完成。下面我们再来说说出队,按照先入先出的规则,排在最前面的人买完票了以后,就会出队(dequeue)。它的实现可以通过JavaScript中自带的shift,通过shift,我们可以去掉一个数组里面最开头的一个元素。
|
||||
|
||||
|
||||
|
||||
除了队列,还有一个概念是双队列(deque)。虽然说通常我们排队的时候,都是遵循先进先出的规则,但是在有些特殊的情况下,也会有特例。比如大家在排队等车,看到一位女士带着很小的宝宝在大热天高温下等待,如果一队的人同意的话,大家一般会让她们排到前面。那么在JavaScript中呢,同样有一个unshift()的方法可以用来做到插队,我们在下面例子里把它叫做dequeAdd。还有另外一种情况就是如果有的人在队尾等不及了,也有可能离开,这样的话我们可以借用弹出栈的pop()来实现,在下面例子中,我们可以管它叫dequeRemove吧。
|
||||
|
||||
|
||||
|
||||
在JavaScript里面,我们可以通过下面的方式来实现一个队列。
|
||||
|
||||
class Queue {
|
||||
constructor () {
|
||||
this.queue = [];
|
||||
}
|
||||
enqueue(item) {
|
||||
return this.queue.push(item);
|
||||
}
|
||||
dequeue() {
|
||||
return this.queue.shift();
|
||||
}
|
||||
dequeAdd(item) {
|
||||
return this.queue.unshift(item);
|
||||
}
|
||||
dequeRemove(item) {
|
||||
return this.queue.pop(item);
|
||||
}
|
||||
peek() {
|
||||
return console.log(this.queue[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
通过队列看浏览器任务管理
|
||||
|
||||
下面我们再来看看Chrome浏览器是如何通过队列来实现线程管理的。首先我们来了解下进程(process)和线程(thread)分别是什么,之间是什么关系。我们拿Chromium来举例,Chromium用的是多进程的架构(multi-process architecture)。我们在浏览器中每当打开一个新页面的时候,就是一个新的浏览器进程。
|
||||
|
||||
在一个浏览器进程里,会有多个线程。Chromium中有两个核心线程,一个是主线程,另外一个是IO线程。因为浏览器是面向前端用户的,所以对于它的架构设计来说,最主要的目标是让主线程和IO线程可以快速响应。为了达到这个目的,就要把阻塞IO或复杂运算分给其它的线程去处理,线程间通信问题通过消息传递来解决。
|
||||
|
||||
所以可以说Chromium用的是一个高并发,但不算是高并行的架构。对于页面加载的脚本中要执行的任务,会采用任务队列的方式通过事件循环给到UI主线程。如果问题是主线程可以解决的,就会处理,如果处理不了的,就会给到IO线程或特殊线程来处理,处理的结果会通过消息通信的方式给到主线程。
|
||||
|
||||
在浏览器进程中,除了主线程、IO线程和特殊线程外,还有一个用来处理通用任务的线程池。线程池有一个共享的任务队列。我们知道,在处理高并发时,人们通常通过锁结构来确保线程安全,而在Chromium线程管理当中,并不提倡用锁的结构,而是提倡序列或虚拟线程管理的概念。因为在Google看来,序列本身就带有线程安全性。这是怎么做到的呢?因为在虚拟线程管理中,只有当一个任务执行完,下一个任务才有可能被分配到线程池中的另一个工作线程来执行,所以下一个任务肯定是基于上一个任务的结果来执行的。所以虽然对于不同的工作线程来讲,它们之间的工作是并行的,但是对于一组需要串行处理的任务来说,它们的执行是有先后顺序的。
|
||||
|
||||
|
||||
|
||||
这里我们可以打一个比方,比如我们在项目管理中,会有一个需求池,这个需求池就是我们的任务队列,而虚拟线程管理就如同一个项目经理。
|
||||
|
||||
项目经理的工作是根据任务和团队,来制定项目计划。比如根据需求,执行中有两个任务分别是设计和开发,这两个任务需要按顺序来,开发团队说你的设计UI没出来,我是不会开始写代码的,这时,作为项目经理,你会把这两个任务作为串行任务,也就是任务2的开始基于任务1的结束。虽然这解决了线程安全问题,但是同时项目经理也清楚投入到项目的生产力是有限的,怎么能通过生产关系的优化让资源更有效的被利用起来呢?
|
||||
|
||||
这时我们可以用迭代的方式。设计团队做迭代1中的设计,然后确保完成再交给开发团队来开发。这时当开发团队在做迭代1的开发时,设计团队已经在继续做迭代2的设计了。对于迭代1中设计和开发这两个任务来说,它们是按照序列串行的。同时,对于两个团队来讲,他俩的工作是并行的。这样既降低了项目风险,又可以做到一定程度的并发。
|
||||
|
||||
|
||||
|
||||
那么这样是说Chromium就完全不支持锁了吗?也不是。那什么时候可以用锁结构呢?同样举个例子,这就好比产品和研发两个员工(worker thread),产品宣讲完业务逻辑,研发在开发过程中,发现当时讨论的流程图有个问题,两个人需要同时修改一份流程文档,这个时候为了避免内容被相互覆盖,两个人商量好了,应该只有一个人去改。所以在Chromium中,通常当有多个线程(worker thread)可以访问一个数据资源,但是同一时间只允许一个线程来访问这些资源或数据的时候,会用到锁。在Chromium当中使用的是互斥锁(mutex)。
|
||||
|
||||
环形队列和线程间数据传递
|
||||
|
||||
说完了常见的队列,我们再来看在一种特殊的队列,就是环形队列。一个环形队列是首尾相连的。它也叫做环形缓冲区(ring buffer),可以用于进程或线程之间的数据传递。
|
||||
|
||||
回答开篇的问题:为什么环形队列适合做Node数据流缓存?你可能会问这样的队列为什么会有用?那是因为它最核心的好处是当一个数据元素被用掉后,其余数据元素不需要移动其存储位置。我们来看看它是怎么做到这一点的。对于一个环形队列来说,我们一般需要两个指针,一个是头指针,一个是尾指针。头指针指向的是下一个要加入的数据,尾指针指向下一个要读取的数据。当数据被读取时,我们就增加尾指针;当数据被写入的时候,我们就增加头指针。
|
||||
|
||||
举个例子,假设我们有一个16位的缓冲,第一步,我们加入了4位数据,头指针就移动到了3。如果再加3个的话,头指针就移动到了6。如果这时,我们读取了前4个,那么尾指针就会到4。
|
||||
|
||||
|
||||
|
||||
用一个形象的比喻,大家如果在美国的一些餐厅吃过饭,可能会见过一个点餐轮盘。来餐厅的顾客一般会把想吃的东西写在纸上,然后放到轮盘上,厨师会按照顺序从轮盘上把点餐的菜单拿下来,然后来做饭。这就很像是一个环形队列。
|
||||
|
||||
|
||||
|
||||
那么在程序中这种环形队列如何实现呢?从实现的角度,一般会建立两个数组,一个是原数组用来定义环形队列的属性和方法,第二个是实际用来存放数据的环形队列数组。在原数组里面,主要存放3个关键属性,分别是头指针、尾指针和环形队列长度。同时,包含几个核心方法:原数组中属性的获取和设置,以及环形队列数组中数据的读和写。通过这种方式,就可以实现一个环形队列了。
|
||||
|
||||
下面我们可以来看看它在缓存数据流中的应用。这种环形队列通常会和“生产者,消费者”模式一起用,也通常会加一个互斥锁到环形队列的读、写方法里,用来实现互斥访问。如下图所示,我们可以看到有4个工作线程。2个是生产者,2个是消费者。 生产者负责写入数据,消费者读取数据。通过加锁的方式,在同一时间,只有一个生产者可以写入,在读的时候,也只有一个消费者可以读取。
|
||||
|
||||
|
||||
|
||||
在数据流这种大量数据持续进入到列队中,再从队列中取出做缓存处理的情况下,使用环形队列就大大增加了生产者和消费者之间协同合作的效率。
|
||||
|
||||
总结
|
||||
|
||||
在这一讲当中,我们通过对队列的原理介绍,学习了它在浏览器线程管理中的应用;之后通过对环形队列的的原理介绍,学习了它在缓存数据流中的应用。数据流缓冲在很多应用中都有体现,除了进程管理外,在网络和文件系统中,都会用到数据流缓冲。在网络中,字节数据都是按照包的大小分成块儿来传输的;在文件系统中,数据块儿都是根据内核的缓冲大小分成块儿来处理的。所以无论是HTTP的数据请求和反馈,还是文件到目的地的传输,这些数据的传输都会用到环形队列缓冲。
|
||||
|
||||
思考题
|
||||
|
||||
在我们用互斥锁的时候,会发现它有一个劣势,就是共享资源,也就是环形队列每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程,这在某种意义上是一个悲观锁。除此之外,还有另外的一种方式是原子操作,它是针对某个值的单个互斥操作。你知道如何通过原子操作来实现环形队列缓冲吗?
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
177
专栏/JavaScript进阶实战课/17如何通过链表做LRU_LFU缓存?.md
Normal file
177
专栏/JavaScript进阶实战课/17如何通过链表做LRU_LFU缓存?.md
Normal file
@ -0,0 +1,177 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 如何通过链表做LRU_LFU缓存?
|
||||
你好,我是石川。
|
||||
|
||||
前面我们在第10-13讲讲过了数组这种数据类型,以及通过它衍生出的栈和队列的数据结构。之后,我们在讲到散列表的时候,曾经提到过一种链表的数据结构。今天,我们就来深入了解下链表和它所支持的相关的缓存算法。链表有很多使用场景,最火的例子当属目前热门的区块链了。它就是用了链表的思想,每一个区块儿之间都是通过链表的方式相连的。在链表结构里,每一个元素都是节点,每个节点有自己的内容和相连的下一个元素的地址参考。
|
||||
|
||||
既然我们讲的是JavaScript,还是回到身边的例子,就是缓存。无论是我们的操作系统,还是浏览器,都离不开缓存。我们把链表和散列表结合起来,就可以应用于我们常说的缓存。那这种缓存是如何实现的呢?接下来就先从链表说起吧。
|
||||
|
||||
如何实现单双循环链表
|
||||
|
||||
单链表
|
||||
|
||||
下面我们先来看看一个单向链表是怎么实现的。链表就是把零散的节点(node)串联起来的一种数据结构。在每个节点里,会有两个核心元素,一个是数据,一个是下一个节点的地址,我们称之为后继指针(next)。在下面的例子里,我们先创建了一个节点(node),里面有节点的数据(data)和下一个节点的地址(next)。
|
||||
|
||||
|
||||
|
||||
在实现上,我们可以先创建一个节点的函数类,里面包含存储数据和下一节点两个属性。
|
||||
|
||||
class Node {
|
||||
constructor(data){
|
||||
this.data = data;
|
||||
this.next = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
之后,我们再创建一个链表的函数类。在链表里,最常见的方法就是头尾的插入和删除,这几项操作的复杂度都是\(O(1)\)。应用在缓存当中的时候呢,通常是头部的插入,尾部删除。但倘若你想从前往后遍历的话,或是在任意位置做删除或插入的操作,那么相应的复杂度就会是\(O(n)\)。这里的重点不是实现,而是要了解链表里面的主要功能。因此我没有把所有代码都贴在这里,但是我们可以看到它核心的结构。
|
||||
|
||||
class LinkedList {
|
||||
constructor(){
|
||||
this.head = null;
|
||||
this.size = 0;
|
||||
}
|
||||
isEmpty(){ /*判断是否为空*/ }
|
||||
size() { /*获取链表大小*/ }
|
||||
getHead() { /*获取链表头节点*/ }
|
||||
indexOf(element) { /*获取某个元素的位置*/ }
|
||||
insertAtTail(element) { /*在表尾插入元素*/ }
|
||||
insertAt(element, index) { /*在指定位置插入*/ }
|
||||
remove(element) { /*删除某个元素*/ }
|
||||
removeAt(index) { /*在指定位置删除*/ }
|
||||
getElementAt(index) { /*根据某个位置获取元素*/ }
|
||||
removeAtHead(){ /*在表头位置删除元素*/ }
|
||||
}
|
||||
|
||||
|
||||
注意观察的你,可能会发现,最后一个节点是null或undefined的,那它是做什么的呢?这个是一个哨兵节点,它的目的啊,是对插入和删除性能的优化。因为在这种情况下,插入和删除首尾节点的操作就可以和处理中间节点用同样的代码来实现。在小规模的数据中,这种区别可能显得微不足道。可是在处理缓存等需要大量频繁的增删改查的操作中,就会有很大的影响。
|
||||
|
||||
双链表
|
||||
|
||||
说完单链表,我们再来看看双链表。在前面单链表的例子里,我们可以看到它是单向的,也就是每一个节点都有下一个节点的地址,但是没有上一个的节点的指针。双链表的意思就是双向的,也就是说我们也可以顺着最后一个节点中关于上一个节点的地址,从后往前找到第一个节点。
|
||||
|
||||
|
||||
|
||||
所以在双链表节点的实现上,我们可以在单链表基础上增加一个上一节点指针的属性。同样的,双链表也可以基于单链表扩展,在里面加一个表尾节点。对于从后往前的遍历,和从前往后的遍历一样,复杂度都是\(O(n)\)。
|
||||
|
||||
class DoublyNode extends Node {
|
||||
constructor(data, next, prev) {
|
||||
super(data, next);
|
||||
this.prev = prev;
|
||||
}
|
||||
}
|
||||
|
||||
class DoublyLinkedList extends LinkedList {
|
||||
constructor() {
|
||||
this.tail = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
循环链表
|
||||
|
||||
我们接下来再来看看循环链表(circular list)。循环链表,顾名思义,就是一个头尾相连的链表。
|
||||
|
||||
|
||||
|
||||
如果是双向循环列表的话,就是除了顺时针的头尾相接以外,从逆时针也可以循环的双循环链表。
|
||||
|
||||
|
||||
|
||||
如何通过链表来做缓存
|
||||
|
||||
了解了单双循环链表后,现在我们回到题目,那我们如何能通过链表来做缓存呢?我们先了解下缓存的目的。缓存主要做的就是把数据放到临时的内存空间,便于再次访问。比如数据库会有缓存,目的是减少对硬盘的访问,我们的浏览器也有缓存,目的是再次访问页面的时候不用重新下载相关的文本、图片或其它素材。通过链表,我们可以做到缓存。下面,我们会看两种缓存的算法,他们是最近最少使用(LRU,least recently used)和最不经常使用(LFU,least frequently used),简称LRU缓存和LFU缓存。
|
||||
|
||||
一个最优的缓存方案应该把最不可能在未来访问的内容换成最有可能被访问的新元素。但是在实际情况中,这是不可能的,因为没人能预测未来。所以作为一个次优解,在缓存当中,通常会考虑两个因素,一个是时间局部性(Temporal Locality),我们认为一个最近被访问的内存位有可能被再次访问;另外一个因素是空间局部性(Spatial Locality),空间局部性考虑的是一个最近被访问的内存位相近的位置也有可能被再次访问。
|
||||
|
||||
在实际的使用中,LRU缓存要比LFU的使用要多,你可能要问为什么?这里我先卖个关子,咱们先看看这两种方法的核心机制和实现逻辑。之后我们再回到这个问题。
|
||||
|
||||
LFU 缓存
|
||||
|
||||
我们先来看看LFU缓存。一个经典的LFU缓存中,有两个散列表,一个是键值散列表,里面存放的是节点。还有一个是频率散列表,里面根据每个访问频率,会有一个双链表,如下图中所示,如果键值为2和3的两个节点内容都是被访问一次,那么他们在频率1下面会按照访问顺序被加到链表中。
|
||||
|
||||
|
||||
|
||||
在这里,我们看一个LFU关键的代码实现部分的说明。LFU双链表节点,可以通过继承双链表的节点类,在里面增加需要用到的键值,除此之外还有一个计数器来计算一个元素被获取和设置的频率。
|
||||
|
||||
class LFUNode extends DoublyNode {
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
this.freqCount = 1;
|
||||
}
|
||||
}
|
||||
|
||||
class LFUDoublyLinkedList extends LinkedList {
|
||||
constructor() {/* LFU 双链表 */ }
|
||||
}
|
||||
|
||||
|
||||
前面说到一个LFU缓存里面有两个散列表,一个是键值散列表,一个是频率散列表。这两个散列表都可以通过对象来创建。键值散列表保存着每个节点的实例。频率散列表里有从1到n的频率的键名,被访问和设置次数最多的内容,就是n。频率散列表里每一个键值都是一个双向链表。
|
||||
|
||||
那围绕它呢,这里有3个比较核心的操作场景,分别是插入、更新和读取。
|
||||
|
||||
对于插入的场景,也就是插入新的节点,这里我们要看缓存是否已经满了,如果是,那么在插入时它的频率是1。如果没有满的话,那么新的元素在插入的同时,尾部的元素就会被删除。
|
||||
|
||||
对于更新场景,也就是更新旧的节点,这时,这个元素会被移动到表头。同时,为了计算下一个被删除的元素,最小的频率minFreq会减1。
|
||||
|
||||
对于读取场景,也就是获取节点的动作,缓存可以返回节点,并且增加它的调用频率的计数,同时将这个元素移动到双链表的头部。同样与插入场景类似,最后一步,最低频率minFreq的值会被调整,用来计算下次操作中会被取代的元素。
|
||||
|
||||
class LFUCache {
|
||||
constructor() {
|
||||
this.keys = {}; // 用来存储LFU节点的键值散列表
|
||||
this.freq = {}; // 用来存储LFU链表的频率散列表
|
||||
this.capacity = capacity; // 用来定义缓存的承载量
|
||||
this.minFreq = 0; // 把最低访问频率初始设置为0
|
||||
this.size = 0; // 把缓存大小初始设置为0
|
||||
}
|
||||
set() {/* 插入一个节点 */}
|
||||
get() {/* 读取一个节点 */}
|
||||
}
|
||||
|
||||
|
||||
LRU 缓存
|
||||
|
||||
以上是LFU的机制和核心实现逻辑,下面我们再来看看最近最少使用(LRU least recently used)。LRU缓存的目的是在有限的内存空间中,当有新的内容需要缓存的时候,清除最老的内容缓存。当一个缓存中的元素被访问了,这个最新的元素就会被放到列表的最后。当一个没有在缓存中的内容被访问的时候,最前面的也就是最老的内容就会被删除。
|
||||
|
||||
|
||||
|
||||
在LRU缓存的实现中,最需要注意的是要追踪哪个节点在什么时间被访问了。为了达到这个目的,我们同样要用到链表和散列表。之所以要用到双向链表,是因为需要追踪在头部需要删除的最老的内容,和在尾部插入最新被访问的内容。
|
||||
|
||||
从实现的角度看,LRU当中的节点和LFU没有特别大的差别,也需要一个键值散列表,但是不需要频率散列表。LRU缓存可以通过传入承载量capacity参数来定义可以允许缓存的量。同样和LFU类似的,LRU也需要在链表头部删除节点和链表尾部增加节点的功能。在此基础之上,有着获取和设置的两个对外使用的方法。所以总体看来,LRU的实现和LFU相比是简化了的。
|
||||
|
||||
class LRUNode extends DoublyNode {
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
class LRUCache {
|
||||
constructor() {
|
||||
this.keys = {}; // 用来存储LFU节点的键值散列表
|
||||
this.capacity = capacity; // 用来定义缓存的承载量
|
||||
this.size = 0; // 把缓存大小初始设置为0
|
||||
}
|
||||
set() {/* 插入一个节点 */}
|
||||
get() {/* 读取一个节点 */}
|
||||
}
|
||||
|
||||
|
||||
总结
|
||||
|
||||
如果从直觉上来看,你可能会觉得对比LRU,似乎LFU是更合理的一种缓存方法,它根据访问的频率来决定对内容进行缓存或清除。回到上面的问题,为什么在实际应用中,更多被使用的是LRU呢?这是因为一是短时间高频访问本身不能证明缓存有长期价值,二是它可能把一些除了短时间高频访问外的内容挤掉,第三就是刚被访问的内容也很可能不会被频繁访问,但是仍然可能被重复访问,这里也增加了它们被意外删除的可能性。
|
||||
|
||||
所以总的来说,LFU没有考虑时间局部性,也就是“最近被访问的可能再次被访问”这个问题。所以在实际的应用中,使用LRU的概率要大于LFU。希望通过这节课,你能对链表和散列表的使用有更多的了解,更主要的是,当你的应用需要写自己的缓存的时候,也可以把这些因素考虑进去。
|
||||
|
||||
思考题
|
||||
|
||||
下面到了思考题时间,虽然说LFU的应用场景比较少,但是它在某些特定场合还是很有用处的,你能想到一些相关的场景和实现吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
|
||||
|
||||
|
189
专栏/JavaScript进阶实战课/18TurboFan如何用图做JS编译优化?.md
Normal file
189
专栏/JavaScript进阶实战课/18TurboFan如何用图做JS编译优化?.md
Normal file
@ -0,0 +1,189 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 TurboFan如何用图做JS编译优化?
|
||||
你好,我是石川。
|
||||
|
||||
今天我们来看下图(graph)这种数据结构,图是一个抽象化的网络结构模型。你可能会问有什么算法应用会用到图这种结构吗?其实很多,像我们经常用的社交媒体(比如国内的微博、微信,或者国外的脸书、领英)中的社交图谱,都可以通过图来表达。另外,图也能用来表达现实世界中的路网、空网以及虚拟的通信网络。
|
||||
|
||||
图在JS引擎的编译器中作用是非常大的。如果说整个V8的编译器TurboFan都基于图也毫不夸张。我们既可以通过图这种数据结构,对编译的原理有更深的理解,在我们了解编译的同时,又可以对相关的数据结构和算法有更深入的认识。
|
||||
|
||||
图的结构
|
||||
|
||||
下面我们先来看一下图的结构吧。图就是由边(edge)连接的节点(vertax),任何一个二分的关系都可以通过图来表示。
|
||||
|
||||
|
||||
|
||||
那我们说的TurboFan是怎么利用图来做编译的呢?在开始前,我们先来了解下编译流程和中间代码。
|
||||
|
||||
编译流程:中间代码
|
||||
|
||||
IR,也就是中间代码(Intermediate Representation,有时也称 Intermediate Code,IC)。从概念层面看,IR可以分为HIR(Higher IR)、MIR(Middle IR)和LIR(Lower IR),这几种高、中、低的中间代码的形式。
|
||||
|
||||
|
||||
|
||||
从上面的图中,我们可以看到在整个编译过程中,可以把它分成前中后三个端。前端主要做的是词法、语法和语义的分析,这个我们在之前的章节也有讲过。中端会做相关的优化工作,最后后端的部分就生成了目标代码。
|
||||
|
||||
前面我们说了,中间代码可以分为高、中、低三个不同的抽象层次。HIR主要用于基于源语言做的一些分析和变换,比较典型的例子就是用于语义分析的的AST(Abstract Syntax Parser)语法树。MIR则是独立于源语言和 CPU 架构来做一些分析和优化,比较典型的例子就是三地址代码 TAC(Three Address Code)和程序依赖图 PDG(Program Dependency Graph),它们主要用于分析和优化算法的部分。LIR这一层则是依赖于 CPU 架构做优化和代码生成,其中比较典型的例子就是有向无环图 DAG(Directed Ayclic Graph),它的主要目的是帮助生成目标代码。
|
||||
|
||||
|
||||
|
||||
在编译流程中分析的这一段里,有一个重要的分析就是控制流分析(CFA,Control Flow Analysis)和数据流分析(DFA, Data Flow Analysis),而我们后面要说的节点之海(SoN,Sea of Node)就是可以同时反映数据流和控制流的一种数据结构,节点之海可以减少CFA和DFA之间的互相依赖,因而更有利于全局优化。
|
||||
|
||||
在编译流程中优化的这一段里,也有几个重要的概念,分别是通用优化、对象优化和函数式优化。比如循环优化就属于通用的优化;内联和逃逸属于关于对象的优化。对于函数式编程而言,比较重要的就是之前,我们在说到迭代和递归的时候,提到过的循环和尾递归的优化。
|
||||
|
||||
在流程中最后生成目标代码的这一段里,重点的任务是寄存器分配和指令的选择和排序。
|
||||
|
||||
今天,我们重点来看一下在分析过程中,图和节点之海的原理,以及为什么说节点之海有利于全局的优化。
|
||||
|
||||
中端优化:分析和优化
|
||||
|
||||
下面我们从中端的优化说起,通过IR的结构你可能可以看出,“中间代表”可能是比“中间代码”更准确的一种描述。因为IR更多的是以一种“数据结构”的形式表现,也就是我们说的线性列表、树、图这些数据结构,而不是“代码”。今天我们重点说的这种数据结构呢,就是TurboFan用到的节点之海 (SoN,Sea of Node)。
|
||||
|
||||
|
||||
|
||||
性能一直是 V8 非常关注的内容。节点之海(SoN)的概念首次提及,是HotSpot创始人Cliff Click在一篇93年的论文中提出的。V8的前一代CrankShaft编译器是基于HotSpot C1实现的,V8现在用的TurboFan编译器是基于第二代HotSpot C2实现的升级版。TurboFan使用了优化了的SoN IR与流水线相结合生成的机器码,比使用 CrankShaft JIT 产生的机器码质量更高。与 CrankShaft 相比,TurboFan采用了更多的优化和分析,比如控制流的优化和精确的数值范围分析,所有这些都是以前无法实现的。节点之海的加入更有利于做全局优化。下面我们就来了解一下节点之海的结构和格式。
|
||||
|
||||
SoN结构:格式和表达
|
||||
|
||||
首先我们可以看一下SoN的结构,整个的SoN只有节点和边构成。除了值以外,所有的运算也都是用节点来表示的。在TurboFan的可视化工具Turbolizer中,使用了不同颜色来区分不同类型的节点。浅蓝色就代表了值,深蓝色代表了中间层的操作,例如运算。就像下面的加法运算,边是用来表示运算之间的依赖关系,表达的是5+x。这种依赖也就限制了先后顺序,比如5+x的加法运算是依赖5和x两个值节点的,所以我们在顺序上可以允许5和x是先后关系,但是必须出现在加法运算前。
|
||||
|
||||
|
||||
|
||||
SoN的格式:静态单赋值
|
||||
|
||||
说完了结构,我们再来看看格式。SoN是符合静态单赋值(SSA,static single assignment)格式的。什么是静态单赋值呢?顾名思义,就是每个变量只有唯一的赋值。比如下面的例子里,第一个操作是x等于3乘以7,之后我们给x加3。这时,x变量就出现了两次。所以TurboFan在创建图的时候会给本地变量做重命名,在改完之后就是3这个值的节点同时有两个操作符指向它,一个是乘以7的结果,一个是结果再加上3。在SoN中没有本地变量的概念,所以变量被节点取代了。
|
||||
|
||||
|
||||
|
||||
数据流表达
|
||||
|
||||
我们前面说过,在分析的过程中,有一个重要的分析就是控制流分析(CFA,Control Flow Analysis)和数据流分析(DFA, Data Flow Analysis)。在数据流中,节点用来表达一切运算,包括常量、参数、数学运算、加载、存储、调用等等。
|
||||
|
||||
关于数据流的边,有两个核心概念,一是实线边表达了数据流的方向,这个决定了输入和输出的关系,下面实线表达的就是运算输出的结果。二是虚线边影响了数据流中的相关数据的读写状态,下面虚线边表示运算读写状态的指定。
|
||||
|
||||
关于WAW(Write-After-Write,写后再写)、WAR(Write-After-Read,先读后写)和RAW(Read-After-Write,先写后读)的读写状态,我们可以看看是如何实现的。我们可以看到SoN中的虚线边可以用来表达不同读写状态的影响。比下面obj对象的val属性状态是可变的,完成加val加3的操作后,通过先读再写存入,之后的结果值先写后读。
|
||||
|
||||
|
||||
|
||||
控制流表达
|
||||
|
||||
我们前面说过,从数据流的角度来看,节点和边表达了一切。而在控制流中,这种说法也是同样成立的。通过下面的例子,我们可以看到,控制中的节点包括开始、分支、循环、合并以及结束等等,都是通过节点来表示。
|
||||
|
||||
在Turbolizer的可视化图中,用黄色节点代表了控制流的节点。下面是几个单纯看数据流的例子,我们可以看到从最简单的只包含开始结束的直线程序,到分支到循环都可以通过节点之海来表达。
|
||||
|
||||
|
||||
|
||||
下面我们来看看在和数据流结合后的一个最简单的例子。这里我们看到了,为了区分不同类型的边,可以通过黑色实线、灰色实线和虚线边分别代表控制流、数据流和影响关系。
|
||||
|
||||
|
||||
|
||||
最后,我们再加上合并,可以看到一个更完整的例子。这里你会看到一个phi指令,这个指令是做什么的呢?它会根据分支判断后的实际情况来确定x的值。因为我们说过,在SSA(静态单赋值)的规则中,变量只能赋值一次,那么我们在遇到分支后的合并,就必须用到phi。
|
||||
|
||||
|
||||
|
||||
分析:变量类型和范围
|
||||
|
||||
我们讲了中间代码的重要作用是分析和优化,为什么要做变量类型和范围的分析呢?这里主要要解决三个问题。第一,我们说JavaScript是动态而不是静态类型的,所以虽然值有固定的数据类型,可变量是没有固定的数据类型的。举个例子,我们可以说 var a = 25,25本身是不变的数字类型,但是如果我们说a + “years old”, 那么变量a的类型就变成了字符串。第二,所有的数学运算都是基于64比特的,然而在实际的应用中,大多数的程序其实用不了这么多比特,大多都小于32比特,并且这里还有很多如NaN、Infinity、-Infinity、-0.0等特殊的值。第三,是为了支持如asm.js这样的JS子集中的注释,以及将上一点提到的如NaN、Infinity这些特殊值截断成0整数这样的需求。
|
||||
|
||||
|
||||
|
||||
优化:基于节点海的优化
|
||||
|
||||
说完以上这三点我们要做变量类型和范围的分析。我们再来说说优化,几乎所有的优化都是在节点上完成的,如果一个节点可以被访问到就代表是活的,如果节点不能从结束节点访问到就是死节点,包含死的控制、效果和运算。它们在编译过程中大多数的阶段是看不到死节点的,并且无效节点也不会被放到最后的调度里。
|
||||
|
||||
优化的算法就太多了,下面我们说几种核心的优化方案。
|
||||
|
||||
方案一:常量提前,简化计算
|
||||
|
||||
在优化的方案中,最基本的就是常量提前和简化计算的优化方式了。
|
||||
|
||||
在常量提前中,比较经典的优化就是常数折叠(constant folding),顾名思义,就是把常数加在一起,比如在下面的第一个例子中,我们把3+5的操作直接折叠为8。
|
||||
|
||||
在简化计算中,比较有代表性的例子就是强度折减(strength reduction)。比如在下面的第二个例子中,x+0和x没有区别,就可以简化成x。再比如下面第三个例子中,可以把x*4改为x<这样的移位运算。
|
||||
|
||||
|
||||
|
||||
方案二:去重计算
|
||||
|
||||
在去重计算中,最常见的是值编号(value numbering)。意思就是在系统里把等于相同的值赋予同一个编号。比如当sin、加法以及LoadField的运算的结果值是一样的时候,那么就只记录一次即可。
|
||||
|
||||
|
||||
|
||||
方案三:控制流优化
|
||||
|
||||
针对控制流,也有很多不同的优化方式。下面是几个例子,包含了分支折叠、合并折减、控制折减。通过这几个例子就可以看到,无论流程本身,还是流程中的分支和合并在优化后,都简化了很多。
|
||||
|
||||
|
||||
|
||||
Lowering:语言的层级
|
||||
|
||||
前面我们看到了,TurboFan的Tubolizer会用不同颜色来表示不同类型的节点。包括代表值的浅蓝色节点、代表流程的黄色节点,以及代表运算的深蓝色语言节点,除了深蓝色以外,还有两种语言的节点,一种是上层的红色的JavaScript语言的节点,还有就是更接近底层的机器语言的绿色节点。基本上层级越高,对人来说可读性就越高;层级越低,则越接近机器语言。
|
||||
|
||||
|
||||
|
||||
我们把这种由高到低的过程,叫做lowering。所以优化的过程,也会是一个不断lowering折减的过程。
|
||||
|
||||
|
||||
|
||||
后端优化:指令和寄存
|
||||
|
||||
关于中端优化的部分先讲到这,下面我们再来看看后端优化,如何生成目标代码,涉及到寄存器分配和指令选择排序。
|
||||
|
||||
指令排序
|
||||
|
||||
先说说指令排序。SoN节点之海最后排序的结果会被放入一个CFG(Control Flow Graph)程序控制图。这里有三个概念,第一是控制支配,第二是减少寄存压力,第三是循环优化。
|
||||
|
||||
首先我们来看第一点,在这个过程中,首先是把固定的节点比如phi、参数等都放到CFG中。
|
||||
|
||||
|
||||
|
||||
之后会用到支配树来计算支配关系,接着会把剩余的节点按照支配关系从SoN节点之海输出,输入到CFG程序控制图中。
|
||||
|
||||
|
||||
|
||||
第二点,那么在编译过程中如何缓解寄存压力呢?我们前面说的SSA也是通过支配树来实现的。排序后,节点之海完全变成了一个程序控制图。
|
||||
|
||||
|
||||
|
||||
最后我们来看第三点,循环优化。在这个过程里尽量对循环中的代码做提升,这个过程叫做Loop Invariant Code Motion,也就是我们常说的hoisting。你可能问这个过程在哪儿?其实这一步在分析优化的时候已经做了,在这里,只是纳入了优化后的结果。
|
||||
|
||||
指令选择
|
||||
|
||||
下面我们再来看看指令选择。从理论上讲,指令选择可以用最大吞噬。
|
||||
|
||||
|
||||
|
||||
但是实际上指令选择的顺序,是从程序控制图中相反的顺序,也就是从基本块儿中自下而上地移动标的位置。
|
||||
|
||||
|
||||
|
||||
寄存器分配
|
||||
|
||||
前面,我们说过,在分析和优化结束后,在编译过程的后端,要最后生成机器码,这个过程中,一件主要的工作就是寄存器的分配。TurboFan 使用线性扫描分配器和实时范围分割来分配寄存器和插入溢出代码。SSA 形式可以在寄存器分配之前或之后通过显式移动进行解构。
|
||||
|
||||
|
||||
|
||||
寄存器分配的结果是用真实寄存器代替虚拟寄存器的使用,并在指令之间插入溢出代码。
|
||||
|
||||
总结
|
||||
|
||||
这一讲可以算是比较硬核了,但是如果你能读到这里,证明你已经坚持下来了。从V8对节点之海的运用,我希望你不仅是了解了这种数据结构,更是对整个编译的流程有了更深入的理解。而且在这个过程中,我们也可以看到,我们写的代码在编译过程中最后被优化的代码不一定是一开始写的代码了。这既说明了我们在写代码的时候可以更关注可读性,因为我们没有必要为了机器会优化的内容而操心,从另外一个角度来看呢,有些开发者对性能有极度的追求,也可能写出更接近优化后的代码,但这样做是会牺牲一些可读性的。
|
||||
|
||||
思考题
|
||||
|
||||
如果你有用过Java的Graal会发现这个编译器的IC用的也是基于SoN的数据结构。那么你能说出HotPot、Graal以及TurboFan在SoN上的相同和不同之处吗?
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
参考
|
||||
|
||||
|
||||
TurboFan JIT Design by Ben L. Titzer from Google Munich
|
||||
|
||||
|
||||
|
||||
|
||||
|
188
专栏/JavaScript进阶实战课/19通过树和图看如何在无序中找到路径和秩序.md
Normal file
188
专栏/JavaScript进阶实战课/19通过树和图看如何在无序中找到路径和秩序.md
Normal file
@ -0,0 +1,188 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 通过树和图看如何在无序中找到路径和秩序
|
||||
你好,我是石川。
|
||||
|
||||
在算法中,最常见的两个操作莫过于排序和搜索了。前面我们通过数组了解了在线性的数据结构中不同的排序算法。之后,我们又通过哈希算法了解了散列表这种比较特殊的线性表,了解了它是如何与链表结合,用空间换时间地支持索引和查找的。
|
||||
|
||||
在现实的生活中,我们可以看到信息并不总是有序的,我们在讲到函数式编程的时候有提到过,函数思想的一个维度就是如何处理未知和混沌。那么函数中的数据结构和算法呢,作为现实世界的映射,也要能处理这样复杂的情况。今天,我们来看两种支持我们处理这种未知和混沌的非线性的数据结构。一种是树,另外一种是图。
|
||||
|
||||
我们知道一些大的咨询公司如麦肯锡,经常用到“金字塔原理”来做解决方案。它的核心原理呢,讲的就是我们平时接收的信息是杂乱无章的网状结构的,而我们要做的是在杂乱无章的信息中梳理出“金字塔”式的树形信息结构,最后呢,再用线性的“讲故事”的方式讲述出来。这就像极了我们在算法中用到的各种数据结构。
|
||||
|
||||
|
||||
|
||||
今天我们就深入来看看图和树这两种数据结构。我们先从图说起,图就是一种非线性的数据结构。我们生活中有很多无序的网络组织都可以用图来表示,比如社交网络,我们的互联网通信、城市的道路、网站的链接。如果用我们前端开发的视角来看的话,不同依赖资源本身也可以被看做是无序的,而例如我们前端经常用到的webpack的模块加载功能就是一个在无序中建立秩序,帮助我们厘清资源之间相关依赖关系的工具,这种功能就可以通过拓扑排序来实现。我们可以先从图这种数据结构本身说起,来一步步看下它是怎么做到的。
|
||||
|
||||
通过拓扑排序建立资源依赖
|
||||
|
||||
首先,我们来看下图的结构。
|
||||
|
||||
深入了解图的类型和结构
|
||||
|
||||
在上一节当中,我们说过一个图就是由边(edge) 相连的节点(node) 组成的。如果延伸来看,我们可以将通过一条线相连接的两个节点叫做相邻节点(adjacent vertices)。同时,我们可以把一个节点连接的数量叫做度(degree)。一个边也可以加权(weighted)。一条路径(path)就是一组相邻节点的序列。
|
||||
|
||||
|
||||
|
||||
一条可以回到原点的路径是循环图(cyclic graph)。一个没有可以回到原点的路径的图被称作无环图(acyclic graph)。如果图之间的边是有指向的就会被称为是有向图(directed graph),反之,如果图之间的边没有指向,就被称之为无向图(undirected graph)。如果一个图是有向无环的,就叫做我们上一讲提到过的有向无环图(DAG,directed acyclic graph)。
|
||||
|
||||
一种用来存储图的方式是通过邻接矩阵(adjacency matrix)。如果一个图的相邻节点较少就被称为稀疏图(sparse graph);相反的如果一个图的相邻节点较多的话,就被称为稠密图(dense graph)。对于稀疏图来说,用邻接矩阵来存储可能就会造成资源的浪费,因为这个矩阵中可能为了几个节点来维护一个较高的空间复杂度。同样,邻接矩阵在支持节点的插入删除也具有劣势。所以另外一种用来存储图的方式就是通过邻接表(adjacency list)。这种数据结构既可以用数组,也可以用链表、 哈希或者字典来表示,所以可以和邻接矩阵形成有效的互补。
|
||||
|
||||
|
||||
|
||||
对图的遍历有两种经典的方式,一种是广度优先搜索(BFS,breath first search),另外一种是深度优先搜索(DFS,depth first search)。广度优先搜索最经典的使用场景就是寻找最短路径(shortest path)的场景了。而深度优先搜索呢,就是我们前面说到的拓扑排序(topological sorting)可以用到的一种遍历方式。这里,我们可以先从广度优先搜索开始看起。
|
||||
|
||||
|
||||
|
||||
如何在选择中找到最短路径
|
||||
|
||||
最短路径在生活中非常常见,比如我们常用的地图。常见的最短路径的算法有迪杰斯特拉Dijkstra和弗洛伊德-沃舍尔Floyd-Warshall。下面我们可以选Dijkstra(迪杰斯特拉)算法来看看。如图所示,假设我们要计算从A到B、C、D、E和Z的最短距离。
|
||||
|
||||
|
||||
|
||||
Dijkstra的算法先将所有的距离初始化为无限dist[i] = INF;已访问的数组为否visited[i] = false,然后把从源头到自己的距离设置为零dist[src] = 0。接下来,为了找到最短距离,我们寻找还未被处理的节点中最短的一个minDist(dist, visited),然后将它标记为已访问visited[u] = true;当找到并设置最短距离dist[u] + graph[u][v]后,所有路径的数组会被返回。下面是简化后的代码:
|
||||
|
||||
const dijkstra = (graph, src) => {
|
||||
for (let i = 0; i < length; i++) {
|
||||
dist[i] = INF;
|
||||
visited[i] = false;
|
||||
}
|
||||
dist[src] = 0;
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
var u = minDist(dist, visited);
|
||||
visited[u] = true;
|
||||
for (let v = 0; v < length; v++) {
|
||||
if (...dist[u] + graph[u][v] < dist[v]) {
|
||||
dist[v] = dist[u] + graph[u][v];
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
};
|
||||
|
||||
|
||||
如何在无序中找到资源依赖
|
||||
|
||||
回到开篇的问题,比如我们在用webpack或类似的工具做资源打包的时候,要知道模块间的依赖关系是很重要的。如果我们把这个命题抽象出来看,就是对于有向图来说,要知道如何在混乱中建立秩序,分析出哪个节点先被处理是很重要的。所以下面我们就来看看拓扑排序(topological sorting)是如何做到对资源依赖的管理的。这里其中一个方法是会用到深度优先DFS的遍历。
|
||||
|
||||
|
||||
|
||||
其实简单地来说,拓扑排序用的就是从一个节点开始,进行深度优先的遍历,直到和它相连的每个顶点都用递归方式穷尽为止。每一次递归中的节点被加到一个访问过的visited集合中,以此类推,最后,在递归结尾,把节点用unshift以相反的顺序插到数组头部。
|
||||
|
||||
dfs = function(v, visited, stack) {
|
||||
visited.add(v);
|
||||
for (var item in this.edges[v]) {
|
||||
if (visited.has(item) == false) {
|
||||
this.topologicalSortUtil(item, visited, stack)
|
||||
}
|
||||
}
|
||||
stack.unshift(v);
|
||||
};
|
||||
|
||||
dfsTopoSort = function() {
|
||||
var visited = {},
|
||||
stack = [];
|
||||
for (var item in this.edges) {
|
||||
if (visited.has(item) == false) {
|
||||
this.dfs(item, visited, stack);
|
||||
}
|
||||
}
|
||||
return stack;
|
||||
};
|
||||
|
||||
|
||||
通过字典树建立Web API的路由
|
||||
|
||||
说完了图,下面,我们再来深入看下树的结构和它所支持的算法。
|
||||
|
||||
深入了解树的类型和结构
|
||||
|
||||
树(tree) 可以说是非常常见的一种数据结构了。它和我们平时看到的树没有区别,在树根树干上,有不同的分支和叶子。这些分支和叶子在树结构里就是节点(node)。只是我们在数据结构中,一般可视化的画一个树的时候,会把它的头尾倒过来。大家应该都见过公司的组织架构,这就可以被理解成树状结构了,它就是一个自上而下的节点组成的。在树的顶端,就是公司的CEO,这个节点叫做根(root)。在前端,我们使用的HTML也是一个非常典型的树形结构,在下的每一个元素都有父节点和子节点。我们前面讲过的编译原理中的AST语法树也是一种树的结构。
|
||||
|
||||
在树型的结构中,有二叉树(binary tree)和二叉查找树(BST,binary search tree)。虽然它们写法类似,但代表的是不同的意思。二叉树是最多只有2个子节点的树,而二叉查找树是指一个左节点的值小于右节点的树。二叉树里面分为满二叉树(full binary tree)和完全二叉树(complete binary tree)。在二叉树的遍历中,分为前中后三种顺序的遍历。下面我们依次来看一下。
|
||||
|
||||
|
||||
|
||||
在二叉查找树中有三种遍历的方式。第一种是中序遍历(in-order traversal),这种方法是对节点展开的从小到大的遍历,它的遍历顺序是左、根、右;第二种是前序遍历(pre-order traversal),这种方法是在先访问根节点再访问子节点,它的遍历顺序是根、左、右;第三种是后序遍历(post-order traversal),这种方法是在访问子节点后访问它的父节点,它的遍历顺序是右、左、根。除了这三种以外,还有层次遍历(level order traversal),这种方式就是上面我们讲到图的时候讲到的深度优先搜索(DFS,depth first search)的原理。
|
||||
|
||||
|
||||
|
||||
二叉查找树有一个问题,就是当这个树的一个分支过深的时候,在增加、减少和查找的时候,可能会出现性能上的问题。所以在二叉查找树的基础上,有了AVL树(AVL tree,Adelson-Velskii and Landi’s tree)和红黑树(Red-Black tree),它们都属于平衡二叉树(balanced binary tree)。对于AVL树来说,它的左边和右边最多就差一级。
|
||||
|
||||
|
||||
|
||||
为了在插入后保持平衡,AVL树会通过左旋或右旋的方式来调整。比如以上的例子就是左旋,下面的例子中,我们也可以看到右旋。在理想的状态下,AVL的复杂度是\(O(log2(n))\)。但是当有频繁的插入、删除等操作的时候,效率就会下降。在极端情况下它的复杂度会退化到\(O(n)\)。
|
||||
|
||||
|
||||
|
||||
虽然AVL的查询效率很高,但是为了保持节点插入或者删除后的平衡所进行的旋转操作,可能会导致复杂度的增加。这时红黑树的出现,就可以避免类似问题。那红黑树是怎么做到保持较低复杂度的呢?下面,我们来看看。红黑树的特点是每个节点都是红色或黑色的。根节点和所有叶子节点都是黑色的。
|
||||
|
||||
如果一个节点是红色的,那么它的两个子节点都是黑色的。不能有两个相邻的红色节点,也就是说红色节点不能有红色父或子节点。从给定节点到其以后的每条路径都包含相同数量的黑色节点。关于插入,它也有2个原则,1是插入的节点需要是红色,2是插入的位置需要是叶子节点。
|
||||
|
||||
|
||||
|
||||
基于上面的原则,我们可以在上图看到,当我们想要加入h的时候,如果它是根节点,就直接用黑色即可;但它是叶子节点,所以是红色。插入后,如果父节点f是黑色,那就什么都不用做;但f是红色,所以就要把f改成黑色,除了父亲,往上的祖父节点也要跟着调整。从c出发的节点要包含相同数量的黑色节点,所以e的颜色也会相应调整为黑色。在下面例子中,我们看到除了换色,红黑树和AVL一样,有时也需要用到旋转。
|
||||
|
||||
|
||||
|
||||
红黑树的好处是它的查询、插入、删除等操作的复杂度都比较稳定,可以控制在\(O(log2(n))\)。
|
||||
|
||||
|
||||
|
||||
在树当中,有一种特殊的树的结构,就是堆(heap)。没错,前面讲到函数调用栈中堆栈这个概念的时候呢,是将堆作为内存存储空间介绍的。今天我们说的堆,是从一个数据结构的角度来看,那么堆是一个完全二叉树。那么完全二叉树和满二叉树有什么区别呢?满二叉树是除最后一层没有任何子节点以外,其它每一层的所有结点都有两个子结点二叉树。而完全二叉树指的是叶节点只能出现在最下层和次下层,并且最下面一层的结点都集中在该层最左边的若干位置的二叉树。
|
||||
|
||||
|
||||
|
||||
堆的特点在于它所有结点的值必须“大于或等于”或“小于或等于”其子结点的值。它和其它的树型结构通过对象存储节点和指针的方式不太一样的地方在于,它可以用一个数组来存储。它可以被用于优先级队列,另外一个比较常见的应用就是堆排序。和快排和归并排序类似,它的时间复杂度也是\(O(nlog2(n))\)。不过有一点需要注意的是在堆排序的场景里,因为它对比交换(compare and swap)的次数可能要比快排多,所以虽然它们的时间复杂度一样,但是实际从性能上看,堆排序的性能会相对较低。
|
||||
|
||||
|
||||
|
||||
字符串的匹配算法有哪些
|
||||
|
||||
说完了树,我们再看一下字符串。你可能问字符串和树有什么关系?其实,这里,我们就要说到另外一种特殊的树型的数据结构,字典树(trie)。但是说到trie前,我们可以先看看暴力(BF,Brute Force)、BM(Boyer-Moore)、RK(Rabin–Karp)和KMP(Knuth–Morris–Pratt) 这几种用于字符串的匹配的算法。首先我们可以对比下暴力和BM算法。在下面图中,我们可以看到将一组字符串和一个模式的对比。
|
||||
|
||||
|
||||
|
||||
从上图中,我们可以看到如果使用暴力,也叫朴素(naive) 算法的话,在每一轮的迭代中,字母是一个一个移动的,直到匹配到为止,这种滚动匹配的方式一共要经历6个迭代。所以暴力算法虽然简单粗暴,但是效率却不高。而BM算法的话,可以用到图中坏字符(bad character) 这种方式,在字符串中看到和模版不匹配的坏字符就滑动跳过。除了坏字符,还有好后缀(good suffix) 的方式也可以达到类似的效果,它的原理就是在字符串中看到和模版匹配的好后缀就滑动对齐。这样通过跳过或对齐的滑动匹配的方式就能减少迭代的步骤。
|
||||
|
||||
|
||||
|
||||
KMP和BM类似,但是匹配的顺序是倒过来的。在BM中,我们看到的是前面是坏字符,后面是好后缀。KMP看到的是前面是好前缀,后面是坏字符。在这里,只要找到了不匹配的,就知道下一个要从哪里开始再搜索了,避免了重新验证之前出现过的字符。
|
||||
|
||||
说完了BM和KMP,我们最后再来再看一下RK算法。这种方式的匹配方法是用一个字符串中的子串和模式串的哈希值做对比,如果哈希值是相等的,那就证明匹配上了。
|
||||
|
||||
|
||||
|
||||
那么对比这几种字符串匹配的方式,它们的复杂度如何呢?如果我们定义字符串的长度是m,模式串的长度是n的话,那么下面就是它们各自的复杂度。我们可以看到暴力没有预处理的花费,但是处理过程本身的复杂度很高。BM在数据量比较大的时候会比较有优势,而KMP在数据量比较小的时候比较有优势。而和单个的字符串相比,RP在处理比较多的需要被匹配的字符串输入的时候,会更有优势。
|
||||
|
||||
|
||||
|
||||
字符串的匹配算法有哪些
|
||||
|
||||
那我们经常说的路由,是用的哪一种的?它用的一种特殊的字典树(trie)。
|
||||
|
||||
|
||||
|
||||
在这个过程中,如果搜索的字符串的长度是w的话,那么它的插入、搜索和删除的时间复杂度都是\(O(w)\);而如果m是输入的词的数量,n是最长的单词的话,那么它的空间复杂度是\(O(m\*n)\)。由于这种比较高的空间复杂度需求,所以字典树的结构最适合用来搜索具有相同的前缀的不同的字符串。但是不适合在某一个字符串中搜索某一个模式串。因此,这一点特别适用于路由中的查询。
|
||||
|
||||
|
||||
|
||||
那么字典树是如何实现的呢?这里面用到的方式是一个嵌套的对象。其中每一层都有子对象作为子节点。这里面需要考虑的是搜索、插入和删除这些场景的操作。搜索可以通过设置一个临时的当前变量来根据目前检查到的节点内容来更新。那么对于插入和删除来说呢,要注意的是在操作前确认之前是否已该节点已存在。如果说n是被查询的字符串的长度的话,那么无论是搜索还是插入或删除,这里的时间复杂度都是\(O(n)\),因为每一个字符都是要被检查的。如果插入的字符串的数量是m,n是最长的字符串的话,它的空间复杂度是\(O(m \* n)\)。
|
||||
|
||||
所以从这里我们可以看出,对比其它常见的字符串查询,字典树更适合的是用来处理具有同样前缀的多个字符串。而在一个字符串中匹配一个单独的模式串,这种方式就不适合了,因为这样就会占用大量的空间内存。
|
||||
|
||||
总结
|
||||
|
||||
这一期,我们就讲到这里。那么通过图和树这两种数据结构呢,我们可以看出,它们最大的特点就是可以映射出现实世界中相对无序和未知的结构。从具象中看,我们可以发现它们可以用来解决web中的模块打包、路由等等。而抽象出来看,我们也可以看到算法所解决的问题经常是在混沌和未知中建立秩序,寻找出路。这对在杂乱的信息中梳理出秩序,或者我们在面临人生选择时,也是很有帮助的。同时,我们看到,在分析复杂度的问题时,我们思考的维度也要随之扩展。除了前几讲中提到的时间、空间和最好、最坏情况下的复杂度外,我们也要考虑预处理、不同应用场景下的复杂度。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题是,我们在讲到拓扑实现的时候,用了数组和unshift。你觉得这种方式是最高效的吗?从空间和时间复杂度或其它的角度的分析,这里有没有什么优化空间?
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
146
专栏/JavaScript进阶实战课/20算法思想:JS中分治、贪心、回溯和动态规划.md
Normal file
146
专栏/JavaScript进阶实战课/20算法思想:JS中分治、贪心、回溯和动态规划.md
Normal file
@ -0,0 +1,146 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 算法思想:JS中分治、贪心、回溯和动态规划
|
||||
你好,我是石川。
|
||||
|
||||
在算法中,我们提到如递归、分治、贪心、回溯和动态规划这些不同的算法思想时一般会分开来说。但实际上,它们之间是有着关联的。比如递归和回溯可以解决贪心顾及不到或者不能重试的问题。而动态规划又可以在利用递推公式的基础上解决递归中的一些短板。
|
||||
|
||||
能够比较好贯穿整个思想的是一个硬币找零的例子,即在几种不同面值硬币中,如何用最少的硬币数量来找零的问题。
|
||||
|
||||
贪心和递归分治
|
||||
|
||||
首先,我们来看下贪心算法如何解这个题。找零问题的核心是在几种不同面值如1、5、10分的硬币中,用最少的枚数凑出针一个需要找零的钱数。解决这个问题最简单的办法就是使用贪心(greedy)算法,它的核心逻辑是我们先选择面值较大的来找,再逐渐选小面额的。为什么这里是从大到小,而不是从小到大呢?因为通常面值越大,用到的数量就越少。
|
||||
|
||||
function minCoinChange(coins, amount) {
|
||||
var change = [];
|
||||
var total = 0;
|
||||
for (let i = coins.length; i >= 0; i--) { // 从大到小循环
|
||||
var coin = coins[i];
|
||||
while (total + coin <= amount) { // 将硬币逐个加入,面值要小于商品价格
|
||||
change.push(coin); // 将硬币加入到结果
|
||||
total += coin; // 将硬币累加到总数
|
||||
}
|
||||
}
|
||||
return change;
|
||||
}
|
||||
|
||||
|
||||
贪心是最简单的一种解决方案,可是这种方案有两个核心问题:
|
||||
|
||||
|
||||
这种方式有时是得不到答案的,比如我们要找的钱数是17元,此时有面值为3元和5元的硬币。我们用了三张5元的后,还差2元要找,可这时我们只有3元,就没法找了。但如果我们先用3元,可以用四张3元加一张5元,就能得到17元。
|
||||
这种方式得到的答案不一定是最优解。比如我们有面值为1、3、4元的硬币,需要找6元。如果先用4元,就要再加两张1元,这时硬币数一共三张。如果用3元的,只需要两张即可。
|
||||
|
||||
|
||||
所以从运用贪心算法的例子里,我们可以看到,在有些情况下通过贪心算法可以得到局部最优解,可是没法保证全局的最优解,甚至有些时候都没有解。那么有什么更好的方式可以做到找零呢?另一种方式就是用到递归(recursion) 和 分治(divide and conquer) 了。我们先回到之前说过的斐波那契数列。我们看,用递归的方式来求解斐波那契中的例子,这个过程像什么呢?没错,它像是我们上一讲说到的树(tree) 形的数据结构,而且它的遍历过程就如同是一个深度优先(DFS)的遍历顺序。
|
||||
|
||||
|
||||
|
||||
回溯和记忆函数
|
||||
|
||||
而我们所说的硬币找零的例子,和斐波那契相似,它也可以通过这样类似的一棵树来求解。这里我们也可以使用暴力递归的算法。这样,我们可以看到通过暴力枚举的朴素方式,就可以解决贪心带来的问题。但是这样也会带来新的问题,就是重叠子问题(overlapping subproblems)。假设我们有一个5元的商品,和面值为1和2的硬币。那么在找零的过程中,我们可以看到很多的重复计算。这个问题也很像我们之前在解斐波那契时遇到的去重问题。
|
||||
|
||||
|
||||
|
||||
那我们如何解决重叠子问题呢?拿找零的场景举例,我们可以将贪心和递归结合,通过减枝的方式来优化递归算法。前面我们说过贪心的核心问题之一是没办法回溯,而我们之前在讲到递归的时候,讲到过基于堆栈和栈帧的原理,先从贪心的方法开始尝试,如果成功,就继续执行。如果不成功,就回到上一步,换一种方法执行,这样就解决了类似的问题。而这里面,就用到了回溯(backtracking) 的思想。同样的,我们也可以通过记忆函数创建一个备忘录(memoization),来起到在执行中去重的作用。
|
||||
|
||||
递推和动态规划
|
||||
|
||||
那如果从上面的例子来看,这和我们之前讲的递归就没有太大不同了。那还有没有解决这个问题更高效的方法呢?下面我们再来看看动态规划(dynamic programming)。这里有两个重要的概念,第一个是无后效性,第二个是最优子结构。后无效性是说,如我们上图中所示,一个顶点下的子问题分支上的问题之间,依赖是单向的,后续的决策不会影响之前某个阶段的状态。而最优子结构指的是子问题之间是相互独立的,我们完全可以只基于子问题路径前面的状态推导出来,而不受其它路径状态的影响。这就好比我们在点外卖时,如果两个商品同时有优惠,那么要达到最大的优惠额度,我们就直接买两个打折后的商品就好了。但是假如折扣只能用于同一订单中的一个产品,那么我们可能就要选单价最高的商品打折。这时,我们的一个分支的选择会影响另外一个分支的选择,这种情况就不能算是最优子结构了。
|
||||
|
||||
我们看在找零问题中,没有以上影响。所以我们可以考虑动态优化。在动态规划中,解决问题用的是状态转移方程,其中包含了几个核心的元素,第一个就是初始化状态,它虽然叫初始状态,其实代表的是终止状态。在找零的例子里,我们希望最后找零的结果是0,所以,初始状态就是0。第二个核心元素就是状态参数,它指的是剩下需要找的零钱。我们每次在找零的过程中,就是这个状态在不断消减的过程,也就是最后它应该归零,达到初始状态。在上面的决策树中,我们看到每次选择的过程中,我们都要在一组集合中选中一种面值的硬币,这种选择,就是决策。
|
||||
|
||||
对于找零来说,我们可以写出状态转移方程$\(min(DP\[i\], DP(i-coin)+1)\)\(它的思考模式就是我们之前讲递归的一讲中提到**递推公式**时的思考模式,也就是说,我们自底而上的思考,如果我们决定用一枚硬币,那么就要加\)DP(i-coin)+1\(。如果我们不采用,那就是\)DP[i]\(。因为这是一个求最值的问题,所以我们通过\)min()$来对比两种决策中最小的值。
|
||||
|
||||
这时,你可能会有一个疑问,如果状态转移方程就是个递推公式,那为啥我们不直接用递归呢?动态规划和它有啥区别?这是因为如我们之前所讲,递归的特点是一递一归,先自上而下深度优先遍历的,这个过程是基于堆栈的原理实现的,就会产生额外的花费。而且从上到下的过程中,不能完全避免冗余的分支计算。所以为了解决这个问题,动态规划就派上用场了。它是通过两层嵌套的迭代循环,在内循环中,用到转移方程,自下而上的计算,解决了不必要的时间消耗。这样看,它可能不是那么函数式(声明式),而更命令式,但是它确实在执行效率上会更高。
|
||||
|
||||
function minCoinChange(coins, amount) {
|
||||
var dp = Array(amount + 1).fill(Infinity); // 每种硬币需要多少
|
||||
dp[0] = 0; // 找0元,需要0个硬币
|
||||
for (let coin of coins) { // 循环每种硬币
|
||||
for (let i = coin; i <= amount; i++) { // 遍历所有数量
|
||||
dp[i] = Math.min(dp[i], dp[i - coin] + 1); // 更新最少需要用到的面值
|
||||
}
|
||||
}
|
||||
return dp[amount] === Infinity ? -1 : dp[amount]; // 如果最后一个是无限的,没法找
|
||||
}
|
||||
|
||||
|
||||
所以如果我们追求的是效率的话,那么动态规划在这个例子中是最高的。
|
||||
|
||||
延伸:位运算符
|
||||
|
||||
讲完以上几种算法,我还想再延伸一点知识。在数据结构和算法中,除了动态规划外,还有哪些可以提高性能的算法思想呢?这里我们可以结合JavaScript的特性看看位运算(bitwise operation)的使用和思想。
|
||||
|
||||
在JavaScript中,整数都是32位的由0和1表示的二进制比特序列。举个例子,在位运算中,101就代表5可以用101来表示,9可以用1001来表示。
|
||||
|
||||
按位与(& AND)表示的是,对于每一个比特位,如果两个操作数相应的比特位都是1时,结果则为1,否则为0。 比如5&9的运算结果就是0001,也就是1。
|
||||
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 // 5&9
|
||||
|
||||
|
||||
那么以此类推,对于按位或(I OR)来说,针对每一个比特位,当两个操作数相应的比特位至少有一个1时,结果为1,否则为0。比如 5|9的运算结果就是1 1 0 1,也就是13。
|
||||
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1 // 5|9
|
||||
|
||||
|
||||
那么对于按位非(~ NOT)来说,指的则是反转操作数的比特位,即0变成1,1变成0。那么~5的运算结果就是-6,~9的运算结果就是-10。
|
||||
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 // -5
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 // -9
|
||||
|
||||
|
||||
接下来我们再看看按位异或(^ XOR),它的操作是对于每一个比特位,当两个操作数相应的比特位有且只有一个1时,结果为1,否则为0。那么5^9的运算结果就是1 1 0 0,也就是12。
|
||||
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 // 5^9
|
||||
|
||||
|
||||
下面我们再来看看位移。这里又分为左移(<< Left shift),有符号右移(>> Right shift)和无符号右移(>>> Zero-fill right shift)。左移会将 a 的二进制形式向左移 b (< 32) 比特位,右边用0填充,所以9<>1的结果就是4。最后无符号右移会将 a 的二进制表示向右移 b (< 32) 位,丢弃被移出的位,并使用 0 在左侧填充,所以9>>1的结果就是2147483643。
|
||||
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 // 9 << 1
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 // 9 >> 1
|
||||
0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 // 9 >>> 1
|
||||
|
||||
|
||||
我们可以用一个列表来总结下相关的操作符,这样看起来更直观。
|
||||
|
||||
|
||||
|
||||
那么学会了位运算后,我们的加减乘除都可以通过位运算来计算。比如加法计算就可以用位运算来进行,这和我们小学时学的数学是一样的,我们在学10进制的加法时,就是在遇到10的时候,进一位。同样的方式,也可以用于位运算。这里同样可以用到递归或循环。下面,你可以参考一个递归的例子。
|
||||
|
||||
var add = function(a, b) {
|
||||
if (b == 0) {
|
||||
return a;
|
||||
} else {
|
||||
return add(a ^ b, (a & b) << 1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
位运算从一定程度上可以让计算的效率更高,但是这样也会牺牲一定的可读性。所以在使用上,应该综合考虑。
|
||||
|
||||
总结
|
||||
|
||||
这节课就到这里,最后我来做个小结。今天,我们看到了算法中的贪心、递归、分治、回溯和动态规划并不是完全独立的,它们在某种意义上是相连的。其实算法思想不光是用在程序中,如果我们仔细观察不同行业很成功的人和团队,多多少少都能发现他们具有算法思想。比如在当年解放战争时期,为什么解放军能赢,就是用到了类似分治和动态规划的思想。看似以少胜多,但每次通过分治,可以在局部以多胜少。同时通过动态规划防止局部成功来带的贪心思想,而是时刻关注着全局最优解,最后取得了全面的胜利。所以通过JS的原理,学好算法,不仅仅是纸上谈兵,而是可以在工作和生活的方方面面帮助我们“运筹帷幄之中,决胜千里之外”。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题是,关于找零的问题,除了贪心和动态规划的实现,你能不能也用文中提到的递归和回溯来动手实现下找零。
|
||||
|
||||
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
131
专栏/JavaScript进阶实战课/21创建型:为什么说Redux可以替代单例状态管理.md
Normal file
131
专栏/JavaScript进阶实战课/21创建型:为什么说Redux可以替代单例状态管理.md
Normal file
@ -0,0 +1,131 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 创建型:为什么说Redux可以替代单例状态管理
|
||||
你好,我是石川。
|
||||
|
||||
时间过得真快,今天要开始专栏的第三个模块“JavaScript之术”了,前面我们已经了解了函数式和面向对象的编程模式、JavaScript 的数据结构与算法,从这节课开始,我们会展开讲解JavaScript用到的设计模式,结合一些三方的库,来帮助你理解和掌握如何通过设计模式进一步提高“生产力”,优化“生产关系”。
|
||||
|
||||
一般在介绍设计模式的时候,都会先说到创建型模式。创建型模式里又包含几种设计模式,最为大家熟知的,可能要数单例模式了,在很多语言中它经常被用来做状态管理。这节课,我们就来看看这种模式的优劣势,以及 Javascript 的 Redux 是怎么解决单例在状态管理中所面临的一些问题的。除了单例模式,我也会延伸讲解几个其他的创建型模式。下面我们还是先从单例模式本身说起吧。
|
||||
|
||||
单例模式
|
||||
|
||||
在ES6之前,单例模式在JavaScript中除了状态管理,其实更经常被用来做封装和命令空间。而在ES6以后,JS中已经加入了很多防止全局污染的手段。比如通过新增加的let和const两个关键字,将使用这两个关键字声明的变量保持在块范围内,创建全局变量已经很少见了。同时,JavaScript中的新加入的模块系统(modular system)使创建全局可访问的值更容易,而不会污染全局范围,这是因为它是从模块中导出值,并将这些值再导入其他文件中的。所以对于开发者来说,单例剩下的一个场景,就是状态管理了。
|
||||
|
||||
单例模式主要用于在应用程序中共享一个全局实例。这意味着使用同一个类创建两次对象的时候,第二次应该得到和第一次相同的对象。在JavaScript中,使用对象字面量创建一个对象本身就可以视作是单例的一个实现。当你创建一个新对象的时候,就已经是单例对象了。为什么这么说呢?因为在 JavaScript 中,对象永远不会相等,除非它们是同一个对象,因此即使我们创建两个具有完全相同属性的对象,它们也不会是相同的:
|
||||
|
||||
var obj1 = {
|
||||
myprop: 'my value'
|
||||
};
|
||||
var obj2 = {
|
||||
myprop: 'my value'
|
||||
};
|
||||
obj1 === obj2; // false
|
||||
|
||||
|
||||
通过下面的例子,我们可以看到一个简单的单例实现。
|
||||
|
||||
|
||||
|
||||
首先是创建一个全局的计数器,和一个包含加减方法的计数器。为了防止这个计数器被改动,我们可以采用freeze冻结对象的方法。最后,我们可以把计数器组件导出,之后如果有不同的模块导入同一个计数器组件并进行加减操作调用的话,得到的是基于同一个计数的加减结果。
|
||||
|
||||
var count = 0;
|
||||
|
||||
var counter = {
|
||||
increment() {
|
||||
return ++count;
|
||||
},
|
||||
decrement() {
|
||||
return --count;
|
||||
}
|
||||
};
|
||||
|
||||
Object.freeze(counter);
|
||||
export { counter };
|
||||
|
||||
|
||||
单例模式下的状态管理
|
||||
|
||||
我们在讲到函数式编程模式的时候,说过它的一个重要思想就是要考虑副作用(side effect)。为什么单例式通常被认为是一个反模式,是因为基于副作用的考虑。
|
||||
|
||||
我们曾说过面向对象和函数式设计模式之间不是非此即彼的概念,而是可以互补的。单例的常见用例是在整个应用程序中拥有某种全局状态。这可能会导致代码库中依赖同一个可变对象的多个部分对它的修改产生冲突。因为通常,代码库的某些部分会修改全局状态中的值,而其他部分会使用该数据。执行顺序也是一个考量,我们不想在数据没进来的时候就不小心先消费数据,而导致异常。在相对复杂的程序中,随着组件数量的增长以及之间的相互依赖,不同的数据流之间的用全局状态管理会变得很难。
|
||||
|
||||
函数式编程的状态管理
|
||||
|
||||
那么面对以上的问题该怎么办呢?在 React 中,我们经常通过 Redux 或 React Context 等状态管理工具来管理全局状态,而不是使用单例对象。这里面就用了很多函数式编程的思想来替代单例模式来解决这些副作用。下面我们就来看看这种方案是怎么实现的。
|
||||
|
||||
虽然Redux 或 React Context 中也有全局状态,相关的行为可能看起来类似于单例,但这些工具提供了只读状态而不是单例的可变状态。比如在使用Redux时,只有纯函数reducer 才能在组件通过调度程序发送一个动作触发后的更新状态。使用这些工具不可能完全消除全局状态的缺点,但因为组件不能直接更新状态,这样做至少可以确保全局状态按照预期方式发生变化。
|
||||
|
||||
Redux的意图可以概括为三个原则:第一,全局的状态都在一个store里保存;第二,这个store里的状态对于整个应用来说都是只读的;第三,如果需要更新改变状态的话,则需要通过reducer来完成。
|
||||
|
||||
我们可以通过一个例子来看看。下图中UI界面有一个存款显示功能,显示目前的存款为0;界面上还有两个按钮分别带有存入和取出的功能。在开始的时候,当有人点击存入10元的动作时,一个存储的事件会传到事件处理器。
|
||||
|
||||
|
||||
|
||||
这个时候,事件处理器会对相关行为的数据打包为动作对象(action object),里面包含了动作类型字段,你可以将动作对象视为描述应用程序中发生的事件的对象。在这个例子中,类型就是“存入”,相关palyload的记录为“10”。动作对象打包好后会派发到存储器。
|
||||
|
||||
|
||||
|
||||
这个时候存储器会先调用已有的状态,也就是说当前的存款数量是0,加上10元,相加的合就是10元。reducer 是一个接收当前状态和行为对象的函数,如果需要,它会决定如何更新状态,并返回新状态(state, action) => newState。 你可以将 reducer 视为事件监听器,它根据接收到的操作(事件)类型处理事件。
|
||||
|
||||
reducer遵循一些特定规则,你也值得了解:第一,reducer只基于状态和行为动作这两个参数来计算新的状态值;第二,它必须遵循不可变原则,不能更改已有的状态,而只能拷贝状态并在拷贝的版本上做修改;第三,是reducer会避免任何副作用,比如异步操作。
|
||||
|
||||
|
||||
|
||||
另外,Redux 存储有一个称为 dispatch 的方法。更新状态的唯一方法是调用 store.dispatch() 并传入一个动作对象。store 将运行 reducer 函数并将新的 state 值保存在里面,我们可以调用 getState() 来检索更新的状态值。
|
||||
|
||||
Redux 还有一个 Selector(选择器)知道如何从存储状态值中提取特定信息的函数。随着应用程序变得越来越大,这有助于避免重复逻辑,因为应用程序的不同部分需要读取相同的数据。最后,当存储器里的工作完成后,UI获得了更新后的10元状态值。
|
||||
|
||||
|
||||
|
||||
Redux 使用“单向数据流”应用程序结构。也就是说,第一,状态描述应用程序在某个时间点的状态,UI 会根据该状态呈现。第二,当应用程序发生某些事件变化时:比如 UI 的一个调度动作、store 运行 reducer、状态根据发生的事件情况更新、store通知UI状态已更改时,UI根据新状态重新渲染。这里我们可以看到,Redux的设计理念虽然不能说百分之百避免状态管理中的副作用,但是从很大程度上说,它要比单例模式更加有效的多。
|
||||
|
||||
工厂模式
|
||||
|
||||
除了单例,今天我还会介绍两个常见的创建型模式:工厂模式和原型模式。我们先来看看工厂模式,工厂模式,是使用工厂函数来创建对象的。它可以使我们调用工厂,而不是直接使用 new 运算符或 Object.create() 从类中创建新对象。在 JavaScript 中,工厂模式只不过是一个不使用 new 关键字就返回对象的函数。
|
||||
|
||||
工厂允许我们将对象的创建与其实现分开。本质上,工厂包装了新实例的创建,从而为我们提供了更多的灵活性和控制权。在工厂内部,我们可以选择使用 new 运算符创建类的新实例,或者利用闭包动态构建有状态的对象字面量,甚至可以根据特定条件返回不同的对象类型。工厂的消费者完全不知道如何创建实例。事实是,通过使用 new,我们将代码绑定到一种创建对象的特定方式,而使用工厂,我们可以拥有更大的灵活性。
|
||||
|
||||
它的优势是,如果我们要创建相对复杂和可配置的对象,工厂模式会很有用。对象中的键值取决于特定环境或配置的情况。使用工厂模式,我们可以轻松创建包含自定义键和值的新对象。当我们必须创建多个共享相同属性的较小对象时,工厂函数可以根据当前环境或用户特定的配置轻松返回自定义对象。相对的,它的劣势是它可能占用更多的内存。在相对没有那么复杂的情况下,每次创建新实例而不是新对象可能更节省空间。
|
||||
|
||||
工厂模式在JavaScript中有很多体现。比如 Object() 本身就像工厂。因为它根据输入创建不同的对象。如果你将数字传递给它,它可以在后台使用 Number() 构造函数创建一个对象。类似的还有字符串和布尔值。任何其他值,包括空值,都将创建一个普通对象。下面我们可以看一个例子。Object() 也是一个工厂这一事实并没有什么实际用途,只是通过这个例子,你能看到工厂模式在JavaScript中是无处不在。
|
||||
|
||||
var o = new Object(),
|
||||
n = new Object(1),
|
||||
s = Object('1'),
|
||||
b = Object(true);
|
||||
// test
|
||||
o.constructor === Object; // true
|
||||
n.constructor === Number; // true
|
||||
s.constructor === String; // true
|
||||
b.constructor === Boolean; // true
|
||||
|
||||
|
||||
还有一个非常常见的例子就是我们说箭头函数(arrow function)就是工厂模式。之所以这么说是因为如果箭头函数体由单个表达式组成的话,在函数创建时会间接返回一个对象,所以是一个小型工厂函数。
|
||||
|
||||
var createUser = (userName) => ({ userName: userName });
|
||||
createUser("bar"); // {userName: 'bar'}
|
||||
createUser("foo"); // {userName: 'foo'}
|
||||
|
||||
|
||||
原型模式
|
||||
|
||||
原型模式对于JavaScript来说不算陌生了,原型就是在许多相同类型的对象之间共享属性。
|
||||
|
||||
如果是从非JavaScript角度看,很多其他语言对原型模式的介绍中会使用到类(class)。而实际在JavaScript的情况下,原型继承可以避免用到类。这样做是利用它自身的优势,而不是试图模仿其他语言的特性。原型模式不仅让继承实现起来更简单,而且还可以提高性能,在对象中定义函数时,它们都是通过引用创建的(因此所有子对象都指向同一个函数),而不是创建单个副本。因为我们在前面的面向对象中已经用了很大篇幅来介绍原型模式,所以在这里我们就不做赘述了。
|
||||
|
||||
总结
|
||||
|
||||
最后,我们做个小结。今天我带你了解了几种不同创建型模式。希望你对这些模式有了不同于教科书说法的视角,比如我们看到在解决单例的问题上,针对前端的应用,如何因地制宜地解决相关的状态管理问题。JavaScript对状态管理并没有只是通过传统的单例和相关解决方案,而是另辟蹊径,通过函数式编程的思想,巧妙地利用了纯函数、不可变性等特点更好的解决了问题。所以我们说哪一种模式都不是死的,而是可以在具体的情况中灵活结合地应用。
|
||||
|
||||
思考题
|
||||
|
||||
给你留个思考题,在说到单例模式时,虽然我们看到了Redux是如何解决状态管理问题的。但是JavaScript并不是只用在前端的语言,比如在Node,也有很多后端的用例使用这些创建型模式,你能分享下你的应用场景、遇到的相关问题,和一些解决方案吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
228
专栏/JavaScript进阶实战课/22结构型:Vue.js如何通过代理实现响应式编程.md
Normal file
228
专栏/JavaScript进阶实战课/22结构型:Vue.js如何通过代理实现响应式编程.md
Normal file
@ -0,0 +1,228 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 结构型:Vue.js如何通过代理实现响应式编程
|
||||
你好,我是石川。
|
||||
|
||||
上一讲我们介绍了几种不同的创建型模式,今天我们来说说设计模式中的结构型模式。在结构型模式中,最经典、最常用的就非代理模式莫属了。在JavaScript的开发中,代理模式也是出现频率比较高的一种设计模式。
|
||||
|
||||
前端的朋友们应该都对Vue.js不会感到陌生。Vue.js的一个很大的特点就是用到了如今流行的响应式编程(Reactive Programming)。那它是怎么做到这一点的呢?这里面离不开代理模式,这一讲我们主要解答的就是这个问题。但是在解开谜底之前,我们先来看看代理模式比较传统直观的一些应用场景和实现方式吧。
|
||||
|
||||
代理的应用场景
|
||||
|
||||
在代理设计模式中,一个代理对象充当另一个主体对象的接口。很多人会把代理模式和门面模式做对比。后面几讲,我们会介绍到门面模式。这里我们需要了解的是代理模式与门面模式不同,门面模式最主要的功能是简化了接口的设计,把复杂的逻辑实现隐藏在背后,把不同的方法调用结合成更便捷的方法提供出来。而代理对象在调用者和主体对象之间,主要起到的作用是保护和控制调用者对主体对象的访问。代理会拦截所有或部分要在主体对象上执行的操作,有时会增强或补充它的行为。
|
||||
|
||||
|
||||
|
||||
如上图所示,一般代理和主体具有相同的接口,这对调用者来说是透明的。代理将每个操作转发给主体,通过额外的预处理或后处理增强其行为。这种模式可能看起来像“二道贩子”,但存在即合理,代理特别是在性能优化方面还是起到了很大作用的。下面我们就一一来看看。
|
||||
|
||||
延迟初始化和缓存
|
||||
|
||||
因为代理充当了主体对象的保护作用,减少了客户端对代理背后真实主体无效的消耗。这就好像是公司里的销售,他们不是收到所有的客户需求都直接透传给到项目组来开发,而是接到客户的需求后,先给对方一个产品服务手册,当对方真的选择某项服务以及确定购买后,这时候售前才会转到项目团队来实施,再把结果交付给客户。
|
||||
|
||||
|
||||
|
||||
这个例子是我们可以称之为延迟初始化的应用。在这种情况下,代理可以作为接口提供帮助。代理接受初始化请求,在明确调用者确实会使用主体之前不会传递给它。客户端发出初始化请求并且代理先做响应,但实际上并没有将消息传递给主体对象,直到客户端明显需要主体完成一些工作,只有这样的情况下,代理才会将两条消息一起传递。
|
||||
|
||||
在这个基础之上,我们就可以看到第二个例子。在这个例子中,代理除了起到延迟初始化的作用外,还可以增加一层缓存。当客户端第一次访问的时候,代理会合并请求给主体,并且把结果先缓存,再分开返回给客户端。在客户端第二次发起请求2的时候,代理可以直接从缓存中读取信息,不需要访问主体,就直接返回给客户端。
|
||||
|
||||
|
||||
|
||||
代理的其它应用场景
|
||||
|
||||
作为一门Web语言,JavaScript经常要和网络请求打交道,基于上述的方式,就能对性能优化起到很大的作用。除了延迟初始化和缓存外,代理还在很多其它方面有着很重要的用途。比如数据验证,代理可以在将输入转发给主体之前对输入的内容进行验证,确保无误后,再传给后端。除此之外,代理模式也可以用于安全验证,代理可以用来验证客户端是否被授权执行操作,只有在检查结果为肯定的情况下,才将请求发送给后端。
|
||||
|
||||
代理还有一个应用场景是日志记录,代理可以通过拦截方法调用和相关参数,重新编码。另外,它还可以获取远程对象并放到本地。说完了代理的应用场景,接下来我们可以看一下它在JavaScript中的实现方式。
|
||||
|
||||
代理的实现方式
|
||||
|
||||
代理模式在JavaScript中有很多种实现方式。其中包含了:1. 对象组合或对象字面量加工厂模式;2. 对象增强;3. 使用从ES6开始自带的内置的Proxy。这几种方式分别有它们的优劣势。
|
||||
|
||||
组合模式
|
||||
|
||||
我们先看看组合模式,在上一节我们讲过了,基于函数式编程的思想,我们在编程中,应该尽量保证主体的不变性。基于这个原则,组合可以被认为是创建代理的一种简单而安全的方法,因为它使主体保持不变,从而不会改变其原始行为。它唯一的缺点是我们必须手动 delegate 所有方法,即使我们只想代理其中的一个方法。此外,我们可能必须 delegate 对主题属性的访问。还有一点就是如果想要做到延迟初始化的话,基本只可以用到组合。下面我用伪代码做个展示:
|
||||
|
||||
class Calculator {
|
||||
constructor () {
|
||||
/*...*/
|
||||
}
|
||||
plus () { /*...*/ }
|
||||
minus () { /*...*/ }
|
||||
}
|
||||
|
||||
class ProxyCalculator {
|
||||
constructor (calculator) {
|
||||
this.calculator = calculator
|
||||
}
|
||||
// 代理的方法
|
||||
plus () { return this.calculator.divide() }
|
||||
minus () { return this.calculator.multiply() }
|
||||
}
|
||||
|
||||
var calculator = new Calculator();
|
||||
var proxyCalculator = new ProxyCalculator(calculator);
|
||||
|
||||
|
||||
除了上述的方式外,我们也可以基于组合的思路用工厂函数来做代理创建。
|
||||
|
||||
function factoryProxyCalculator (calculator) {
|
||||
return {
|
||||
// 代理的方法
|
||||
plus () { return calculator.divide() },
|
||||
minus () { return calculator.multiply() }
|
||||
}
|
||||
}
|
||||
|
||||
var calculator = new Calculator();
|
||||
var proxyCalculator = new factoryProxyCalculator(calculator);
|
||||
|
||||
|
||||
对象增强
|
||||
|
||||
再来说第二种模式对象增强(Object Augmentation),对象增强还有一个名字叫猴子补丁(Monkey Patching)。对于对象增强来说,它的优点就是不需要 delegate 所有方法。但是它最大的问题是改变了主体对象。用这种方式确实是简化了代理创建的工作,但弊端是会造成函数式编程思想中的“副作用”,因为在这里,主体不再具有不可变性。
|
||||
|
||||
function patchingCalculator (calculator) {
|
||||
var plusOrig = calculator.plus
|
||||
calculator.plus = () => {
|
||||
// 额外的逻辑
|
||||
// 委托给主体
|
||||
return plusOrig.apply(calculator)
|
||||
}
|
||||
return calculator
|
||||
}
|
||||
var calculator = new Calculator();
|
||||
var safeCalculator = patchingCalculator(calculator);
|
||||
|
||||
|
||||
内置Proxy
|
||||
|
||||
最后,我们再来看看使用ES6内置的Proxy。从ES6之后,JavaScript便支持了Proxy。它结合了对象组合和对象增强各自的优点,我们既不需要手动的去 delegate 所有的方法,也不会改变主体对象,保持了主体对象的不变性。但是它也有一个缺点,就是它几乎没有polyfill。也就是说,如果使用内置的代理,就要考虑在兼容性上做出一定的牺牲。真的是鱼和熊掌不能兼得。
|
||||
|
||||
var ProxyCalculatorHandler = {
|
||||
get: (target, property) => {
|
||||
if (property === 'plus') {
|
||||
// 代理的方法
|
||||
return function () {
|
||||
// 额外的逻辑
|
||||
// 委托给主体
|
||||
return target.divide();
|
||||
}
|
||||
}
|
||||
// 委托的方法和属性
|
||||
return target[property]
|
||||
}
|
||||
}
|
||||
var calculator = new Calculator();
|
||||
var proxyCalculator = new Proxy(calculator, ProxyCalculatorHandler);
|
||||
|
||||
|
||||
VUE如何用代理实现响应式编程
|
||||
|
||||
我们在上一讲讲到单例模式时,从解决状态管理时用到的Redux和reducer,可以看出一些三方库对传统面向对象的模式下,加入函数式编程来解决问题的思路。今天我们再来剖析下另外一个库,也就是Vue.js状态管理的思想。回到开篇的问题,Vue.js 是如何用代理实现响应式编程的呢?这里Vue.js通过代理创建了一种Change Obsverver的设计模式。
|
||||
|
||||
Vue.js 最显着的特点之一是无侵入的反应系统(unobtrusive reactivity system)。组件状态是响应式 JavaScript 对象,当被修改时,UI会更新。就像我们使用Excel时,如果我们在A2这个格子里设置了一个 A0 和 A1 相加的公式 “= A0 + A1”的话,当我们改动 A0 或 A1 的值的时候,A2也会随之变化。这也是我们在前面说过很多次的在函数式编程思想中的副作用(side effect)。
|
||||
|
||||
|
||||
|
||||
在JavaScript中,如果我们用命令式编程的方式, 可以看到这种副作用是不存在的。
|
||||
|
||||
var A0 = 1;
|
||||
var A1 = 2;
|
||||
var A2 = A0 + A1;
|
||||
console.log(A2) // 返回是 3
|
||||
A0 = 2;
|
||||
console.log(A2) // 返回仍然是 3
|
||||
|
||||
|
||||
但响应式编程(Reactive Programming)是一种基于声明式编程的范式。如果要做到响应式编程,我们就会需要下面示例中这样一个 update 的更新功能。这个功能会使得每次当 A0 或 A1 发生变化时,更新 A2 的值。这样做,其实就产生了副作用,update 就是这个副作用。A0 和 A1 被称为这个副作用的依赖。这个副作用是依赖状态变化的订阅者。whenDepsChange 在这里是个伪代码的订阅功能。
|
||||
|
||||
var A2;
|
||||
function update() {
|
||||
A2 = A0 + A1;
|
||||
}
|
||||
whenDepsChange(update);
|
||||
|
||||
|
||||
在 JavaScript 中没有 whenDepsChange 这样的机制可以跟踪局部变量的读取和写入 。Vue.js 能做的,是拦截对象属性的读写。JavaScript 中有两种拦截属性访问的方法:getter/setter 和 Proxies。由于浏览器支持限制,Vue 2 仅使用 getter/setter。在 Vue 3 中,Proxies 用于响应式对象,getter/setter 用于通过属性获取元素的 refs。下面是一些说明响应式对象如何工作的伪代码:
|
||||
|
||||
function reactive(obj) {
|
||||
return new Proxy(obj, {
|
||||
get(target, key) {
|
||||
track(target, key)
|
||||
return target[key]
|
||||
},
|
||||
set(target, key, value) {
|
||||
target[key] = value
|
||||
trigger(target, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
你可能会想,上面对set和get的拦截和自定义,怎么就能做到对变化的观察呢?这就要说到 handler 里包含一系列具有预定义名称的可选方法了,称为陷阱方法(trap methods),例如:apply、get、set 和 has,在代理实例上执行相应操作时会自动调用这些方法。所以我们在拦截和自定义后,它们会在对象发生相关变化时被自动调用。所以假设我们可以订阅A0和A1的值的话,那么在这两个值有改动的情况下,就可以自动计算A2的更新。
|
||||
|
||||
import { reactive, computed } from 'vue'
|
||||
var A0 = reactive(0);
|
||||
var A1 = reactive(1);
|
||||
var A2 = computed(() => A0.value + A1.value);
|
||||
A0.value = 2;
|
||||
|
||||
|
||||
延伸:Proxy还可以用于哪些场景
|
||||
|
||||
JavaScript内置的Proxy除了作为代理以外,还有很多作用。基于它的拦截和定制化的特点,Proxy也广泛用于对象虚拟化(object virtualization)、运算符重载(operator overloading)和最近很火的元编程(meta programming)。这里我们不用伪代码,换上一些简单的真代码,看看陷阱方法(trap methods)的强大之处。
|
||||
|
||||
对象虚拟化
|
||||
|
||||
我们先来看一下对象虚拟化,下面的例子中的 oddNumArr 单数数组就是一个虚拟的对象。我们可以查看一个单双数是不是在单数数组里,我们也可以获取一个单数,但是实际上这个数组里并没有储存任何数据。
|
||||
|
||||
const oddNumArr = new Proxy([], {
|
||||
get: (target, index) => index % 2 === 1 ? index : Number(index)+1,
|
||||
has: (target, number) => number % 2 === 1
|
||||
})
|
||||
|
||||
console.log(4 in oddNumArr) // false
|
||||
console.log(7 in oddNumArr) // true
|
||||
console.log(oddNumArr[15]) // 15
|
||||
console.log(oddNumArr[16]) // 17
|
||||
|
||||
|
||||
运算符重载
|
||||
|
||||
运算符重载就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。比如在下面的例子中,我们就是通过重载“.”这个符号,所以在执行obj.count时,我们看到它同时返回了拦截get和set自定义的方法,以及返回了计数的结果。
|
||||
|
||||
var obj = new Proxy({}, {
|
||||
get: function (target, key, receiver) {
|
||||
console.log(`获取 ${key}!`);
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
set: function (target, key, value, receiver) {
|
||||
console.log(`设置 ${key}!`);
|
||||
return Reflect.set(target, key, value, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
obj.count = 1; // 返回:设置 count!
|
||||
obj.count;
|
||||
// 返回:获取 count!
|
||||
// 返回:设置 count!
|
||||
// 返回:1
|
||||
|
||||
|
||||
除了对象虚拟化和运算符重载 ,Proxy的强大之处还在于元编程的实现。但是元编程这个话题过大,我们后面会专门花一节课来讲。
|
||||
|
||||
总结
|
||||
|
||||
通过今天的内容,我们再一次看到,面向对象、函数式和响应式的交集。经典的关于设计模式的书对代理的解释更多是单纯的面向对象,但是通过开篇的问题,我们可以看到代理其实也是解决响应式编程的状态追踪问题的一把利器。所以,从Vue.js用到的基于代理的变化观察者(change observer)模式,我们也可以看出任何设计模式都不是绝对的,而是可以互相结合形成新的模式来解决问题。
|
||||
|
||||
思考题
|
||||
|
||||
我们说响应式设计用到了很多函数式编程的思想,但是又不完全是函数式编程,你能说出它们的一些差别吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
320
专栏/JavaScript进阶实战课/23结构型:通过jQuery看结构型模式.md
Normal file
320
专栏/JavaScript进阶实战课/23结构型:通过jQuery看结构型模式.md
Normal file
@ -0,0 +1,320 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 结构型:通过jQuery看结构型模式
|
||||
你好,我是石川。
|
||||
|
||||
今天,我带你顺着上一节的内容,再来看看在GoF(四人组)的《设计模式:可复用面向对象软件的基础》这本书中介绍的另外几种经典的结构型设计模式。我们可以通过jQuery来看看结构型的设计,说到这里,你可能会说jQuery可以算是被吐槽比较多的一个框架了,它有什么参考价值呢?但是我认为用户是用脚投票的,虽然很多人在骂着jQuery,但是同时也在用着。这也证明了它从开发上提供给人们的便捷,作为优点要大于它的缺点。其实,它的很多让人们“恨不释手”的设计背后都能看到结构型的设计模式。今天,我们就一起来看看吧。
|
||||
|
||||
几种经典的结构型模式
|
||||
|
||||
我们先来看看几种经典的结构型模式。分别是享元、门面和组合模式。
|
||||
|
||||
享元模式(flyweight)
|
||||
|
||||
享元模式(flyweight)的核心思想是通过减少对象的创建数量来节约内存。
|
||||
|
||||
享元模式最早是保罗·考尔德和马克·林顿在 1990 年提出的。喜欢看拳击的朋友可能知道享元的英文单词flyweight,其实是拳击里面的一个重量等级,叫做“特轻量”,也就是重量低于112磅的拳手。我们来看一个UFC比赛画面,一个重量级(heavy weight)和特轻量级 (flyweight)的选手放在一起对比,感受就更直观了。所以顾名思义,该模式旨在帮助我们实现轻量级的内存占用。
|
||||
|
||||
|
||||
|
||||
那么它是怎么减少对象的创建数量来节约内存的呢?这里的实现主要通过优化重复、缓慢且低效地共享数据的代码,与相关对象(例如应用程序配置、状态等)共享尽可能多的数据来做到最大限度地减少应用程序中内存的使用。在享元模式下,有3个核心概念:享元工厂、接口和具体享元。它们的关系如下图所示。
|
||||
|
||||
|
||||
|
||||
在享元模式中有两种数据 ,内在数据(intrinic)和外在数据(extrinsic)。 内在数据是在对象内部存储和调用的, 外部信息则是存储在外部的可被删除的数据。具有相同内在数据的对象可以由工厂方法创建单个共享对象。
|
||||
|
||||
享元接口(flyweight interface)可以接收和作用于外部状态。具体享元(concrete flyweight)是接口的实际实现,它负责存储可分享的内在状态,并且可以控制外部状态。享元工厂(flyweight factory)负责管理和创建享元对象。在客户端发起对享元对像的请求时,如果对象已经存在,就返回对象;如果尚未存在,则创建一个相关对象。
|
||||
|
||||
我们用一个租车的例子来解释,在租车行里,同一款车(car)可能有好几辆,这几款车的车型(model)、制造商(maker)、识别码(vin) 都是一样的,那我们可以说这些就是内在数据,可以通过单个共享对象 cars 存储。但是每个车目前出租的状态(availability)、租金 (sales) 是不同的,这些我们就可以作为外在数据存储。当通过 addCar 加进来新车时,createCar 可以判断是创建新的车型,还是基于vin返回已有的车型就可以了。所以下面我们虽然创建了5条车辆记录,但只有3个车型的实例。
|
||||
|
||||
// 储存车型的独立对象
|
||||
class Car {
|
||||
constructor(model, maker, vin) {
|
||||
this.model = model;
|
||||
this.maker = maker;
|
||||
this.vin = vin;
|
||||
}
|
||||
}
|
||||
|
||||
// 储存具体车型对象的容器
|
||||
var cars = new Map();
|
||||
|
||||
// 如果车型已知,就返回vin;未知就创建
|
||||
var createCar = (model, maker, isbn) => {
|
||||
var existingCar = cars.has(vin);
|
||||
|
||||
if (existingCar) {
|
||||
return cars.get(vin);
|
||||
}
|
||||
|
||||
var car = new Car(model, maker, vin);
|
||||
cars.set(vin, car);
|
||||
|
||||
return car;
|
||||
};
|
||||
|
||||
// 存储租赁车的容器
|
||||
var carList = [];
|
||||
|
||||
// 登记租赁车到列表
|
||||
var addCar = (model, maker, vin, availability, sales) => {
|
||||
var car = {
|
||||
...createCar(model, maker, vin),
|
||||
sales,
|
||||
availability,
|
||||
vin
|
||||
};
|
||||
|
||||
carList.push(car);
|
||||
return car;
|
||||
};
|
||||
|
||||
addCar("911", "Porsche", "FR345", true, 2300);
|
||||
addCar("911", "Porsche", "FR345", false, 2400);
|
||||
addCar("Togun", "VW", "AZ567", false, 800);
|
||||
addCar("C-Class", "Mercedes-Benz", "AS356", false, 1200);
|
||||
addCar("C-Class", "Mercedes-Benz", "AS356", true, 1100);
|
||||
|
||||
|
||||
随着硬件的发展,现在的内存RAM的大小基本都是GB级别的了,所以享元模式现如今已经不是那么重要了。但是如果创建的对象数量特别巨大,即使微小的差别也可能在规模化的过程中变得明显,因此这种设计模式我们依然需要关注。
|
||||
|
||||
上面我们举的例子是从数据角度的享元。下面,我们也可以从从另外一个事件处理的角度看看享元模式的应用。因为DOM(文档对象模型)是层层嵌套的,所以一个单一的,比如点击事件,可能会被多个DOM层级中的事件处理程序处理。DOM支持两种对象事件监听的方法,一种是自顶向下的事件捕获,一种是自底向上的事件冒泡。在事件捕获中,事件首先被最外层的元素捕获并传播到最内层的元素。 在事件冒泡中,事件被捕获并提供给最内部的元素,然后传播到外部元素。
|
||||
|
||||
|
||||
|
||||
在利用冒泡的场景里,基于它是自底向上的事件冒泡在最底层的元素,执行事件处理程序的,假设我们在文档中有许多相似的元素,当用户对它们执行操作的时候,比如点击或鼠标经过时,这些元素都会做出相似的行为反应。通常我们在构建菜单或其他基于列表的小部件时,所做的是将点击事件绑定到容器中的每个链接元素上,比如 $(‘ul li a’).on(…)。但如果用的是享元模式呢,我们可以将一个享元加到元素外部的容器上,自上而下地侦听来自下方的事件,然后根据需要使用相应逻辑来处理这些事件。而不是像冒泡那样,将点击绑定到多个元素。
|
||||
|
||||
下面的例子是用享元构建一个非常基本的accordion。在这里,jQuery用于将初始点击绑定到container div上,把许多独立的行为转化为共享的行为。
|
||||
|
||||
var stateManager = {
|
||||
flyweight() {
|
||||
var self = this;
|
||||
$('#container')
|
||||
.unbind()
|
||||
.on('click', 'div.toggle', ({
|
||||
target
|
||||
}) => {
|
||||
self.handleClick(target);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Facebook的詹姆斯·帕德奥尔西(James Padolsey)提出过另外一个jQuery中用到享元的概念。他说到在用jQuery的一些工具方法时,最好使用内部的jQuery.methodName底层方法,例如jQuery.text;而不是用对外暴露的jQuery.fn.methodName外部方法,例如jQuery.fn.text。jQuery.methodName是jQuery库本身在内部用来支持 jQuery.fn.methodName的底层方法。使用它,也就是等于在函数方法调用时,减少一层抽象或避免创建新的jQuery对象。因此詹姆斯提出了一个jQuery.single的想法,每次调用jQuery.single ,意味着多个对象的数据被整合到一个中心化共享的数据结构中,所以它也算是一种享元。
|
||||
|
||||
jQuery.single = (o => {
|
||||
var collection = jQuery([1]);
|
||||
return element => {
|
||||
// Give collection the element:
|
||||
collection[0] = element;
|
||||
// Return the collection:
|
||||
return collection;
|
||||
};
|
||||
})();
|
||||
|
||||
$('div').on('click', function() {
|
||||
var html = jQuery
|
||||
.single(this)
|
||||
.next()
|
||||
.html();
|
||||
console.log(html);
|
||||
});
|
||||
|
||||
|
||||
门面模式(facade)
|
||||
|
||||
门面模式(facade)是一种经常可以在jQuery等JavaScript库中看到的结构,它的特点是把很多的解决复杂的兼容性问题的实现隐藏在背后,只通过“门面”将对外的接口抽象提供给使用者。
|
||||
|
||||
|
||||
|
||||
打个比方,我们平时用的搜索引擎可以说是“门面”,它的界面和操作简单的不能再简单。但是背后的实现逻辑是非常复杂的,这里牵扯到了调度、网络信息的爬取、解析、索引等等,最后呈现出来的才是搜索。
|
||||
|
||||
|
||||
|
||||
同样的,比如我们常用的jQuery的 $() 查询器做的就是把很多复杂的用来接收和解析多种类型的查询功能在后端通过Sizzle引擎处理,呈现给开发者的是一套更加简便的选择器。
|
||||
|
||||
下面我们可以看一个 $(document).ready(…) 的例子,在背后,它是基于一个bindReady的函数来实现的。
|
||||
|
||||
function bindReady() {
|
||||
// ...
|
||||
if (document.addEventListener) {
|
||||
// Use the handy event callback
|
||||
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false);
|
||||
// A fallback to window.onload, that will always work
|
||||
window.addEventListener('load', jQuery.ready, false);
|
||||
// If IE event model is used
|
||||
} else if (document.attachEvent) {
|
||||
document.attachEvent('onreadystatechange', DOMContentLoaded);
|
||||
// A fallback to window.onload, that will always work
|
||||
window.attachEvent('onload', jQuery.ready);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
门面模式对于jQuery的使用者来说提供了很多方便,但这也不是没有代价的。它虽然降低了开发成本,但在一定程度上牺牲了性能。对于一些简单的页面开发,很多开发者还是会选择使用它,原因呢就是因为这些应用中页面开发的要求远不到工业级,但是通过jQuery能节省的开发成本确是指数级的,这也从一个侧面体现了为什么jQuery还这么流行的原因。所以在开发的时候,我们除了要关注设计模式能带来什么好处以外,更要注意使用的场景,在开发效率和性能之间做出平衡。
|
||||
|
||||
组合模式(composite)
|
||||
|
||||
组合模式(composite)指的是可以通过同一种方式处理单个或一组对象。
|
||||
|
||||
|
||||
|
||||
在jQuery中,可以用统一的方式处理单个元素以及一个元素的合集,因为它们返回的都是一个 jQuery对象。下面的选择器的代码示例演示了这一点。在这里,可以为单个元素,比如具有唯一ID的元素,或具有相同标签名称元素类型或类属性的一组元素的两个选择添加同一个展示类类的属性。
|
||||
|
||||
// 单个元素
|
||||
$( "#specialNote" ).addClass( "show" );
|
||||
$( "#mainContainer" ).addClass( "show" );
|
||||
// 一组元素
|
||||
$( "div" ).addClass( "show" );
|
||||
$( ".item" ).addClass( "show" );
|
||||
|
||||
|
||||
延伸:什么是包装器模式
|
||||
|
||||
在设计模式中,我们经常提到重构(refactoring),与之相对的就是包装(wrapper)。在设计模式中,装饰器(decorator)和适配器(adaptor)通常是起到包装的作用。装饰器和适配器的特点是它们都是在不改变原始对象的情况下做相关的装饰和适配。比如我们在用一个第三方的库或者接口的时候,是没办法修改人家的代码的,不然就很可能引起副作用。这个时候包装就起到了曲线救国的作用。我们说装饰器和适配器都是包装器,那它们有什么区别呢?下面我们就来看看这两种模式分别是如何做到包装的吧。
|
||||
|
||||
装饰器(decorator)
|
||||
|
||||
举个例子,我们看到现在大街上的帅哥美女比前几年多了。这里面可能有几个原因,一个是化妆,一个是整容。化妆在这里面就是一个典型的装饰器模式。因为我们的面容是很难改变的,如果整容,就需要伤筋动骨,可能还会有毁容的风险,所以化妆就可以避免这种风险。化的不好可以随时卸妆重画,我们也看到网上有很多很牛的化妆师可以画出各种不同的明星脸,简直和换脸术无二。这样每天都可以换一张脸,想想也是很酷的。化妆就是对对象的包装,当我们不想直接修改一个组件时,装饰器在这时就派上了用场。
|
||||
|
||||
|
||||
|
||||
如果我们从一个非自研的、不想,或不能直接操作的组件中提取类,那么就可以用到装饰器。而且,装饰器可以让我们的程序中减少大量的子类。装饰器模式可以提供方便的一个特点呢,就是对预期行为的定制和配置。下面我们可以看看它的一个比较基础的实现。在这个例子中,我们给一个车装饰成了限量升级版。
|
||||
|
||||
class Car {
|
||||
constructor(model, maker, price) {
|
||||
this.model = model;
|
||||
this.maker = maker;
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
getDetails() {
|
||||
return `${this.model} by ${this.maker}`;
|
||||
}
|
||||
}
|
||||
|
||||
// decorator 1
|
||||
function specialEdition(car) {
|
||||
car.isSpecial = false;
|
||||
car.specialEdition = function() {
|
||||
return `special edition ${car.getDetails()}`;
|
||||
};
|
||||
|
||||
return car;
|
||||
}
|
||||
|
||||
// decorator 2
|
||||
function upgrade(car) {
|
||||
car.isUpgraded = true;
|
||||
car.price += 5000;
|
||||
return car;
|
||||
}
|
||||
|
||||
// usage
|
||||
var car1 = specialEdition(new Car('Camry', 'Toyota', 10000));
|
||||
|
||||
console.log(car1.isSpecial); // false
|
||||
console.log(car1.specialEdition()); // 'special edition Camry by Toyota'
|
||||
|
||||
var car2 = upgrade(new Car('Crown', 'Toyota', 15000));
|
||||
|
||||
console.log(car2.isUpgraded); // true
|
||||
console.log(car2.price); // 20000
|
||||
|
||||
|
||||
在 jQuery当中,装饰者可以用extend() 来实现。比如在下面例子中,假设我们有一个车载OS系统,具有默认的选项和一些功能选项,我们可以通过extend的方式,把它们加进去。并且在这个过程中,不改变defaults和options的对象本身。
|
||||
|
||||
// define the objects we're going to use
|
||||
vehicleOS = {
|
||||
defaults: {},
|
||||
options: {},
|
||||
settings: {},
|
||||
};
|
||||
|
||||
// merge defaults and options, without modifying defaults explicitly
|
||||
vehicleOS.settings = $.extend(
|
||||
{},
|
||||
decoratorApp.defaults,
|
||||
decoratorApp.options
|
||||
);
|
||||
|
||||
|
||||
适配器(adaptor)
|
||||
|
||||
适配器也不难理解,这个在我们生活中太常见了。比如我们购买了一个英标的电子产品,如果在国内使用,是找不到合适的插座的。因为标准不同,孔型也不一样,就无法插入。但是如果我们使用一个转换接头,这个问题就迎刃而解了。类似的例子还有很多,比如Type-C和Type-A接头之间的转换也是同样的道理。下面我们就来看看适配器的原理和实现。
|
||||
|
||||
|
||||
|
||||
适配器的例子在jQuery中也是无处不见,比如CSS中关于透明度的get和set,只需要通过以下方式就可以使用了,看起来是不是很方便呢:
|
||||
|
||||
// Cross browser opacity:
|
||||
// opacity: 0.9; Chrome 4+, FF2+, Saf3.1+, Opera 9+, IE9, iOS 3.2+, Android 2.1+
|
||||
// filter: alpha(opacity=90); IE6-IE8
|
||||
// Setting opacity
|
||||
$( ".container" ).css( { opacity: .5 } );
|
||||
// Getting opacity
|
||||
var currentOpacity = $( ".container" ).css('opacity');
|
||||
|
||||
|
||||
但其实在背后,jQuery做了很多的工作。
|
||||
|
||||
get: function( elem, computed ) {
|
||||
// IE uses filters for opacity
|
||||
return ropacity.test( (
|
||||
computed && elem.currentStyle ?
|
||||
elem.currentStyle.filter : elem.style.filter) || "" ) ?
|
||||
( parseFloat( RegExp.$1 ) / 100 ) + "" :
|
||||
computed ? "1" : "";
|
||||
},
|
||||
set: function( elem, value ) {
|
||||
var style = elem.style,
|
||||
currentStyle = elem.currentStyle,
|
||||
opacity = jQuery.isNumeric( value ) ?
|
||||
"alpha(opacity=" + value * 100 + ")" : "",
|
||||
filter = currentStyle && currentStyle.filter || style.filter || "";
|
||||
// IE has trouble with opacity if it does not have layout
|
||||
// Force it by setting the zoom level
|
||||
style.zoom = 1;
|
||||
// if setting opacity to 1, and no other filters
|
||||
//exist - attempt to remove filter attribute #6652
|
||||
if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" ) {
|
||||
// Setting style.filter to null, "" & " " still leave
|
||||
// "filter:" in the cssText if "filter:" is present at all,
|
||||
// clearType is disabled, we want to avoid this style.removeAttribute
|
||||
// is IE Only, but so apparently is this code path...
|
||||
style.removeAttribute( "filter" );
|
||||
// if there there is no filter style applied in a css rule, we are done
|
||||
if ( currentStyle && !currentStyle.filter ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// otherwise, set new filter values
|
||||
style.filter = ralpha.test( filter ) ?
|
||||
filter.replace( ralpha, opacity ) :
|
||||
filter + " " + opacity;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
装饰器还是适配器?
|
||||
|
||||
对比两者,我们可以看出,它们之间的相同性在于它们都是在无法直接改变主体对象的情况下,加了一层包装。而区别在于不同使用场景的包装方式不同,装饰器更多是通过包装嵌套添加一些不同的特征,适配器的包装更多是一个对象和另一个对象之间的接口映射。
|
||||
|
||||
另外,我们什么时候应该避免使用装饰器和适配器呢?如果我们可以控制整个实现(也就是说我们拥有自己的库)的话,就可以通过更改基本代码而不是通过包装使接口变得复杂化来实现相同的实用程序了。此外,与任何设计模式一样,如果非要使用包装模式的话,要确保实际的结果和影响比原始的、无模式的代码更简单且更易于理解。
|
||||
|
||||
总结
|
||||
|
||||
这节课先到这里,我来做个小结。今天我带你通过jQuery来了解了几种比较经典的结构型设计模式。我们看出虽然jQuery被很多人吐槽,但是存在即合理。它确实通过门面模式、组合模式和适配器模式为开发提供了很多的便利。同时它也有着自己独特的享元模式的应用场景,并且也有着自己对装饰器的支持。
|
||||
|
||||
思考题
|
||||
|
||||
我们前面说到装饰器,也可以用我们上一讲提到的Proxy来实现,你能说说它是怎么实现的吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
269
专栏/JavaScript进阶实战课/24行为型:通过观察者、迭代器模式看JS异步回调.md
Normal file
269
专栏/JavaScript进阶实战课/24行为型:通过观察者、迭代器模式看JS异步回调.md
Normal file
@ -0,0 +1,269 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 行为型:通过观察者、迭代器模式看JS异步回调
|
||||
你好,我是石川。
|
||||
|
||||
说完了创建和结构型的设计模式,这一讲,我们来学习行为型的设计模式。我们前面说前端编程是事件驱动(event driven)的,之所以这么说,是因为前端编程几乎离不开用户和应用的交互,通常我们的程序会根据用户的屏幕点击或者页面滑动操作,而做出相应的反应。这一点从我们前面讲到的React和Vue的例子中也可以发现,响应式编程(reactive programming)的思想对前端应用设计有着很强的影响。
|
||||
|
||||
今天我们会讲到行为型设计模式中的观察者模式,它是事件驱动在设计层面上的体现。通过这一讲的内容,你也可以更了解JS开发中事件驱动和异步的特性。
|
||||
|
||||
事件驱动
|
||||
|
||||
说到事件驱动,就离不开两个主要的对象,一个是被观察对象change observable,一个是观察者observer。被观察对象会因为事件而发生改变,而观察者则会被这个改变驱动,做出一些反应。
|
||||
|
||||
|
||||
|
||||
我们可以通过上图一个简单的例子来了解下这种模式。假设我们有两个观察者1和2,它们的初始值分别是11和21。observable是被观察对象,这个时候如果被观察对象做出了增加1的行为,观察者1和2的值就会更新为12和22。下面我们可以看看它的实现,通常一个被观察者的实现是模版式的;而观察者则是根据不同的反应需求,来设计不同的逻辑。
|
||||
|
||||
class Observable {
|
||||
constructor() {
|
||||
this.observers = [];
|
||||
}
|
||||
subscribe(func) {
|
||||
this.observers.push(func);
|
||||
}
|
||||
unsubscribe(func) {
|
||||
this.observers = this.observers.filter(observer => observer !== func);
|
||||
}
|
||||
notify(change) {
|
||||
this.observers.forEach(observer => {observer.update(change);});
|
||||
}
|
||||
}
|
||||
|
||||
class Observer {
|
||||
constructor(state) {
|
||||
this.state = state;
|
||||
this.initState = state;
|
||||
}
|
||||
update(change) {
|
||||
let state = this.state;
|
||||
switch (change) {
|
||||
case 'increase':
|
||||
this.state = ++state;
|
||||
break;
|
||||
case 'decrease':
|
||||
this.state = --state;
|
||||
break;
|
||||
default:
|
||||
this.state = this.initState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
var observable = new Observable();
|
||||
|
||||
var observer1 = new Observer(11);
|
||||
var observer2 = new Observer(21);
|
||||
|
||||
observable.subscribe(observer1);
|
||||
observable.subscribe(observer2);
|
||||
|
||||
observable.notify('increase');
|
||||
|
||||
console.log(observer1.state); // 12
|
||||
console.log(observer2.state); // 22
|
||||
|
||||
|
||||
在这个事件驱动的案例里,用到的就是观察者(observer)模式。观察者模式是行为型模式当中的一种,并且算是出镜率最高的、被谈及最多的一种模式了,它是事件驱动在设计层面的体现。事件驱动最常见的就是UI事件了,比如有时我们需要程序根据监听触屏或滑动行为做出反应。除了UI事件驱动,还有两个事件驱动的场景使用频率非常高,就是网络和后端事件。
|
||||
|
||||
|
||||
|
||||
我们先说网络事件,这是观察者模式使用频率非常高的一个场景,原因是我们现在大多的应用都是通过XHR这种模式,动态加载内容并且展示于前端的,通常会等待客户端请求通过网络到达服务器端,得到返回的状态,然后再执行任何操作。这就需要我们通过观察者模式来订阅不同的状态,并且根据状态做出不同的行为反应。
|
||||
|
||||
另外一个场景是后端事件,比如在Node.js当中,观察者模式也是非常重要的甚至可以说是最核心的模式之一,以至于被内置的EventEmmiter功能所支持。举个例子,Node 中的“fs”模块是一个用于处理文件和目录的API。我们可以把一个文件当做一个对象,那么当它被打开、读取或关闭的时候,其实就是不同的状态事件,在这个过程中,如果要针对这些不同的事件做通知和处理,就会用到观察者模式。
|
||||
|
||||
事件驱动和异步
|
||||
|
||||
我们前面说了观察者模式通常和事件驱动相关,那它和异步又有什么关系呢?
|
||||
|
||||
这个关系不难理解,一些计算机程序,例如科学模拟和机器学习模型,是受计算约束的,它们连续运行,没有停顿,直到计算出结果,这种是同步编程。然而,大多数现实世界的计算机程序都是异步的,也就是说这些程序经常不得不在等待数据到达或某些事件发生时停止计算。Web 浏览器中的JavaScript程序通常是事件驱动的,这意味着它们在实际执行任何操作之前会等待用户点击。或者在网络事件或后端事件中,也是要等待某个状态或动作才能开启程序运行。
|
||||
|
||||
所以回到上面的问题,观察者模式和异步的关系在于:事件就是基于异步产生的,而我们需要通过观察对基于异步产生的事件来做出反应。
|
||||
|
||||
|
||||
|
||||
JavaScript提供了一系列支持异步观察者模式的功能,分别是callback、promise/then、generator/next 和 aync/await。下面,让我们分别来看看这几种模式吧。
|
||||
|
||||
Callback模式
|
||||
|
||||
在 JavaScript 中,回调模式(callback pattern) 就是我们在一个函数操作完时把结果作为参数传递给另外一个函数的这样一个操作。在函数式编程中,这种传递结果的方式称为连续传递样式(CPS,continous passing style)。它表示的是调用函数不直接返回结果,而是通过回调传递结果。作为一个通用的概念,CPS不代表一定是异步操作,它也可以是同步操作。下面,我们可以针对同步和异步分别来看一下。
|
||||
|
||||
同步CPS
|
||||
|
||||
我们先来看看同步的CPS。下面的这个加法函数你应该很容易理解,我们把a和b的值相加,然后返回结果。这种方式叫做直接样式(direct style)。
|
||||
|
||||
function add (a, b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
|
||||
那如果用callback模式来做同步CPS会是怎样呢。在这个例子里,syncCPS不直接返回结果,而是通过callback来返回 a 加 b 的结果。
|
||||
|
||||
function syncCPS (a, b, callback) {
|
||||
callback(a + b);
|
||||
}
|
||||
|
||||
console.log('同步之前');
|
||||
syncCPS(1, 2, result => console.log(`结果: ${result}`));
|
||||
console.log('同步之后');
|
||||
|
||||
// 同步之前
|
||||
// 结果: 3
|
||||
// 同步之后
|
||||
|
||||
|
||||
异步CPS
|
||||
|
||||
下面我们再看看异步的CPS。这里最经典的例子就是setTimeout了,通过示例代码,你可以看到,同样的,异步CPS不直接返回结果,而是通过callback来返回a加b的结果。但是在这里,我们通过setTimeout让这个结果是在0.1秒后再返回,这里我们可以看到在执行到setTimeout时,它没有在等待结果,而是返回给asyncCPS,执行下一个console.log(‘异步之后’)的任务。
|
||||
|
||||
function asyncCPS (a, b, callback) {
|
||||
setTimeout(() => callback(a + b), 100);
|
||||
}
|
||||
|
||||
console.log('异步之前');
|
||||
asyncCPS(1, 2, result => console.log(`结果: ${result}`))
|
||||
console.log('异步之后');
|
||||
|
||||
// 异步之前
|
||||
// 异步之后
|
||||
// 结果: 3
|
||||
|
||||
|
||||
在上面的例子中,其函数调用和控制流转顺序可以用下图表示:
|
||||
|
||||
|
||||
|
||||
你可能会有疑问,就是在同步CPS的例子中,这种方式有没有意义呢?答案是没有。因为我们上面只是举个例子来看同步CPS是可以实现的,但其实如果函数是同步的,根本没有用CPS的必要。使用直接样式而不是同步CPS来实现同步接口始终是更加合理的实践。
|
||||
|
||||
回调地狱
|
||||
|
||||
在ES6之前,我们几乎只能通过callback来做异步回调。举个例子,在下面的例子中,我们想获取宝可梦的machineInfo机器数据,可以通过网上一个公开的库基于XMLHttpRequest来获取。
|
||||
|
||||
|
||||
|
||||
需要基于这样一个链条 pockmon=>moveInfo=>machineInfo。
|
||||
|
||||
(function () {
|
||||
var API_BASE_URL = 'https://pokeapi.co/api/v2';
|
||||
var pokemonInfo = null;
|
||||
var moveInfo = null;
|
||||
var machineInfo = null;
|
||||
|
||||
var pokemonXHR = new XMLHttpRequest();
|
||||
pokemonXHR.open('GET', `${API_BASE_URL}/pokemon/1`);
|
||||
pokemonXHR.send();
|
||||
|
||||
pokemonXHR.onload = function () {
|
||||
pokemonInfo = this.response
|
||||
var moveXHR = new XMLHttpRequest();
|
||||
moveXHR.open('GET', pokemonInfo.moves[0].move.url);
|
||||
moveXHR.send();
|
||||
|
||||
moveXHR.onload = function () {
|
||||
moveInfo = this.response;
|
||||
var machineXHR = new XMLHttpRequest();
|
||||
machineXHR.open('GET', moveInfo.machines[0].machine.url);
|
||||
machineXHR.send();
|
||||
|
||||
machineXHR.onload = function () { }
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
你可以看到,在这个例子里,我们每要获取下一级的接口数据,都要重新建立一个新的HTTP请求,而且这些回调函数都是一层套一层的。如果是一个大型项目的话,这么多层的嵌套是很不好的代码结构,这种多级的异步嵌套调用的问题也被叫做“回调地狱(callback hell)”,是使用callback来做异步回调时要面临的难题。 这个问题怎么解呢?下面我们就来看看promise和async的出现是如何解决这个问题的。
|
||||
|
||||
ES6+的异步模式
|
||||
|
||||
自从ES6开始,JavaScript中就逐步引入了很多硬核的工具来帮助处理异步事件。从一开始的Promise,到生成器(Generator)和迭代器(Iterator),再到后来的async/await。回调地狱的问题被一步步解决了,让异步处理重见阳光。下面,我们从Promise开始,看看这个问题是怎么一步步被解决的。
|
||||
|
||||
Promises
|
||||
|
||||
自从ES6之后,JavaScript就引入了一系列新的内置工具来帮助处理异步事件。其中最开始的是promise和then. 我们可以用then的连接方式,在每次fetch之后都调用一个then来进行下一层的操作。我们来看这段代码,这里减少了很多XMLHttpRequest的代码,但是仍然没有脱离一层层的调用。所以这种代码也不优雅。
|
||||
|
||||
(function () {
|
||||
var API_BASE_URL = 'https://pokeapi.co/api/v2';
|
||||
var pokemonInfo = null;
|
||||
var moveInfo = null;
|
||||
var machineInfo = null;
|
||||
|
||||
var showResults = () => {
|
||||
console.log('Pokemon', pokemonInfo);
|
||||
console.log('Move', moveInfo);
|
||||
console.log('Machine', machineInfo);
|
||||
};
|
||||
|
||||
fetch(`${API_BASE_URL}/pokemon/1`)
|
||||
.then((response) => {
|
||||
pokemonInfo = response;
|
||||
fetch(pokemonInfo.moves[0].move.url)
|
||||
})
|
||||
.then((response) => {
|
||||
moveInfo = response;
|
||||
fetch(moveInfo.machines[0].machine.url)
|
||||
})
|
||||
.then((response) => {
|
||||
machineInfo = response;
|
||||
showResults();
|
||||
})
|
||||
})();
|
||||
|
||||
|
||||
生成器和迭代器
|
||||
|
||||
那么怎么才能像同步的方式一样来执行异步的调用呢?在ES6版本中,在引入Promise和then的同时,也引入了生成器(Generator)和迭代器(Interator)的概念。生成器是可以让函数中一行代码执行完后通过yield先暂停,然后执行外部代码,等外部代码执行中出现next时,再返回函数内部执行下一条语句。是不是很神奇!这个例子中的next其实就是行为型模式中的迭代器模式的一种体现。
|
||||
|
||||
function* getResult() {
|
||||
var pokemonInfo = yield fetch(`${API_BASE_URL}/pokemon/1`);
|
||||
var moveInfo = yield fetch(pokemonInfo.moves[0].move.url);
|
||||
var machineInfo = yield fetch(moveInfo.machines[0].machine.url);
|
||||
}
|
||||
|
||||
var result = showResults();
|
||||
|
||||
result.next().value.then((response) => {
|
||||
return result.next(response).value
|
||||
}).then((response) => {
|
||||
return result.next(response).value
|
||||
}).then((response) => {
|
||||
return result.next(response).value
|
||||
|
||||
|
||||
async/await
|
||||
|
||||
但是使用next也有问题,就是这样的回调链条也会非常的长。意识到了promise/then的问题后,在ES8的版本中,JavaScript又引入了async/await的概念。这样,每一次获取信息的异步操作如pokemonInfo、moveInfo等都可以独立通过await来进行,写法上又可以保持和同步类似的简洁性。下面,我们来看看:
|
||||
|
||||
async function showResults() {
|
||||
try {
|
||||
var pokemonInfo = await fetch(`${API_BASE_URL}/pokemon/1`)
|
||||
console.log(pokemonInfo)
|
||||
var moveInfo = await fetch(pokemonInfo.moves[0].move.url)
|
||||
console.log(moveInfo)
|
||||
var machineInfo = await fetch(moveInfo.machines[0].machine.url)
|
||||
console.log(machineInfo)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
showResults();
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天,我带你通过观察者和迭代器等模式了解了异步编程的设计模式。可以说事件驱动、响应式编程、异步编程包含了JavaScript中很大一部分设计模式的核心概念。所以这篇文章虽然篇幅不大,但是确实是了解和应用JavaScript的核心非常重要的内容。
|
||||
|
||||
当然,异步编程是一个很大的话题,我们今天一次也讲不完,后面我们还会用一讲内容继续来看异步中的并行和串行开发,并且在讲到多线程的时候,我们会更深入理解异步的实现逻辑。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题,前面我们说CPS就是回调,那反之,我们可以说回调就是CPS吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
340
专栏/JavaScript进阶实战课/25行为型:模版、策略和状态模式有什么区别?.md
Normal file
340
专栏/JavaScript进阶实战课/25行为型:模版、策略和状态模式有什么区别?.md
Normal file
@ -0,0 +1,340 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 行为型:模版、策略和状态模式有什么区别?
|
||||
你好,我是石川。
|
||||
|
||||
今天我们来说说设计模式中剩下的几种行为型模式。我个人觉得剩下这六种模式可以大致分为两类,一类是偏向“策略模型”的设计模式,这里包含了策略、状态和模版这三种模式。另外一大类是偏向“数据传递”的设计模式,这里就包含了中介、命令和职责链这几种模式。这些类别的模式,有什么共同和不同呢?我们先从它们各自的思想和实现来看看。
|
||||
|
||||
策略模型类的行为模式
|
||||
|
||||
首先,我们来看看策略、状态和模版这三种偏向“策略模型”的设计模式吧。
|
||||
|
||||
策略模式
|
||||
|
||||
先说策略模式(strategy),它的核心思想是在运行时基于场景选择策略。
|
||||
|
||||
我们可以举一个例子,我们的汽车轮胎适配就算是一种策略模式,比如在冰天雪地的西伯利亚,可以选择冬季轮胎;如果是平时用的买菜车,就选择普通轮胎;如果是去草原狂奔,就换上越野轮胎。
|
||||
|
||||
|
||||
|
||||
下面,我们可以通过一个红绿灯程序来看一下这一概念的实现。在这个例子中,我们可以看到 交通控制(TrafficControl)就决定了运行时环境的上下文,它可以通过转换( turn )这个方法来切换不同的策略。红绿灯(TrafficLight)是一个抽象类的策略,它可以根据环境需要,延伸出具体类的策略。
|
||||
|
||||
// encapsulation
|
||||
class TrafficControl {
|
||||
turn(trafficlight) {
|
||||
return trafficlight.trafficcolor();
|
||||
}
|
||||
}
|
||||
|
||||
class TrafficLight {
|
||||
trafficcolor() {
|
||||
return this.colorDesc;
|
||||
}
|
||||
}
|
||||
|
||||
// strategy 1
|
||||
class RedLight extends TrafficLight {
|
||||
constructor() {
|
||||
super();
|
||||
this.colorDesc = "Stop";
|
||||
}
|
||||
}
|
||||
|
||||
// strategy 2
|
||||
class YellowLight extends TrafficLight {
|
||||
constructor() {
|
||||
super();
|
||||
this.colorDesc = "Wait";
|
||||
}
|
||||
}
|
||||
|
||||
// strategy 3
|
||||
class GreenLight extends TrafficLight {
|
||||
constructor() {
|
||||
super();
|
||||
this.colorDesc = "Go";
|
||||
}
|
||||
}
|
||||
|
||||
// usage
|
||||
var trafficControl = new TrafficControl();
|
||||
|
||||
console.log(trafficControl.turn(new RedLight())); // Stop
|
||||
console.log(trafficControl.turn(new YellowLight())); // Wait
|
||||
console.log(trafficControl.turn(new GreenLight())); // Go
|
||||
|
||||
|
||||
状态模式
|
||||
|
||||
下面我们再来看看状态模式(state),它的核心概念是根据运行时状态的不同,切换不同的策略。所以我们可以说它是策略模式的一个延伸。
|
||||
|
||||
这里,我们可以拿酒店预定举个例子,比如我们都有在一些文旅类门户网站上预定酒店的经验。在预定的时候,通常有几种不同的状态,比如当我们下单支付前,订单状态可能是“未确认”,这时我们可以确认或删除,但是因为还没有预定成功,所以没有取消的选项。但是当我们已确认并完成支付,就没有再次确认或删除的动作了,这时,我们只能选择取消。再然后,一般很多酒店都规定只能在入住前24小时选择取消,而如果在临近入住的24小时之内,那么在这个区间内连取消的按钮可能都失效了。这时,我们只能选择入住或和客服沟通取消。这就是状态模式,也就是说程序依据不同运行时状态,做不同的策略反应。
|
||||
|
||||
|
||||
|
||||
同样,我们可以通过讲策略模式时的红绿灯案例做一些改造,加入状态 state,看看会发生什么。这里,我们可以看到每次当我们执行turn在做切换的时候,随着状态在红、黄、绿三种状态之间循环更新,红绿灯的指示也跟着更新。
|
||||
|
||||
class TrafficControl {
|
||||
constructor() {
|
||||
this.states = [new GreenLight(), new RedLight(), new YellowLight()];
|
||||
this.current = this.states[0];
|
||||
}
|
||||
turn() {
|
||||
const totalStates = this.states.length;
|
||||
let currentIndex = this.states.findIndex(light => light === this.current);
|
||||
if (currentIndex + 1 < totalStates) this.current = this.states[currentIndex + 1];
|
||||
else this.current = this.states[0];
|
||||
}
|
||||
desc() {
|
||||
return this.current.desc();
|
||||
}
|
||||
}
|
||||
|
||||
class TrafficLight {
|
||||
constructor(light) {
|
||||
this.light = light;
|
||||
}
|
||||
}
|
||||
|
||||
class RedLight extends TrafficLight {
|
||||
constructor() {
|
||||
super('red');
|
||||
}
|
||||
desc() {
|
||||
return 'Stop';
|
||||
}
|
||||
}
|
||||
|
||||
class YellowLight extends TrafficLight {
|
||||
constructor() {
|
||||
super('yellow');
|
||||
}
|
||||
desc() {
|
||||
return 'Wait';
|
||||
}
|
||||
}
|
||||
|
||||
class GreenLight extends TrafficLight {
|
||||
constructor() {
|
||||
super('green');
|
||||
}
|
||||
desc() {
|
||||
return 'Go';
|
||||
}
|
||||
}
|
||||
|
||||
// usage
|
||||
var trafficControl = new TrafficControl();
|
||||
console.log(trafficControl.desc()); // 'Go'
|
||||
trafficControl.turn();
|
||||
console.log(trafficControl.desc()); // 'Stop'
|
||||
trafficControl.turn();
|
||||
console.log(trafficControl.desc()); // 'Wait'
|
||||
|
||||
|
||||
模版模式
|
||||
|
||||
最后,我们再来看看模版模式(template)。它的核心思想是在一个方法中定义一个业务逻辑模版,并将某些步骤推迟到子类中实现。所以它和策略模式有些类似。
|
||||
|
||||
|
||||
|
||||
下面我们可以看一个实现的例子。在这个例子里,我们看到员工employee里的工作work就是一个模版,它里面的任务tasks是延迟到开发developer和设计designer两个子类中去实现的。这就是一个简单的模版模式的设计实现。
|
||||
|
||||
class Employee {
|
||||
constructor(name, salary) {
|
||||
this.name = name;
|
||||
this.salary = salary;
|
||||
}
|
||||
work() {
|
||||
return `${this.name}负责${this.tasks()}`;
|
||||
}
|
||||
getPaid() {
|
||||
return `${this.name}薪资是${this.salary}`;
|
||||
}
|
||||
}
|
||||
|
||||
class Developer extends Employee {
|
||||
constructor(name, salary) {
|
||||
super(name, salary);
|
||||
}
|
||||
// 细节由子类实现
|
||||
tasks() {
|
||||
return '写代码';
|
||||
}
|
||||
}
|
||||
|
||||
class Designer extends Employee {
|
||||
constructor(name, salary) {
|
||||
super(name, salary);
|
||||
}
|
||||
// 细节由子类实现
|
||||
tasks() {
|
||||
return '做设计';
|
||||
}
|
||||
}
|
||||
|
||||
// usage
|
||||
var dev = new Developer('张三', 10000);
|
||||
console.log(dev.getPaid()); // '张三薪资是10000'
|
||||
console.log(dev.work()); // '张三负责写代码'
|
||||
var designer = new Designer('李四', 11000);
|
||||
console.log(designer.getPaid()); // '李四薪资是11000'
|
||||
console.log(designer.work()); // '李四负责做设计'
|
||||
|
||||
|
||||
这里我先做个阶段性小结,从上面的例子中,我们可以看出,无论是策略、状态还是模版模式,它们都是基于某种“策略模型”来实现的。比如策略模式中的策略是基于上行文来切换;在状态模式中是根据状态来做切换;而最后在模版模式的例子中,某些策略模版在父类中定义,有些则在子类中实现。
|
||||
|
||||
信息传递类的行为模式
|
||||
|
||||
中介模式
|
||||
|
||||
中介者(mediator)模式的核心是使组件可以通过一个中心点相互交互。现实生活中,航空地面塔台就是一个例子,我们不可能让飞机之间交谈,而是通过地面控制台协调。地面塔台人员需要确保所有飞机都接收到安全飞行所需的信息,而不会撞到其他飞机。
|
||||
|
||||
|
||||
|
||||
我们还是通过一段代码,来看看这种模式的实现。塔台(TrafficTower)有着接收每架飞机坐标和获取某架飞机坐标方法。同时,飞机会登记自己的坐标和获取其它飞机的坐标。这些信息都是统一由塔台(TrafficTower)来管理的。
|
||||
|
||||
class TrafficTower {
|
||||
#airplanes;
|
||||
constructor() {
|
||||
this.#airplanes = [];
|
||||
}
|
||||
|
||||
register(airplane) {
|
||||
this.#airplanes.push(airplane);
|
||||
airplane.register(this);
|
||||
}
|
||||
|
||||
requestCoordinates(airplane) {
|
||||
return this.#airplanes.filter(plane => airplane !== plane).map(plane => plane.coordinates);
|
||||
}
|
||||
}
|
||||
|
||||
class Airplane {
|
||||
constructor(coordinates) {
|
||||
this.coordinates = coordinates;
|
||||
this.trafficTower = null;
|
||||
}
|
||||
|
||||
register(trafficTower) {
|
||||
this.trafficTower = trafficTower;
|
||||
}
|
||||
|
||||
requestCoordinates() {
|
||||
if (this.trafficTower) return this.trafficTower.requestCoordinates(this);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// usage
|
||||
var tower = new TrafficTower();
|
||||
|
||||
var airplanes = [new Airplane(10), new Airplane(20), new Airplane(30)];
|
||||
airplanes.forEach(airplane => {
|
||||
tower.register(airplane);
|
||||
});
|
||||
|
||||
console.log(airplanes.map(airplane => airplane.requestCoordinates()))
|
||||
// [[20, 30], [10, 30], [10, 20]]
|
||||
|
||||
|
||||
命令模式
|
||||
|
||||
说完中介模式,我们再来看看命令模式,命令模式(command)允许我们将命令和发起命令操作的对象分离,这么做的好处是对于处理具有特定生命周期或者列队执行的命令,它会给我们更多的控制权。并且它还提供了将方法调用作为传参的能力,这样做的好处是可以让方法按需执行。
|
||||
|
||||
|
||||
|
||||
下面我们可以看看这种模式的样例。事务管理者OperationManager 接到了执行任务,会根据不同的命令,如启动行动(StartOperationCommand)、追踪行动状态(TrackOperationCommand)及取消行动CancelOperationCommand 等来执行。
|
||||
|
||||
class OperationManager {
|
||||
constructor() {
|
||||
this.operations = [];
|
||||
}
|
||||
|
||||
execute(command, ...args) {
|
||||
return command.execute(this.operations, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
class Command {
|
||||
constructor(execute) {
|
||||
this.execute = execute;
|
||||
}
|
||||
}
|
||||
|
||||
function StartOperationCommand(operation, id) {
|
||||
return new Command(operations => {
|
||||
operations.push(id);
|
||||
console.log(`你成功的启动了${operation}行动,代号${id}`);
|
||||
});
|
||||
}
|
||||
|
||||
function CancelOperationCommand(id) {
|
||||
return new Command(operations => {
|
||||
operations = operations.filter(operation => operation.id !== id);
|
||||
console.log(`你取消了行动代号${id}`);
|
||||
});
|
||||
}
|
||||
|
||||
function TrackOperationCommand(id) {
|
||||
return new Command(() =>
|
||||
console.log(`你的行动代号${id},目前正在执行中`)
|
||||
);
|
||||
}
|
||||
|
||||
var manager = new OperationManager();
|
||||
|
||||
manager.execute(new StartOperationCommand("猎豹", "318"));
|
||||
// 返回:你成功的启动了猎豹行动,代号318
|
||||
manager.execute(new TrackOperationCommand("318"));
|
||||
// 返回:你的行动代号318,目前正在执行中
|
||||
manager.execute(new CancelOperationCommand("318"));
|
||||
// 返回:你取消了行动代号318
|
||||
|
||||
|
||||
命令模式可以在许多不同的情况下使用,特别是在创建重交互的UI上,比如编辑器里撤消的操作,因为它可以让UI对象和行为操作做到高度解耦。这种模式也可以用来代替回调函数,这也是因为它更支持模块化地将行为操作在对象之间传递。
|
||||
|
||||
职责链模式
|
||||
|
||||
最后,再来看下职责链模式,职责链模式(chain of responsibility)核心是将请求的发送者和接收者解耦。它的实现是通过一个对象链,链中的每个对象都可以处理请求或将其传递给下一个对象。其实在我们前面讲享元时,就提到过事件捕获和冒泡,JavaScript 内部就是用这个方式来处理事件捕获和冒泡的。同样在享元例子中,我们也提到过,jQuery 是通过职责链每次返回一个对象来做到的链接式调用。
|
||||
|
||||
|
||||
|
||||
那么这种职责链是如何实现的呢?其实它的实现并不复杂,通过下面的例子我们可以看一下。你也可以很容易实现一个简化版的链式累加。我们通过累加(CumulativeSum)中的加法(add)可以循环上一个对象的结果和参数相加后的结果,作为返回值传给下一个方法。
|
||||
|
||||
class CumulativeSum {
|
||||
constructor(intialValue = 0) {
|
||||
this.sum = intialValue;
|
||||
}
|
||||
|
||||
add(value) {
|
||||
this.sum += value;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// usage
|
||||
var sum = new CumulativeSum();
|
||||
console.log(sum.add(10).add(2).add(50).sum); // 62
|
||||
|
||||
|
||||
通过上面的三种模式的例子,我们都可以看到数据在不同对象中的传递。中介模式中,我们需要在网状的环境中,信息对多个对象中通过中介进行传输;命令模式中,我们看到了信息在对象和对象之间的传输;而最后,在职责链的模式中,我们又看到了信息在一个流水线中的传输。因此我说它们是偏向“数据传递”的设计模式。
|
||||
|
||||
总结
|
||||
|
||||
今天,我带你看了几种不同的行为型设计模式。到现在为止,我们所有的经典模式就都讲完了。
|
||||
|
||||
这一讲我们看的这些模式除了在JavaScript中会用到以外,在多数其它语言中也都适用,所以算是比较脱离语言本身的几种“普世”模式了。在之后的一讲中,我们会再次看几种在JavaScript中特有的一些设计模式。
|
||||
|
||||
思考题
|
||||
|
||||
如果你用过 Redux 的话,应该用过它的开发者工具中的时间旅行式调试,它可以将应用程序的状态向前、向后或移动到任意时间点。你知道这个功能的实现用到了今天学到的哪(些)种行为型设计模式吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||
|
||||
|
||||
|
||||
|
300
专栏/JavaScript进阶实战课/26特殊型:前端有哪些处理加载和渲染的特殊“模式”?.md
Normal file
300
专栏/JavaScript进阶实战课/26特殊型:前端有哪些处理加载和渲染的特殊“模式”?.md
Normal file
@ -0,0 +1,300 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 特殊型:前端有哪些处理加载和渲染的特殊“模式”?
|
||||
你好,我是石川。
|
||||
|
||||
在之前的几讲中,我们介绍完了经典的设计模式,今天我们来看看在JS中特有的一些设计模式。其实从函数式编程开始,我们就一直强调了前端所重视的响应式编程思想,所以我认为这个部分可以分三大块儿来系统地看下响应式编程在JS中的设计模式,分别是组件化、加载渲染和性能优化模式。下面,我们就来深入地了解下。
|
||||
|
||||
组件化的模式
|
||||
|
||||
首先,我们来看下组件化的设计模式。我想请你先思考这样一个问题:为什么说组件化在前端,特别是基于JS开发的React框架中,有着非常重要的位置呢?
|
||||
|
||||
随着Web和WebGL的逐渐普及,我们之前用的很多桌面应用都被网页版的应用替代,一是它可以达到和桌面应用类似的功能,二是这样节省了资源在我们的手机或是PC上的下载和存储,三是因为这样可以让我们随时随地访问我们需要的内容,只要有网络,输入一个URL便可以使用。而且在办公类的软件中它也大大增加了工作的协同,比如我们常用的QQ邮箱、Google Docs或石墨文档都可以看做是由组件组成的复杂的Web应用。
|
||||
|
||||
|
||||
|
||||
接下来我们就来讲讲几种在React中会用到的组件化的模式。这里我们首先需要注意的是,在React中的组件化和我们通常了解的Web Component是有区别的。我们在说到Web Component的时候,更多关注的是组件的封装和重用,也就是经典的面向对象的设计模式思想。而React Component更多关注的是,通过声明式的方式更好地让DOM和状态数据之间同步。为了更好地实现组件化,React推出了一些不同的模式,这里比较重要的就包含了上下文提供者、渲染属性、高阶组件和后来出现的Hooks。
|
||||
|
||||
|
||||
|
||||
这里,我们可以大体将这些组件化的模式分为两类,一类是在Hooks出现之前的上下文提供者、渲染属性、高阶组件模式,一类是Hooks出现后带来的新模式。下面,就让我们从经典模式依次来看下。
|
||||
|
||||
经典模式
|
||||
|
||||
首先我们先来看上下文提供者模式(Context Provider Pattern),这是一种通过创建上下文将数据传给多个组件的组件化方式。它的作用是可以避免prop-drilling,也就是避免将数据从父组件逐层下传到子组件的繁琐过程。
|
||||
|
||||
那么这种模式有什么实际的应用呢?那就是当我们想根据应用的界面目前所处的上下文来做展示的时候,这种模式就派上用场了。比如我们需要对于登录和未登录态的用户展现不同的内容,或者是当春节或国庆等特殊节日的主题皮肤发放时,又或是根据用户所在的国家或地区做展示调整的时候,都可以用到提供者模式。
|
||||
|
||||
举个例子,假如我们有一个菜单,里面包含了一个列表和列表元素。通过以下代码,我们看到如果将数据一层层传递,就会变得非常繁琐。
|
||||
|
||||
function App() {
|
||||
const data = { ... }
|
||||
return (<Menu data={data} />);
|
||||
}
|
||||
|
||||
var Menu = ({ data }) => <List data={data} />
|
||||
var List = ({ data }) => <ListItem data={data} />
|
||||
var ListItem = ({ data }) => <span>{data.listItem}</span>
|
||||
|
||||
|
||||
而通过React.createContext,我们创建一个主题。之后通过ThemeContext.Provider,我们可以创建一个相关的上下文。这样我们无需将数据一一传递给每个菜单里的元素,便可以让上下文中的元素都可以获取相关的数据。
|
||||
|
||||
var ThemeContext = React.createContext();
|
||||
|
||||
function App() {
|
||||
var data = {};
|
||||
return (
|
||||
<ThemeContext.Provider value = {data}>
|
||||
<Menu />
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
通过React.useContext,可以获取元素上下文中的数据来进行读写。
|
||||
|
||||
function useThemeContext() {
|
||||
var theme = useContext(ThemeContext);
|
||||
return theme;
|
||||
}
|
||||
|
||||
function ListItem() {
|
||||
var theme = useThemeContext();
|
||||
return <li style={theme.theme}>...</li>;
|
||||
}
|
||||
|
||||
|
||||
说完了上下文提供者,下面我们再来看看渲染属性模式(Render Props Pattern)。先思考一个问题,我们为什么需要渲染属性模式呢?
|
||||
|
||||
下面我们可以看看,在没有渲染模式的情况下,可能会出现的问题。比如在下面的价格计算器的例子中,我们想让程序根据输入的产品购买数量计算出价格。但是,在没有渲染属性的情况下,我们虽然想通过value * 188计算根据输入的购买的数量展示计算的价格,但是价格计算拿不到输入的购买数量的value值,所以实际上计算不出价格。
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>价格计算器</h1>
|
||||
<Input />
|
||||
<Amount />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input() {
|
||||
var [value, setValue] = useState("");
|
||||
return (
|
||||
<input type="text"
|
||||
value={value}
|
||||
placeholder="输入数量"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Amount({ value = 0 }) {
|
||||
return <div className="amount">{value * 188}元</div>;
|
||||
}
|
||||
|
||||
|
||||
为了解决这个问题,我们就可以用到render props,把amount作为input的子元素,在其中传入value参数。也就是说通过渲染属性,我们可以在不同的组件之间通过属性来共享某些数据或逻辑。
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
...
|
||||
<Input>
|
||||
{value => (
|
||||
<>
|
||||
<Amount value={value} />
|
||||
</>
|
||||
)}
|
||||
</Input>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input() {
|
||||
...
|
||||
return (
|
||||
<>
|
||||
<input ... />
|
||||
{props.children(value)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Amount({ value = 0 }) {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
说完了渲染属性,下面我们再来看看高阶组件模式(HOC,Higher Order Components Pattern),这种叫法听上去像是我们在说到函数式编程时提到的高阶函数。当时我们说过,当我们把一个函数作为参数传入,并且返回一个函数作为结果的函数,叫做高阶函数。那么在高阶组件中,它的概念类似,就是我们可以把一个组件作为参数传入,并且返回一个组件。
|
||||
|
||||
|
||||
|
||||
那么它有什么应用呢?假设在没有高阶组件的情况下,我们想给一些按钮或文字组件增加一个圆边,可能要修改组件内的代码,而通过高阶函数,我们可以在原始的直角的文字框和按钮组件的基础上面包装一些方法来得到圆边的效果。在实际的应用中,它可以起到类似于“装饰器”的作用。它不仅让我们不需要对组件本身做修改,而且还可以让我们重复使用抽象出来的功能,避免代码冗余。
|
||||
|
||||
// 高阶函数
|
||||
var enhancedFunction = higherOrderFunction(originalFunction);
|
||||
// 高阶组件
|
||||
var enhancedComponent = higherOrderComponent(originalComponent);
|
||||
|
||||
// 高阶组件作为装饰器
|
||||
var RoundedText = withRoundCorners(Text);
|
||||
var RoundedButton = withRoundCorners(Button);
|
||||
|
||||
|
||||
Hooks模式
|
||||
|
||||
前面,我们看完了几种经典的可以帮助我们实现和优化组件化的方式,下面,我们再来看看从React 16.8开始新增的Hooks。
|
||||
|
||||
Hooks最直接的作用是可以用函数来代替ES6引入的新的class创建组件。如我们之前在讲到JavaScript中面向对象时所提到的,关于this的绑定理解,对于从其他语言转来的开发者来说还是比较绕脑的。而通过Hooks,可以通过函数表达更直观地创建组件。
|
||||
|
||||
我们先来看一个计数器的例子。如果用传统class的方式来创建的话,需要用到 this.state,this.setState 和 this.state.count 来初始化,设置和读取计数状态。
|
||||
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { count: 0 };
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
|
||||
点击了{this.state.count}次。
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
而如果用Hooks的话,我们可以通过 useState(0) 来代替 this.state 创建一个初始化设置为0的计数状态。同时,再点击计数按钮的时候,我们可以用 count 和 setCount 分别来代替 this.state.count 和 this.setState 做计数状态的读写。
|
||||
|
||||
import React, { useState } from 'react';
|
||||
function APP() {
|
||||
var [count, setCount] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setCount(count + 1)}>点击了{count}次。
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,你可以看到,我们刚才是通过用解构(destructure)的方式,创建了两个计数状态的变量,一个是 count,另外一个是 setCount。这样当我们将这两个值赋值给 userState(0) 的时候,它们会分别被赋值为获取计数和更新计数。
|
||||
|
||||
// 数组解构
|
||||
var [count, setCount] = useState(0);
|
||||
|
||||
// 上等同于
|
||||
var countStateVariable = useState(0);
|
||||
var count = countStateVariable[0];
|
||||
var setCount = countStateVariable[1];
|
||||
|
||||
|
||||
除了用函数代替类以外,Hooks另外的一个作用是可以让组件按功能解耦、再按相关性组合的功能。比如在没有Hooks的情况下,我们可能需要通过组件的生命周期来组合功能,我们的一个应用组件会有两个不同的功能,一个是显示购物车商品数量,一个是显示现有的客服状态。如果我们用的是同一个组件的生命周期 componentDidMount 管理,那就会将不相干的功能聚合在了一起,而通过useEffect这样的一个Hook,就可以把不相干的功能拆开,再根据相关性聚合在一起。
|
||||
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
this.state = { count: 0, isAvailable: null };
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
componentDidMount() {
|
||||
document.title = `目前购物车中有${this.state.count}件商品`;
|
||||
UserSupport.subscribeToChange(this.props.staff.id, this.handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
function App(props) {
|
||||
var [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
document.title = `目前购物车中有${count}件商品`;
|
||||
});
|
||||
var [isOnline, setIsOnline] = useState(null);
|
||||
useEffect(() => {
|
||||
function handleChange(status) {setIsOnline(status.isAvailable);}
|
||||
UserSupport.subscribeToChange(props.staff.id, handleChange);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
除了上述的两个好处以外,Hooks还可以让逻辑在组件之间更容易共享。我们在上面的例子中看到过,之前如果我们想要让一些行为重用在不同的组件上,可能要通过渲染属性和高阶组件来完成,但这样做的成本是要以改变了组件原有的结构作为代价的,组件外被包裹了大量的提供者、渲染属性和高阶函数等,这种过度的抽象化就造成了包装地狱(wrapper hell)。而有了Hooks之后,它可以通过更原生的方式替代类似的工作。
|
||||
|
||||
加载渲染模式
|
||||
|
||||
之前我们在讲到响应式设计的时候,有讲过前端渲染(CSR)、后端渲染(SSR)和水合(hydration)的概念。今天,就让我们更系统地了解下加载渲染模式吧。首先,我们先从渲染模式说起。
|
||||
|
||||
渲染模式
|
||||
|
||||
在Web应用刚刚流行的时候,很多的单页应用(SPA)都是通过前端渲染开发的,这样的模式不需要浏览器的刷新就可以让用户在不同的页面间进行切换。这样在带来了方便的同时,也会造成性能上问题,比如它的FCP(First Contentful Paint,首次内容绘制时间)、 LCP(Largest Contentful Paint,最大内容绘制时间)、TTI(Time to Interactive,首次可交互时间) 会比较长,遇到这种情况,通过初始最小化代码、预加载、懒加载、代码切割和缓存等手段,性能上的问题可以得到一些解决。
|
||||
|
||||
但是相比后端渲染,前端渲染除了性能上的问题,还会造成SEO的问题。通常为了解决SEO的问题,一些网站会在SPA的基础上再专门生成一套供搜索引擎检索的后端页面。但是作为搜索的入口页面,后端渲染的页面也会被访问到,它最大的问题就是到第一字节的时间(TTFB)会比较长。
|
||||
|
||||
|
||||
|
||||
为了解决前端和后端渲染的问题,静态渲染(static rendering)的概念便出现了。静态渲染使用的是一种预渲染(pre-render)的方式。也是说在服务器端预先渲染出可以在CDN上缓存的HTML页面,当前端发起请求的时候,直接将渲染好了的文件发送给后端,通过这种方式,就降低了TTFB。相比静态的页面内容而言,JS的文件通常较小,所以在静态渲染的情况下页面的FCP和TTI也不会太高。
|
||||
|
||||
静态渲染一般被称之为静态生成(SSG,static generation),而由此,又引出了静态渐进生成(iSSG,incremental static generation)的概念。我们看到静态生成在处理静态内容,比如“关于我们”的介绍页时是可以的,但是如果内容是动态更新的比如“博客文章”呢?换句话说,就是我们要在面对新页面增加的同时也要支持旧页面的更新,这时渐进静态生成就派上用场了。iSSG可以在SSG的基础上做到对增量页面的生成和存量部分的再生成。
|
||||
|
||||
|
||||
|
||||
无论是“关于我们”还是“博客文章”之类的页面,它们虽然从内容的角度看,有静态和动态之分,但总体上都是不需要频繁互动的。但是如果静态渲染的内容需要有相关的行为代码支持互动的情况下,SSG就只能保证FCP,但是很难保证TTI了。这种情况就是我们之前讲到的水合(hydration)可以起到作用的时候了,它可以给静态加载的元素赋予动态的行为。而在用户发起交互的时候,再水合的动作就是渐进式水合(progressive hydration)。
|
||||
|
||||
同时,除了静态渲染和水合可以渐进外,后端渲染也可以通过node中的流(stream)做到后端渐进渲染(progressive SSR)。通过流,页面的内容可以分段传到前端,前端可以先加载先传入的部分。除了渐进式水合外,选择性水合可以利用node stream暂缓部分的组件传输,而将先传输到前端的部分进行水合,这种方式就叫做选择性水合(selective hydration)。
|
||||
|
||||
|
||||
|
||||
除了上述的这些模式外,还有一种集大成的模式叫做岛屿架构(islands architecture)。就好像我们在地理课学到的,所有的大陆都可以看作是漂流在海洋上的“岛屿”一样,这种模式把页面上所有的组件都看成是“岛屿”。把静态的组件视为静态页面“岛屿”,使用静态渲染;而对于动态的组件则被视为一个个的微件“岛屿”,使用后端加水合的方式渲染。
|
||||
|
||||
加载模式
|
||||
|
||||
为了配合上述的渲染模式,自然需要一系列的加载模式。对于静态内容,就通过静态倒入;动态的内容则通过动态倒入。基于渐进的思想,我们也可以在部分内容活动到特定区域或者交互后,将需要展示的内容渐进地导入。被导入的内容可以通过分割打包(bundle splitting),根据路径(route based splitting)来做相关组件或资源的加载。
|
||||
|
||||
|
||||
|
||||
除此之外,另外一个值得了解的模式是PRPL(Push Render, Pre-Cache, Lazy-load)。PRPL模式的核心思想是在初始化的时候,先推送渲染最小的初始化内容。之后在背后通过service worker缓存其它经常访问的路由相关的内容,之后当用户想要访问相关内容时,就不需要再请求,而直接从缓存中懒加载相关内容。
|
||||
|
||||
|
||||
|
||||
PRPL的思想是如何实现的呢?这里就要说到HTTP2的特点了。相比HTTP1.1,HTTP2中提供的服务器推送可以一次把初始化所需要的资源以外的额外素材都一并推送给客户端,PRPL就是利用到了HTTP2的这个特点。可是光有这个功能还不够,因为虽然这些素材会保存在浏览器的缓存中,但是不在HTTP缓存中,所以用户下次访问的时候,还是需要再次发起请求。
|
||||
|
||||
为了解决这个问题,PRPL就用到了service worker来做到将服务器推送过来的内容做预缓存。同时它也用到了代码分割(code splitting),根据不同页面的路由需求将不同的组件和资源分割打包,来按需加载不同的内容。
|
||||
|
||||
在说到加载的时候,还有一个我们需要注意的概念就是,pre-fetch不等于pre-load。pre-fetch更多指的是预先从服务器端获取,目的是缓存后,便于之后需要的时候能快速加载。而预加载则相反,是加载特别需要在初始化时使用的素材的一种方式,比如一些特殊字体,我们希望预先加载,等有内容加载时能顺滑地展示正确样式的字体。
|
||||
|
||||
|
||||
|
||||
性能优化模式
|
||||
|
||||
前面我们看了JS中特有的一些组件化和加载渲染模式。在加载渲染的模式中,我们已经可以看到提高性能的影子,最后我们再来看一个类别,就是进一步做到前端性能优化的模式,这里值得一提的,包括摇树优化(tree shaking)和虚拟列表优化(list virtualization)。
|
||||
|
||||
摇树优化
|
||||
|
||||
其中,摇树优化的作用是移除 JavaScript上下文中未引用的代码(dead-code)。那为什么我们需要移除这些代码呢?因为这些未被使用的代码如果存在于最后加载的内容中,会占用带宽和内存,而如果它们并不会在程序执行中用到,那就可以被优化掉了。
|
||||
|
||||
|
||||
|
||||
摇树优化中为什么会有“树”这个字呢?实际上它是一种图结构,但是你可以把它想象成一个类似于AST语法树的结构。摇树算法会遍历整代码中的执行关系,而没有被遍历到的元素则被认为是不需要的,会做相关的“剪枝”。它依赖于ES6中的import和export语句,检测代码模块是否被导出、导入,且被 JavaScript 文件使用。在JavaScript程序中,我们用到的模块打包工具例如webpack或Rollup,准备预备发布代码时,就会用到摇树算法。这些工具在把多个JavaScript文件打包为一个文件时,可以自动删除未引用的代码,使打包后的生成文件更简洁、轻便。
|
||||
|
||||
虚拟列表优化
|
||||
|
||||
说完摇树优化,我们再来看看列表虚拟化,它名字中的“虚拟化”一词从何而来呢?这就有点像我们在量子力学里面提到的“薛定谔的猫”思想,就是我们眼前的事物只有在观测的一瞬间才会被渲染出来,在这里我们的世界就好像是“虚拟”的沙箱。而在虚拟列表中,我们同样也只关注于渲染窗口移动到的位置。这样就可以节省算力和相关的耗时。
|
||||
|
||||
|
||||
|
||||
在基于React的三方工具中,有支持虚拟列表优化的react-window 和react-virtualized。它们两个的作者是同一个人。相比react-virtualized,react-window结合了上面讲到的摇树优化,同时它也比react-virtualized更加轻量化。感兴趣的同学可以在Github上了解更多。
|
||||
|
||||
总结
|
||||
|
||||
通过今天的学习,我们更系统化地了解了在前端强交互的场景下,响应式编程中的几种设计模式。我们学到了可以通过Hooks来减少组件间嵌套关系,更高效地建立数据、状态和行为上的联系。在加载和渲染的模式我们看到了,为了提高展示和交互的速度,降低资源的消耗,如何渐进式地提供内容和交互。最后在性能优化模式中,我们可以看到更多通过优化资源及节省算力的方式来提高性能的模式。
|
||||
|
||||
思考题
|
||||
|
||||
我们前面说到了通过Hooks可以减少嵌套,那么你觉得这种方式可以直接取代上下文提供者(context provider)、渲染属性(render props)、高阶组件(HOC)的这些模式吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
162
专栏/JavaScript进阶实战课/27性能:如何理解JavaScript中的并行、并发?(上).md
Normal file
162
专栏/JavaScript进阶实战课/27性能:如何理解JavaScript中的并行、并发?(上).md
Normal file
@ -0,0 +1,162 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 性能:如何理解JavaScript中的并行、并发?(上)
|
||||
你好,我是石川。
|
||||
|
||||
在上一个单元,我们在JS之道的编程模式和JS之法的语法、算法的基础上,又学习了JS之术的设计模式,从今天开始,我们将开启JS之术非功能性优化篇。从前面的学习中,我们可以看到,无论是编程模式、算法还是设计模式都不是独立存在,而是一环扣一环的。那我们今天要讲到的并行、并发,其实也是延续上一讲提到的异步编程模式。
|
||||
|
||||
所以,今天,就让我们了解下并行、并发和异步编程的关系,以及多线程开发在JS中的实现。
|
||||
|
||||
线程vs进程、并行vs并发
|
||||
|
||||
在说到JavaScript中的并发和并行前,我们先来看看程序、线程和进程之间的关系。在一个程序当中,可以有多个进程。在一个进程里,也可以有多个线程。内存在进程间是不共享的,而在线程里,是可以共享的。通过下图,你可以有更直观的理解。
|
||||
|
||||
|
||||
|
||||
并发是同一时间段进行的任务,而并行是两个在同一时刻进行的任务。除非是多核CPU,否则如果只有一个CPU的话,多线程本身并不支持并行,而只能通过线程间的切换执行来做到并发。从下图中,你可以对并发和并行有更直观的认识。
|
||||
|
||||
|
||||
|
||||
前后端语言如何支持线程并行开发
|
||||
|
||||
如果单从语言的层面来看,JavaScript本身没有线程的规范,也没有内置的对外暴露给开发者用来创建线程的接口。所以JavaScript本身的执行是单线程的,也就是说JavaScript的执行环境中只有一个虚机、指令指针和垃圾回收器。即使是在不同的 realms 中,如浏览器端不同的iframe或服务器端不同的context中,也都只有一条JavaScript指令在同一时间内执行。以此我们可以看出,JavaScript本身并不是多线程友好的。
|
||||
|
||||
|
||||
|
||||
这一点和其它的前端语言很不同,我们知道在iOS、安卓和unity的开发中,开发者分别有GradeCentralDispatch、WorkManager和Job System这些支持并行开发的强大工具包。之所以这些语言不仅支持多线程,还助力并行的开发,是因为这些语言所编写的程序都和前端用户有深度的交互。特别在unity的例子里,需要大量的3D运算,所以必须非常重视性能,让应用快速地做出响应。JavaScript同样作为前端语言,在这方面对比其它的前端语言显得有些不足。
|
||||
|
||||
|
||||
|
||||
和前端不同,在很多的后端高级语言,如Ruby或Python中,无论CPU是单核还是多核,线程都是被全局解释器锁(GIL,global interpreter lock)限制并行的,而只支持并发。所以在做多线程开发的时候,一定要注意并行是否受限制,因为如果并行受限,只允许并发的话,那么线程间切换的成本可能高于它带来的好处。在JavaScript的环境中,并没有限制线程的GIL锁,但是JS也有自己的限制,就是它会限制对象在线程间直接的共享,所以这从某个角度来讲也影响了线程在前端的使用。
|
||||
|
||||
|
||||
|
||||
JavaScript中的多线程并行开发
|
||||
|
||||
从上面的例子中,我们可以看到,前端语言对线程和并行的支持,总体要优于后端。而JavaScript虽然自带函数式编程的支持,而且在这几年加强了响应式编程的设计思想,但是在多线程和并行开发方面的能力还是有缺失的,但是这并不代表多线程在前端完全不能实现。下面,我们就来看看并行开发在JavaScript中实现的可能性。
|
||||
|
||||
JavaScript中的异步
|
||||
|
||||
先思考一个问题,为什么我们在开篇说并行开发和异步编程相关呢?因为近些年中,JavaScript中大多数的所谓“多任务处理”,都是通过将任务拆分成任务单元后异步执行的。这些不同的任务可以被看做是某种“并发”。
|
||||
|
||||
这里的任务拆分,就是通过我们上节讲过的事件callback回调和promise,将任务分成异步的任务单元。即使是await,底层用的也是callback回调。除了网络,我们上一讲说的用户事件和timeout 等也是同样的任务拆分原理。既然是异步回调,我们知道这种callback带来的并发并不是并行的,也就是说当回调中的代码在执行的时候,是没有其它代码在执行的。换句话说,在异步的场景中,同一时间只有一个调用栈。
|
||||
|
||||
我们说在前端的体验中,最重要的是流畅度(smoothness)和响应度(responsiveness)。那这两个度如何量化呢?
|
||||
|
||||
一般响应度衡量的是我们的程序能不能在100ms之内反应,而流畅度衡量的是每秒60帧(fps)的渲染。所以1000ms除以60帧,也就是说我们有约16.6ms来渲染每一帧,这个16.6ms在前端叫做帧预算。但实际这些工作都是浏览器做的,作为实际的开发者,我们的核心工作是监测用户对页面元素的行为、触发事件、运行相关的事件处理器、对样式进行计算、渲染布局等。
|
||||
|
||||
在之前的响应式编程和设计模式中,我们花了大量的时间学习了如何通过异步的方式和分段渐进的方式,让应用“并发”处理一些任务,从而更快地响应和按需渲染,这些优化也只能从一定程度上解决性能上的问题。因为浏览器的UI主线程是帧同步(lock step)的,就如前面所说,也就是说只有一个JavaScript指令结束后,下一个指令才能执行,所以异步也是有成本的,再加上不同浏览器可能会有不同程度的支持,所以响应和渐进式的设计只能解决一部分性能问题。也就是说响应和渐进式仍然是在串行的基础上操作,而不是在并行的基础上实现的。
|
||||
|
||||
JavaScript中用Web Worker支持并行
|
||||
|
||||
而要真正做到并行开发,实现质的变化,就要用到 WebWorker。它可以打破帧同步,允许我们在和主线程并行的工作线程上执行其它的指令。这里你可能会有疑问,我们前面不是说JavaScript不支持多线程吗?那为什么这里又出现了支持的Web Worker了?这是因为和其它语言不同的是,JavaScript确实没有一个统一的线程实现。
|
||||
|
||||
我们知道的Chrome、Safari和FireFox等浏览器都有自己的虚机来实现JavaScript。这和我们在前端用到的文件系统、网络、setTimeout、设备等功能一样,都是由嵌入环境的Node或浏览器虚机提供,而不是语言本身提供的。所以多线程的接口也是一样,也是由浏览器提供的。而浏览器或虚机提供的支持多线程的API就是Web Worker。它是由万维网联盟(W3C)和网页超文本应用技术工作小组定义(WHATWG)而不是TC39定义的。
|
||||
|
||||
要创建一个Web Worker非常简单,我们只需要类似下面的一个new语句。之后,如果可以通过postMessage在main.js和worker.js之间传递信息,双方也可以通过onMessage来接收来自对方的消息。
|
||||
|
||||
// main.js
|
||||
var worker = new Worker('worker.js');
|
||||
worker.postMessage('Hello world');
|
||||
worker.onmessage = (msg) => {
|
||||
console.log('message from worker', msg.data);
|
||||
}
|
||||
|
||||
// worker.js
|
||||
self.onmessage = (msg) => {
|
||||
postMessage('message sent from worker');
|
||||
console.log('message from main', msg.data);
|
||||
}
|
||||
|
||||
|
||||
在JavaScript中,有几种不同的工作线程,分别为dedicated worker、shared worker和service worker。我们可以分别来看看它们的作用。这里我们先来看看dedicated worker。
|
||||
|
||||
dedicated worker 只在一个realm中可以使用。它也可以有很多的层级,但是如果层级过多,可能会引起逻辑的混乱,所以在使用上还是要注意。
|
||||
|
||||
|
||||
|
||||
和dedicated worker相对的是 shared worker,顾名思义,如果说dedicated是专属的,那么shared则是可共享的,所以shared worker可以被不同的标签页、iframe和worker访问。但shared worker 也不是没有限制,它的限制是只能被在同源上跑的JavaScript访问。在概念上,shared worker是成立的,但是目前支持 shared worker 的虚机或浏览器非常少,并且几乎没法polyfill,所以在这里我们就不做深入的讲解了。
|
||||
|
||||
|
||||
|
||||
最后,我们再来看看 service worker,它既然叫做服务,那就和后端有关。它的特点就是在前端的页面都关闭的情况下,也可以运行。我们之前一讲提到响应式设计模式的时候,说过的预缓存和服务器推送等概念,离不开service worker的身影。它可以在首次收到浏览器请求时将加载的和可以缓存的内容一同推送给前端做预缓存,当前端再次发起请求时,可以先看是否有缓存,只有在没有的情况下,再从服务端请求。通过这种模式,我们可以大大提高Web应用的性能。
|
||||
|
||||
|
||||
|
||||
信息传递模式
|
||||
|
||||
JavaScript要实现多线程的并行开发,最核心的就是要做到信息在主线程和工作线程间的交互,下面我们就来看看如何通过Web Worker定义和JS引擎提供的接口做到这一点。
|
||||
|
||||
结构化拷贝算法
|
||||
|
||||
JavaScript的设计是帧同步(lock step)的,也就是说在main.js所运行的UI主线程上,同时只有一个JavaScript指令可以执行。这样做是为了让前端能把重点放在渲染和用户交互的工作上,而不需要将太多的精力放在和Worker的互动上。但这样的设计也带来一些弊端,比如JavaScript的虚机设计通常不是线程安全(thread safe)的。一个线程安全的数据结构可以在Worker间共享,通过互斥锁(mutex),可以保证不同线程同时访问修改一个数据的时候可以避免竞争条件(race condition)引起的副作用。
|
||||
|
||||
而因为在JavaScript的虚机是非线程安全的,也不存在互斥锁。所以为了数据的安全,对象数据在Worker间是不共享的。如果说数据是不能共享的,那我们之前看到的postMessage中,数据在JavaScript的环境中主线程和工作线程间为什么能传递呢?
|
||||
|
||||
这是因为在前面postMessage例子中的信息传递,不是直接的访问,而是通过先拷贝再传递的方式。这里使用到的一个拷贝算法就是类似我们之前说到的深拷贝,也叫做结构化拷贝(structured clone)。也就是说我们想要通过worker.js来直接访问和控制UI主线程中的main.js中的数据是不可能的。这种通过结构化的拷贝,让main和Worker能在环境之间通信的方法,叫做信息传递(message passing)。
|
||||
|
||||
请求和反馈模式
|
||||
|
||||
在我们前面讲到的hello world的例子中,传递的只是字符串,这么看来可能也没什么问题。但是当我们要传递更复杂的数据结构时,就会出现问题了。
|
||||
|
||||
比如如果我们需要传递的是下面这样一个带有参数的函数调用,在这个时候,我们需要先将函数调用转化为一个序列,也就是一个对应的是我们的本地调用远程过程调用,叫做PRC(Remote Procedure Call)。基于postMessage异步的特性,它返回的不是一个结果,而是一个await 的 promise。
|
||||
|
||||
isOdd(3);
|
||||
is_odd|num:3
|
||||
|
||||
worker.postMessage('is_odd|num:3');
|
||||
worker.postMessage('is_odd|num:5');
|
||||
|
||||
worker.onmessage = (result) => {
|
||||
// 'true'
|
||||
// 'false'
|
||||
};
|
||||
|
||||
|
||||
也就是说,在worker.js做完计算之后,会将结果返回。这时,会有另外一个问题,就是如果我们有很多个不同的请求,怎么能知道返回的结果和请求的对应关系呢?
|
||||
|
||||
为了解决这个问题,我们可以用JSON格式的JSON-PRC。结构化拷贝算法支持除了Symbol以外的其它类型的原始数据,这里包含了布尔、null、undefined、数字、BigInt和我们用到的字符串。结构化拷贝算法也可以支持多种的数据结构,包括数组、字典和集合等。还有就是用来存储二进制数据的ArrayBuffer、ArrayBufferView和Blob也可以通过postMessage传递。ArrayBuffer也可以解决数据传递中性能的问题,但是函数(function)和(class)类是不能通过postMessage来传递的。
|
||||
|
||||
// worker.postMessage
|
||||
{"jsonrpc": "2.0", "method": "isOdd", "params": [3], "id": 1}
|
||||
{"jsonrpc": "2.0", "method": "isEven", "params": [5], "id": 2}
|
||||
// worker.onmessage
|
||||
{"jsonrpc": "2.0", "result": "false", "id": 2}
|
||||
{"jsonrpc": "2.0", "result": "true", "id": 1}
|
||||
|
||||
|
||||
命令和派发模式
|
||||
|
||||
上面我们看到了请求和反馈间的映射,下面,我们再来看看命令和派发的映射。比如我们有两个功能,一个是判断奇数,另外一个是判断偶数,但是这两个数据是在两个不同代码路径上的,这个时候,我们也需要某种方式的映射。这里可以使用字典,或者更简单的对象结构也能够支持指令中这种映射关系。这种通过开发者自己实现的一个来保证指令派发的逻辑就叫做 command dispatcher 模式。
|
||||
|
||||
var commands = {
|
||||
isOdd(num) { /*...*/ },
|
||||
isEven(num) { /*...*/ }
|
||||
};
|
||||
|
||||
function dispatch(method, args) {
|
||||
if (commands.hasOwnProperty(method)) {
|
||||
return commands[method](...args);
|
||||
}
|
||||
//...
|
||||
}
|
||||
|
||||
|
||||
通过上述结构化拷贝算法,再加上请求和反馈,以及命令和派发模式,就可以通过 JS 引擎提供的 Web Worker 接口,在主线程和工作线程间实现信息的传递了。
|
||||
|
||||
总结
|
||||
|
||||
今天,我们了解了线程和进程、并行和并发的区别,也对比了前后端的不同语言对多线程并行开发的支持。针对JavaScript,我们也看到了多线程开发中核心的信息传递机制,包括结构化拷贝算法,以及基于postMessage自身的不足,如何通过JSON-RPC和command dispatch的方式来解决相关的问题。
|
||||
|
||||
思考题
|
||||
|
||||
这道思考题也是下一讲的铺垫。我们课程中说,在信息传递层面,我们可以使用ArrayBuffer来提高性能,你知道它是如何做到的以及背后的原理吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
168
专栏/JavaScript进阶实战课/28性能:如何理解JavaScript中的并行、并发?(下).md
Normal file
168
专栏/JavaScript进阶实战课/28性能:如何理解JavaScript中的并行、并发?(下).md
Normal file
@ -0,0 +1,168 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 性能:如何理解JavaScript中的并行、并发?(下)
|
||||
你好,我是石川。
|
||||
|
||||
在上一讲中,我们初步介绍了并发和并行的概念,对比了不同语言对多线程开发的支持。我们也通过postMessage,学习了用信息传递的方式在主线程和Worker线程间实现交互。但是,我们也发现了JavaScript对比其它语言,在多线程方面还有不足,似乎信息传递本身不能让数据在不同的线程中真正做到共享,而只是互传拷贝的信息。
|
||||
|
||||
所以今天,我们再来看看如何能在信息互传的基础上,让数据真正地在多线程间共享和修改。不过更重要的是,这种修改是不是真的有必要呢。
|
||||
|
||||
SAB+Atomics模式
|
||||
|
||||
前面,我们说过对象的数据结构在线程间是不能共享的。如果通过postMessage来做信息传递的话,需要数据先被深拷贝。那有没有什么办法能让不同的线程同时访问一个数据源呢?答案是有,要做到数据的共享,也就是内存共享,我们就需要用到 SAB(SharedArrayBuffer)和 Atomics。下面,我们先从SAB开始了解。
|
||||
|
||||
共享的ArrayBuffer
|
||||
|
||||
SAB是一个共享的ArrayBuffer内存块。在说到SAB前,我们先看看ArrayBuffer是什么,这还要从内存说起。我们可以把内存想象成一个储藏室中的货架,为了找到存放的物品,有从1-9这样的地址。而里面存储的物品是用字节表示的,字节通常是内存中最小的值单元,里面可以有不同数量的比特(bit),比如一个字节(byte)里可以有8、32或64比特。我们可以看到 bit 和 byte 它俩的英文写法和读音有些相似,所以这里要注意不要把字节和比特混淆。
|
||||
|
||||
还有一点需要注意的是,计算机在内存中的数据存储是二进制的,比如数字2的二进制写法就是00000010,用8个比特来表示,就如下图所示。如果是字母的话,则可以先通过UTF-8这样的方式,先转换成数字,再转换为二进制。比如字母H,转换成数字就是72,再转换为二进制就是01001000。
|
||||
|
||||
|
||||
|
||||
在JavaScript语言当中,内存管理是自动的,也就是说当我们敲一行代码后,我们的虚机会自动地帮助我们在内存中找到剩余的空间,把数据放进去存储。并且会追踪这段代码在我们的程序中是否还可以被访问到,如果发现已经无法访问了,就会做相关的清除处理。这个过程也被称之为垃圾回收。
|
||||
|
||||
如果你使用C语言编写再编译成WebAssembly的话,那么基于C语言的手动内存管理和垃圾回收的机制,你就需要通过内存分配(malloc)的功能从一个空闲列表(free list)中找到存放位置来存放,使用后,再通过释放(free)的功能将内存释放。
|
||||
|
||||
再回到JavaScript的场景中,为什么我们前面要介绍自动和手工的内存管理呢?
|
||||
|
||||
这也就回到了我们上一讲最后留的问题了,就是为什么说使用ArrayBuffer的性能更高。这里我们顺便解决下上节课的思考题,如果在开发中使用更高级的数据类型,并且把数据处理的工作完全交给JavaScript的虚机如V8来处理,这样确实能给我们带来方便,但同时,副作用就是降低了性能极度调优的灵活性。比如当我们创建一个变量的时候,虚机为了猜测它的类型和在内存中的表现形式,可能要花费2-8倍的内存。而且有些对象创建和使用的方式,可能会增加垃圾回收的难度。
|
||||
|
||||
但如果我们使用ArrayBuffer这样更原始的数据类型,并通过C语言来编写程序并编译成WebAssembly的话,可以给开发者更多的控制,来根据使用场景更细粒度地管理内存的分配,提高程序的性能。
|
||||
|
||||
那一个ArrayBuffer和我们经常用的数组有什么区别呢?一起来看下面的代码,一个普通的数组中可以有数字、字符串、对象等不同类型的数据,但是在ArrayBuffer当中,我们唯一可用的就是字节。
|
||||
|
||||
// 数组
|
||||
[5, {prop: "value"}, "一个字符串"];
|
||||
|
||||
[0] = 5;
|
||||
[1] = {prop: "value"};
|
||||
[2] = "一个字符串";
|
||||
|
||||
// ArrayBuffer
|
||||
[01001011101000000111]
|
||||
|
||||
|
||||
这里的字节虽然可以用一串数字表示,但是有个问题是,机器怎么能知道它的单位呢?比如我前面介绍的,这串数字本身是没意义的,只有根据不同的8、32或者64比特单位,它才能具有意义。这时,我们就需要一个view来给它分段。
|
||||
|
||||
|
||||
|
||||
所以一个ArrayBuffer中的数据是不能直接被操作,而要通过 TypedArray 或 DataView 来操作。
|
||||
|
||||
// main.js
|
||||
var worker = new Worker('worker.js');
|
||||
|
||||
// 创建一个1KB大小的ArrayBuffer
|
||||
var buffer = new SharedArrayBuffer(1024);
|
||||
|
||||
// 创建一个TypedArray的DataView
|
||||
var view = new Uint8Array(buffer);
|
||||
|
||||
// 传递信息
|
||||
worker.postMessage(buffer);
|
||||
|
||||
setTimeout(() => {
|
||||
// buffer中的第1个字节
|
||||
console.log('later', view[0]); // later 5
|
||||
// buffer中foo的属性值
|
||||
console.log('prop', buffer.foo); // prop 32
|
||||
}, 1000);
|
||||
|
||||
// worker.js
|
||||
self.onmessage = ({data: buffer}) => {
|
||||
buffer.foo = 32;
|
||||
var view = new Uint8Array(buffer);
|
||||
view[0] = 5;
|
||||
}
|
||||
|
||||
|
||||
其实一个ArrayBuffer或SAB在初始化的时候,也是要用到postMessage和结构化拷贝算法的。但是和信息传递不同的是,这里在请求端发起请求时传入的数据,被拷贝后,如果在接收端做了修改,这个修改后的数据的指向和之前的数据是一致的。我们可以对比下普通的postMessage和ArrayBuffer以及SAB的区别。
|
||||
|
||||
|
||||
|
||||
所以,我们在上面SAB的例子中可以发现,通过setTimeout而不是onmessage,就可以获取在worker.js修改后的buffer的字节和属性了。但这里需要注意的是,字节的数量在SAB中是一开始就定好,且不可修改的。
|
||||
|
||||
Atomics和原子性
|
||||
|
||||
说完了SharedArrayBuffer,我们再来看看原子性。既然数据要共享,就要考虑原子性的问题。
|
||||
|
||||
如果你有做过数据库开发的话,应该听过 ACID 的原则,它是原子性、一致性、隔离性、持久性的缩写。原子性指的是一个事务中的所有操作,或者全部完成,或者全部不完成,不会结束在中间环节。任务在执行过程中发生的错误,都会被回滚到初始状态。这样做的结果是,事务不可分割、不可约简。
|
||||
|
||||
那为什么在数据库的开发中,会如此重视原子性呢?
|
||||
|
||||
你可以想想,如果我们单独看一个客户端的请求,它可能是原子性的,可如果是几个请求,可能就不是原子性的了。但是如果这些请求都属于同一个交易,那么当用户成功付款后,付款结果没能抵达电商接口,这个交易是不完整的,不仅在经济上可能造成损失,并且会给客户带来很不好的体验。所以从这个角度来看,包含这三条请求的整个交易就是一个原子性事务。
|
||||
|
||||
同样的,在分布式的设计中,一个网络中的不同节点间的互动也应该保证原子性的原则。那么再回到线程,我们说一个计算机中不同的线程对一个共享的数据也应该保持原子性的操作。
|
||||
|
||||
那这时你可能会问,如我们之前所说,并发中我们的程序就很容易进入一个竞争条件(race condition),那既然在并发设计中需要让事务保持原子性,那在JavaScript中面对并发怎么处理?
|
||||
|
||||
别担心,这个问题可以通过JavaScript提供的原子(Atomics)来解决。Atomics提供了所需的工具来进行原子性的操作,并且提供了线程安全的等待机制。在JavaScript中,有一个全局的Atomics对象,它自带一些内置的方法。
|
||||
|
||||
|
||||
|
||||
在SAB的内存管理中,上述这些方法可以解决3大类的问题。第一个问题是在单个操作中的竞争条件,第二个问题是在多个操作中的竞争条件,第三个问题是在指令顺序造成的问题。下面,我们依次来看一下。
|
||||
|
||||
单个操作中的竞争条件
|
||||
|
||||
这里你可能会好奇,一个单个操作为什么还会有竞争?举个例子,如果我们用2个工作线程,都对一个数字做+1的增量运算,你可能觉得无论谁操作都一样,结果都是+1,但是问题并没有这么简单。因为在实际计算的时候,我们的数据是会从内存中取出,放到寄存器里,然后通过运算器来运算的,这个时候,如果有一个数字6,同时被工作线程1和2取出,然后计算+1,那么结果可能就是7,而不是8。因为这两个线程在访问内存中的数据计算前收到的都是6,所以+1的结果被覆盖计算了2次。
|
||||
|
||||
|
||||
|
||||
那为了解决这个问题,上面提到的 Atomics.add()、Atomics.sub()、Atomics.and()、Atomics.or()、Atomics.xor()、Atomics.exchange() 等运算就是很好地避免这一类问题的方法。如果要做乘法除法,则可以通过Atomics.compareExchange()来创建相关的功能。
|
||||
|
||||
多个操作中的竞争条件
|
||||
|
||||
说完了单个操作中的竞争条件,我们再来看看多个操作中的竞争条件。在JavaScript中,我们可以通过 futex 来起到互斥锁的作用。它来源于Linux内核中,有一种互斥锁(mutex)叫做快速用户空间互斥体(futex,fast userspace mutex)的锁。futex中有两个方法,一个是Atomics.wait(),另外一个是Atomics.wake()。这里也很好基于字面意思来理解,一个代表等待,一个代表唤醒。
|
||||
|
||||
在用锁的时候,我们要注意,前端浏览器中主线程是不允许加锁的,在后端的Node中,主线程是可以加锁的。之所以在前端浏览器中不能加锁,是因为阻碍JavaScript的主线程执行,对用户体验的影响实在太大了,而对于后端来讲,则没有这么直接的影响。
|
||||
|
||||
如果在前端主线程想使用wait(),也不是完全没办法,这里可以使用waitAsync()。相比wait()可以暂停主线程再传递字符串,waitAsync要另起线程,所以从性能上来说,它比wait()会差一些。所以对于热路径(hotpath),也就是程序中那些会被频繁执行到的代码来说,可能不是那么有用,但是对于非信息传递类的工作来说,比如通知另外的线程,它还是有用的。
|
||||
|
||||
指令顺序造成的竞争条件
|
||||
|
||||
最后,我们再来看看指令顺序造成的竞争条件。如果你对计算机有芯片层面的理解的话,就会知道我们的代码在指令执行的流水线层面会被重新排序。如果在单线程的情况下,这可能不会造成什么问题,因为其它的代码需要在当前的函数在调用栈中完成执行才看到结果。但是如果在多线程下,其它的线程在结果出现前,就可能看到变化而没有考虑后序运行的代码指令结果。那这个问题要怎么解决呢?
|
||||
|
||||
这个时候,就要用到 Atomics.store() 和 Atomics.load()。函数中 Atomics.store() 以前的所有变量更新都保证在将变量值写回内存之前完成,函数中 Atomics.load() 之后的所有变量加载都保证在获取变量值之后完成。这样就避免了指令顺序造成的竞争条件。
|
||||
|
||||
数据传输的序列化
|
||||
|
||||
在使用SAB的时候,还有一点需要注意的是数据的序列化,也就是说我们在使用它来传递字符串、布尔或对象的时候,都需要一个编码和解码的过程。特别是对象,因为我们知道对象类型是没法直接传递的,所以这个时候,我们就需要用到“通过JSON将对象序列化成字符串”的方法,所以它更适合用postMessage和onmessage来做传递,而不是通过SAB。
|
||||
|
||||
Actor Model模式
|
||||
|
||||
通过上面的例子,我们可以看出,直接使用SAB+Atomics的方式还是蛮复杂的,稍有不慎,可能引起的性能问题远远大于优化的效果。所以除非真的是研发型的项目,否则只是纯应用类的项目,最好是通过一些成熟的库或者WebAssembly将复杂的内存管理抽象成简单的接口,这样的方式会更加适合。另外,我们也可以看看SAB+Atomics的一个替代方案,Actor Model 模式。
|
||||
|
||||
在Actor Model模式中,因为Actor是分布在不同的进程里的,如我们之前所说,进程间的内存是不共享的,每个Actor不一定在同一个线程上,而且各自管理自己的数据,不会被其它线程访问到,所以就没有互斥锁和线程安全的问题。Actor之间只能互相传递和接收信息。
|
||||
|
||||
|
||||
|
||||
这种模式更符合JavaScript的设计,因为它本身就对手动内存管理的支持度不高,所以在 Actor Model 这种模式下,我们只是通过线程做信息传递,而不做共享。但是,这并不代表主线程和Worker间的影响就仅限于信息传递,比如通过Worker传给主线程的数据,主线程完全可以自行基于接收到的数据来改变DOM,只是在这个过程中,需要自己做一些转换的工作。
|
||||
|
||||
这里,我们针对数据量较大的信息传递时,应该注意一些优化策略:
|
||||
|
||||
|
||||
我们可以将任务切分成小块儿依次传递;
|
||||
每次我们可以选择传递delta,也就是有变化的部分,而不是将全量数据进行传递;
|
||||
如果我们传递的频率过高,也可以将消息捆绑来传递;
|
||||
最后一点就是通过ArrayBuffer提高性能。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过这两节课的学习,我们可以看到多线程的开发在前端还有很长的路要走。
|
||||
|
||||
我们也看到了SAB+Atomics的模式,虽然从某种程度上看,在JavaScript中可以实现,但实际上,Actor Model更易于在JavaScript中的使用,特别是在前端场景中。很明显,我们不想因为多线程对同一组对象的并行修改,而引起竞争条件;或者为了数据在内存中的共享,增加过于复杂的逻辑来支持数据的编码、解码和转换;亦或为了手工的内存管理,增加额外的逻辑。
|
||||
|
||||
虽然多线程的开发,在前端更多处于实验性阶段,但是我认为它还是有着很大的想象空间的。因为前端如果有着比较耗内存的需要大量运算的任务,可以交给Worker Thread来处理,这样JavaScript的主线程就可以把精力放在UI渲染上。特别是通过Actor模式,可以大大提高主程序的性能,同时又避免副作用。
|
||||
|
||||
思考题
|
||||
|
||||
我们说过对象在线程间是不能共享的,那通过SharedArrayBuffer你觉得可以实现对象的共享吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
117
专栏/JavaScript进阶实战课/29性能:通过Orinoco、JankBusters看垃圾回收.md
Normal file
117
专栏/JavaScript进阶实战课/29性能:通过Orinoco、JankBusters看垃圾回收.md
Normal file
@ -0,0 +1,117 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 性能:通过Orinoco、Jank Busters看垃圾回收
|
||||
你好,我是石川。
|
||||
|
||||
在前两讲中,我们从多线程开发的角度了解了JavaScript中的性能优化。
|
||||
|
||||
今天,我们再来看一下JavaScript中内存管理相关的垃圾回收(garbage collection)机制,以及用到的性能优化的相关算法。
|
||||
|
||||
实际上,在JS语言中,垃圾回收是自动的,也就是说并不是我们在程序开发中手工处理的,但是,了解它对理解内存管理的底层逻辑还是很有帮助的。特别是结合我们前面两节课讲到的,在前端场景中,当我们的程序使用的是图形化的WebGL+Web Worker的多线程来处理大量的计算或渲染工作时,了解内存管理机制则是非常必要的。特别提醒一下,这节课会涉及到比较多的理论和底层知识,一定要跟紧我们的课程节奏啊。
|
||||
|
||||
闲置状态和分代回收
|
||||
|
||||
我们在上一讲说到并行和并发的时候,有讲到过,前端的性能指标中,我们通常关注的是流畅度和反应度。在理想的状态下,为了获得丝滑流畅的体验,我们需要达到60fps,也就是每帧在16.6ms内渲染。在很多的情况下,浏览器都可以在16.6ms内完成渲染,这个时候,如果提前渲染完了,剩下的时间我们的主线程通常是闲置(idle)的状态。而Chrome通常会利用这个闲置的时间来做垃圾回收。通过下面的图示,我们可以更加直观地看到主线程上这些任务的执行顺序。
|
||||
|
||||
|
||||
|
||||
在内存管理中,我们有必要先了解几个概念。在垃圾回收中,有个概念是分代回收(generational garbage collector),它所做的是将内存堆(memory heap)分代,不同类型的对象被分到半空间(semi space),里面包括了年轻代(young generation)和老年代(old generation)。
|
||||
|
||||
这样的分代专区,是基于垃圾回收界的一个著名的代际假说(generational hypothesis)来设置的。在这个假说当中,会认为年轻代中是较新的数据,这些数据中大多对象的生命周期都比较短;而那些在老年代中存活下来的数据,它们的生命周期又会特别长。
|
||||
|
||||
所以在V8中有一副一主两个垃圾回收器,分别负责年轻代和老年代的垃圾回收。
|
||||
|
||||
副垃圾回收器(minor GC,scavenger)的作用就是回收新生代中生命周期较短的对象,并且将生命周期较长的对象移动到老年代的半空间。年轻代空间里又包含对象区域(from-space)和空闲区域(to-space)。
|
||||
|
||||
这里你可能会想,为啥年轻代里还要再分两个区呢?因为这样方便对数据进行处理。在对象区域,数据会被标记成存活和垃圾数据。之后垃圾数据会被清除,存活数据会被晋升整理到空闲区域。这时,空闲区域就变成了对象区域,对象区域就变成了空闲区域。也就是说在不创建新的区域的情况下,可以沿用这两个区域交换执行标记和清除的工作。
|
||||
|
||||
|
||||
|
||||
而主垃圾回收器(major GC)会在老年代半空间中的对象增加到一定限度的时候,对可以清除的对象做渐进的标记。通常当页面在很长一段时间空闲时,会进行全量清理,清除的动作是由专属的清除线程来完成的,最后对于碎片化的内存还要进行整理的动作。所以整体下来,主回收器的操作流程是标记-清除-整理(mark-sweep-compact)。
|
||||
|
||||
在内存管理中,特别是垃圾回收中,它的底层逻辑其实很简单,总结起来其实就3点:
|
||||
|
||||
|
||||
如何标记存活的对象;
|
||||
回收扫清非存活的对象;
|
||||
回收后对碎片进行整理。主回收器也不例外。
|
||||
|
||||
|
||||
首先,我们先来看一下标记(mark)。标记是找到可触达对象的过程。垃圾回收器通过可触达度来判断对象的“活跃度”。这也就代表着要保留运行时(runtime)内部当前可访问的任何对象,同时回收任何无法访问的对象。标记从一组已知的对象指针开始,如全局对象和执行堆栈中当前活动的函数,称为根集。
|
||||
|
||||
GC将根(root)标记为存活的,并根据指针递归发现更多存活对象,标记为可访问。之后,堆上所有未标记的对象都被视为无法从应用程序访问的可回收对象。从数据结构和算法的角度,我们可以把标记看作是图的遍历,堆上的对象是图的节点(node)。从一个对象到另一个对象的指针是图的边(edge)。基于图中的一个节点,我们可以使用对象的隐藏类找到该节点的所有向外边缘。
|
||||
|
||||
|
||||
|
||||
标记完成后,在清除(sweep)的过程中,GC会发现无法访问的对象留下的连续间隙,并将它们添加到一个被称为空闲列表(free list)的数据结构中。空闲列表由内存块的大小分隔,以便快速查找。将来,当我们想分配内存时,我们只需查看空闲列表并找到适当大小的内存块即可。
|
||||
|
||||
清除后的下一步就是整理(compact),你可以把它想象成我们平时电脑上的硬盘碎片整理,将存活的对象复制到当前未被压缩的其它内存页中(使用内存页的空闲列表)。通过这种方式,可以利用非存活的对象留下的内存中的小而分散的间隙,这样可以优化内存中的可用空间。
|
||||
|
||||
V8的 Orinoco 项目是为了能不断提高垃圾回收器的性能而成立的,它的目的是通过减少卡顿(jank buster),提高浏览器的流畅度和响应度。在这个优化的过程中,V8 在副回收器中用到了并发和并行。下面,我们就分别来看看它们的原理及实现。
|
||||
|
||||
副回收器中使用的并行
|
||||
|
||||
首先,我们先来看看副回收器(minor GC,Scavenger)用到的并行回收(Scavenger Parallel)。平行回收,顾名思义,就是垃圾回收的工作是在多线程间平行完成的。相比较并发,它更容易处理,因为在回收的时候,主线程上的工作是全停顿的(stop the world)。
|
||||
|
||||
V8用并行回收在工作线程间分配工作。每个线程会被分到一定数量的指针,线程会根据指针把存活的对象疏散到对象空间。因为不同的任务都有可能通过不同的路径找到同一个对象并且做疏散和移动的处理,所以这些任务是通过原子性的读写、对比和交换操作来避免竞争条件的。成功移动了对象的线程会再更新指针供其它线程参考更新。
|
||||
|
||||
|
||||
|
||||
早期,V8所用到的是单线程的切尼半空间复制算法(Cheney’s semispace copying algorithm)。后来把它改成多线程。与单线程一样,这里的收集工作主要分3步:扫描根、在年轻代中复制、向老年代晋升以及更新指针。
|
||||
|
||||
这三步是交织进行的。这是一个类似于霍尔斯特德半空间复制回收器(Halstead’s semispace copying collector)的GC,不同之处在于,V8使用动态工作窃取(work stealing)和相对较为简单的负载均衡机制来扫描根。
|
||||
|
||||
|
||||
|
||||
在此期间,V8也曾尝试过一种叫做标记转移(Mark Evacuate algorithm)的算法。这种算法的主要优点是,可以利用V8中已经存在的较为完整的Mark Sweep Compact收集器作为基础,进行并行处理。
|
||||
|
||||
这里的并行处理分为三步:首先是将年轻代做标记;标记后,将存活的对象复制到对象空间;最后更新对象间的指针。
|
||||
|
||||
这里虽然是多线程,但它们是锁步(lock step)完成的,也就是说虽然这三步本身可以在不同的线程上平行执行,但线程之间必须在同步后再到下一阶段。所以,它的性能要低于前面说的交织完成的Scavenger并行算法。
|
||||
|
||||
主回收器中使用的并发
|
||||
|
||||
并发标记
|
||||
|
||||
说完了副回收器中的并行GC,我们再来看看主回收器中用到的并发标记(concurrent marking)。在主线程特别繁忙的情况下,标记的工作可以独立在多个工作线程上完成标记操作。但由于在此期间,主线程还在执行着程序,所以它的处理相比并行会复杂一些。
|
||||
|
||||
|
||||
|
||||
在说到并发标记前,我们先来看看标记工作怎么能在不同的线程间同时执行。在这里,对象对于不同的主线程和工作线程是只读的,而对象的标记位和标记工作列表是既支持读,也支持写访问的。
|
||||
|
||||
|
||||
|
||||
标记工作列表的实现对性能至关重要,因为它可以平衡“完全使用线程的局部变量”或“完全使用并发”的两种极端情况。
|
||||
|
||||
下图显示了V8使用基于分段标记的工作列表的方法,来支持线程局部变量的插入和删除,从而起到平衡这两种极端场景的作用。一旦一个分段已满,就会被发布到一个共享的全局池中,在那里它可以被线程窃取。通过这种方式,V8允许标记工作线程尽可能长时间地在本地运行而不进行任何同步。
|
||||
|
||||
|
||||
|
||||
增量标记
|
||||
|
||||
除了并发标记法之外,主回收器还用到了增量标记法,也就是利用主线程空闲时间处理增量标记的任务。为了实现增量标记,要保证之前进行一半的工作有“记忆”;同时要处理期间JavaScript对原有对象可能造成的变化。在这里,V8运用了三色标记法和写屏障。
|
||||
|
||||
三色标记法的原理是将从根部开始引用的节点标记成黑、灰和白色。黑色是引用到也标记处理的,灰色是引用到但未标记处理的,白色是未被引用到的。所以当没有灰色节点的时候,便可以清理,如果有灰色的,就要恢复标记,之后再清理。
|
||||
|
||||
|
||||
|
||||
因为增量标记是断断续续进行的,所以被标记好的数据存在可能被JavaScript修改的情况,比如一个被引用的数据被重新指向了新的对象,它和之前的对象就断开了,但因为垃圾回收器已经访问过旧的节点,而不会访问新的,新的节点就会因此而被记录成未被引用的白色节点。所以在这里必须做一个限制,就是不能让黑色节点指向白色的节点。而这里的限制就是通过一个写屏障来实现的。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们通过V8,了解了JS引擎是如何利用闲置状态来做垃圾回收的,以及考虑到程序性能时,这种回收机制可能带来的卡顿和性能瓶颈。我们看到Chrome和V8为了解决性能问题,通过分代和主副两个回收器做了很多的优化。这里面也用到了很多并发和并行的工作线程来提高应用执行的流畅度。
|
||||
|
||||
虽然对于一般的Web应用,这些问题并不明显。但是随着Web3.0、元宇宙的概念的兴起,以及WebGL+Web Worker的并行、并发实现越来越普及,由此使得前端对象创建和渲染工作的复杂度在不断提高,所以内存管理和垃圾回收将是一个持续值得关注的话题。
|
||||
|
||||
思考题
|
||||
|
||||
虽然我们说JavaScript的垃圾回收是自动的,但是我们在写代码的时候,有没有什么“手工”优化内存的方法呢?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
190
专栏/JavaScript进阶实战课/30网络:从HTTP_1到HTTP_3,你都需要了解什么?.md
Normal file
190
专栏/JavaScript进阶实战课/30网络:从HTTP_1到HTTP_3,你都需要了解什么?.md
Normal file
@ -0,0 +1,190 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 网络:从HTTP_1到HTTP_3,你都需要了解什么?
|
||||
你好,我是石川。
|
||||
|
||||
说到HTTP你可能并不陌生,但是你真的确定你对它有所了解吗?或者你可能会觉得了解它对前端开发没什么用。但实际上,对HTTP的理解可以帮助我们更好地制定前端应用层性能优化的策略。
|
||||
|
||||
所以今天,我们就来看看不同的HTTP版本。对于HTTP/1的不足之处,我们能做哪些优化;对于HTTP/2的优点,我们如何加以利用;对于HTTP/3,我们又可以有哪些期望。下面,我们就先从HTTP的前世今生开始说起吧。
|
||||
|
||||
HTTP/1.0
|
||||
|
||||
HTTP最早的版本是1991年由万维网之父蒂姆·伯纳斯-李(Tim Berners-Lee)定义的,这个HTTP/0.9的版本起初只有一页纸一行字的描述。HTTP/0.9仅支持GET方法,仅允许客户端从服务器检索HTML文档,甚至都不支持任何其他文件格式或信息的上传。
|
||||
|
||||
之后的HTTP/1是从1992年开始草拟到1996年定版的,这个版本的协议就包含了更多的我们如今耳熟能详的元素和功能,比如我们常用的headers、errors、redirect都是在这个版本中出现了。但是它仍然有几个核心问题:一是没有办法让连接在不同请求间保持打开;二是没有对虚拟服务器的支持,也就是说在同一个IP上没有办法搭建多个网站;三是缺乏缓存的选项。
|
||||
|
||||
时隔半年,紧跟其后出现的就是HTTP/1.1,它的出现解决了上述问题。但是相比互联网的使用者日益增加和Web应用的不断兴起,HTTP的标准在之后的近二十年里却几乎止步不前。在这期间,一些瓶颈没有得到进一步的解决,造成的问题日益凸显。
|
||||
|
||||
这些性能上的瓶颈可以归为几个方面:
|
||||
|
||||
|
||||
延时,也就是一个IP包从一点到另外一点间的时长。而且当我们考虑往返时间(RTT)的时候,要计算两倍的延时;一个HTTP请求又可能都包含多个往返时间,这时延时会进一步增加。
|
||||
带宽,这就好比我们每天开车的路一样,车道越窄,车流越大,越有可能造成阻塞,而多车道的道路,通常会减少通勤时间。
|
||||
连接时间,一次网络连接要经过三次握手,第1次是客户端对服务器端发起的同步请求,第2次是服务器发给客户端的获知,第3次是客户端发给服务器端的获知。
|
||||
TLS协商时间,这个是为了建立HTTPS的安全连接所需要的在三次握手基础上的额外处理时间。
|
||||
|
||||
|
||||
与此同时,开发者就想出了一系列的“曲线救国”的方案。这里包含了利用HTTP的流水线,使用域名分片,捆绑资源以减少HTTP请求,内联较小的资源等等。下面,我们可以看看在这一时期,人们所使用的一些性能优化方案。
|
||||
|
||||
持久HTTP加流水线
|
||||
|
||||
首先我们来看看持久HTTP加流水线的方案。在下面的例子里我们看到,如果前端想要请求HTML和CSS这两个资源,在先后请求的情况下,我们要建立两次连接。每次3次握手的话,两次请求就是一共6次握手。显然这么操作是很费时的。
|
||||
|
||||
|
||||
|
||||
持久HTTP(Keep Alive)允许我们在多个应用程序请求之间重用现有连接。通过对持久HTTP的使用,我们可以减少握手的时间。但它仍然不是最优解,因为使用持久HTTP,也就意味着客户端上有严格的先入先出(FIFO)排队顺序,第一个请求先发送,等待完整响应,然后再发起下一个请求。从下图(左),我们可以看到它的流程。
|
||||
|
||||
|
||||
|
||||
为了能更进一步优化,HTTP流水线则可以在持久HTTP的工作流上做一个优化,也就是将FIFO队列从客户端(请求队列)改到服务器(响应队列)。从上图(右)中,我们可以看出它的流程。对比在左图中持久HTTP的例子,CSS的请求要等到HTML的请求返回后再进行;而在右图HTTP的工作流的例子中,我们可以看到两个请求可以同时进行。这就进一步大大减少了总时长。
|
||||
|
||||
但是在使用持久HTTP加流水线的时候,因为连接是一直开放的,要特别注意安全性,使用HTTPS的连接。在一些实际的案例中,比如苹果的iTunes就通过这种方式提高了应用的性能。
|
||||
|
||||
域名分片
|
||||
|
||||
另外一个非常常见的在HTTP/1.1时代的性能优化方案是域名分片,它的核心是通过建立更多的子域名来对资源进行加载。为什么要这么做呢?
|
||||
|
||||
我们可以以高速公路为例,在HTTP/1.1中,一个主机只能有6个TCP连接,也就相当于6车道。当我们想在同一时间让更多的资源加载,就可以创建多个主机,这样就可以有更多的TCP连接。比如我们如果有3个子域名,就是3乘以6,等于18条车道,这样在同一时间就有更多的资源可以加载。但是这个方案也不是一劳永逸的,每个新的主机名都需要额外的DNS查找,为每个额外的socket消耗额外资源,而且需要开发手动管理资源的分片位置和方式。所以在使用的时候还是要综合考虑。
|
||||
|
||||
捆绑资源以减少HTTP请求
|
||||
|
||||
另外一个HTTP/1.1时代的科技与狠活儿就是资源捆绑,在图片层面使用的是CSS精灵(CSS sprite),JS或CSS类的文件则用的是打包的技术。CSS精灵的实现是将不同的图片元素整合在一张图加载,通过CSS控制显示的位置。JS或CSS类的文件打包就是将不同的CSS和JS文件打包成一个,来减少请求。
|
||||
|
||||
同样的,这些方法有利有弊,这样的优化虽然减少了请求和返回的数量,但是同时也造成了一些问题。比如捆绑后的资源可能并不是在当前页面都受用的,这样就可能造成资源的浪费。所以,在使用这种优化方案的时候,也要结合实际场景考虑。
|
||||
|
||||
内联较小的资源
|
||||
|
||||
最后,我们再来看看内联。使用数据URI,我们可以在页面对较小的资源做内联。这样,可以减少请求的数量。同样,这种方式是一把双刃剑,如果什么资源都加载到页面里,也会增加一次性加载的负担。所以通常的最佳实践是1~2KB大小的数据,可以用这种方式来内联,但是如果是过于大的资源,则不适合用这种方式了。
|
||||
|
||||
HTTP/2.0
|
||||
|
||||
说完了HTTP/1.1时代的历史和性能优化,下面,我们再来看看HTTP/2.0时代。你可能会问,为什么HTTP在1.1版之后,这么长时间都没有什么变化呢?这是因为它的升级成本太高了,要考虑到浏览器、服务器、代理,还有其它的中间件都需要协调才能完成切换,这就代表着各种兼容性问题可能会导致应用的服务中断。所以整个行业没有什么动力去推动改革。
|
||||
|
||||
但是在2009年的时候,谷歌的两位工程师Mike Belshe和Roberto Peon提出了一个HTTP/1.1的替代方案,叫做SPDY,谐音就是speedy,也就是速度的意思。SPDY起初并没有要替代并成为HTTP/1.1的升级版,但是由于它更适应现代Web应用的开发需求,到2012年,SPDY已经迅速得到了主流浏览器如Chrome、火狐和友朋的支持,而且相关的互联网公司如Google、Facebook、Twitter的后端服务器和代理也在同一时间做出了支持。
|
||||
|
||||
这时,负责维护HTTP标准的IETF坐不住了,从2012年到2015年间开始定义了一系列的对HTTP/2的改进,主要包括了:通过完整的请求和响应的多路复用,减少延迟;通过压缩HTTP头字段来最小化协议开销;增加对“请求优先级”和对“服务器推送”的支持。
|
||||
|
||||
在2015年HTTP/2正式推出后,SPDY也就退出历史舞台了。当然SPDY和HTTP/2并不是竞争的关系,相反SPDY在整个过程中体现了排头兵和“小白鼠”的精神,在无数次实验中,检验每个优化概念在具体实施中的表现和效果。IETF也本着开放的态度,将SPDY中很多的元素纳入了最终的标准。那HTTP/2为了实现上述功能都做了哪些改变呢?下面,我们就具体来看看。
|
||||
|
||||
HTTP/2.0的特点
|
||||
|
||||
在HTTP/2中,最核心的概念就是二进制帧层(binary framing layer),它规定了HTTP消息如何在客户端和服务器之间封装和传输。
|
||||
|
||||
在了解它之前,我们要先了解几个概念,就是流、消息和帧。流是建立连接中的双向字节流,可以携带一条或多条消息。消息是映射到逻辑请求或响应消息的完整帧序列。帧是HTTP/2中最小的通信单元,每个单元都包含一个帧头,它标识帧所属的流。
|
||||
|
||||
|
||||
|
||||
总的来说,二进制帧层中的“层”,指的是socket和对外暴露的HTTP接口之间的部分。HTTP/2将HTTP/1.x协议通信明文中的换行改为了二进制编码帧的分段,然后将分段映射到指定的流的消息中,所有这些消息都可以在单个TCP连接中复用。这是支持HTTP/2协议提供的所有其他功能和性能优化的基础。
|
||||
|
||||
|
||||
|
||||
这样的设计使得客户端和服务器都必须使用新的二进制编码机制来相互解析,也就是说HTTP/1.x客户端无法理解HTTP/2的服务器,反之亦然。这也就是我们之前说的导致HTTP/1.1裹足不前的原因之一。下面我们就具体看看 HTTP/2 具备哪些新特性。
|
||||
|
||||
请求和响应多路复用
|
||||
|
||||
对于HTTP/1.x,每个连接一次只能传递一个响应,如果客户端希望发出多个并行请求以提高性能,则必须使用多个TCP连接。这样不仅会导致前端阻塞,而且还会造成底层TCP连接的低效使用。
|
||||
|
||||
|
||||
|
||||
HTTP/2中新的二进制帧层消除了这些限制,而且因为HTTP消息被分解为了独立的帧,所以它们之间可以进行交织,然后在另一端重新组合来实现完整的请求和响应复用。在上图中,我们可以看到同一连接中的多个流,客户端正在向服务器传输流5的DATA帧,而服务器正在向客户端传输流1和3交织的帧序列。因此,有三个并行流正在传输。
|
||||
|
||||
这种方式可以带来一系列的好处:
|
||||
|
||||
|
||||
它可以避免阻塞,允许多个请求并行交错和响应;
|
||||
它可以使用单个连接并行传递多个请求和响应,删除前面我们说过的不必要的HTTP/1.x解决方案,如连接文件、图像精灵和域碎片;
|
||||
通过消除不必要的延迟并提高可用网络容量的利用率,降低页面加载时间。
|
||||
|
||||
|
||||
标头压缩
|
||||
|
||||
每个HTTP传输都包含一组标头,用于描述传输的资源及其属性。在HTTP/1.x中,这类元数据是纯文本的形式,每次传输会增加500~800字节的开销,而如果使用的是HTTP cookie,有时还会增加到上千字节。
|
||||
|
||||
为了减少这样的开销并提高性能,HTTP/2使用HPACK压缩格式压缩请求和响应标头的数据,该格式使用了两种看上去很简单但强大的技术:一是它允许通过静态霍夫曼代码对传输的头部字段进行编码,从而减小了它们各自传输的大小;二是它要求客户端和服务器维护和更新先前看到的标头字段的索引列表(即建立共享压缩上下文),可以作为更加高效编码的参考。
|
||||
|
||||
|
||||
|
||||
HPACK有静态和动态两个表。静态的是一些常用的标头元素;动态表开始是空的,后面根据实际的请求来增加。如果想更深入理解霍夫曼算法的同学,也可以参考隔壁班黄清昊老师算法课中的《哈夫曼树:HTTP2.0是如何更快传输协议头的?》。
|
||||
|
||||
请求优先级
|
||||
|
||||
因为上面我们看到,HTTP/2中传递的信息可以被分割成多个单独的帧,并且允许来自多个流的帧被复用,那么考虑到客户和服务器端对帧进行交叉和传递的顺序,HTTP/2标准允许每个流定义1~256之间的权重和依赖性。
|
||||
|
||||
这里面,流的依赖性和权重是一个“优先级树”型的结构,这个树表达了客户端希望如何接收响应。反过来,服务器可以通过控制CPU、内存和其他资源的分配,使用这些信息来确定流处理的优先级,并且一旦响应数据可用,就可以分配带宽,以最优的方式向客户端传递高优先级响应。
|
||||
|
||||
|
||||
|
||||
比如在上面的例1中,流A和流B平级,A的权重为8,B的权重为4,因此,A应分配2/3可用资源,B应获得剩余的1/3。在例2中,C依赖于D,因此,D应该在C之前获得全部资源分配。以此类推,在第3个例子中,D应先于C获得全部资源分配,C应先于A和B获得全部资源分配,A应获得可用资源的2/3,B应获得剩余的1/3。
|
||||
|
||||
服务器推送
|
||||
|
||||
HTTP/2的另一个强大的新特性是服务器能够为单个客户端请求发送多个响应。也就是说,除了对原始请求的响应之外,服务器还可以向客户端推送额外的资源,而客户端不必特意请求每个资源。
|
||||
|
||||
|
||||
|
||||
为什么我们在浏览器中需要这样的机制呢?因为一个典型的Web应用程序可能就会由几十个资源组成,因此,不需等待客户端请求就将资源推送到客户端,可以消除额外的延迟。
|
||||
|
||||
其实我们前面说过的,通过数据URI将CSS或JavaScript资源手动内联到文档中得到的结果就类似服务器推送的结果了,但服务器推送具有几个额外的优势:
|
||||
|
||||
|
||||
客户端可以缓存推送的资源;
|
||||
推送的资源可以跨不同页面重用;
|
||||
推送的资源可以与其他资源一起复用;
|
||||
推送的资源可以由服务器确定优先级;
|
||||
客户端也可以拒绝推送的资源。
|
||||
|
||||
|
||||
在使用服务器推送时,需要注意的是基于前端浏览器的安全限制,要求推送资源必须遵守同源策略。同源是个术语,这里的源是来源,也就是说服务器必须保障提供的内容具有权威性。
|
||||
|
||||
HTTP/2.0的优化
|
||||
|
||||
通过HTTP/2上述的优势,我们可以看到,与其说我们需要优化,不如说针对HTTP/1.1可以反优化。也就是说我们前面说的域名分片、捆绑资源、CSS精灵、内联资源都是可以省掉的了,但是这不代表说我们不需要任何的优化了。
|
||||
|
||||
因为有一些优化是不基于任何HTTP版本都可以采用的。从应用实现的角度来看,就包含了客户端的缓存、资源的压缩、减少无用的请求字节、并行请求和返回的处理。基于技术重要性和产生的影响,下面我们可以重点看看缓存和压缩。
|
||||
|
||||
客户端的缓存
|
||||
|
||||
首先,我们可以说最快的网络请求是没有请求。所以我们可以缓存先前下载数据,这样客户端在后续访问中可以使用资源的本地副本,从而消除请求。我们可以通过 Cache-Control 标头指定资源的缓存生存期,再通过 Last Modified 和 ETag 标头提供验证机制。
|
||||
|
||||
资源的压缩
|
||||
|
||||
虽然利用本地缓存,客户端可以避免在每个请求中获取重复内容。但是,如果必须提取资源,或者资源已过期、是新的,或者无法缓存,则应以最小字节数传输。压缩常用的方法有 Gzip 和 Brotli 压缩。
|
||||
|
||||
Gzip压缩格式已经有近30年历史了,几乎所有主要浏览器都支持Gzip。它是一种基于Deflate算法的无损算法。Deflate算法本身对输入数据流中的数据块使用的是 LZ77 算法和霍夫曼编码的组合。LZ77算法识别重复的字符串,并将其替换为反向引用,反向引用是指向先前出现的位置的指针,后跟字符串的长度。随后,霍夫曼编码识别了常用的引用,并将其替换为具有较短比特序列的引用。较长的位序列用于表示不常用的引用。
|
||||
|
||||
|
||||
|
||||
之后,在2012年,Google推出了一个Zopfli压缩算法,它的优势是可以生成更小的Gzip兼容文件。但相比Deflate/Gzip,它的速度较慢,所以更适合静态压缩。
|
||||
|
||||
再之后,到了2015年,谷歌又推出了Brotli算法和压缩数据格式。与GZip一样,Brotli也是一种基于LZ77算法和霍夫曼编码的无损算法。此外,它使用二阶上下文建模,以类似的速度产生更密集的压缩。上下文建模是一种允许在同一块中对同一字母表使用多个霍夫曼树的功能。Brotli还支持较大的反向引用窗口,并具有静态字典。这些特性有助于提高其作为压缩算法的效率。Brotli现在受到主要服务器、浏览器和托管提供商、中间件(包括阿里云和AWS等)的支持。
|
||||
|
||||
HTTP/3
|
||||
|
||||
说完了HTTP/1和HTTP/2,我们再来看看HTTP/3。HTTP/3的前身,快速UDP互联网连接(QUIC,Quick UDP Internet Connections)是由谷歌于2012年推出的。它不再通过TCP,而是通过用户数据报协议(UDP,User Datagram Protocol)进行传输。
|
||||
|
||||
同为传输层的协议,相比较TCP,UDP更加简便和快速。你可能会觉得UDP是不可靠的,因为它缺少稳定性。而QUIC针对这个问题对UDP做了改良,让它可以像TCP一样避免丢包问题,让传输的内容使命必达。从传输的对象来看,QUIC传输的是包和帧,一个包里可以有多个帧。从安全层面来看,QUIC内置了TLS协议,所以不需要额外再添加相关的功能。另外一个变化是,头部压缩算法从HTTP/2中的HPACK升级成了HTTP/3中的QPACK来解决头部阻塞问题。
|
||||
|
||||
QUIC被谷歌用于YouTube等网站。虽然QUIC从未获得过广泛使用,但它促进了HTTP/3标准委员会的团队工作,并帮助指导委员会利用UDP作为协议的基础层。另外,它还将SSL/TLS安全性内置到了QUIC协议中,并引领HTTP/3将其也内置到协议。在HTTP/3开发过程中,其他公司已经开始实施QUIC协议。其中一个值得注意的例子是Cloudflare。最终,HTTP/3在今年(2022年)6月正式定版了。
|
||||
|
||||
总结
|
||||
|
||||
通过这一讲,我们看到了HTTP的前世今生,以及在这个过程中,对前端开发的影响。我们发现,HTTP/2推出后的接受度并不高,之后推出的HTTP/3似乎也有待市场的验证。所以很多开发者认为,这些技术似乎没有在真正意义上发挥到作用。
|
||||
|
||||
我认为有些技术是厚积薄发的,比如我们看到很多大厂像苹果、谷歌等一直走在前面去实践。主要是他们的业务可以形成规模化,所以这些性能优化可以给他们带来可观的影响,但是对于小一些的应用,似乎这些影响就没有这么明显了。
|
||||
|
||||
但是我认为这些会随着时间而改变。这有点像5G一样,似乎5G还没有火起来,6G的标准已经在制定了。它虽然在民间应用上较少,但在2B业务上已经有很大的应用市场了。而且有些技术是要随着配套的相关技术,如基础设施、算力和人们习惯的改变而产生规模效应的。
|
||||
|
||||
随着流媒体、元宇宙和Web3.0概念的兴起,我们对网络的要求只会变得更高,所以结合我们前面说的并发、并行的发展,未来某一天一定会引爆这些技术的普及。但是如果我们不能忍受它爆发前的寂寞,因此而放弃理解和尝试,可能就会在它们到来时错失把握机会的能力。所以我认为虽然HTTP/2和HTTP/3可能不是前端开发中实际用到的技术,但是对前端开发和服务器端如Node.js的开发还是有着深刻影响,值得我们学习和了解。
|
||||
|
||||
思考题
|
||||
|
||||
前面,我们说HTTP/3使用QPACK替代了HTTP/2的HPACK,而起到了性能优化的效果,那么你知道它是通过改进哪些算法来实现性能优化吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
173
专栏/JavaScript进阶实战课/31安全:JS代码和程序都需要注意哪些安全问题?.md
Normal file
173
专栏/JavaScript进阶实战课/31安全:JS代码和程序都需要注意哪些安全问题?.md
Normal file
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 安全:JS代码和程序都需要注意哪些安全问题?
|
||||
你好,我是石川。
|
||||
|
||||
对于大多Web应用来说,我们的程序所提供的功能是对外公开的,这给我们带来了流量的同时,也给我们的系统带来了风险。而且越是关注度高的应用,就越有可能遭到攻击,所以安全是任何应用都要重视的一个话题。
|
||||
|
||||
那么今天,我们来看一下在JavaScript中需要做的安全考虑。这里就包含了跨站脚本攻击(XSS)、跨站请求伪造(CSRF)和XXE漏洞。下面,我们来一一看一下这些问题,总结下相关的安全解决方法。
|
||||
|
||||
跨站脚本攻击
|
||||
|
||||
跨站脚本攻击是一种非常常见的攻击。顾名思义,它指的就是通过来自跨网站的脚本发起的攻击。为了降低出现XSS漏洞的可能性,有一条重要的开发原则是我们应该禁止任何用户创建的未经过处理的数据传递到DOM中。而当用户创建的数据必须传递到DOM中时,应该尽量以字符串的形式传递。
|
||||
|
||||
类字符串检查
|
||||
|
||||
我们可以通过多种方式在客户端和服务器上对数据进行检查。其中一种方法是利用JavaScript中的内置函数 JSON.parse(),它可以将文本转换为JSON对象,因此将数字和字符串与转换后的内容做对比。如果一致,就可以通过检查,但函数等复杂的数据类型的对比是会失败的,因为它们不符合与JSON兼容的格式。
|
||||
|
||||
var isStringLike = function(val) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(val)) === val;
|
||||
} catch (e) {
|
||||
console.log('not string-like');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
字符串净化
|
||||
|
||||
另外需要注意的是,字符串/类字符串虽然不是DOM本身,但仍然可以被解释或转换为DOM。为了避免这种情况,我们必须确保DOM会将其直译为字符串或类字符串,而不作转换。
|
||||
|
||||
因此在使用 DOM API 时,需要注意的是,任何将文本转换为DOM或脚本的行为都是潜在的XSS 攻击。我们应该尽量避免使用 innerHTML、DOMParser、Blob 和 SVG 这些接口。以 innerHTML 为例,我们最好使用 innerText 而不是 innerHTML 来注入 DOM,因为innerText 会做净化处理,将任何看起来像HTML标签的内容表示为字符串。
|
||||
|
||||
var userInput = '<strong>hello, world!</strong>';
|
||||
var div = document.querySelector('#userComment');
|
||||
div.innerText = userInput; // 这里的标签被解释成字符串
|
||||
|
||||
|
||||
HTML实体编码
|
||||
|
||||
另外一种防御措施是,对用户提供的数据中存在的所有HTML标记执行HTML实体转义。实体编码允许某些特定的字符在浏览器中显示,但不能将其解释为JavaScript来执行。比如标签<>对应的编码就是 & + lt; 和 & + gt;。
|
||||
|
||||
CSS净化
|
||||
|
||||
CSS 的净化包括了净化任何 HTTP 相关的 CSS 属性,或者只允许用户更改指定的 CSS 字段,再或者干脆禁止用户上传 CSS 等。因为相关的属性,比如 background:url,可能会导致黑客远程改变页面的展示。
|
||||
|
||||
#bankMember[status="vip"] {
|
||||
background:url("https://www.hacker.com/incomes?status=vip");
|
||||
}
|
||||
|
||||
|
||||
内容安全策略
|
||||
|
||||
内容安全策略(CSP)是一个安全配置工具。CSP 可以允许我们以白名单的方式列出允许动态加载脚本的网站。实现 CSP 的方式很简单。在后端的话,可以通过加Content-Security-Policy的标头来实现;如果是在前端的话,则可以通过元标签实现。
|
||||
|
||||
Content-Security-Policy: script-src "self" https://api.example.com.
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src https://www.example.com;">
|
||||
|
||||
|
||||
另外,默认的情况下,CSP 可以禁止任何形式的内联脚本的执行。在使用 CSP 的时候,要注意不要使用eval(),或者类似 eval() 的字符串。eval() 的参数是一个字符串,但如果字符串表示的是一个函数表达式,eval() 会对表达式进行求值。如果参数表示一个或多个 JavaScript 语句,那么 eval() 也会执行这些语句。
|
||||
|
||||
eval("17+9") // 26
|
||||
var countdownTimer = function(mins, msg) {
|
||||
setTimeout(`console.log(${msg});`, mins * 60 * 1000);
|
||||
};
|
||||
|
||||
|
||||
尽量避免的API
|
||||
|
||||
有些 DOM API 是要尽量避免使用的。下面我们再看看DOMParser API,它可以将 parseFromString 方法中的字符串内容加载到DOM节点中。对于从服务器端加载结构化的 DOM,这种方式可能很方便,但是却有安全的隐患。而通过 document.createElement() 和 document.appendChild() 可以降低风险,虽然使用这两种方法增加了人工成本,但是可控性会更高。因为在这种情况下,DOM 的结构和标签名称的控制权会在开发者自己手中,而负载只负责内容。
|
||||
|
||||
var parser = new DOMParser();
|
||||
var html = parser.parseFromString('<script>alert("hi");</script>`);
|
||||
|
||||
|
||||
Blob 和 SVG 的 API 也是需要注意的接口,因为它们存储任意数据,并且能够执行代码,所以很容易成为污点汇聚点(sinks)。Blob 可以以多种格式存储数据,而 base64 的 blob 可以存储任意数据。可缩放矢量图形(SVG)非常适合在多种设备上显示一致的图像,但由于它依赖于允许脚本执行的XML规范,SVG 可以启动任何类型的 JavaScript加载,因此使用的风险要大于其他类型的可视化图像。
|
||||
|
||||
另外,即使是将不带标签的字符串注入DOM时,要确保脚本不会趁虚而入也很难。比如下面的例子就可以绕开标签或单双引号的检查,通过冒号和字符串方法执行弹窗脚本,显示”XSS”。
|
||||
|
||||
<a href="javascript:alert(String.fromCharCode(88,83,83))">click me</a>
|
||||
|
||||
|
||||
跨站请求伪造
|
||||
|
||||
以上就是针对跨站脚本攻击的一些防御方案,接下来我们看看跨站请求伪造(CSRF),这也是另外一个需要注意的安全风险。
|
||||
|
||||
请求来源检查
|
||||
|
||||
因为CSRF的请求是来自应用以外的,所以我们可以通过检查请求源来减少相关风险。在HTTP中,有两个标头可以帮助我们检查请求的源头,它们分别是 Referer 和 Origin。这两个标头不能通过JavaScript在浏览器中修改,所以它们的使用可以大大降低CSRF攻击。Origin 标头只在 HTTP POST 请求中发送,它表明的就是请求的来源,和Referer不同,这个标头也在 HTTPS 请求中存在。
|
||||
|
||||
Origin: https://www.example.com:80
|
||||
|
||||
|
||||
Referer标头也是表示请求来源。除非设置成 rel=noreferer,否则它的显示如下:
|
||||
|
||||
Referer: https://www.example.com:80
|
||||
|
||||
|
||||
如果可以的话,你应该两个都检查。如果两个都没有,那基本可以假设这个请求不是标准的请求并且应该被拒绝。这两个标头是安全的第一道防线,但在有一种情况下,它可能会破防。如果攻击者被加入来源白名单了,特别是当你的网站允许用户生成内容,这时可能就需要额外的安全策略来防止类似的攻击了。
|
||||
|
||||
使用CSRF令牌
|
||||
|
||||
使用CSRF令牌也是防止跨站请求伪造的方法之一。它的实现也很简单,服务器端发送一个令牌给到客户端。
|
||||
|
||||
这个令牌是通过很低概率出现哈希冲突的加密算法生成的,也就是说生成两个一样的令牌的概率非常低。这个令牌可以随时重新生成,但通常是在每次会话中生成一次,每一次请求会把这个令牌通过表单或Ajax回传。当请求到达服务器端的时候,这个令牌会被验证,验证的内容会包含是否过期、真实性、是否被篡改等等。因为这个令牌对于每个会话和用户来讲都是唯一的,所以在使用令牌的情况下,CSRF的攻击可能性会大大降低。
|
||||
|
||||
下面我们再来看看无状态的令牌。在过去,特别是 REST API 兴起之前,很多服务器会保留一个用户已连接的记录,所以服务器可以为客户管理令牌。但是在现代网络应用中,无状态往往是 API 设计的一个先决条件。通过加密,CSRF令牌可以轻易地被加到无状态的 API 中。像身份认证令牌一样,CSRF 令牌包含一个用户的唯一识别,一个时间戳,一个密钥只在服务器端的随机密码。这样的一个无状态令牌,不仅更实用,而且也会减少服务器资源的使用,降低会话管理所带来的成本。
|
||||
|
||||
无状态的GET请求
|
||||
|
||||
因为通常最容易发布的CSRF攻击是通过HTTP GET请求,所以正确地设计API的结构可以降低这样的风险。HTTP GET请求不应该存储或修改任何的服务器端状态,这样做会使未来的HTTP GET请求或修改引起CSRF攻击。
|
||||
|
||||
// GET
|
||||
var user = function(request, response) {
|
||||
getUserById(request.query.id).then((user) => {
|
||||
if (request.query.updates) { user.update(request.updates); }
|
||||
return response.json(user);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
参考代码示例,第一个API把两个事务合并成了一个请求+一个可选的更新,第二个API把获取和更新分成了GET和POST两个请求。第一个API很有可能被CSRF攻击者利用;而第二个API,虽然也可能被攻击,但至少可以屏蔽掉链接、图片或其它HTTP GET风格的攻击。
|
||||
|
||||
// GET
|
||||
var getUser = function(request, response) {
|
||||
getUserById(request.query.id).then((user) => {
|
||||
return response.json(user);
|
||||
});
|
||||
};
|
||||
// POST
|
||||
var updateUser = function(request, response) {
|
||||
getUserById(request.query.id).then((user) => {
|
||||
user.update(request.updates).then((updated) => {
|
||||
if (!updated) { return response.sendStatus(400); }
|
||||
return response.sendStatus(200);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
泛系统的CSRF防御
|
||||
|
||||
根据木桶原则,一个系统往往是最弱的环节影响了这个系统的安全性。所以我们需要注意的是,如何搭建一个泛系统的CSRF防御。大多的现代服务器都允许创建一个在执行任何逻辑前,在所有访问中都可以路由到的中间件。
|
||||
|
||||
这样的一个中间件检查我们前面说的 Origin/Referer 是否正确,CSRF 令牌是否有效。如果没有通过这一系列的检查,这个请求就应该中断。只有通过检查的请求,才可以继续后续的执行。因为这个中间件依赖一个客户端对每一个请求传一个 CSRF 令牌到服务器端,作为优化,这个检查也可以复制到前端。比如可以用包含令牌的代理模式来代替 XHR 默认的行为,或者也可以写一个包装器把 XHR 和令牌包装在一个工具里来做到复用。
|
||||
|
||||
XXE漏洞
|
||||
|
||||
XXE是XML外部实体注入(XML External Entity)的意思。防止这个攻击的方法相对简单,我们可以通过在XML解析器中禁止外部实体的方式来防御这种攻击。
|
||||
|
||||
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
|
||||
|
||||
对基于Java语言的XML解析器,OWASP把XXE标记为尤为危险;而对于其它语言来说,XML外部实体默认可能就是禁止的。但为了安全起见,无论使用什么语言,最好还是根据语言提供的API文档找到相关的默认选项,来看是否需要做相关安全处理。
|
||||
|
||||
而且如果有可能的话,使用JSON来替代XML也是很好的选择。JSON相对比XML更轻盈、更灵活,能使负载更加快速和简便。
|
||||
|
||||
总结
|
||||
|
||||
今天,我们看到了一些Web中常见的漏洞和攻击。网络和信息安全不仅是影响业务,更是对用户数字资产和隐私的一种保护,所以在安全方面,真的是投入再多的精力都不过分。这节课我们只是站在Web应用的角度分析了一些常见的漏洞,实际上安全是一个更大的值得系统化思考的问题。
|
||||
|
||||
思考题
|
||||
|
||||
今天,我们提到了XML和SVG都是基于XML Schema 的,XML我们大多可以通过JSON来代替,那么你知道SVG可以通过什么来代替吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
96
专栏/JavaScript进阶实战课/32测试(一):开发到重构中的测试.md
Normal file
96
专栏/JavaScript进阶实战课/32测试(一):开发到重构中的测试.md
Normal file
@ -0,0 +1,96 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 测试(一):开发到重构中的测试
|
||||
你好,我是石川。
|
||||
|
||||
在软件工程中有很多思想,其中被谈及最多的就是敏捷交付了。虽然人们一直讲敏捷,快速交付,但是往往忽略了敏捷不代表牺牲质量。而问题是,在强调开发速度的同时,交付质量在很多时候容易被忽视。于是,测试驱动开发(TDD)的思想就诞生了,虽然后面又出现了面向用户的行为驱动测试,但是对于开发者而言,TDD仍然是和敏捷最成熟的搭配开发模式。
|
||||
|
||||
今天,就让我们来看看测试驱动的开发。
|
||||
|
||||
红绿重构
|
||||
|
||||
测试驱动开发中有个很重要的概念就是红绿重构(red/green/refactor)循环,在一个循环中,有三个步骤:
|
||||
|
||||
|
||||
第1步是创建一个失败的测试,因为这时我们还没有相关的功能被开发出来,所以测试肯定是失败的,这是红色的部分;
|
||||
第2步是写出恰好能通过测试的代码,这个就是绿色的部分;
|
||||
第3步是重构,在这一步中我们会看之前的代码是否有可以优化的部分来进行优化。
|
||||
|
||||
|
||||
|
||||
|
||||
乍一看,你可能觉得这似乎是反直觉的。TDD和传统的开发相反,在传统的开发中,按道理来说我们应该先做第3步,设计出“优雅”的代码结构;然后再做第2步,写好代码;最后再做第1步,测试基于我们的设计开发出来的代码。
|
||||
|
||||
那为什么在TDD中我们要反其道而行呢?这其中的原因是我们的测试用例并不是凭空想象的,它是根据我们的用户故事和验收条件创建的。它的目的是让我们从第1步开发前就清楚开发的目的和想要得到的结果,然后第2步才是写出满足我们目标的代码,也就是一步步实现目标的过程。这时,如果我们的代码写好了,那它自然而然就通过测试了,从而避免积累了大量的问题后,才发现之前写的程序有问题。
|
||||
|
||||
那为什么重构是第3步呢?因为对于我们大多数的项目,特别是业务驱动的项目来说,时间就是金钱,效率就是生命。所以能按时开发出可以运行的代码比开发出优雅的代码更重要。因为可以运行的代码是用户可以直接获益的,而优雅的代码很多时候是程序员更关注的。
|
||||
|
||||
那这里你可能会担心,这样一再追求结果的开发模式,会不会导致长期过度以结果为导向,而影响了代码的质量,造成长期的技术债?不用担心,这正是“红绿重构循环”中的循环要解决的问题。重构不是在技术债的积累爆发后进行的,而是每次的开发周期中的一个环节。也就是说它还是存在的,并且一直迭代。这样就在避免了开始的过度设计的同时,也保证了持续的优化和迭代。
|
||||
|
||||
重构的目的一般是通过对代码结构的调整和去重来优化我们的软件设计,让我们的代码更容易理解。所以它的目的虽然不是让我们更快地交付最小化可行产品(MVP),但是它可以让我们的代码变得更易懂和容易维护,如我们在函数式编程中曾经提到的,毕竟我们写的代码更多是给人读的,而不是给机器读的。
|
||||
|
||||
测试TDD还是行为BDD驱动开发
|
||||
|
||||
我们知道除了TDD外,另外一个概念是 BDD开发。基本上,BDD和TDD的测试流程很相似,它们的不同主要体现在测试的角度上。
|
||||
|
||||
对于TDD来说,测试用例是用代码写的,面向的对象是程序员。而BDD的测试用例一般是业务人员或用户写的,所以用例可以是我们平时用的中英文语言,而不是代码。
|
||||
|
||||
TDD核心关注的是最小单元的测试,也就是单元测试(unit test),其次是集成(integration test)和端到端测试(end to end test)。因为BDD的测试已经到行为层面了,所以这样的测试一般要在端到端的基础上才能运行,也就是说如果我们要在BDD中也用红绿重构的话,它里面也会有更小的TDD单元测试的红绿重构循环。
|
||||
|
||||
这也就引出了下一个问题,红绿重构可以是循环嵌套的。比如,如下图所示,我们在左上角编写了一个失败的测试,也就是红色状态的测试。从这个状态出发,有3种可能性。一是我们可以顺着第一个失败的测试编写下一个失败测试用例;二是我们可以顺着这个失败用例写出相关的程序来通过测试;三是我们没办法通过当前这个测试,这时,我们需要下一层的循环嵌套,在里面再创建一个红绿重构的流程,继续创建新的失败测试。
|
||||
|
||||
|
||||
|
||||
对于处于绿色状态的测试而言,也可能出现两种场景。第1种场景是重构;第2种场景是完成调用,离开循环,也就代表测试已经通过且没有需要重构的代码。最后,对于处于蓝色的重构状态来说,我们可以重构,基于测试结果,我们可以继续重构,或者完成代码测试,并离开循环。
|
||||
|
||||
当所有测试都完成后,所有的流程都会离开循环。如果我们处于内部循环,这意味着我们进入外部循环的绿色阶段。如果我们处于外部循环中,并且我们想不出更多的测试要写,那么整体的测试就完成了。
|
||||
|
||||
这个过程中值得注意的是,只有在绿色测试通过之后,考虑重构的时候,才会再创建一个新的红色测试。如果不需要重构,就表示没有更多工作要做。比如最近刚刚优化过一次,没有再需要重构的,那就可以直接退出循环了。
|
||||
|
||||
在嵌套的测试中,我们把外面一层的测试叫做高级测试,把里面一层叫做低级测试。如果上面的理论还有些抽象的话,我们可以具体到一个TDD循环嵌套的例子来看看。假设我们有一个用户登录的模块,在这个模块中,我们可以有如下里外两层的测试。
|
||||
|
||||
|
||||
外部循环失败测试用例可以是:客户可以登录。
|
||||
|
||||
|
||||
内部循环的失败测试用例可以是:登录路径 /login 返回200响应;
|
||||
为了通过低级测试,就要编写和测试路由的代码,直到测试状态转为绿色;
|
||||
通过低级测试,状态转绿后,可以开始重构内部循环中的代码;
|
||||
此时外部循环测试可能仍然失败,我们发现缺少一个内部循环的测试,因此我们又编写了一个新的内部循环的失败测试:短信和社交媒体登录验证通过,位于 /login-post 的表单post路由应该重定向到 /personal-center;
|
||||
为了通过新的测试,我们编写了处理成功通过短信/社交媒体验证并返回登录的个人主页的代码;
|
||||
这个新的内部循环测试也通过了,所以我们现在可以重构在内部循环上编写的代码。
|
||||
|
||||
现在,内部循环的低级测试和外部循环的高级测试都通过了。
|
||||
当外部循环测试中的所有用例都通过变为绿色后,我们就可以重构外部循环的代码了。
|
||||
|
||||
|
||||
延伸:除了单元,还有哪些测试类型?
|
||||
|
||||
前面,我们说过对于TDD来说,最核心的是单元测试。因为这个时候我们主要关注的是代码的细节和应用自身的功能实现,所以这时的开发和测试可能都是本地的。那么什么可以被看做是一个单元呢?
|
||||
|
||||
在很多语言里,单元指的是类或函数。而在JavaScript中,单元既可以是类或函数,也可以是模块或包。无论我们如何定义单元测试,重点是这类测试关注的是每个单元中功能输入和输出的行为,以及过程中创建的对象。在这个过程中,我们也可能会对上下游系统有依赖,不过这种依赖通常都会通过Mock和Stub的方式模拟代替。通过这种方式,可以将系统间的测试延后。
|
||||
|
||||
当前端的自身测试都通过之后,我们会继续集成测试。集成测试通常是一对一的和自己系统相关的上下游的系统间测试。这个时候的上下游系统,可能依然是自己系统的一部分,特别是在大前端的概念下,多个前端可能都会共享一个后端的服务器提供的内容,相关的内容服务可能会通过BFF,也就是为前端提供的后端层(backend for frontend)来提供的。
|
||||
|
||||
之后的测试通过后,才会是和外部系统的端到端测试。在端到端测试中,我们可能就会和更多层的系统联调测试。比如一个电商功能,可能我们在前端下的订单,会先调用支付系统的接口完成支付,同时发送电商下单请求,支付成功后收到结果,再发送给电商系统通知发货,电商系统会再通知下游系统发货,将发货通知再返回给前端。这样一个全链路的功能,就需要端到端测试的支持。对于此类测试而言,应用代码应该和其它系统的代码结合。这时,就应该尽量避免使用Mock或Stub来模拟前后端的交互,而是应该强调前端和后端系统之间的接口能实际跑通,也就是基于系统间的集成。
|
||||
|
||||
总结
|
||||
|
||||
在很多讲TDD测试的书或文章中,通常会偏概念化,希望通过今天的学习,你能对它有更具象的了解。下一讲,我们会更具象化地展开它的实践。
|
||||
|
||||
围绕JavaScript而产生的测试工具有很多。而且这些工具很多都是以模块化的方式存在的,也就是说一类测试可以用工具A来完成,另外一类测试又可以通过工具B。在下一讲中,我们就会来看一下Jest,相对于其它的测试框架而言,Jest有对不同类型的测试相对更广泛的支持。这也就避免了我们在不同的测试场景之间切换工具的情况。
|
||||
|
||||
除了功能性的测试,还有非功能性测试。非功能性测试又包含了性能测试、安全测试和辅助功能测试等等。这些,我们也会进一步在“测试三部曲”中的第三讲来看。
|
||||
|
||||
思考题
|
||||
|
||||
我们今天从红绿重构的角度了解了测试驱动的开发,这里我们主要看的是测试的深度(嵌套),除此之外,可能我们也要关注覆盖率和复杂度(圈数),那么你能分享下平时在开发中你是否有测试驱动开发的习惯?通常测试的覆盖率能达到多少呢?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
253
专栏/JavaScript进阶实战课/33测试(二):功能性测试.md
Normal file
253
专栏/JavaScript进阶实战课/33测试(二):功能性测试.md
Normal file
@ -0,0 +1,253 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 测试(二):功能性测试
|
||||
你好,我是石川。
|
||||
|
||||
在上一讲中,我们通过红绿重构循环对抽象的测试驱动开发(TDD)做了具象化的理解。今天,我们将进一步通过具体的单元测试来掌握这种开发模式的实施。
|
||||
|
||||
测试工具对比
|
||||
|
||||
目前,市面上已经有很多围绕JavaScript产生的第三方测试工具,所以在这里,我们不需要重复造轮子,通过已有的测试框架来帮助我们进行测试就可以了。首先我们可以对比下几个比较流行的框架:Mocha、Jest和Jasmine。
|
||||
|
||||
这三个工具都基于断言函数(assertion functions)来帮助我们增加测试的可读性和可扩展性,也都支持我们了解测试进度和生成最终的测试结果报告,了解代码的覆盖率。除此之外,Jest更能支持Mock/Stub的测试,同时也可以生成快照来对比前后的测试结果,它也支持多线程。
|
||||
|
||||
|
||||
|
||||
最小化的单元测试
|
||||
|
||||
我们可以看一个简单的例子。首先我们要安装Jest,这要基于Node和NPM,你可以在Terminal通过运行下面的命令来看是否已经安装了Node和NPM。如果返回的是相关的版本信息,那么证明这两个工具已经存在了。
|
||||
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
|
||||
下一步,我们需要再安装Jest,在Terminal,我们可以通过下面这行命令安装Jest。global的flag可以允许我们从命令行的客户端如 Terminal 或 Command Prompt 直接运行Jest测试。
|
||||
|
||||
npm install jest --global
|
||||
|
||||
|
||||
下面,假设我们想要写一个斐波那契数列函数,按照测试驱动的思想,我们先来创建一个fib.test.js的测试文件,里面包含如下的测试用例:如果我们输入7,斐波那契数列结果应该是13。
|
||||
|
||||
test('7的斐波那契结果是13', () => {
|
||||
expect(fib(7, 0, 1)).toBe(13);
|
||||
});
|
||||
|
||||
|
||||
通过下面的指令,我们可以运行上面的测试脚本:
|
||||
|
||||
jest fib.test.js
|
||||
|
||||
|
||||
这时,我们如果运行上述的测试,结果肯定是报错。因为在这个时候,我们还没创建斐波那契数列的函数!所以这一步就是我们红绿重构中的红色部分。
|
||||
|
||||
这时,我们知道为了通过测试,下一步需要创建一个斐波那契的函数。我们可以通过如下的方式创建一个并且保存在fib.js里。在这个文件的尾部,我们做了模块化的导出,为的是让我们能够在刚才创建的测试文件中做导入和引用。
|
||||
|
||||
function fib(n, lastlast, last){
|
||||
if (n == 0) {
|
||||
return lastlast;
|
||||
}
|
||||
if (n == 1) {
|
||||
return last;
|
||||
}
|
||||
return fib(n-1, last, lastlast + last);
|
||||
}
|
||||
|
||||
module.exports = fib;
|
||||
|
||||
|
||||
之后,我们可以在前面的用例中导入斐波那契函数。
|
||||
|
||||
var fib = require('./fib');
|
||||
|
||||
test('7的斐波那契结果是13', () => {
|
||||
expect(fib(7, 0, 1)).toBe(13);
|
||||
});
|
||||
|
||||
|
||||
当我们再次通过之前的指令运行上面的文件时,就可以看到通过的结果。也就是到了红绿重构中的绿色。因为这是一个相对较为简单的测试,我们不需要重构,所以当执行到这里时,我们就可以当做测试完成了。
|
||||
|
||||
数据值类型的匹配
|
||||
|
||||
在数据类型的一讲中,我们讲过了JavaScript赋值中的一些常见的坑,比如值的比较和严格比较所返回的结果是不同的,以及除了布尔值之外,可能会返回否值的数据类型。所以在测试的时候,我们也应该注意我们期待的结果和实际结果是不是匹配的。Jest就自带了很多的内置方法来帮助我们做数据类型的匹配。
|
||||
|
||||
下面我们可以通过两个例子来看看。在第一个例子中,我们可以看到当我们使用toEqual来做比较的时候,undefined就被忽略了,所以测试可以通过,但当我们使用toStrictEqual的时候,则可以看到严格比较的结果,测试的结果就是失败。在第二个例子中,我们可以看到因为数字的值可以是NaN,它是falsy的值,所以测试的结果是通过。
|
||||
|
||||
// 例子1
|
||||
test('check equal', () => {
|
||||
var obj = { a: undefined, b: 2 }
|
||||
expect(obj).toEqual({b: 2});
|
||||
});
|
||||
|
||||
test('check strict equal', () => {
|
||||
var obj = { a: undefined, b: 2 }
|
||||
expect(obj).toStrictEqual({b: 2});
|
||||
});
|
||||
|
||||
//例子2
|
||||
test('check falsy', () => {
|
||||
var num = NaN;
|
||||
expect(num).toBeFalsy();
|
||||
});
|
||||
|
||||
|
||||
我们在前面一个小节斐波那契的例子中用到的 toBe(),是代表比较还是严格比较的结果呢?实际上都不是,toBe() 用的是 Object.is。除了toBeFasly,其它的测试真值的方法还有toBeNull()、toBeUndefined()、toBeDefined()、toBeTruthy()。同样,在使用的时候,一定要注意它们的实际意义。
|
||||
|
||||
除了严格比较和否值外,另外一个也是我们在数据类型讲到的问题,就是数字中的浮点数丢精问题。针对这个问题,我们也可以看到当我们用 0.1 加 0.2 的时候,我们知道它不是等于 0.3,而是等于 0.30000000000000004(\(0.3+4\\times10^{-17}\))。所以 expect(0.1+0.2).toBe(0.3) 的结果是失败的,而如果我们使用toBeCloseTo()的话,则可以看到接近的结果是可以通过测试的。
|
||||
|
||||
除了对比接近的数字外,Jest还有toBeGreaterThan()、toBeGreaterThanOrEqual(),toBeLessThan()和toBeLessThanOrEqual()等帮助我们对比大于、大于等于、小于、小于等于的方法,便于我们对数值进行比较。
|
||||
|
||||
test('浮点数相加', () => {
|
||||
var value = 0.1 + 0.2;
|
||||
expect(value).toBe(0.3); // 失败
|
||||
});
|
||||
|
||||
test('浮点数相加', () => {
|
||||
var value = 0.1 + 0.2;
|
||||
expect(value).toBeCloseTo(0.3); // 通过
|
||||
});
|
||||
|
||||
|
||||
说完了数字,我们再来看看字符串和数组。在下面的两个例子中,我们可以通过toMatch()用正则表达式在字符串中测试一个词是否存在。类似的,我们可以通过toContain()来看一个元素是否在一个数组当中。
|
||||
|
||||
test('单词里有love', () => {
|
||||
expect('I love animals').toMatch(/love/);
|
||||
});
|
||||
|
||||
test('单词里没有hate', () => {
|
||||
expect('I love peace and no war').not.toMatch(/hate/);
|
||||
});
|
||||
|
||||
var nameList = ['Lucy', 'Jessie'];
|
||||
test('Jessie是否在名单里', () => {
|
||||
expect(nameList).toContain('Jessie');
|
||||
});
|
||||
|
||||
|
||||
嵌套结构的测试
|
||||
|
||||
下面,我们再来看看嵌套结构的测试。我们可以把一组测试通过 describe 嵌套在一起。比如我们有一个长方形的类,测试过程可以通过如下的方式嵌套。外面一层,我们描述的是长方形的类;中间一层是长方形面积的计算;内层测试包含了长和宽的设置。除了嵌套结构,我们也可以通过 beforeEach 和 afterEach 来设置在每组测试前后的前置和后置工作。
|
||||
|
||||
describe('Rectangle class', ()=> {
|
||||
describe('area is calculated when', ()=> {
|
||||
test('sets the width', ()=> { ... });
|
||||
test('sets the height', ()=> { ... });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
响应式异步测试
|
||||
|
||||
我们说前端的测试很多是事件驱动的,之前我们在讲异步编程的时候,也说到前端开发离不开异步事件。那么通常测试工具也会对异步调用的测试有相关的支持。还是以Jest为例,就支持了callback、promise/then和我们说过的async/await。下面,就让我们针对每一种模式具体来看看。
|
||||
|
||||
首先,我们先来看看callback。如果我们单纯用callback,会有一个问题,那就是当异步刚刚返回结果,还没等callback的执行,测试就执行了。为了避免这种情况的发生,可以用一个done的函数参数,只有当 done() 的回调执行了之后,才会开始测试。
|
||||
|
||||
test('数据是:价格为21', done => {
|
||||
function callback(error, data) {
|
||||
if (error) {
|
||||
done(error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
expect(data).toBe({price:21});
|
||||
done();
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
}
|
||||
fetchData(callback);
|
||||
});
|
||||
|
||||
|
||||
下面,我们再来看看promise/then以及async/await的使用。同样如我们在异步时提到过的,实际上,async/await也是promise/then基语法糖的实现。我们也可以将await与resolve和reject结合起来使用。在下面的例子中,我们可以看到当获取数据后,我们可以通过对比期待的值来得到测试的结果。
|
||||
|
||||
// 例子1:promise then
|
||||
test('数据是:价格为21', () => {
|
||||
return fetchData().then(data => {
|
||||
expect(data).toBe({price:21});
|
||||
});
|
||||
});
|
||||
|
||||
// 例子2:async await
|
||||
test('数据是:价格为21', async () => {
|
||||
var data = await fetchData();
|
||||
expect(data).toBe({price:21});
|
||||
});
|
||||
|
||||
|
||||
Mock和Stub测试
|
||||
|
||||
最后,我们再来看看 Mock 和 Stub,但是它俩有啥区别呢?
|
||||
|
||||
其实,Mock 和 Stub 都是采用替换的方式来实现被测试的函数中的依赖关系。它们的区别是 Stub 是手动替代实现的接口,而 Mock 采用的则是函数替代的方式。Mock可以帮助我们模拟带返回值的功能,比如下面的myMock,可以在一系列的调用中,模拟返回结果。
|
||||
|
||||
var myMock = jest.fn();
|
||||
console.log(myMock()); // 返回 undefined
|
||||
|
||||
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
|
||||
|
||||
console.log(myMock(), myMock(), myMock(), myMock()); // 返回 10, 'x', true, true
|
||||
|
||||
|
||||
在这里,Jest使用了我们前面在函数式编程中讲过的连续传递样式(CPS),这样做的好处是可以帮助我们尽量避免使用Stub。Stub的实现会有很多手动的工作,而且因为它并不是最终的真实接口,所以手工实现真实接口的复杂逻辑不仅不能保证和实际接口的一致性,还会造成很多额外的开发成本。而使用基于CPS的Mock,可以取代Stub,并且节省模拟过程中的工作量。
|
||||
|
||||
var filterTestFn = jest.fn();
|
||||
|
||||
// 首次返回 `true` ;之后返回 `false`
|
||||
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
|
||||
var result = [11, 12].filter(num => filterTestFn(num));
|
||||
|
||||
console.log(result); // 返回 [11]
|
||||
console.log(filterTestFn.mock.calls[0][0]); // 返回 11
|
||||
console.log(filterTestFn.mock.calls[1][0]); // 返回 12
|
||||
|
||||
|
||||
延伸:UI自动化测试
|
||||
|
||||
在上面的例子中,我们看到的是单元测试。但是,我们只要在前端的使用场景中,几乎离不开UI测试。之前,如果我们想测试UI方面的功能反应,要通过手动的方式点击屏幕上的元素,得到相关的反馈。但是对于开发人员来说,有没有什么自动化的方式呢?
|
||||
|
||||
这就要说到无头浏览器和自动化测试了。无头浏览器(headless browser)指的是不需要显示器的浏览器,它可以帮助我们做前端的自动化测试。
|
||||
|
||||
例如Google就开发了Puppeteer,一个基于Node.js的自动化测试工具,它提供了通过开发者工具协议来控制Chrome的API接口。Puppeteer在默认情况下以无头模式运行,但可以配置为在有头下运行的模式。Puppeteer也可以通过预置或手工配置的方式和Jest结合使用。如果选择预置方式的话,相关的第三方库也可以通过NPM来安装。
|
||||
|
||||
npm install --save-dev jest-puppeteer
|
||||
|
||||
|
||||
安装后,可以在Jest的预置配置中加入 "preset": "jest-puppeteer"。
|
||||
|
||||
{
|
||||
"preset": "jest-puppeteer"
|
||||
}
|
||||
|
||||
|
||||
下面,我们以“极客时间”为例,如果要测试极客时间的标题是否显示正确,我们可以通过如下的测试来实现。这个过程中,我们没有用到显示器,但是程序可以自动访问极客时间的首页,并且检查页面的标题是否和期待的结果一致。
|
||||
|
||||
describe('极客时间', () => {
|
||||
beforeAll(async () => {
|
||||
await page.goto('https://time.geekbang.org/');
|
||||
});
|
||||
|
||||
it('标题应该是 "极客时间-轻松学习,高效学习-极客邦"', async () => {
|
||||
await expect(page.title()).resolves.toMatch('Google');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过今天这节课,我们看到了在单元测试中如何实现前一讲中提到的红绿重构。同时,我们也看到了测试的结果是要和断言作对比的,因此在比较不同数据类型返回值的过程中,要特别注意避坑。
|
||||
|
||||
之后,我们看到了在嵌套结构的测试中,如何将相关的测试组合在一起,以及设置相关的前置和后置功能。并且在基于事件驱动的设计变得越来越重要的今天,我们如何在测试中处理异步的响应,在真实的接口和逻辑尚未实现的情况下如何通过Mock来模拟接口的反馈,以及通过CPS来尽量避免Stub的复杂逻辑实现和与真实接口不一致的问题。
|
||||
|
||||
思考题
|
||||
|
||||
今天我们提到了,Jest对比其它工具有着对快照和多线程的支持,你能想到它们的使用场景和实现方式吗?
|
||||
|
||||
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
122
专栏/JavaScript进阶实战课/34测试(三):非功能性测试.md
Normal file
122
专栏/JavaScript进阶实战课/34测试(三):非功能性测试.md
Normal file
@ -0,0 +1,122 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 测试(三):非功能性测试
|
||||
你好,我是石川。
|
||||
|
||||
上节课,我们学习了功能类测试。今天,我们来看一下非功能性测试中的性能、安全和辅助功能测试。对于一个Web应用而言,性能测试的复杂程度并不低于后端或端到端的测试。导致前端性能测试复杂度很高的主要原因是,影响Web应用性能的因素有很多,并且很多是应用本身不完全可控的。所以今天,我们重点来看一下关于性能测试,都有哪些指标、影响性能的因素和性能调优的方式。
|
||||
|
||||
性能测试
|
||||
|
||||
在任何测试前,我们都需要对要达到的目标有清晰的认识,针对性能测试也不例外。所以首先,我们要来看看对于Web应用来说,都有哪些性能指标。
|
||||
|
||||
性能指标
|
||||
|
||||
之前,我们说过JS虚机开发中做内存管理和优化的时候,会关注浏览器和应用的流畅度(smoothness)和响应度(responsiveness)。其实,对于JS应用的开发者来说,也可以参考类似的 Rail 指标。Rail(response, amination, idle,load)是响应、动效、空闲和加载这4个单词的缩写。下面,我们来分别看下它们所代表的意义。
|
||||
|
||||
|
||||
关于响应,原则是我们的应用应当在50ms内处理响应,并且在50-100ms内提供一个可见的响应;
|
||||
关于动效,如之前讲过的,在处理渲染时,我们的帧预算是每秒60帧,也就是每帧要控制在16.6ms内渲染完成;
|
||||
关于空闲,无论是在加载模式还是垃圾回收的一讲中,我们都提到过应该最大化地利用空闲时间,但是这里需要注意的是,这个利用也需要一个度,如果利用这个时间需要处理的任务超过了50ms,就会影响我们第一点说的100ms内的响应;
|
||||
关于加载,对于页面的加载而言,我们的应用应该尽量在5秒内完成初次加载,并且在2秒内完成后续页面的加载。
|
||||
|
||||
|
||||
影响性能的因素
|
||||
|
||||
说完了测试指标,我们再来看看影响Web性能的因素。前面,我们说过,影响性能的因素是错综复杂的。从大的种类来看,其中就包括了但不限于网络环境、资源的加载和浏览器渲染等外在因素,以及内存管理、垃圾回收等内在因素。
|
||||
|
||||
面对这么多的因素,如果完全通过手动的方式来做性能测试,会非常耗费时间和精力,而且要把这些测试的结果叠加转换成上述指标,又会是一个繁琐的过程。为了解决这个问题,大多的浏览器都提供了开发者工具帮助我们进行这些测试。以Chrome为例,我们可以用到的就有Lighthouse和Performance Insights两个工具。
|
||||
|
||||
性能测试工具
|
||||
|
||||
首先,我们可以看下Lighthouse。在开发者工具的 Lighthouse 页签下,我们可以选择性能检测的选项。从检测的结果中,我们可以看到之前提到的在渲染和加载中关注的FCP、TTI等指标。同时我们可以看到一些性能诊断的建议,比如减少过多的DOM节点、针对静态资源使用高效的缓存策略。同时,我们可以验证一些已经优化项,比如压缩后的JS和CSS文件。
|
||||
|
||||
|
||||
|
||||
但是上述的结果中,我们得到的基本上是一个个的数字,对加载过程中的时间分布没有直观的感受。而通过性能洞察(Performance Insights)的页签,我们可以更直观地基于页面实际的使用场景,让页面加载的过程中产生的耗时在一条时间线上展示出来。这样,我们可以更直接地对一些性能瓶颈做分析判断和优化。
|
||||
|
||||
|
||||
|
||||
性能瓶颈排查
|
||||
|
||||
上面我们通过Lighthouse和Performance Insights,可以很好地对外在因素做分析。可是针对一些内存管理和垃圾回收类的问题,就无从知晓了。所以我们可以看到表象,但无法分析后面的根因。这个时候我们就需要对内存管理有深入的分析工具。Chrome同样在开发者工具中提供了相应的分析排查工具。
|
||||
|
||||
首先,我们可以通过更多工具->任务管理进入到内存管理的窗口,在这里,我们可以看到内存列展示的内存使用情况。这时,如果我们右键选择JavaScript Memory,也可以同时加载出JS的内存使用信息。那么Memory和JavaScript Memory这两列有什么区别呢?
|
||||
|
||||
这里的区别是Memory中包含的是我们的页面产生的DOM元素。而JS Memory包含的是JS生成的对象的数量和括号中包含的目前有的存活的对象数量。如果这里的数量过高,则需要进一步排查。
|
||||
|
||||
|
||||
|
||||
在排查的过程中,我们可以从空间和时间的维度来分析。
|
||||
|
||||
首先从时间的维度,我们可以通过开发者工具中的性能页签(Performance Tab)来做性能分析。在这里,我们可以勾选内存选项,然后选择录制,并且在开始和结束的时候通过点击垃圾桶的标志各做一次垃圾回收来避免来自测试时间段以外的干扰。
|
||||
|
||||
在结果的总览窗口中,heap图表示的是JS的堆,概览下方是计数器窗口。在这里,你可以看到按JS堆、节点和GPU内存等细分的内存使用情况。如果此时我们看到,随着记录的进行,结尾处的JS堆或节点的数量高于开始处,则意味着过程中可能存在着内存泄漏。
|
||||
|
||||
|
||||
|
||||
另外通过内存页签(Memory Tab),我们可以通过空间的维度来分析性能瓶颈。对于浏览器而言,只有当DOM节点在页面的DOM树或JavaScript代码中没有被引用时,才会被垃圾回收。当节点从DOM树中被移除,但仍然被一些JavaScript引用时,这些节点就被称为“脱离DOM树的节点”。脱离DOM树的节点是造成内存泄漏的常见原因。
|
||||
|
||||
堆快照是识别分离节点的一种方法。顾名思义,堆快照向我们展示了在快照时页面的JS对象和DOM节点之间的内存分布情况。在内存页签中,我们可以看到JS堆快照的选项,它可以帮助我们了解页面上JS对象和相关DOM节点的分布。
|
||||
|
||||
在获取快照后,在对象类型的筛选器中通过“detached”关键词,我们可以搜索脱离DOM树的节点。这时我们可能会看到红色和黄色两种节点。而我们需要关注的是黄色节点,因为它们是直接被引用的节点,在使用后需要被及时回收,它们的回收会顺带删除下面被间接引用的红色节点。
|
||||
|
||||
|
||||
|
||||
另外,我们也可以通过JavaScript内存分配的时间线,从时间的维度来看对象的内存分配,排查内存泄漏点。
|
||||
|
||||
|
||||
|
||||
安全测试
|
||||
|
||||
说完性能测试,我们再来看看安全测试。在网络的安全测试中,主要包含渗透测试和漏洞扫描。
|
||||
|
||||
渗透测试可以是黑盒或白盒的,也就是扮演入侵者的测试人员通过前端提供的测试环境和相关开发出来的应用来测试。在黑盒的情况下,入侵者默认没有代码访问,通过尝试,试图渗透到系统内部。
|
||||
|
||||
而漏洞扫描通常是白盒测试,比如很多的云服务厂商都会支持代码托管,在这个基础上,一般会附加漏洞扫描的测试工具。在这个扫描的过程中,负责自动测试的程序可以访问代码库中的代码,并对代码进行扫描,从中发现漏洞。
|
||||
|
||||
无论是渗透测试,还是漏洞扫描,这两种测试都是在软件工程中由专门的测试人员或云平台提供的工具来执行的,所以我们就不在开发测试这里展开讲了。
|
||||
|
||||
下面,我们就从开发的角度,看看我们在程序开发中可以做哪些初步的测试。之前,我们也讲过前端开发过程中可能会遇到的安全问题和解决方案,并且在讲到HTTP的时候,我们也了解了由传输层安全(TLS)和安全套接层(SSL)组成的HTTPS。所以,从前端,如何测试安全连接呢?
|
||||
|
||||
这里,我们同样可以通过浏览器提供的开发者工具。以Chrome为例,在安全页签(Security Tab)看到的主源的安全证书和连接的安全性,以及相关的资源的安全性,可以帮助我们对安全问题做初步的排查。
|
||||
|
||||
|
||||
|
||||
辅助功能测试
|
||||
|
||||
说完了安全,下面我们再来看看辅助功能测试。
|
||||
|
||||
辅助功能在很多欧美的网站是很重要的一个功能,它允许一些残障人士通过辅助功能浏览我们开发的页面。这里包含了色弱症、色盲症和失明的人等,他们按道理也应该可以无障碍地访问我们所开发的页面。在开发辅助功能时,我们一般要考虑两个问题,一是用户是否可以用键盘或屏幕阅读器浏览页面,二是页面元素是否为屏幕阅读器做了正确标记。
|
||||
|
||||
这里插播一条数据信息,当我们说到视障人士,大家可能会觉得这部分人群的比例很低,但实际上中国视障群体超过1700万,其中超过800万人完全失明。另外一个经常被忽略的群体是色盲或色弱的群体,中国色弱人数有6000多万,色盲人群有2000多万,加在一起,又是惊人的8000万人。也就是说光在国内,上亿人可能都会因为我们的页面没有辅助功能,而没法正常访问相关的内容。
|
||||
|
||||
|
||||
|
||||
开发对这部分人群友好的应用,不仅对大公司来讲是企业责任,即便是从功利的角度来看,这部分人群也是购买我们服务或产品的潜在客户。而越是访问量高的网站,要面对的这个群体就越大。如果我们对这部分用户不加以重视,那影响的不仅是声誉,也是我们的业务。所以无论是从社会发展进步的角度,还是市场角度,我们都应该尽可能为这部分用户提供相关的功能和测试。
|
||||
|
||||
色盲或色弱的人群所面临的主要困扰是无法分辨一些对比度较低的颜色。那么我们怎么能对这部分用户选用更友好的界面颜色呢?我们可以参考相关的指导性的技术标准WCAG,这是一部内容无障碍指南。其中一共有3级,A级是残障用户能够访问和使用Web内容的基本要求;AA级表示了内容总体可访问,并消除了访问内容的较大障碍;AAA级为一些残障用户提供了网络辅助功能的改进和增强。
|
||||
|
||||
还是以Chrome开发者工具为例,在 CSS 总览页签(CSS Overview Tab)下面,我们可以在对比度问题上,看到对比度有问题的字体和背景。比如在下面的例子中,我们可以看到当文字的灰色和背景的灰色相差不多的时候,是没有达到AA标准的,而基于蓝色背景的白色字体达到了AA标准,但是相比较AAA还是差一些。
|
||||
|
||||
|
||||
|
||||
另外除了CSS总览页签外,在开发者工具中,我们也可以通过在Lighthouse页签中勾选辅助功能来对网站做审计。通过审计,我们可以看到通过的结果、需要额外手工查看的问题以及在本次测试中不适用的测试用例。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过这一讲,我们看到如何在开发中针对非功能性的性能、安全、辅助功能做测试。而通过测试的这三讲,我们也知道了需要关注的是在开发过程中测试的重要性。从功能性测试中,我们了解了测试并不是一项开发后丢给测试人员的工作,而是测试驱动下“以终为始”的起点。从非功能测试的角度,我们可以看出基于JavaScript开发的前端应用,无论从性能、安全还是辅助功能角度,都有很多的容易被忽视的问题,而单独靠手工的测试无法有效地解决这些问题。这时,就需要借助工具来帮助我们提高测试的效率。
|
||||
|
||||
思考题
|
||||
|
||||
之前我们说过在JavaScript的多线程开发中,会用到ArrayBuffer和SharedArrayBuffer。那么,你知道如何通过浏览器提供的工具或独立的开发者工具来分析、排查和解决多线程开发中的性能问题吗?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
138
专栏/JavaScript进阶实战课/35静态类型检查:ESLint语法规则和代码风格的检查.md
Normal file
138
专栏/JavaScript进阶实战课/35静态类型检查:ESLint语法规则和代码风格的检查.md
Normal file
@ -0,0 +1,138 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 静态类型检查:ESLint语法规则和代码风格的检查
|
||||
你好,我是石川。
|
||||
|
||||
前面几讲中,我们介绍了JS中的测试,其中包括了单元、UI自动化类的功能性测试,还有性能、安全以及辅助工具等非功能性的测试。通常这些测试都是我们产品上线前的软件工程流水线中重要的环节,因为这些测试所发现的问题会直接影响我们的程序能不能正常运行。
|
||||
|
||||
但是除此之外,有些问题是潜在的,虽然不会对我们的程序有直接的影响,但是可能会间接产生系统风险。根据墨菲定律,我们知道,会出错的事总会出错。那么除了测试以外,我们怎么解决代码中这种潜在的风险呢?这时,就需要用到 linter 代码检查工具。
|
||||
|
||||
今天,我们就通过检查代码质量和风格的工具 ESLint,来看看代码质量检查这项工作。
|
||||
|
||||
代码规范工具
|
||||
|
||||
在编程中 “lint” 这个术语指的是虽然可以运行,但从某种程度上不是最优的代码。这些代码可能引起潜在问题,如bug、安全隐患,或者可读性问题。linter是一种检测代码中是否含有问题代码的工具。linting 则是在代码上运行linter工具,然后修复代码、去除问题代码,直到 linter 不再报错的整个过程。
|
||||
|
||||
lint 并不是 JavaScript 的专有名词。它来源于 C 语言,当 C 语言问世不久的时候,有几个常见的编程错误没有被原始编译器捕捉到,因此一个名为 linter 的辅助工具就诞生了,它可以通过扫描源代码文件来查找问题。随着 C 语言的成熟,该语言的定义得到了加强,消除了风险隐患;同时代码的编译器本身一般也会发出警告,所以之后就不再需要这样额外的代码检查工具了。
|
||||
|
||||
前面,我们介绍过,JavaScript 是一个相对灵活轻量的语言,这是它的优点,但同时也是不足。因为它没有像 C 或Java语言一样的严谨,所以特别是对于大型项目来说,这种灵活性也就代表开发者自身成了用好这门语言的第一责任人。而且除了语言定义上缺乏严谨性外,JavaScript本身也没有官方的编译器,所以时至今日,linting 仍然是代码检查中需要单独工具来处理的一项重要工作。
|
||||
|
||||
其实在 ESLint 之前,道格拉斯·克劳福德曾经开发了 JSLint,一个早期的 JavaScript 代码质量检查工具。它的工作原理就是对源代码进行扫描,如果发现潜在问题,就返回问题在源码中的大致位置和描述。这些潜在问题不一定是语法错误,JSLint 也会关注一些样式约定以及代码结构问题。如之前所说,它不像单元测试,通过检查不能证明程序可以正常运行。JSLint 更多是提供了单元测试外,另外发现潜在问题的途径。因为 JSLint 规范比 ECMAScript 语言的官方定义更严格,所以可以被看做是 JavaScript 的子集。
|
||||
|
||||
在后来的发展中,ESLint 逐渐成为了更加受欢迎的一个 linter 工具。
|
||||
|
||||
首先,我们可以简单了解下它的组成。ESLint 最核心的模块是 Linter、CLIEngine 和 RuleTester。
|
||||
|
||||
|
||||
Linter 是核心中的核心,它没有文件I/O,也不与控制台直接交互。它的主要工作就是根据配置的选项来进行代码验证。
|
||||
CLIEngine 的主要作用是找到源代码文件和配置文件,它包含了配置文件、解析器、插件和格式器的加载逻辑。ESLint使用的是Espree的解析器。
|
||||
RuleTester 的作用是对每一条检查规则做单元测试,RuleTester 内部包装的是 Mocha,对,你没看错,ESLint 用 Mocha 作为内部的单元测试工具。RuleTester 效仿 Mocha 的接口,可以与 Mocha 的全局测试方法一起使用。当然除了 Mocha 以外,RuleTester 也可以和其它的单元测试工具结合使用。
|
||||
|
||||
|
||||
|
||||
|
||||
下面我们再来看看ESLint、API和CLI的关系。ESLint 是通过命令行来执行的文件,实际上它只是一个将命令行作为参数传递给CLI的包装器。CLI 接收到参数后,使用 ESLint 执行命令。API 是 require("esint") 的入口,它暴露的对象包括了 Linter、ESLint、RuleTester 和 Source Code 的公共类。
|
||||
|
||||
最后,我们再来看看Source Code 和 Rules。顾名思义,它们分别代表了源代码和代码测试的内置规则。
|
||||
|
||||
代码规范检查
|
||||
|
||||
以上就是关于代码规范工具的一些介绍,下面,我们就可以具体看看 ESLint 的安装和使用步骤了。首先,通过执行以下命令来安装ESLint。
|
||||
|
||||
npm init @eslint/config
|
||||
|
||||
|
||||
在初始化的过程中,ESLint 会问使用的场景、模块化的类型、项目所使用的框架、是否有使用TypeScript、代码运行的环境和 config 文件的格式等等。之后,程序会检查之前有没有 ESLint 的本地安装。如果没有,就会先进行安装。安装前可以选择相关的包管理工具,这里,我使用的是 NPM,除此之外,有些同学也可能选择 YARN。在一系列的选择之后,ESLint就会运行并完成安装。
|
||||
|
||||
|
||||
|
||||
之后,我们通过下面的命令,便可执行代码检查。
|
||||
|
||||
npx eslint yourfile.js
|
||||
|
||||
|
||||
代码规范类型
|
||||
|
||||
那么在检查中,通常会有哪些类型的报错呢?这里,我们先了解下代码规范的目的。因为按照测试驱动的设计思想,如果没有相关的测试目的,那么检查和报错也就无从谈起了。总结来说,代码规范通常有这样两个目的:
|
||||
|
||||
|
||||
提高代码的质量;
|
||||
统一代码的风格。
|
||||
|
||||
|
||||
我们可以通过下面的示例代码来看两个例子。第一个例子,使用 constructor 来构建函数,同 eval() 类似,会使得字符串的内容可能被执行。所以这个例子不仅是代码质量问题,甚至会有安全隐患。
|
||||
|
||||
// bad
|
||||
const add = new Function('a', 'b', 'return a + b');
|
||||
|
||||
// still bad
|
||||
const subtract = Function('a', 'b', 'return a - b');
|
||||
|
||||
|
||||
在下面的例子中,第一行的对象字面量的表达非常长。虽然代码本身的质量没问题,但是这么长的句子会影响代码的可读性。而第二种写法则更加可读。所以通常 linter 工具会要求一行代码的长度不要超过80个字符。这样,可以提高代码的可读性。
|
||||
|
||||
这里,你可能会问,难道写在一行就没有优点吗?其实也不是,如果我们把代码写在一行的话,按说在没有换行的情况下占用的空间会更小,可以压缩文件的大小。但是这个问题通常不是在写代码的时候解决的,而是在程序写完后,可以通过压缩器 JS minifier 来处理。在编写代码的环节,我们始终更重视的问题是我们的代码对于自己和同事是否易读。
|
||||
|
||||
// Bad
|
||||
const foo = { "bar": "This is a bar.", "baz": { "qux": "This is a qux" }, "difficult": "to read" };
|
||||
|
||||
// Good
|
||||
const foo = {
|
||||
"bar": "This is a bar.",
|
||||
"baz": { "qux": "This is a qux" },
|
||||
"difficult": "to read"
|
||||
};
|
||||
|
||||
|
||||
从上面的例子中,我们可以看到 linter 是一种可以让我们的代码变得更好的方式。但是类似这样的问题,在JavaScript 中有很多。同时在有些时候,大家对“好”的定义可能还不一样。那么遇到这么庞大的规则数量,以及大家对代码风格的不同定义,应该怎么处理呢?
|
||||
|
||||
我们先从规则数量说起。这里,ESLint 已经帮助我们整理了一些常用的规则,我们可以将 ESLint 内置的规则作为一个基准,在上面做相关的定制。在内置的规则中,分为问题、建议、布局和格式。问题和建议的目的主要是提高代码质量,布局和格式的目的主要是统一代码编写风格。
|
||||
|
||||
其次我们看看不同的个人或团队都有自己的风格,该怎么处理?遇到这样的问题,开发者可以通过自定义规则来满足不同的需求,而且这些规则也是可以共享的。比如,Airbnb 就总结了一套自己的 JavaScript 代码编写规范,并且将相关的 ESLint 配置开源了出来,可以供其他开发者使用。那如果我们自己一个人写代码,还有没有必要使用 linter 呢?答案是即使在维护者只有自己的情况下,我们也应该让代码形成自己的前后一致的风格。
|
||||
|
||||
通过插件的方式,ESLint 也可以作为插件和 Angular、React 等三方库结合使用。
|
||||
|
||||
延伸:代码规范化工具
|
||||
|
||||
我们在用到 ESLint 时,核心的诉求还是写出更高质量的代码,其次才是代码的美化。一些项目使用 linter 的原因之一是为了实施一致的编码风格,这样当一个开发团队在共享同一个产品或项目代码库的时候,他们就可以使用兼容的代码约定。这包括代码缩进规则,但也可以包括应该用单引号还是双引号,以及 for 关键字和其后的右括号之间是否应该有空格之类的规则。
|
||||
|
||||
除了 linter 这种代码检查类的工具外,还有一种代码规范化的工具,其中一个例子就是 Prettier。它的工作原理也是对代码进行解析和格式化。例如我们编写了下面的函数,从功能层面讲,它是有效的,但格式不符合常规。
|
||||
|
||||
function HelloWorld({greeting = "hello", greeted = '"World"', silent = false, onMouseOver,}) {
|
||||
}
|
||||
|
||||
|
||||
在此代码上运行 Prettier 可以修复缩进,添加缺少的换行,让代码更加可读。
|
||||
|
||||
function HelloWorld({
|
||||
greeting = "hello",
|
||||
greeted = '"World"',
|
||||
silent = false,
|
||||
onMouseOver,
|
||||
}) {}
|
||||
|
||||
|
||||
在使用 Prettier 的时候,如果使用 --write 选项调用,会就地格式化指定的文件,而不是复制后格式化。如果你是用 Git 来管理源代码的话,则可以在代码提交的 hook 中使用 --write 选项来调用 Prettier,它可以让我们在代码提交前自动格式化代码。如果将代码编辑器配置为在每次保存文件时自动运行,则会更加方便。
|
||||
|
||||
|
||||
|
||||
Prettier 是可配置的,你可以选择代码行的长度限制、缩进量,是否应使用分号,字符串是应该通过单引号还是双引号包裹。虽然它只有几个选项,但总的来说,Prettier 的默认选项非常合理,不需要特别多的改动就可以达到想要的效果。同时,Prettier 也可以通过 eslint-config-prettier 和前面我们说到的 ESLint 结合使用。
|
||||
|
||||
总结
|
||||
|
||||
今天的一讲,我们看到了 JavaScript 在给我们提供了编写代码的灵活性的同时,也有着很多潜在的问题,而 linter 作为代码检查工具,可以帮助我们将代码出现质量问题的风险降到最低。同时,我们也可以通过类似 Prettier 这样的代码规范化工具,让我们的代码无论是对自己而言,还是对其他开发者来说,都更加的清晰易读。
|
||||
|
||||
最近,有个很流行的说法是“悲观的人总是正确,乐观的人总是成功”。所以有人可能会觉得墨菲定律过于悲观,但是在程序足够复杂、用户量足够多的情况下,真的是会出错的事总会出错。所以我认为要开发一个成功的应用,开发者应该是一个结合了悲观和乐观主义的人,悲观让我们守住程序稳定运行的基础,乐观让我们不断去突破、创新和优化,这两者之间的空间才决定了我们程序的发展。
|
||||
|
||||
思考题
|
||||
|
||||
前面我们说过 ESLint 用到的解析器是 Espree,它是基于JQuery 的Esprima 改进的。那么你知不知道它为什么没有沿用Esprima,而是自行创建了一个新的解析器呢?
|
||||
|
||||
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
250
专栏/JavaScript进阶实战课/36Flow:通过Flow类看JS的类型检查.md
Normal file
250
专栏/JavaScript进阶实战课/36Flow:通过Flow类看JS的类型检查.md
Normal file
@ -0,0 +1,250 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 Flow:通过Flow类看JS的类型检查
|
||||
你好,我是石川。
|
||||
|
||||
前面我们讲了除了功能性和非功能性测试外,代码的质量检查和风格检查也能帮助我们发现和避免程序中潜在的问题,今天我们再来看看另外一种发现和避免潜在问题的方法——代码类型的检查。说到类型检查,TypeScript 可能是更合适的一门语言,但既然我们这个专栏主讲的是JavaScript,所以今天我们就通过 Flow ——这个 JavaScript 的语言扩展,来学习 JavaScript 中的类型检查。
|
||||
|
||||
如果你有 C 或 Java 语言的开发经验,那么对类型注释应该不陌生。我们拿 C 语言最经典的 Hello World 来举个例子,这里的 int 就代表整数,也就是函数的类型。那么,我们知道在JavaScript中,是没有类型注释要求的。而通过 Flow,我们既可以做类型注释,也可以对注释和未注释的代码做检查。
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
printf("Hello World! \n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
它的工作原理很简单,总结起来就3步。
|
||||
|
||||
|
||||
给代码加类型注释;
|
||||
运行Flow工具来分析代码类型和相关的错误报告;
|
||||
当问题修复后,我们可以通过Babel或其它自动化的代码打包流程来去掉代码中的类型注释。
|
||||
|
||||
|
||||
这里,你可能会想,为什么我们在第3步会删除注释呢?这是因为 Flow 的语言扩展本身并没有改变 JavaScript 本身的编译或语法,所以它只是我们代码编写阶段的静态类型检查。
|
||||
|
||||
为什么需要类型
|
||||
|
||||
在讲 Flow 前,我们先来熟悉下类型检查的使用场景和目的是什么?类型注释和检查最大的应用场景是较为大型的复杂项目开发。在这种场景下,严格的类型检查可以避免代码执行时由于类型的出入而引起的潜在问题。
|
||||
|
||||
另外,我们也来看看 Flow 和 TypeScript 有什么共同点和区别。这里,我们可以简单了解下。先说说相同点:
|
||||
|
||||
|
||||
首先,TypeScript 本身也是 JavaScript 的一个扩展,顾名思义就是“类型脚本语言”;
|
||||
其次,TypeScript 加 TSC 编译器的代码注释和检查流程与 Flow 加 Babel 的模式大同小异;
|
||||
对于简单类型的注释,两者也是相似的;
|
||||
最后,它们的应用场景和目的也都是类似的。
|
||||
|
||||
|
||||
再看看 Flow 和 TypeScript 这两者的区别:
|
||||
|
||||
|
||||
首先,在对于相对高阶的类型上,两者在语法上有所不同,但要实现转换并不难;
|
||||
其次,TypeScript 是早于 ES6,在2012年发布的,所以它虽然名叫 TypeScript,但在当时除了强调类型外,也为了弥补当时 ES5 中没有 class、for/of 循环、模块化或 Promises 等不足而增加了很多功能;
|
||||
除此之外,TypeScript 也增加了很多自己独有的枚举类型(enum)和命名空间(namespace)等关键词,所以它可以说是一个独立的语言了。而 Flow 则更加轻量,它建立在JavaScript的基础上,加上了类型检查的功能,而不是自成一派,但改变了 JavaScript 语言本身。
|
||||
|
||||
|
||||
安装和运行
|
||||
|
||||
有了一些 Flow 相关的基础知识以后,下面我们正式进入主题,来看看Flow的安装和使用。
|
||||
|
||||
和我们之前介绍的 JavaScript 之器中的其它工具类似,我们也可以通过 NPM 来安装 Flow。这里,我们同样也使用了 -g,这样的选项可以让我们通过命令行来运行相关的程序。
|
||||
|
||||
npm install -g flow-bin
|
||||
|
||||
|
||||
在使用 Flow 做代码类型检查前,我们需要通过下面的命令在项目所在地文件目录下做初始化。通过初始化,会创建一个以 .flowconfig 为后缀的配置文件。虽然我们一般不需要修改这个文件,但是对于 Flow 而言,可以知道我们项目的位置。
|
||||
|
||||
npm run flow --init
|
||||
|
||||
|
||||
在第一次初始化之后,后续的检查可以直接通过 npm run flow 来执行。
|
||||
|
||||
npm run flow
|
||||
|
||||
|
||||
Flow 会找到项目所在位置的所有 JavaScript 代码,但只会对头部标注了 // @flow 的代码文件做类型检查。这样的好处是对于已有项目,我们可以对文件逐个来处理,按阶段有计划地加上类型注释。
|
||||
|
||||
前面,我们说过即使对于没有注释的代码,Flow 也可以进行检查,比如下面的例子,因为我们没有把 for 循环中的 i 设置为本地变量,就可能造成对全局的污染,所以在没有注释的情况下,也会收到报错。一个简单的解决方案就是在 for 循环中加一段 for (let i = 0) 这样的代码。
|
||||
|
||||
// @flow
|
||||
let i = { x: 0, y: 1 };
|
||||
for(i = 0; i < 10; i++) {
|
||||
console.log(i);
|
||||
}
|
||||
i.x = 1;
|
||||
|
||||
|
||||
同样,下面的例子在没有注释的情况下也会报错。虽然 Flow 开始不知道 msg 参数的类型,但是看到了长度 length 这个属性后,就知道它不会是一个数字。但是后面在函数调用过程中传入的实参却是数字,明显会产生问题,所以就会报错。
|
||||
|
||||
// @flow
|
||||
function msgSize(msg) {
|
||||
return msg.length;
|
||||
}
|
||||
let message = msgSize(10000);
|
||||
|
||||
|
||||
类型注释的使用
|
||||
|
||||
上面,我们讲完了在没有注释的情况下的类型检查。下面,我们再来看看类型注释的使用。当你声明一个 JavaScript 变量的时候,可以通过一个冒号和类型名称来增加一个 Flow 的类型注释。比如下面的例子中,我们声明了数字、字符串和布尔的类型。
|
||||
|
||||
// @flow
|
||||
let num: number = 32;
|
||||
let msg: string = "Hello world";
|
||||
let flag: boolean = false;
|
||||
|
||||
|
||||
同上面未注释的例子一样,即使在没有注释的情况下,Flow 也可以通过变量声明赋值来判断值的类型。唯一的区别是在有注释的情况下,Flow 会对比注释和赋值的类型,如果发现两者间有出入,便会报错。
|
||||
|
||||
函数参数和返回值的注释与变量的注释类似,也是通过冒号和类型名称。在下面的例子中,我们把参数的类型注释为字符串,返回值的类型注释为了数字。当我们运行检查的时候,虽然函数本身可以返回结果,但是会报错。这是因为我们期待的返回值是字符串,而数组长度返回的结果却是数字。
|
||||
|
||||
// 普通函数
|
||||
function msgSize(msg: string): string {
|
||||
return msg.length;
|
||||
}
|
||||
console.log(msgSize([1,2,3]));
|
||||
|
||||
|
||||
不过有一点需要注意的是 JavaScript 和 Flow 中 null 的类型是一致的,但是 JavaScript 中的 undefined 在 Flow 中是 void。而且针对函数没有返回值的情况,我们也可以用 void 来注释。
|
||||
|
||||
如果你想允许 null 或 undefiend 作为合法的变量或参数值,只需要在类型前加一个问号的前缀。比如在下面的例子中,我们使用了 ?string,这个时候,虽然它不会对 null 参数本身的类型报错,但会报错说 msg.length 是不安全的,这是因为 msg 可能是 null 或 undefined 这些没有长度的值。为了解决这个报错,我们可以使用一个判断条件,只有在判断结果是真值的情况下,会再返回 msg.length。
|
||||
|
||||
// @flow
|
||||
|
||||
function msgSize(msg: ?string): number {
|
||||
return msg.length;
|
||||
}
|
||||
console.log(msgSize(null));
|
||||
|
||||
function msgSize(msg: ?string): number {
|
||||
return msg ? msg.length : -1;
|
||||
}
|
||||
console.log(msgSize(null));
|
||||
|
||||
|
||||
复杂数据类型的支持
|
||||
|
||||
到目前为止,我们学习了几个原始数据类型:字符串、数字、布尔、null 和 undefined 的检查,并且也学习了 Flow 在变量声明赋值、函数的参数和返回值中的使用。下面我们来看一些 Flow 对其它更加复杂的数据类型检查的支持。
|
||||
|
||||
类
|
||||
|
||||
首先我们先来看一下类。类的关键词 class 不需要额外的注释,但是我们可以对里面的属性和方法做类型注释。比如在下面的例子中,prop 属性的类型是数字。方法 method 的参数是字符串,返回值我们可以定义为数字。
|
||||
|
||||
// @flow
|
||||
class MyClass {
|
||||
prop: number = 42;
|
||||
method(value: string): number { /* ... */ }
|
||||
}
|
||||
|
||||
|
||||
对象和类型别名
|
||||
|
||||
Flow 中的对象类型看上去很像是对象字面量,区别是 Flow 的属性值是类型。
|
||||
|
||||
// @flow
|
||||
var obj1: { foo: boolean } = { foo: true };
|
||||
|
||||
|
||||
在对象中,如果一个属性是可选的,我们可以通过下面的问号的方式来代替 void 和 undefined。如果没有注释为可选,那就默认是必须存在的。如果我们想改成可选,同样需要加一个问号。
|
||||
|
||||
var obj: { foo?: boolean } = {};
|
||||
|
||||
|
||||
Flow 对函数中没有标注的额外的属性是不会报错的。如果我们想要 Flow 来严格执行只允许明确声明的属性类型,可以通过增加以下竖线的方式声明相关的对象类型。
|
||||
|
||||
// @flow
|
||||
function method(obj: { foo: string }) {
|
||||
// ...
|
||||
}
|
||||
method({
|
||||
foo: "test", // 通过
|
||||
bar: 42 // 通过
|
||||
});
|
||||
|
||||
{| foo: string, bar: number |}
|
||||
|
||||
|
||||
对于过长的类型对象参数,我们可以通过自定义类型名称的方式将参数类抽象提炼出来。
|
||||
|
||||
// @flow
|
||||
export type MyObject = {
|
||||
x: number,
|
||||
y: string,
|
||||
};
|
||||
|
||||
export default function method(val: MyObject) {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
我们可以像导出一个模块一样地导出类型。其它的模块可以用导入的方式来引用类型定义。但是,这里需要注意的是,导入类型是 Flow 语言的一个延伸,不是一个实际的 JavaScript 导入指令。类型的导入导出只是被 Flow 作为类型检查来使用的,在最终执行的代码中,会被删除。最后需要注意的是,和创建一个 type 比起来,更简洁的方式是直接定义一个 MyObject 类,用来作为类型。
|
||||
|
||||
我们知道在 JavaScript 中,对象有时被用来当做字典或字符串到值的映射。属性的名称是后知的,没法声明成一个 Flow 类型。但是,我们还是可以通过 Flow 来描述数据结构。假设你有一个对象,它的属性是城市的名称,值是城市的位置。我们可以将数据类型通过下面的方式声明。
|
||||
|
||||
// @flow
|
||||
var cityLocations : {[string]: {long:number, lat:number}} = {
|
||||
"上海": { long: 31.22222, lat: 121.45806 }
|
||||
};
|
||||
export default cityLocations;
|
||||
|
||||
|
||||
数组
|
||||
|
||||
数组中的同类元素类型可以在角括号中说明。一个有着固定长度和不同类型元素的数组,叫做元组(tuple)。在元组中,元素的类型在用逗号相隔的方括号中声明。
|
||||
|
||||
我们可以通过解构(destructuring)赋值的方式,加上 Flow 的类型别名功能来使用元组。
|
||||
|
||||
如果我们希望函数能够接受一个任意长度的数组作为参数时就不能使用元组了,这时我们需要用的是 Array<mixed>。mixed 表示数组的元素可以是任意类型。
|
||||
|
||||
// @flow
|
||||
|
||||
function average(data: Array<number>) {
|
||||
// ...
|
||||
}
|
||||
|
||||
let tuple: [number, boolean, string] = [1, true, "three"];
|
||||
let num : number = tuple[0]; // 通过
|
||||
let bool : boolean = tuple[1]; // 通过
|
||||
let str : string = tuple[2]; // 通过
|
||||
|
||||
function size(s: Array<mixed>): number {
|
||||
return s.length;
|
||||
}
|
||||
console.log(size([1,true,"three"]));
|
||||
|
||||
|
||||
如果我们的函数对数组中的元素进行检索和使用,Flow 检查会使用类型检查或其他测试来确定元素的类型。如果你愿意放弃类型检查,你也可以使用 any 而不是 mixed,它允许你对数组的值做任何处理,而不需要确保这些值是期望的类型。
|
||||
|
||||
函数
|
||||
|
||||
我们已经了解了如何通过添加类型注释来指定函数的参数类型和返回值的类型。但是,在高阶函数中,当函数的参数之一本身是函数时,我们也需要能够指定该函数参数的类型。要用 Flow 表示函数的类型,需要写出用逗号分隔、再用括号括起来的每个参数的类型,然后用箭头表示,最后键入函数的返回类型。
|
||||
|
||||
下面是一个期望传递回调函数的示例函数。这里注意下,我们是如何为回调函数的类型定义类型别名的。
|
||||
|
||||
// @flow
|
||||
type FetchTextCallback = (?Error, ?number, ?string) => void;
|
||||
function fetchText(url: string, callback: FetchTextCallback) {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
总结
|
||||
|
||||
虽然使用类型检查需要很多额外的工作量,当你开始使用 Flow 的时候,也可能会扫出来大量的问题,但这是很正常的。一旦你掌握了类型规范,就会发现它可以避免很多的潜在问题,比如函数中的输入和输出值类型与期待的参数或结果不一致。
|
||||
|
||||
另外,除了我们介绍的数字、字符串、函数、对象和数组等这几种核心的数据类型,类型检查还有很多用法,你也可以通过Flow 的官网了解更多。
|
||||
|
||||
思考题
|
||||
|
||||
在类型检查中,有两种思想,一种是可靠性(soundness),一种是完整性(completeness)。可靠性是检查任何可能会在运行时发生的问题,完整性是检查一定会在运行时发生的问题。第一种思想是“宁可误杀,也不放过”;而后者的问题是有时可能会让问题“逃脱”。那么你知道Flow或TypeScript遵循的是哪种思想吗?你觉得哪种思想更适合实现?
|
||||
|
||||
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
100
专栏/JavaScript进阶实战课/37包管理和分发:通过NPM做包的管理和分发.md
Normal file
100
专栏/JavaScript进阶实战课/37包管理和分发:通过NPM做包的管理和分发.md
Normal file
@ -0,0 +1,100 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 包管理和分发:通过NPM做包的管理和分发
|
||||
你好,我是石川。
|
||||
|
||||
在前面几讲中,我们看到无论是响应式编程框架React,还是测试用的Jest、Puppeteer工具,亦或是做代码检查和样式优化的ESLint、Prettier工具,都离不开第三方库,而我们在之前的例子中,都是通过NPM下载和安装工具的。
|
||||
|
||||
所以,今天我们就来深入了解下NPM以及包的管理和发布。
|
||||
|
||||
包的发布
|
||||
|
||||
NPM(Node Package Manager)虽然它叫做 Node 包管理,但是其实你也可以用它管理或发布用 JavaScript 以外的语言编写的程序的包,只不过NPM最主要的受众还是 JavaScript 在Web和服务器端的开发者。在 NPM 中,有两个核心的概念,一个是包(package),另外一个是模块(module)。
|
||||
|
||||
包是一个含有 package.json 文件的文件夹。package.json 的作用是对包中的文件或目录的描述。一个包必须含有一个 package.json 文件才能发布到 NPM 的注册列表。
|
||||
|
||||
模块是任何可以被 Node.js 的 require() 加载的文件或目录。能够被成功加载的模块必须是一个带有 main 字段的 pakcage.json 的目录,或者一个JavaScript的文件。
|
||||
|
||||
一个包的发布很简单,首先在命令行通过创建 mkdir 和改变目录 cd 的命令,我们可以创建一个包的文件夹,并导航到包的根目录。在目录下,我们可以创建一个 package.json 的文件。package.js 文件的创建方式有两种,一种是直接创建,另外一种是在命令行上执行 npm init 的命令,通过提示输入后,生成 package.json 的文件。
|
||||
|
||||
在一个 package.json 中,必须要包含的字段是名称和版本号。有些时候,一个包是带有对其它包的依赖的,在这种情况下,在package.json中,也可以设置相关的依赖关系。
|
||||
|
||||
如果模块是在Git上管理的,可以在包所在的根目录下,将模块加进来。或者,我们也可以选择直接在目录下写一个模块。以下面的 printMsg 模块为例,这里通过 export 导出的模块,可以在其它的程序中通过 require() 导入引用。
|
||||
|
||||
exports.printMsg = function() {
|
||||
console.log("This is a message from the demo package");
|
||||
}
|
||||
|
||||
|
||||
在我们创建了一个包之后,在正式发布前,最好先通过 npm install 自己测试一下。确保无误之后,我们可以通过 npm publish 对包进行发布。无论是公开还是私有包的发布,都需要在发布前在NPM的注册页面上创建一个用户。为了安全,发布包以前,最好通过2FA的双因子认证。
|
||||
|
||||
以上是对公开包的发布流程。除了公开的包以外,我们也可以发布私有的包。那么公开和私有的包有什么区别呢?对于公开的包来说,所有人都有读取和下载的权限,针对指定的用户或组织,可以设置写和发布的权限。对于私有的包来说,只有指定的人员或组织内的成员才可以读取、下载、写入或发布相关的包。
|
||||
|
||||
包的持续集成和部署
|
||||
|
||||
在DevOps,我们经常强调 CI/CD 的持续集成和部署。在使用 NPM 的时候,我们也可以通过访问令牌的方式来代替用户名和密码的认证方式。这里的访问令牌是一个十六进制字符串,我们可以使用它来进行身份验证、安装或发布模块。通过这种方式,我们可以让其它工具,例如持续集成测试环境访问 NPM 的包。当我们的工作流在运行时,它可以完成包括安装私有包的任务。
|
||||
|
||||
而令牌又分为两类,一种是传统令牌,另外一种是粒度令牌。其中,传统令牌主要分为3类,一种是只读的,它只可以下载包;第二种是在下载的基础上可以安装;第三种是在前两种的基础上可以发布包。但是从安全的角度考虑,粒度令牌,顾名思义,有更细粒度的权限管理。粒度令牌还可以更好地区分可以访问的包和范围,授权给指定的组织,设置过期时间,基于CIDR的方法来控制授权的IP范围,并且提供只读和读写的权限选项。
|
||||
|
||||
举个例子,我们可以通过下面的方式,创建一个基于IP范围的访问令牌。
|
||||
|
||||
npm token create --cidr=192.0.2.0/24
|
||||
|
||||
|
||||
之后,我们可以将令牌设置为CI/CD服务器中的环境变量或密钥。例如,在GitHub Actions中,我们可以将令牌添加为密钥。然后根据该密钥,创建一个名为 NPM_TOKEN 的环境变量,将密钥提供给工作流。
|
||||
|
||||
steps:
|
||||
- run: |
|
||||
npm install
|
||||
- env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
|
||||
这里值得强调的是,这里的令牌具有可以读取私有包,代表我们发布新包,更改用户或包设置的权限。所以从安全的角度考虑,我们必须保护好令牌。千万不能将令牌添加到版本控制或存在不安全的地方。最好是将令牌存储在密码管理器、云提供商的安全存储或CI/CD工具提供的安全存储器中。如果可能,应该使用我们前面讲到的具有最低权限的粒度访问令牌,并为令牌设置较短的过期时间。
|
||||
|
||||
包的管理
|
||||
|
||||
前面,我们说过,对于包的权限,我们可以选择公开或者私有化。无论是哪种模式下,我们都可以对用户和组织进行管理。区别只是在于,公开的包所有人都可以读取和下载;而私有的包,无论是读取下载还是写入发布,都需要指定的用户或组织,才能赋予权限。下面,我们就来看看,如何在 NPM 中对组织进行管理。
|
||||
|
||||
首先,我们在登录后,点击头像可以选择创建一个组织,之后,我们需要给组织起一个名字。这个时候,我们可以选择免费和付费的方式,这两者的区别在于免费的版本可以让我们创建公开的包,而私有的包则需要付费。在这个过程中,你也可以选择将一个个人账户转换成一个组织账户。
|
||||
|
||||
这时,我们也可以通过姓名和邮箱邀请加入组织的用户。我们可以选择用户的角色和加到的团队,比如角色可以是“成员”或者是“管理员”。在这里,只有所有者有权利添加或删除组织中的成员,改变成员的角色,或者对组织的名称做修改或者删除。“管理员”可以管理团队,这里包括了团队的创建和删除,同时可以管理团队成员的加入和移除,以及包的访问权限。“成员”的权限是可以创建、发布组织范围内的包。
|
||||
|
||||
在组织创建的时候,一个“开发”组是自动生成的。除此之外,组织的所有者和管理员也可以创建更多的组。比如对于有些用户,我们不希望他们拥有开发权限,可以将他们从“开发”组移到项目管理组。
|
||||
|
||||
安全考虑
|
||||
|
||||
前面,我们学习了 NPM 的发布、CI/CD 和管理的流程。下面,我们再来看看 NPM 的安全考虑。在使用 NPM 的时候,很重要的一点就是安全考量。这里,我们可以从开发者和使用者两个不同的角度来看。
|
||||
|
||||
首先,我们先从开发者的角度来看,这里最需要警惕的就是我们的账户安全。
|
||||
|
||||
针对子账户安全,最容易受到的就是密码攻击。当然密码攻击是一种常见的网络攻击,不仅限于 NPM,而是在任何Web服务上都有可能遭受到的攻击。保护帐户安全的最佳方法就是我们前面提到的启用双因子身份验证(2FA)。
|
||||
|
||||
在此基础上,安全性最强的选项是使用安全密钥,无论是内置于设备还是外部硬件的密钥。安全密钥可以将身份验证绑定到正在访问的站点,大大降低网络钓鱼的风险。但因为并不是所有人都可以访问到安全密钥,所以NPM还支持为2FA生成一次性密码的身份验证应用程序。
|
||||
|
||||
由于这种攻击的常见性,以及 NPM 包的流行程度和对开源生态系统的影响,NPM 采取了分期的方法,对排名前100的软件包维护者和排名前500的软件包管理者强制实行2FA的认证。当然,这样的举措还是远远不够的,在不久的将来,所有高影响力软件包的维护者(每周下载量超过100万次或依赖500次以上的软件包)都将被强制执行2FA验证。如果你作为包的发布者不选择2FA的验证,NPM 会通过向你的电子邮箱发送一次性密码来加强登录验证,以防止帐户被盗取。
|
||||
|
||||
盗取帐户的另一种方法是,通过使用过期域名作为电子邮件地址来识别帐户。攻击者可以注册过期的域名并重新创建用于注册帐户的电子邮件地址。通过访问帐户的注册电子邮件地址,攻击者可以通过重置密码来盗取不受2FA保护的帐户。
|
||||
|
||||
当一个包在发布的时候,发布包与帐户关联的电子邮件地址是包含在公共元数据中的。攻击者能够利用这些公共数据来识别可能容易被账户盗取的账户。NPM 也会定期检查帐户电子邮件地址是否有过期的域名或无效的MX记录。域名在过期之后,NPM 会禁用帐户进行密码重置,并要求用户在重置密码之前进行帐户恢复或成功通过身份验证的流程。需要注意的是,作为包的维护者,在你更新电子邮箱地址的时候,存储在包的公共元数据中的电子邮箱地址是不会更新的。由于这种抓取公共元数据以识别易受过期域名影响的帐户将导致误报,因此这些帐户看似易受攻击,但实际上并非如此。
|
||||
|
||||
下面,我们再从使用者的角度来看看。攻击者可能会试图通过注册与流行软件包名称相似的软件包来诱骗他人安装恶意软件包,希望人们因为笔误输入错误的名称,或者用其它的方式混淆两者。NPM 能够检测错别字攻击并阻止这些包的发布。这种攻击的一种衍生攻击是当公共包与组织正在使用的私有包以相同的名称注册时,私有包被公共注册表中的包取代。所以这里的一个建议是,使用范围包(scoped package),以确保私有包不会被公共注册表中的包取代。
|
||||
|
||||
另外一个问题是对现有包的恶意更改行为,在这里,攻击者也有可能不会诱骗用户使用类似名称的软件包,而是试图将恶意行为添加到现有的流行软件包中。为了解决这个问题,NPM 也在与微软合作,扫描软件包中已知的恶意内容,并运行软件包来寻找潜在的新的恶意行为模式。这使得 NPM 包中带有恶意内容的比例大幅减少。此外,NPM 的信任和安全团队会检查并删除用户报告的恶意行为和内容。
|
||||
|
||||
总结
|
||||
|
||||
通过这一讲,我们看到了在JavaScript不断模块化的今天,NPM 让我们更容易分享和使用其他开发者所提供的工具,而且除了模块自身的功能外,NPM 也可以是很好的版本和依赖管理工具,并且 NPM 的使用也可以促进团队的协作。但是同时,我们也看到了它的很多安全隐患。所以在使用的时候,还是要谨慎。作为开发者,我们不希望自己好心提供的工具成为了黑客进行攻击的手段,同时,作为用户,我们更应该注意管理从 NPM 下载的第三方代码的风险。
|
||||
|
||||
思考题
|
||||
|
||||
前面,我们讲了在使用 NPM 的时候,最重要的就是安全的考量,当然除了我们上面说到的这些方法外,之前在安全的一讲中,我们提到的漏扫也都是降低风险的方式。那么在实际开发中,你会使用 NPM 吗?除了我们讲到的方法外,你还能想到其他的安全措施吗?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
124
专栏/JavaScript进阶实战课/38编译和打包:通过Webpack、Babel做编译和打包.md
Normal file
124
专栏/JavaScript进阶实战课/38编译和打包:通过Webpack、Babel做编译和打包.md
Normal file
@ -0,0 +1,124 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 编译和打包:通过Webpack、Babel做编译和打包
|
||||
你好,我是石川。
|
||||
|
||||
在 JavaScript 从 ES5 升级到 ES6 的时候,在大多浏览器还尚未支持新版的JavaScript的时候,很多开发者就想要预先体验相关的功能,但是却愁于没有相关的环境支持。当时,为了解决这个问题,一些面向 JavaScript 开发者的编译器如 Babel.js 就诞生了,它允许开发者按照 ES6 版的 JavaScript 语法来编写程序,然后再将代码编译转化成与 ES5 兼容的版本。
|
||||
|
||||
当然后来,随着浏览器对新版 JavaScript 的支持,Babel.js 不单单是解决了 ES6 的使用问题,同时它也加入了很多新的功能,支持更多的编译需求。今天,我们就来看下 JavaScript 开发中用到的 Babel 编译。
|
||||
|
||||
JavaScript的编译
|
||||
|
||||
我们知道 JavaScript 是以 ES6 版本作为一个重要里程碑的。它和在此之前的 ES5 版本相隔了6年,而在 ES6 问世之后,JavaScript 就保持了每年的更新。所以 ES6 可以被看做是一个“大版本”的更新,里面包含了很多的新功能和语法糖。
|
||||
|
||||
面对不同浏览器对 ES6 的支持程度的不一致,有两种处理的办法。一种是编译,一种是 polyfill。这两者没有明显的界限,但是大致的区别是编译会在运行前先将代码转化成低版本的代码,再运行。而 polyfill 则是在运行时判断浏览器是否支持一个功能,只有在不支持的情况下,才使用补丁代码;如果支持,就使用原生的功能。
|
||||
|
||||
今天,我们重点说到的就是编译这种方式。Babel 在过去很长一段时间,提供了用来帮助开发者提前使用下一代的 JavaScript 版本来编写代码的编译工具,它可以将 ES6+ 的代码编译成向下兼容的版本。你可能会问,编译不是浏览器的工作吗?为什么我们说Babel是一个编译器呢?
|
||||
|
||||
其实Babel是一种从源代码到源代码的编译,不是从源代码到机器码的编译,所以为了和浏览器中的 JavaScript 引擎做区分,Babel 也被叫做“转”译器(transcompiler 或 transpiler)。Babel 在2014年推出后,在很多浏览器尚未提供 ES6 支持的情况下,就让Web开发人员能够使用 ES6 和更高版本的新语言功能了。
|
||||
|
||||
有些 ES6 的语言特性可以相对容易地转换为 ES5,例如函数表达式。但是有些语言特性,例如class 关键字等则需要更复杂的转换。通常,Babel 输出的代码不一定是开发者可读的,但是可以将生成的代码与源代码的位置进行映射,这在编译后,运行时对问题的排查有很大的帮助。
|
||||
|
||||
随着主流浏览器对 ES6+ 的版本支持越来越快,以及 IE 退出了历史舞台,如今编译箭头函数和类声明的需求也大大减少了。但对一些更新的语言特性,Babel 仍然可以提供帮助。与我们前面描述的大多数其它工具一样,你可以使用 NPM 安装 Babel 并使用 NPX 运行它。
|
||||
|
||||
Babel 可以通过 npm install --save-dev @babel/core 来安装,然后直接在代码中导入使用,但是这样的使用会把编译的工作加到终端用户侧,更合理的方式应该是将 Babel 的使用放在开发流程中。
|
||||
|
||||
const babel = require("@babel/core");
|
||||
babel.transformSync("code", optionsObject);
|
||||
|
||||
|
||||
在开发的流程中加入 Babel 的方式如下:
|
||||
|
||||
npm install --save-dev @babel/core @babel/cli
|
||||
./node_modules/.bin/babel src --out-dir lib
|
||||
|
||||
|
||||
之后,我们可以加入预设(preset)。预设的加入有两种方式,一种是将所用的编译插件都一次性安装;另外一种则是只针对特定的插件,比如箭头函数做安装。
|
||||
|
||||
npm install --save-dev @babel/preset-env
|
||||
./node_modules/.bin/babel src --out-dir lib --presets=@babel/env
|
||||
|
||||
npm install --save-dev @babel/plugin-transform-arrow-functions
|
||||
./node_modules/.bin/babel src --out-dir lib --plugins=@babel/plugin-transform-arrow-functions
|
||||
|
||||
|
||||
假如我们安装了上述的箭头函数插件,那么在代码中,如果我们使用相关的箭头,Babel 在编译的过程中就会将代码转化成 ES5 版本的样式。在 Babel 内部,通过读取一个 .babelrc 配置文件,来判断如何转换 JavaScript 代码。所以,你可以按需创建想要编译的功能,来进行这些预设,也可以全量使用所有插件,来对所有的功能进行相关的转译。比如在下面这个例子中,就是将一个箭头函数转化为和ES5版本兼容的代码。
|
||||
|
||||
// Babel输入: ES6箭头函数
|
||||
[1, 2, 3].map(n => n + 1);
|
||||
|
||||
// Babel输出: ES5匿名函数
|
||||
[1, 2, 3].map(function(n) {
|
||||
return n + 1;
|
||||
});
|
||||
|
||||
|
||||
尽管现在很多时候,我们已经不太需要转换 JavaScript 的核心语言了,但 Babel 仍然常用于支持 JavaScript 语言的非标准扩展,其中一个就是我们在前面一讲提到的 Flow。Babel 在编译的过程中,可以帮助我们去掉 Flow 的类型注释。除了 Flow 外,Babel 也支持去掉 TypeScript 语言的类型注释。
|
||||
|
||||
npm install --save-dev @babel/preset-flow
|
||||
npm install --save-dev @babel/preset-typescript
|
||||
|
||||
|
||||
通过把 Babel 和一个代码打包工具结合起来使用,我们可以在 JavaScript 文件上自动运行 Babel。这样做可以简化生成可执行代码的过程。例如,Webpack 支持一个 “babel 加载器”模块,你可以安装并配置该模块,以便在打包的每个 JavaScript 模块上运行 babel。那么说到这里,下面我们再来看看 JavaScript 中的打包工具。
|
||||
|
||||
JavaScript的打包
|
||||
|
||||
如果你使用 JavaScript 做模块化开发的话,应该对代码打包不会陌生。即使是在ES6之前,相关的导入(import)和导出(export)指令还没有被正式引入之前,人们就开始使用这些功能了。
|
||||
|
||||
当时,为了能提前使用这些功能,开发者会使用一个代码打包工具以一个主入口为开始,顺藤摸瓜,通过导入指令树查找程序所依赖的所有模块。然后,打包工具会把所有单独模块的文件合并成一个JavaScript 代码文件,然后重写导入导出指令,让代码可以以转译后的形式运行。打包后的结果,是一个可以被加载到不支持模块化的浏览器的单一文件。
|
||||
|
||||
时至今日,ES6的模块几乎被所有的主流浏览器支持了,但是开发者却仍然使用代码打包工具,至少在生产发布时还是这样的。这么做的原因是为了将核心功能一次性加载,这样比起一个个模块单独加载,性能更高,并且可以带来更好的用户体验。
|
||||
|
||||
目前市面上有很多不错的 JavaScript 打包工具。其中比较出名的有Webpack、Rollup 和 Parcel。这些打包工具的基础功能基本上都大同小异,它们的区别主要是在配置和易用性上。Webpack可以算是这几个工具中最元老级的一个了,并且可以支持比较老的非模块化的库。但同时,它也比较难配置。和它正好相反的是Parcel,一个零配置的替代方案。而 Rollup 相比 Webpack,更加简约,适合小型项目的开发。
|
||||
|
||||
除了基础的打包外,打包工具也可以提供一些额外的功能。比如加载的优化,非标模块化插件,更新加载和源代码问题排查等等。下面,让我们一一来看下这些功能。
|
||||
|
||||
加载优化
|
||||
|
||||
比如很多程序都有多个入口。一个有很多页面的Web应用,每个页面都有不同的入口。打包工具通常会允许我们基于每个入口或几个入口来创建一个包。
|
||||
|
||||
前面我们在讲到前端的设计模式的时候,曾经说过,一个程序除了可以在初始化时静态加载资源外,也可以使用导入来按需动态加载模块,这样做的好处是可以优化应用初始化的时间。通常支持导入的打包工具可以创建多个导出的包:一个在初始时加载的包,和一个或多个动态加载的包。多个包适用于程序中只有几个 import 调用,并且它们加载的模块没有什么交集。如果动态加载的模块对依赖高度共享的话,那么计算出要生成多少个包就会很难,并且很可能需要手动配置包来进行排序。
|
||||
|
||||
有时,当我们在模块中引入一个模块的时候,我们可能只用其中的几个功能。一个好的打包工具可以通过分析代码,来判断有哪些未使用的代码是可以从打包中被删除的。这样的功能就是我们前面讲过的摇树优化(tree-shaking)。
|
||||
|
||||
非标模块化插件
|
||||
|
||||
打包工具通常有一个支持插件的架构,并且支持导入和打包非JavaScript代码的模块。假设你的程序包含一个很大的JSON兼容的数据结构,我们可以用代码打包工具来将它配置成一个单独的JSON文件,然后通过声明的方式将它导入到程序中。
|
||||
|
||||
import widgets from "./app-widget-list.json"
|
||||
|
||||
|
||||
类似的,我们也可以使用打包工具的插件功能,在 JavaScript 中,通过 import 来导入 CSS 文件。不过,这里需要注意的是,导入任何 JS 以外的文件所使用的都是非标准的扩展,并且会让我们的代码对打包工具产生一定程度上的依赖。
|
||||
|
||||
更新加载
|
||||
|
||||
在像 JavaScript 这种在执行前不需要预先编译打包的语言里,运行一个打包工具的感觉像是一个预先编译的过程,每次写完代码,都需要打包一次才能在浏览器中执行,这个步骤对于有些开发者而言,可能感觉比较繁琐。
|
||||
|
||||
为了解决这个问题,打包工具通常支持文件系统以观察者的模式来侦测项目目录中文件的改动,并且基于改动来自动重新生成所需的包。通过这个功能,你通常可以在保存编辑过的文件后,在不需要手动再次打包的情况下,及时地刷新。有些打包工具还会支持针对开发者的“热更新”选项。每次重新打包的时候,会自动加载到浏览器。
|
||||
|
||||
源代码排查
|
||||
|
||||
和 Babel 等编译工具类似,打包工具通常也会生成一个源代码和打包后代码的映射文件。这样做的目的,同样是帮助浏览器开发者工具在报错的时候,可以自动找到问题在源文件中的所在位置。
|
||||
|
||||
总结
|
||||
|
||||
通过今天的学习,我们了解了 JavaScript 中编译和打包工具的前世今生和“成功转型”。
|
||||
|
||||
Babel 作为编译器,在很长一段时间能让人们提前使用到 ES6 的功能,在“转型”后,又是在不改变原始的 JavaScript 语言的基础上,让人们可以使用 Flow 和 TypeScript 做类型标注,为其进行编译。
|
||||
|
||||
之后,我们学习了Webpack、Rollup等代码打包工具,在早期起到了模块化导入和导出的作用,在后期逐渐“转型”为加载优化,提供非标模块化插件的支持,并且提供了代码实时更新打包和热加载的功能。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题,也是作为下面两节课的预习。转型后的 Babel 除了能做到对 Flow 和 TypeScript 进行转译支持,也能支持 JSX 的转译,你知道 JavaScript 中的 JSX 语法扩展的作用是什么吗?
|
||||
|
||||
另外一个问题,是我们今天提到除了 Babel 转译外,通过 polyfill 我们也可以解决功能兼容的问题,那么你知道它俩使用场景上的区别吗?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
241
专栏/JavaScript进阶实战课/39语法扩展:通过JSX来做语法扩展.md
Normal file
241
专栏/JavaScript进阶实战课/39语法扩展:通过JSX来做语法扩展.md
Normal file
@ -0,0 +1,241 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 语法扩展:通过JSX来做语法扩展
|
||||
你好,我是石川。
|
||||
|
||||
在前面一讲中,我们提到了 React 也有一个 JavaScript 的语法扩展,叫做 JSX。它的全称是 JavaScript XML,顾名思义,它是以 XML 为格式设计的。它最主要的目的是与 React 框架结合,用于应用中 Web UI 相关的开发。在React中,DOM 树是通过 JSX 来定义的,最终会在浏览器中渲染成 HTML。基于 React 的普及率,即使你没有用过 React,估计对 JSX 也有所耳闻。
|
||||
|
||||
今天,我们就来看看 JSX 是如何用在 Web UI 开发中的。即使你不使用 React,这样的模版模式也有很大的借鉴意义。
|
||||
|
||||
类HTML的元素
|
||||
|
||||
首先,我们来看下 JSX 的基本使用,它是如何创建元素、相关的属性和子元素的。
|
||||
|
||||
你可以把 JSX 元素当成一种新的 JavaScript 语法表达。我们知道在标准的 JavaScript 中,字符串的字面量是通过双引号来分隔的,正则表达式的字面量是通过斜线号来分隔的。类似的,JSX 的字面量则是通过角括号来分隔的。比如下面的 标签就是一个简单的例子,通过这种方式,我们就创建了一个 React 的标题元素。
|
||||
|
||||
var title = <h1 className="title">页面标题</h1>;
|
||||
|
||||
|
||||
但是像上面这样的标签,我们知道JS引擎是没办法理解的,那它如何被运行呢?这时,我们可以通过我们在上一讲提到的 Babel 把 JSX 的表达先转译成标准的 JavaScript,然后才会给到浏览器做编译和执行。Babel 会把上面变量声明赋值中的 JSX 表达,转换成一个符合 JavaScript 规范的 createElement() 函数,来进行函数的调用。
|
||||
|
||||
var title = React.createElement("h1", {className: 'title'}, "页面标题");
|
||||
|
||||
|
||||
因为通过 JSX 来创建的 React 元素和 HTML 的标签元素类似,所以 JSX 生成的 React 元素表达也可以带有属性。当一个元素有一个或多个属性的时候,会作为 createElement() 的第二个参数传入,参数的值是一个带有元素相关属性的对象。
|
||||
|
||||
// 转译前
|
||||
var image = <img src="logo.png" alt="company logo" />;
|
||||
|
||||
// 转译后
|
||||
var image = React.createElement("img", {
|
||||
src: "logo.png",
|
||||
alt: "company logo"
|
||||
});
|
||||
|
||||
|
||||
像 HTML 的元素一样,除了字符串,React 的元素中也可以有子元素。React 元素也可以通过层级的嵌套,来创建 DOM 树。在转译的过程中,上面的这些 JSX 嵌套的 DOM 子元素,将作为 createElement() 调用的第三个参数及以后的参数来传递。
|
||||
|
||||
// 转译前
|
||||
var sidebar = (
|
||||
<div className="sidebar">
|
||||
<h2 className="menu">菜单</h2>
|
||||
<p className="text">菜单内容</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 转译后
|
||||
var sidebar = React.createElement(
|
||||
"div", { className: "sidebar"},
|
||||
React.createElement("h1", className: "menu"},
|
||||
"菜单"),
|
||||
React.createElement("p", className: "text"},
|
||||
"菜单内容"));
|
||||
|
||||
|
||||
React 中的 createElement() 函数所返回的值是一个 JavaScript 对象,React 使用它在浏览器窗口中输出渲染的UI。在下面的例子中,我们可以看到一个通过 JSX 表达的 React 元素,在转译后,通过 createElement() 生成为对象。之后,再将对象通过 React 中的 render() 渲染到页面上。
|
||||
|
||||
// 转译前
|
||||
var element = <h1 class"title">页面标题</h1>;
|
||||
|
||||
// 转译后
|
||||
var element = React.createElement(
|
||||
'h1',
|
||||
{className: 'title'},
|
||||
'页面标题'
|
||||
);
|
||||
|
||||
// createElement 返回的对象
|
||||
var element = {
|
||||
type: 'h1',
|
||||
props: {
|
||||
className: 'title',
|
||||
children: '页面标题'
|
||||
}
|
||||
};
|
||||
|
||||
// createElement 返回对象的渲染
|
||||
var root = ReactDOM.createRoot(
|
||||
document.getElementById('root')
|
||||
);
|
||||
root.render(element);
|
||||
|
||||
|
||||
因为今天我们主要讲的是 JSX 的语法扩展,而不是 React 本身,所以我们就不对返回的 element 对象和渲染过程的细节做赘述了。不过,有一点值得注意的是,你可以用 Babel 配置对 React 中 createElement() 以外的创建元素的函数做转译。这样做,就可以在 React 以外的地方来使用类似的 JSX 表达了。
|
||||
|
||||
JS和CSS的表达
|
||||
|
||||
上面,我们学习了 JSX 语法扩展的核心概念,下面,我们再来看看它是如何与 JavaScript 以及 CSS 的表达来结合使用的。
|
||||
|
||||
React 元素的一个重要特性是可以在 JSX 表达式中嵌入标准的 JavaScript 表达式。你可以使用大括号来嵌入标准的 JavaScript 表达式。大括号内的脚本,会在转译的环节被解释为标准的 JavaScript。React 元素中的嵌套表达式可以作为属性值和子元素。下面就是一个例子:
|
||||
|
||||
function article(className, title, content, linebreak=true) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h1>{title}</h1>
|
||||
{ linebreak && <br /> }
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function article(className, title, content, subtitle=true) {
|
||||
return React.createElement("div", { className: className },
|
||||
React.createElement("h1", null, title),
|
||||
subtitle && React.createElement("br", null),
|
||||
React.createElement("p", null, content));
|
||||
}
|
||||
|
||||
|
||||
在这个例子中,article() 函数的作用是返回一个 JSX 元素,它有四个参数。Babel 会将例子中的代码翻译为以下内容。
|
||||
|
||||
这段代码很容易阅读和理解:转译后,大括号消失了,生成的代码将 article() 函数传入的参数传递给 React.createElement()。请注意我们在这里对 linebreak 参数和 && 运算符的使用。在实际的调用中,如果只有三个实参传入 article(),则 默认为 true,外部 createElement() 调用的第四个参数是 元素。但是,如果我们将 false 作为第四个参数传递给 article(),那么外部 createElement() 调用的第四个自变量的值将为 false,就不会创建 元素。使用 && 运算符是 JSX 中常见的习惯用法,它可以根据其它表达式的值有条件地包含或排除子元素。这个习惯用法适用于 React,因为 React 会忽略 false 或 null 的子级,而不会为它们生成任何输出。
|
||||
|
||||
在 JSX 表达式中使用 JavaScript 表达式的时候,不限于前面例子中如字符串和布尔的基础类型值,也可以是对象类型的 JavaScript 值。实际上,使用对象、数组和函数值在 React 中很常见。例如在下面的函数中,JavaScript 和 CSS 的表达如下:
|
||||
|
||||
function list(items, callback) {
|
||||
return (
|
||||
<ul style={ {padding:10, border:"solid red 4px"} }>
|
||||
{items.map((item,index) => {
|
||||
<li onClick={() => callback(index)} key={index}>{item}</li>
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
这里,函数使用对象字面量作为 <ul> 元素上 CSS 样式(style)的属性的值。注意,这里需要用到的是双大括号。<ul>元素有一个子元素,但该子元素的值是一个数组。输出的数组是通过在输入的数组上使用 map() 的映射方法来创建 <li> 子元素的。并且在这里,每个嵌套的 <li> 子元素都有一个 onClick 事件处理程序属性,其值是一个箭头函数。
|
||||
|
||||
如果我们将上面的 JSX 代码编译为标准的 JavaScript 代码,则会是以下的形式:
|
||||
|
||||
function list(items, callback) {
|
||||
return React.createElement(
|
||||
"ul",
|
||||
{ style: { padding: 5, border: "dotted green 3px" } },
|
||||
items.map((item, index) =>
|
||||
React.createElement(
|
||||
"li",
|
||||
{ onClick: () => callback(index), key: index },
|
||||
item
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
React元素类型
|
||||
|
||||
另外,JSX 还有一个更重要的特性,就是定义 React 元素的类型。
|
||||
|
||||
所有 JSX 元素都以一个标识符开头,紧跟在开口角括号之后。如果该标识符的第一个字母是小写的,那么该标识符将作为字符串传递给 createElement()。但是,如果标识符的第一个字母是大写的,那么它将被视为实际标识符,并且该标识符的 JavaScript 值将作为第一个参数传递给 createElement()。
|
||||
|
||||
这意味着 JSX 表达式 <CustomButton/> 编译为 JavaScript 代码,将全局 CustomButton 对象传递给 React.createElement()。对于 React 来说,这种将非字符串值作为第一个参数传递给 createElement() 的能力可以用来创建组件。
|
||||
|
||||
在React中定义新组件的最简单方法是编写一个函数,该函数将 props 对象作为参数并返回 JSX 表达式。props 对象只是一个表示属性值的 JavaScript 对象,就像作为第二个参数传递给createElement() 的对象一样。例如,这里是我们的 article() 函数的另外一种写法:
|
||||
|
||||
function Article(props) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
{ props.linebreak && <br /> }
|
||||
<p>{props.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function article(className, title, content, linebreak=true) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h1>{title}</h1>
|
||||
{ linebreak && <br /> }
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
这个新的 Article() 函数很像以前的 article() 函数。但它的名称以大写字母开头,并且只有一个对象作为参数。这可以使得它成为一个 React 的组件,这就意味着它可以用来代替 JSX 表达式中标准的 HTML 标记:
|
||||
|
||||
var article = <Article title="文章标题" content="文章内容"/>;
|
||||
|
||||
|
||||
这个 <Article/> 元素在转译后如下:
|
||||
|
||||
var article = React.createElement(Article, {
|
||||
title: "文章标题",
|
||||
content: "文章内容"
|
||||
});
|
||||
|
||||
|
||||
这是一个简单的 JSX 表达式,但当 React 渲染它时,它会将第二个参数,也就是 props 对象,传递给第一个参数,也就是 Article() 函数,并将使用该函数返回的 JSX 函数代替 表达式。
|
||||
|
||||
另外,如果我们的一个模块中有多个组件的话,也可以用 dot notation 的方式,来表达模块中的组件。
|
||||
|
||||
import React from 'react';
|
||||
var MyComponents = {
|
||||
Calendar: function Calendar(props) {
|
||||
return <div>一个{props.color}颜色的日历.</div>;
|
||||
}
|
||||
}
|
||||
function GreenCalendar() {
|
||||
return <MyComponents.DatePicker color="绿" />;
|
||||
}
|
||||
|
||||
|
||||
|
||||
JSX 中对象表达式的另一个用途是使用对象扩展运算符一次指定多个属性。如果你编写了一组带有可能被重用的公共属性的 JSX 表达式,则可以通过将属性抽象定义为一个属性对象,并将其“扩展”到 JSX 元素中,来简化表达式。
|
||||
|
||||
var greeting = <div firstName="三" lastName="张" />;
|
||||
|
||||
var props = {firstName: '三', lastName: '张'};
|
||||
var greeting = <div className = "greeting" {...props} />;
|
||||
|
||||
|
||||
Babel 会将其编译为一个使用 _extends() 的函数,该函数将 className 属性与 props 对象中包含的属性相结合。
|
||||
|
||||
var greeting = React.createElement("div",
|
||||
_extends({className: "greeting"}, props)
|
||||
);
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天,我们看到了在JavaScript中,如何通过JavaScript的扩展 JSX,在 JavaScript 中表达 DOM 元素。这样做有几点好处:
|
||||
|
||||
|
||||
它可以更好地赋能以UI和事件驱动的开发,并且通过我们在上节讲到的 Babel 编译器,将相关的表达转译成 createElement() 的函数表达。再利用 createElement() 的函数调用,创建并返回包含相关元素属性定义的对象。最后再通过 render() 的方式将元素对象在浏览器中做渲染。同时,它可以和 JavaScript 和 CSS 的表达交叉使用,我们可以在 JSX 的表达中参入 JS 和 CSS 的表达。并且,除了基础类型的值,我们也可以使用数组、函数等对象的数据类型。
|
||||
通过对 JSX 的使用,也有助于模块化和组件化的开发。
|
||||
最后,从安全性上来看,JSX 的好处是所有的内容在渲染前都被转换成了字符串,这样也可以有效地防止我们在安全的一讲中提到的跨站脚本攻击(XSS),让我们的应用更安全。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,留给你一道思考题,其实在 JSX 之前,基于 JavaScript 就有很多的模版引擎,例如jQuery Template 和 Mustache.js 等。你觉得 JSX 和传统的 JavaScript 模版引擎有什么区别吗?各自的优劣势是怎样的?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
137
专栏/JavaScript进阶实战课/40Polyfill:通过Polyfill让浏览器提供原生支持.md
Normal file
137
专栏/JavaScript进阶实战课/40Polyfill:通过Polyfill让浏览器提供原生支持.md
Normal file
@ -0,0 +1,137 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 Polyfill:通过Polyfill让浏览器提供原生支持
|
||||
你好,我是石川。
|
||||
|
||||
在之前的课程中,我们提到过,用 JavaScript 写的程序不是在统一的环境中运行的。虽然我们知道现实中存在定义 ECMAScript 规范的组织 TC39,以及编写 HTML5 和 CSS 规范的组织 W3C(万维网联盟),但不同的浏览器厂商对 JavaScript 虚机的实现都会影响我们的程序在最终执行时的结果。因为虽然标准是存在的,但问题是执行这些标准的公司是独立于标准之外存在的,这就导致了同样的标准,在不同浏览器厂商和JS引擎的理解下,产生了不同的实现。
|
||||
|
||||
今天,我们就看看,如何能在不同的浏览器厂商对同一组较新的 JavaScript 和相关的 Web API 功能支持程度不一的情况下,通过创建一个 Polyfill,来解决原生支持问题。Polyfill 在英文中直译过来的意思就是“填缝剂”,我们可以把它理解成一种补丁。
|
||||
|
||||
造成原生支持问题的原因
|
||||
|
||||
首先,我们来看看浏览器为什么会存在原生支持问题。
|
||||
|
||||
原生支持问题其实一直都困扰着开发者,在互联网应用出现的早期,出现最多原生支持问题的便是 IE。这主要是由几方面的原因造成的:
|
||||
|
||||
|
||||
第一,IE早期的版本没有自动更新,而且我们大多数人都没有手动更新软件的习惯,这也就造成了很多的用户在新版本的 IE 提供了更多新功能升级和 bug 修复的情况下,仍然停留在老版本的浏览器上;
|
||||
第二,因为 Windows 的系统早期并不是和电脑捆绑售卖的,也就造成了很多人使用的是破解版的 Windows 系统,为了避免升级时影响盗版的使用,很多人也不会主动更新操作系统,这就进一步造成了人们对系统连带的浏览器版本更新的滞后;
|
||||
第三,企业中使用的 IE 也是浏览器兼容性问题的重灾区。因为越是大的公司,越是“稳定”压倒一切,就越是需要 IT 管理员来集中地更新公司电脑操作系统和软件的版本,而对浏览器这种“病从口入”的连接互联网的窗口软件,更是安全防护的重点,需要集中更新,所以更是大大减缓了浏览器更新的速度。所以早期的网站和 Web 应用开发,主要的问题都集中在 IE6+ 的版本对新版的 JavaScript 原生支持的问题。
|
||||
|
||||
|
||||
如今,随着 IE 退出了历史舞台,向下兼容的问题在逐渐减少,但是问题也不是一下子就完全消失了。因为除了 IE 的问题外,其它的浏览器提供商,如 Chrome、Safari、Mozilla、Opera 等也都是公司。既然是公司,那么它们之间就会存在竞争,从而争取更多的市场份额。
|
||||
|
||||
也正因为如此,他们有时会在新定义的 JavaScript 规则还比较模糊的时候加入自己的理解,而不是所有的浏览器都使用同一套新功能发布周期和标准。因为一些公司认为,当 JavaScript 规范中出现新功能时,他们更了解该如何实现,让开发者可以提前体验到新功能,这样也有助于对标准的验证,而另外一些浏览器则并不总是支持一些具有前瞻性的功能。
|
||||
|
||||
解决原生支持问题的方法
|
||||
|
||||
那么面对不同浏览器对新功能支持的不统一,如果我们想使用较新的 JavaScript 功能,有两个方法:
|
||||
|
||||
|
||||
第一,是使用我们在前面提到过的 Babel.js,通过对 JavaScript 的转译,我们可以在提前用到新功能的同时,保证较老版本浏览器的支持;
|
||||
第二个方法,就是使用 Polyfill,Polyfill 作为一个插件,它提供了较新浏览器的功能,但也提供了较旧版本的功能。
|
||||
|
||||
|
||||
前面,我们讲过了代码转译的工具 Babel.js,那我们是不是可以用类似的工具解决所有的原生支持问题呢?
|
||||
|
||||
这里,我们需要注意的是,Babel 最主要的能力是让我们提前用上一些新的 JavaScript 语法功能,类似 let、const 这些变量和函数,但是它不能对 JavaScript 中一些新的功能做转译处理。比如转译没法支持数组上的 map 方法函数,也不能对 promise 做转译。这时,我们就需要结合使用到 Polyfill 了。
|
||||
|
||||
那我们什么时候该用 Polyfill,什么时候可以用转译器呢?
|
||||
|
||||
这里虽然没有明显的界定,但是有个大的原则,你可以参考:如果你想提前使用一个新的 JavaScript 语法,那么需要用到的是转译器;但如果你想实现一种新的功能或方法,那用到的可能会是Polyfill。
|
||||
|
||||
另外值得注意的是,虽然 Polyfill 是用 JavaScript 编写的,并且在 JavaScript 引擎中运行,但是它不只适用于 JavaScript 的补丁,HTML 和 CSS 的功能也可以使用 Polyfill 来解决原生支持的问题,甚至我们现在很多手动写的 Polyfill 都是 HTML 和 CSS 相关的补丁。这是因为,Babel 作为 JavaScript 的转译结合 Polyfill 的工具已经解决了大多数语法和功能上的问题。
|
||||
|
||||
前面讲到 Babel 的那节课,我们也提到过 ECMAScript 第6版是 JavaScript 的主要更新版本,这一版中添加了大量的新功能和语法,所以,如果你想支持 Internet Explorer,或者在其它不支持 ES6 的浏览器中使用最新的 ECMAScript 功能,那么通过使用 Babel,就可以系统性地解决这些问题。但是针对 HTML5 和 CSS3,市面上并没有一个像 Babel 一样的统一工具,因为相关的功能太多,且很多不是都在程序中需要用到的,所以大多是以独立的执行特定任务的补丁的形式出现的。
|
||||
|
||||
在标准和浏览器都不断升级的今天,跟踪不断发展的 HTML5 和 CSS 的支持可以说是一项非常艰巨的任务。比如我们想使用 CSS 动画来创造一些页面上的效果,想要知道哪些浏览器支持该功能,对于那些不懂这些代码的浏览器该怎么办,这些问题原本是一个很大的挑战。好在针对这个问题,有一个 html5please.com 的网站,提供了 HTML5 元素和 CSS3 规则的完整列表,并概述了浏览器支持以及列出了每个元素的任何 Polyfill。
|
||||
|
||||
Polyfill 的具体实现
|
||||
|
||||
但既然我们的重点是讲 JavaScript,我们还是用一个 JS 的例子来看看如何通过 Polyfill 来解决 JavaScript 原生支持问题。Polyfill 的运转机制很简单,当某些功能不被浏览器支持时,它提供了向下兼容的方式。
|
||||
|
||||
Polyfill 在编写的时候,通常都遵循一个模式,就是我们先判断脚本想要实现的功能是否已经被当前运行时的浏览器支持了。如果是,我们就不需要使用 Polyfill 了,如果不支持,那我们就需要 JavaScript 引擎来执行我们定义的补丁。下面,我们可以通过写一个 Polyfill,来更好地了解它的工作机制。
|
||||
|
||||
这里我们可以用数组中的 forEach 方法来举例。首先,我们来看看 forEach 的定义和用途。在定义上,当我们在数组上使用 forEach 方法的时候,需要传入一个回调函数。回调函数带有3个参数,第一个是必选,代表当前元素的值;第二个是可选,代表当前元素在数组中的索引;第三个也是可选,代表当前元素所属的数组对象。这里面比较常用的是前两个参数,所以也是我们尝试实现的重点。
|
||||
|
||||
array.forEach(function(currentValue, index, arr), thisValue)
|
||||
|
||||
|
||||
下面我们再来看看 forEach 方法的用途。首先让我们先创建一个数组,我们称之为 oldArray。然后里面,我们加入三个地名,纽约、东京、巴黎。之后,让我们再创建一个空数组,newArray。我们可以通过遍历第一个数组的方式,将里面的元素推送到第二个数组中。这就是一个 forEach 的简单用例。
|
||||
|
||||
var oldArray = ["纽约", "东京", "巴黎"];
|
||||
var newArray = [];
|
||||
|
||||
oldArray.forEach( function(item, index) {
|
||||
newArray.push(index + "." + item);
|
||||
}, oldArray);
|
||||
|
||||
newArray; // ['0.纽约', '1.东京', '2.巴黎']
|
||||
|
||||
|
||||
通常在写 Polyfill 的时候,我们都会看功能本身是否已经被支持了。为了确定我们当前使用的浏览器是否支持 forEach 方法,我们可以使用开发者工具中的控制台,编写一段代码来测试下。
|
||||
|
||||
我们可以通过检查 forEach 方法是否在数组的 prototype 原型上的方式,来看这个功能是否存在。所以,接下来让我们测试下 forEach 是否返回 undefined,如果返回的结果是 undefined,就表示当前浏览器不支持这个方法,也就是说浏览器根本不知道它是什么;如果返回的结果是 true,则意味着 forEach 不等于 undefined,那么 forEach 在我当前版本的浏览器上是本地支持的。
|
||||
|
||||
Array.prototype.forEach !== undefined;
|
||||
|
||||
|
||||
因为 forEach 的方法是从 ES6 开始就被支持的了,所以如果你运行上面这一段代码,大概率得到的结果会是 true,也就是说你应该可以看到浏览器对 forEach 方法的支持。但为了尝试手写 Polyfill 的过程,我们假设当前的浏览器太旧了,它不知道 forEach 函数,也未定义 forEach 方法,因此它不存在于当前使用的浏览器中。那么让我们手动写个补丁来实现这个功能吧。
|
||||
|
||||
下面,我们就可以正式编写 Polyfill 的功能了。首先,我们在建立这个Polyfill 的时候,第一步要看转参是不是一个函数。既然我们希望这个 Polyfill 能够在所有的数组原型上都能使用,那么我们就需要在 JavaScript 中访问 Array 对象,并在其原型上定义一个名为 forEach 的新函数方法。
|
||||
|
||||
这里有一点需要注意的是,因为我们现在的浏览器是支持 forEach 功能的,所以这么做其实就等于覆盖了原生支持的 forEach 功能。所以首先,我们需要判断用户传入的是不是一个函数参数。这里,我们可以使用 JavaScript 内置的 typeof 方法来实现对参数类型的检查。如果返回的结果不是函数类型的话,程序应该返回一条报错。这其实就是一种类型检查。
|
||||
|
||||
Array.prototype.forEach = function(callback, thisValue){
|
||||
if (typeof(callback) !== "function") {
|
||||
throw new TypeError(callback + "不是一个函数");
|
||||
}
|
||||
var arrayLength = this.length;
|
||||
for (var i=0; i < arrayLength; i++) {
|
||||
callback.call(thisValue, this[i], i, this);
|
||||
}
|
||||
}
|
||||
|
||||
var oldArray = ["纽约", "东京", "巴黎"];
|
||||
var newArray = [];
|
||||
|
||||
oldArray.forEach( function(item, index) {
|
||||
newArray.push(index + "." + item);
|
||||
}, oldArray);
|
||||
|
||||
newArray; // ['0.纽约', '1.东京', '2.巴黎']
|
||||
|
||||
|
||||
如果参数通过了类型检查,那么接下来,我们就要正式写 forEach 实现的部分了。
|
||||
|
||||
在这个部分中,我们首先需要获取的是数组的长度。因为 this 代表的是函数在调用时的主体,也就是说我们是通过数组来调用 forEach 方法的,所以在这里,我们可以通过 this.length 来获得数组的长度。
|
||||
|
||||
之后,我们可以通过一个 for 循环,来遍历数组中的元素,针对每个元素执行回调函数。在回调函数中,我们可以传入当前元素值、该元素的索引和数组三个参数。这里,为了 this 的引用是正确的,我们使用了 call() 方法,这样做是为了让 this 指向我们传入的正确的第二个参数对象。
|
||||
|
||||
同样,我们可以用前面的用例来测试,你会发现我们用来覆盖原始 forEach 功能的 Polyfill,达到了同样的效果。
|
||||
|
||||
总结
|
||||
|
||||
通过这一讲的学习,我们知道了如何使用 Polyfill 来解决原生支持的问题,也了解了它和转译工具的不同点和互补之处。如果只有使用,没有了解过它们的工作原理,这两个工具是很容易被混淆的。所以我在这里也再次强调下,加深一下理解,转译和 Polyfill 的共同点是它们的目的都是为了提供原生支持;而它们的区别在于支持的内容和实现的方式。
|
||||
|
||||
|
||||
转译解决的主要是语法的原生支持问题,它的实现方式是通过将源代码转换成源代码,而不是机器码。
|
||||
Polyfill 解决的主要是功能的原生支持问题,它的实现方式可以是通过在对象原型上增加补丁来支持相关功能。
|
||||
|
||||
|
||||
转译和 Polyfill 并不是非此即彼的概念,相反,它们是高度互补的,大多情况下可以结合起来使用。
|
||||
|
||||
想必很多同学之前也都使用过 Polyfill,希望现在的你不仅会用,还可以掌握设计和编写一个 Polyfill 的能力。
|
||||
|
||||
思考题
|
||||
|
||||
在平时的开发中你有用到 Polyfill 的场景吗?你主要使用它来解决哪些问题呢?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
111
专栏/JavaScript进阶实战课/41微前端:从MVC贫血模式到DDD充血模式.md
Normal file
111
专栏/JavaScript进阶实战课/41微前端:从MVC贫血模式到DDD充血模式.md
Normal file
@ -0,0 +1,111 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 微前端:从MVC贫血模式到DDD充血模式
|
||||
你好,我是石川。
|
||||
|
||||
在前面“JS之器”的一个单元,我们介绍了为 JavaScript 开发赋能的工具。今天,我们来到了专栏的最后一个单元,也就是“JS之势”。在这个单元里,我们会看一下在 JavaScript 和前端开发中的一些趋势。这节课我们就来看看“微前端”这个近些年兴起的概念。
|
||||
|
||||
从微服务到微前端
|
||||
|
||||
在说微前端之前,我们先来看看启发微前端概念的微服务。
|
||||
|
||||
微服务是在 2011 年首次被提出的一种架构模式。在微服务的概念被提出之前,Web 服务的开发主要经历了 C/S 结架构和 SOA 结构两个阶段。下面我们先来看看 C/S 结构。
|
||||
|
||||
C/S结构
|
||||
|
||||
C/S 结构的全称是“客户机和服务器结构(Client Server)”,这种结构可以算是 Web 开发中早期的系统架构之一了。当时,大多的 Web 开发依赖的是开发者创建几个 HTML、CSS 和 JavaScript 文件,然后通过 SFTP 将开发完的程序上传到服务器,供客户端用户通过浏览器,经过网络来访问和下载相关资源。随着网站和 Web 应用程序规模的增加,简单的 C/S 结构已经不再能够处理大型企业 Web 应用程序的复杂性需求了。
|
||||
|
||||
SOA结构
|
||||
|
||||
这时,C/S 结构的演变——SOA,也就是面向服务的系统架构的概念,在 90 年代末诞生了。SOA 通过面向服务的体系结构设计,建立了一种分层体系结构设计方法。顶层是应用程序;中间层是服务;底层是后端,包含业务逻辑和数据库。
|
||||
|
||||
在 SOA 结构下,我们就可以看到组件化的设计思想了,因为在这种结构下,每个服务都可能被封装。每个服务本身是可重用的,既可以用于构建复合服务,也可以单独服务于特定的业务对象。并且它也可以具有不同级别的颗粒度,并提供一个抽象层,因此业务逻辑和数据不必对应用程序可见。总体而言,服务提供者和消费者两端的开发人员可以基于服务之间的合约进行独立的开发和相互的集成。
|
||||
|
||||
微服务结构
|
||||
|
||||
随着时间的推移,微服务的结构又在 2011 年前后诞生了,随后兴起。乍一看,它只是 SOA 的不断演变的结果,并与 SOA 架构有许多共同特点。例如,微服务结构的理念中也包含封装、颗粒度、抽象、契约协议等概念。不过不同之处在于,微服务更关注基于域驱动的限界上下文、协作和 DevOps。微服务的到来对业务架构、软件开发和软件工程都有着不同程度的影响。
|
||||
|
||||
从业务架构的角度来看,它是基于域驱动的设计,这样的设计更多关注的是面向业务对象而不是面向过程的设计。第二个是从软件开发的角度,传统的服务开发模式大多是基于贫血的域模型(ADM,Anemic Domain Model)的开发,而随着微服务和 DDD 的兴起,逐渐转为了充血的域模型(RDM,Rich Domain Model)。
|
||||
|
||||
贫血的域模型中一个典型的例子就是 MVC 的结构,可是说到这里,你可能会问,MVC 难道不是一个前端的开发架构,为什么说它是 Web 后端服务开发中的贫血模型呢?这是因为其实在后端开发中,也有三层的分层结构叫做 Repository、Service、Controller,这三层分别负责数据访问、业务逻辑和接口暴露。
|
||||
|
||||
|
||||
|
||||
在后端分层的开发中,数据对象、业务对象和展示对象都只包含数据,不包含业务逻辑,所以被称之为是贫血模式。而与之相对的充血模式,其实主要是指在业务逻辑层的充血。也就是在服务层,在 Service 类的基础上加入了 Domain 类,Domain 类中既包含了数据,也包含了逻辑;而 Service 类则被弱化,更多只负责调用逻辑、权限和事务控制等。
|
||||
|
||||
这里,你可能会问,那这样看来,DDD 听上去很玄乎,可从开发层面感觉只是把原本的 Service 类换成了 Domain 类,这对开发出来的服务能有什么影响呢?
|
||||
|
||||
其实在这里,主要的影响是设计理念和开发流程引起的。我们可以想象下,在传统的贫血模式下,服务的设计是被动的,几乎都谈不上设计,而更多的是基于前端的界面数据需求的响应,我们甚至可以把它称作是 SQL 驱动的开发。比如前端需要一个数据的展示,会把需求提给后端,后端开发这时来想什么样的 SQL 语句可以获取相关数据,然后创建或更改数据访问对象、业务对象和展示对象,最后以接口暴露的方式反馈给前端。
|
||||
|
||||
如果是一个并不复杂的应用系统,用这种模式开发还好,然而我们一旦面对较为复杂的业务逻辑的时候,这样的设计就会变得难以维护,因为任何一个细小的数据请求变化,都可能会产生新的代码,其中有很多可能是重复的代码。
|
||||
|
||||
而域驱动和 SQL 驱动的主要不同点在于,域驱动要求开发人员在一开始就要梳理清楚所有的业务架构,定义领域模型,以及模型中所包含的属性和方法。这些领域模型其实就等于是可以复用的业务中间层。所以面对新的功能需求开发时,都可以基于之前定义好的领域模型来完成。
|
||||
|
||||
除了在开发设计和开发流程上的转变外,微服务和 DDD 也从协作和软件工程的角度改变了开发的习惯。这种模块化的服务构建方法,允许将每个后端服务设计和开发为更小的构建模块,从而实现更大的系统灵活性。通过对 DevOps、持续集成和部署的支持,还可以减少开发的周期时间,提高运维效率。越来越多的软件公司和大型企业一直在推广和改造这种架构,然而,同样的想法在前端 Web 开发中尚未成熟。
|
||||
|
||||
微前端结构
|
||||
|
||||
但随着时间的推移,到了 2016 年,微前端的概念就出现了。如果说微服务是打破了后端的单体结构一样,那么微前端的概念则是打破了前端的单体结构。这样,前端的不同模块也可以被组件化地基于页面需求来加载和相互交互。此外,微前端也允许具有不同技术、技能和工具集的不同前端团队可以在每个单独封装的业务领域中独立开发一组功能。
|
||||
|
||||
微前端的设计和实现
|
||||
|
||||
下面,在正式说到微前端之前,我们再来看看前端系统结构的演变。
|
||||
|
||||
早期的网站更多是“多页面应用程序”,这意味着终端用户访问的是网站的每个独立页面,页面间的跳转是通过点击超链接的方式操作的。每当跳转的操作被调用的时候,页面就会刷新。网站的内容和页面创建通常是由 CMS 提供的支持,CMS 代表内容管理系统,用于创建、编辑和发布动态内容,尤其是在网络上。
|
||||
|
||||
早年,网络上曾出现过很多流行的 CMS 工具,如 WordPress 或 Drupal。CMS 系统有两个不同的用户界面,一个用户界面可供公众访问,另一个界面可供后台作者和编辑访问。业务逻辑和内容主要在后端呈现,输出是包含 HTML 格式信息的网页,供公众查看。
|
||||
|
||||
CMS 的工作流程是,内容作者将在后台选择内容创作的模板和组件,一旦完成页面的构建和内容填充,作者将发布页面,页面将被发送。通常,CMS 会结合 CDN(Content Delivery Network)一起使用,通过缓存,加快页面和资源加载时间。当然,这种经典的 CMS 方法至今仍被广泛使用,例如现在使用 WordPress 创建个人博客的人还有很多。CMS 工具流行的一个原因是,选择用 CMS 来开发主要是着重信息更新,不需要大量的业务逻辑交互的页面。
|
||||
|
||||
随着 Ajax 的兴起,以及后来的 HTML5 和 CSS3 技术的出现,网站的互动性越来越强,丰富的互联网应用程序也不断涌现。由于更多的用户交互,SPA(单页应用程序)的技术就随之兴起了,在 Web 开发中得到了越来越广泛的应用。虽然在单页应用程序中,仍然可以使用 CMS,但它的功能将仅限于管理内容而不是创建HTML页面。在这种情况下,前端应用程序承载了页创建和展示的任务,而 CMS 只负责内容的提供。通常这样的 CMS 被称为无头 CMS(headless CMS)。
|
||||
|
||||
SPA 不仅限于 PC 端,还可以用于移动端和其他嵌入式系统,并且可能会根据客户端类型对内容进行一些更改,在这种情况下,还可能需要添加一个 BFF 的适配层。当然 BFF 的作用不仅在适配,它也可以用于 API 层的复合或聚合、服务的认证或授权,或者在遗留系统之上构建微服务。
|
||||
|
||||
单页应用程序的另一个问题是搜索引擎优化。搜索引擎通常基于 URL 链接搜索内容。然而,对于单页应用程序,搜索引擎不再能够轻松找到页面,因为当用户从一个部分导航到另一个部分时,没有 URL 更新。谷歌开发了强大的算法,甚至可以抓取 SPA 页面。然而,谷歌并不是世界上唯一使用的搜索引擎,仍然有许多搜索引擎无法抓取服务器端渲染和客户端渲染的 HTML 页面。
|
||||
|
||||
针对这个问题,目前最常用的解决方案是同时使用客户端和服务器端渲染,这意味着前端和后端将共享相同的模板文件,并使用单独的模板引擎来渲染包含数据的页面。这种与 Node.js 结合的模式被称为同构 JavaScript(isomorphic JavaScript)。
|
||||
|
||||
而 Web 应用系统从 SPA 进一步演变到微前端的过程中,有几点是我们需要特别注意的。微前端的设计通常包括4层结构的考虑,分别是:系统层结构、应用层结构、模块层结构和编码层结构。
|
||||
|
||||
|
||||
|
||||
系统层的架构:涉及了我们希望微前端如何与其它系统或应用程序(例如后端或后台系统)交互。后端系统需要某种类型的集成,通常涉及使用微服务、BFF、API 契约、用于开发测试的 mock API 和进行身份验证的 OAuth 等。
|
||||
|
||||
应用层的架构:包括我们用来构建微前端应用程序本身的框架、设计系统与周围的实用工具、库和程序。
|
||||
|
||||
模块层的架构:涉及了用作应用程序中主应用和子应用构建块的所有组件和模块。比如 Web Components 的技术对于微前端开发非常重要,因为它主要提供了前端开发中的模块化支持。但在使用 Web Components 的时候,要特别注意的是,它不是W3C定义的官方标准,并且也存在兼容性的问题,市面上使用 Web Components 开发通常都基于如 Lit 或 Stencil 这样的工具来降低使用风险。
|
||||
|
||||
编码层的结构:这一层次的体系结构涉及开发流程(包括存储库管理、代码合并、代码提交和拉取请求等)、代码质量、编码标准等。这里,这些是我们在微前端开发中应统一考虑的原则。
|
||||
|
||||
基于这4层的微前端结构,我们可以重点看看应用层的 App 容器、配置、注册和发现、中央路由、生命周期管理,以及编码层的开发流程、构建和部署流程。
|
||||
|
||||
App注册发现和路由
|
||||
|
||||
尽管微前端架构是一个去中心化的概念,但一个中央的 App 容器对于创建所有组件协同工作的一致体验仍然是至关重要的。除了定义了一个 App 容器以外,定义一个子应用注册和发现的机制也很重要,这和微服务的注册和发现类似。一旦有了子应用的注册和发现机制,中央 App 容器则可以通过路由来发现,并将用户重定向到正确的内容所在位置。
|
||||
|
||||
App生命周期管理
|
||||
|
||||
除了上述机制外,每个子 App 自身的生命周期管理也是很重要的。在一个 App 的生命周期中,需要处理的任务包括了:从容器中插入、更新或删除的模块,或处理组件的加载、更新、卸载和错误处理等关键任务。这个过程从子 App 注册器获取配置开始,host 会检索配置的详细信息,当发生 URL 更改时,更改事件将根据配置详细信息触发现有子 App 的卸载和新的子 App 的装载。当 URL 更改时,新的子 App 将加载到 host。
|
||||
|
||||
App开发和部署管理
|
||||
|
||||
在任何开发工作中,一个高效的开发流程都很重要,在微前端开发中更是如此。为了保持高效的开发管道,可以通过我们在上一单元“JS 之器”中讲到的多种工具(如Jest、ESLint)结合来检查几个关键点,以实现更好的代码功能、结构、格式以及质量检查。同样的,我们可以在流程中加入 NPM 或 Yarn 等软件包的管理和依赖管理工具,通过 Babel 来对代码进行编译,以及通过 webpack 的工具对应用进行打包,这些都可以在构建和部署中使用来提高管理的效率。
|
||||
|
||||
总结
|
||||
|
||||
从这一讲中,我们看到了微前端的出现离不开在它之前出现的微服务以及基于领域的设计模型。但是在前端,不同于后端的点在于,前端并不是服务的提供方,而是消费者。所以微前端虽然受到了微服务的启发,都是强调基于业务领域的设计、组件的封装和模块化的开发,但还是有着使用场景上的本质不同。
|
||||
|
||||
同时,微前端也不是完全的去中心化,因为我们还是需要一个主 App 容器负责子 App 的加载。在子 App 间,不同于单体设计,这里我们需要花更多的精力来保证子 App 的注册和发现,路由以及生命周期的管理。而且越是去中心化,我们越是需要团队间有一套“共识机制”保证开发和部署流程的管理。但是这样带来的好处是,我们可以在子团队间有更大的开发自由,同时,在规模化的情况下,可以保证协作的效率。
|
||||
|
||||
思考题
|
||||
|
||||
在微前端中,每个组件都是独立封装的,你知道如何实现它们之间的通信吗?
|
||||
|
||||
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
126
专栏/JavaScript进阶实战课/42大前端:通过一云多端搭建跨PC_移动的平台应用.md
Normal file
126
专栏/JavaScript进阶实战课/42大前端:通过一云多端搭建跨PC_移动的平台应用.md
Normal file
@ -0,0 +1,126 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
42 大前端:通过一云多端搭建跨PC_移动的平台应用
|
||||
你好,我是石川。
|
||||
|
||||
如今的 JavaScript 早已不只是可以满足 Web 开发,更是可以用于后端服务器,甚至数据库相关的开发。在上一讲,我们聊到了“微”前端,今天,我们再来看看“大”前端这种前端驱动的开发模式和传统的开发模式有什么不一样,以及如何利用大前端来实现前后端全部所需要的开发。
|
||||
|
||||
|
||||
|
||||
前端应用
|
||||
|
||||
首先,我们来看看在我们最熟悉的前端开发中,如何通过 JavaScript 语言来满足不同端的应用的开发需求。在说到解决方案之前,我们先来看看移动开发的几种模式,以及它们的优劣势。
|
||||
|
||||
第一种是原生开发,比如苹果的应用,我们通常会使用 Swift 来开发,而对于安卓的应用,我们通常会使用 Kotlin 来开发。
|
||||
|
||||
原生开发有这样几点好处:
|
||||
|
||||
|
||||
对于终端用户来说,它的体验会更顺畅,因为一般在 App 包下载安装的过程中,会一次性下载初始化的资源包,再加上加载过程的预处理,就减少了动态渲染的压力;
|
||||
对于开发者来说,原生的 App 有强大的开发工具,从开发到打包再到发布,都有着很成熟的一条龙的流水线,而不像在 Web 应用的开发中,需要处理各种的原生支持问题、浏览器兼容问题等。
|
||||
|
||||
|
||||
但是这种开发模式也不是没有短板,其中比较明显的就是在开发的过程中,作为开发者需要同时掌握 Swift、Kotlin 和 JavaScript 三种语言,再加上测试和应用商店审核等工作,可以说这样的工作量是成倍增加的,所以三端通常是由不同团队完成的。
|
||||
|
||||
第二种是 Web 应用,对于 Web 应用来说,程序是在浏览器中运行的。
|
||||
|
||||
Web 应用有这样几点好处:
|
||||
|
||||
|
||||
大大减少了用户手机内存空间;
|
||||
避免了 App 在部分区域的商店不支持的问题,用户需要访问相关应用的时候,只需要打开浏览器,输入相关的应用地址,就可以浏览相关信息;
|
||||
对开发者来说,大大避免了漫长的应用商店审核流程,缩短了开发和发布周期。
|
||||
|
||||
|
||||
当然,它的劣势也很明显:
|
||||
|
||||
|
||||
从终端用户的体验上,Web 应用的体验是逊色于原生 App 的,毕竟浏览器的渲染还是比不上原生 App 的丝滑体验;
|
||||
从用户体验上来说,用户需要输入地址才能访问相关的 App,虽然 Web 应用也支持生成快捷图标到桌面,或者移动浏览器也支持书签的功能,但是从使用习惯上来说,用户并没有相关的习惯,所以对于用户粘性很高、使用频率也比较高的应用来说,Web 应用就显得不是那么合适了;
|
||||
对于品牌来说,想要给用户一个值得信赖的体验,一个可以下载的原生应用也是比一个只能访问的 Web 应用的 URL 看上去更加靠谱的。
|
||||
|
||||
|
||||
第三种是混合开发模式,在混合开发模式下,顾名思义,就是 App 本身是通过原生的方式发布的,但是里面从不同程度嵌入了 Web 应用的混合应用。
|
||||
|
||||
首先,我们先来说说优势:
|
||||
|
||||
|
||||
这样的开发模式下,让应用“看上去”更加原生了,毕竟它是“借壳上架”到应用商店的;
|
||||
这样的开发模式下,内容的更新变得更加容易了,因为在一层原生应用的壳下,内部的应用实际是动态加载的 Web 应用,这样每次在更新的时候就可以采用“热更新”,而不需要重新对应用进行打包、再次审核及发布。
|
||||
|
||||
|
||||
当然,这样做,也会有相关的问题:
|
||||
|
||||
|
||||
应用商店也不傻,如果大家都只做一个壳,然后发布到应用商店,那这个审核机制就形同虚设了,所以通常一个应用的原生部分低于一定比例的时候,是无法通过审核的;
|
||||
即使通过了审核,它的体验也会比较差,因为 App 本身就是一个应用,内部再调用浏览器来渲染一个 Web 应用,这样的体验通常是低于原生应用,甚至还不如直接在浏览器访问的 Web 应用。所以对于开发的应用,特别是游戏类的,这种方式肯定是不适合的。
|
||||
|
||||
|
||||
那么为了解决上面的问题,在开发中,有没有什么解决方案呢?
|
||||
|
||||
在早期,曾经有一段时间,前端试图通过渐进式 Web 应用(PWA)的方式来代替原生应用。PWA 的概念是在 2016 年谷歌的 I/O 大会上被提出来的,它的核心理念是在 Web 应用的基础上,通过一系列的技术实现,来提高用户的粘性、增加响应、增强离线消息的可靠性。
|
||||
|
||||
为了能够给用户更加类原生的体验,这里用到了 Notification/Web Push API、Web Manifest、 Service Worker 等等。这些功能可以让 Web 应用也实现消息推送、形成桌面应用和离线功能等。但是,PWA 虽然在开发者的圈子中激起了很大的热情,但是对于终端用户来说,并没有很买单,对于大多数的手机平板用户来说,人们的选择还是下载原生 App 居多,但是在 PC 桌面,倒是大多数的用户都形成了用浏览器而尽量不安装原生应用的习惯。
|
||||
|
||||
既然通过开发驱动去改变用户的使用习惯是不可能的,那么开发者又换了一个思路,就是怎么在保持原生的基础上,能够选择 JavaScript 作为开发语言。
|
||||
|
||||
这时,在 2015年,由 Meta 在同一时期推出的 React Native 就逐渐变得流行起来。它可以允许开发者同时用一套 JavaScript 代码开发出 iOS、Android 和 Web 应用,以及基于 Android 的电视应用。也就是说,作为开发者,不再需要同时学习几种语言进行前端的移动开发。当然,随着 React Native 的流行,后面又相继出现了基于 React Native 的 Windows 和 macOS 应用开发模式。
|
||||
|
||||
所以,同一时期,2013 年推出的 Electron 也逐渐流行了起来,开发者可以在 Windows 和 macOS 应用的基础上,开发 Linux 的桌面应用。再往后,随着小程序的出现,国内也出现了基于 Vue.js 的 uni-app,它可以在原生和 Web 应用的基础上,加上对小程序的支持。
|
||||
|
||||
Web 服务
|
||||
|
||||
说完了前端,我们再来看看中间层。
|
||||
|
||||
通常,我们的网站需要一个 Web 应用框架,用来创建前端的页面,并且提供相关的数据给到前端渲染。为了达到这个目的,Web 应用最核心需要支持的功能是路由(routing)、中间件(middleware)和模版系统(template engine)。
|
||||
|
||||
路由的目的是能够根据 URL 来解析用户请求访问的页面;中间件的目的是根据前端的请求做出反馈,并且调用下一个中间件;模版引擎的作用是根据定义好的 HTML 模版和变量参数,对页面进行渲染。目前,市面上比较流行的 Web 服务器端的框架是 Express.js,可以满足我们上面说到的几个核心功能。
|
||||
|
||||
同时,如果我们展开来看中间件的功能,会进一步发现几个需要的核心子功能:
|
||||
|
||||
|
||||
作为服务的提供方,在中间层除了构建页面外,也需要对应不同的前端,一云多端地提供按照不同的需求适配的 API;
|
||||
在面对中间件下面的不同类型的数据库或后端系统,也需要提供一层聚合,让转化后的数据格式可以满足前端的需求;
|
||||
从安全的角度来看,API 的创建需要一系列的鉴权。
|
||||
|
||||
|
||||
为了满足这些条件,通常在服务器端需要一个相关的平台来完成相关的功能,例如 Apollo Server 便是一个可以和 Express.js 结合提供类似服务的平台。
|
||||
|
||||
数据的存储和查询
|
||||
|
||||
前面,我们说完了前端应用、中间件和 Web 服务 API,那么为了创建一个完整的 Web 应用系统,离不开数据本身。所以最后,我们再来看下数据的存储和查询。
|
||||
|
||||
首先,我们需要一个数据库来存储前端产生的数据。数据库可以大致分为两类,一类是关系型数据库(RDBMS),另外一类是非关系型数据库(NoSQL)。在服务器端的 JavaScript 中,有基于 JS 引擎 SpiderMonky 来实现的 MongoDB,它是一个类文件型的非关系型数据库。通过MongoDB,我们可以对应用产生和依赖的数据进行存储。因为它天然就是用类 JSON 的格式来做数据文件存储的,所以非常适用于 Web 应用的开发。
|
||||
|
||||
我们知道在前端开发中,当我们想要查询数据库中的信息时,需要通过一种查询语言。在传统的查询语言中,特别是在关系型的数据库中,我们通常使用的是 select from 这样的查询请求格式,返回的是一张表;而一种叫做 GraphQL 的查询请求和返回的格式,则更像是 JSON 或对象的字面量表达。
|
||||
|
||||
换句话说,它的使用更符合我们前端开发的直觉。GraphQL 便是在 2012 年发展起来的,当时 Meta 内部开发了一个 GraphQL 的项目,随后,在2015年对外公布,之后在 2018 年从 Meta 移到了独立的组织来管理。
|
||||
|
||||
GraphQL 有几大特点:
|
||||
|
||||
|
||||
从对 JSON 格式的使用上,我们可以看出,GraphQL 是一种更适用于 API 服务的查询语言,也是使用现有数据完成这些查询的运行时。 GraphQL 为 API 中的数据结构提供了完整且易于理解的描述,使客户端能够准确地表达想要查询的对象。
|
||||
GraphQL 的另外一大特点是,它用到了我们在前面 JavaScript 类型检查中提到的类型系统。这样可以更好地对输入和输出的数据类型有所定义和规范。
|
||||
在传统的 REST API 中,我们有时需要通过几个请求才能拿到一条完整的返回信息,而通过 GraphQL,我们可以在一条信息中包含所需的全部信息,这样大大减少了反复的请求。
|
||||
|
||||
|
||||
在传统的开发中,前后端产生分歧是常有的事,其中很大的原因就是后端很难站在前端的角度想如何能提供面向业务对象的数据;在数据服务于业务的情况下,应该如何保证类型系统的规范;又或者是应该如何让请求的数量减少,增加每次服务调用的效率。而在大前端的概念下,一切的系统设计都是服务于前端用户的,在这样重业务驱动的模式下,可以倒逼后端更好地满足前端开发的需求。
|
||||
|
||||
总结
|
||||
|
||||
通过这一讲的学习,我们可以看到,从前端应用的开发,到服务器端以及 API 开发,再到后端的数据存储和查询,我们几乎都可以通过 JavaScript 和相关的工具来完成。
|
||||
|
||||
从数据的存储和查询,我们可以看到两个关键词,就是 JSON 和 GraphQL。JSON 作为一种数据存储结构,运用在前端驱动的开发中,可以赋能业务数据对象和逻辑的实现;而 GraphQL 的出现,也更符合在网络环境中生成的数据往往是非线性的、网状的数据的查询,在这一点上,它有别于传统的、规范的、表格式的数据查询。所以大前端的出现,可以让数据的存储和数据的获取方式更好地服务于前端的交互和业务的需求。
|
||||
|
||||
思考题
|
||||
|
||||
今天,我们提到大前端,说到它最核心的优势是可以通过前端驱动,让 API 和数据更高效地服务于前端用户和业务的需求。但它的好处却也不止于此,从另外一个角度来看,它通过 JavaScript 一种核心技术,满足了前中后台的开发需求,也等于起到了降本增效的作用。但除此之外,你还能想到它的其它优势或者一些短板吗?
|
||||
|
||||
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
129
专栏/JavaScript进阶实战课/43元编程:通过Proxies和Reflect赋能元编程.md
Normal file
129
专栏/JavaScript进阶实战课/43元编程:通过Proxies和Reflect赋能元编程.md
Normal file
@ -0,0 +1,129 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
43 元编程:通过Proxies和Reflect赋能元编程
|
||||
你好,我是石川。
|
||||
|
||||
今天,我们来到了这个单元最后的一课。前面的两节课中,我们分别学习了微前端和大前端“一大一小”两个趋势,今天我们再来看看“元”编程。那么,元编程中的“元”代表什么呢?“元”有“之上”或“超出一般限制”的意思。听上去,比较玄乎,实际上,要理解和使用元编程,并不难,甚至你可能每天都在用,只是没有觉察到而已。所以今天,就让我们来一步步了解下元编程的概念及使用。
|
||||
|
||||
在 JavaScript 中,我们可以把元编程的功能分为几类:第一类是查找和添加对象属性相关的功能;第二类是创建 DSL 这样的特定领域语言;第三类就是可以作为代理用于对象的装饰器。今天,就让我们一一来看一下。
|
||||
|
||||
对象属性的属性
|
||||
|
||||
1. 对象属性的设置
|
||||
|
||||
首先,我们先来看下对象相关属性的查找和添加。我们都知道 Javascript 对象的属性包含了名称和值。但是另外,我们需要了解的是,每个属性本身也有三个相关的属性,它们分别为可写属性(writable)、可枚举属性(enumerable)以及可配置属性(configurable)。
|
||||
|
||||
这三个属性指定了该属性的行为方式,以及我们可以使用它们做什么。这里的可写属性指定了属性的值是否可以更改;可枚举属性指定了属性是否可以由 for/in 循环和 Object.keys() 方法枚举;可配置属性指定了是否可以删除属性或更改属性的属性。
|
||||
|
||||
这里,需要注意的是,我们自定义的对象字面量和通过赋值定义的对象属性都是可写、可枚举和可配置的,但 JavaScript 标准库中定义的很多对象属性都不是。从这里,你应该可以看出,只要你使用过 for/in,那么,恭喜你,基本你已经使用过元编程开发了。
|
||||
|
||||
那么对属性的查询和设置有什么用呢?这对于很多第三方库的开发者来说是很重要的,因为它允许开发者向原型对象中添加方法,而且也可以像标准库中的很多内置方法一样,将它们设置成不可枚举;同时,它也可以允许开发者“锁定”对象,让属性定义无法被更改或删除。下面,我们就来看看可以为 JavaScript 第三方库的开发赋能的查询和设置属性的 API。
|
||||
|
||||
我们可以将属性分为两类,一类是“数据属性”,一类是“访问器属性”。如果我们把值、getter、setter 都看做是值的话,那么,数据属性就包含了值、可写、可枚举和可配置这4个属性;而访问器属性则包含了 get、set、可枚举和可配置这4个属性。其中可写、可枚举和可配置属性是布尔值,get 和 set 属性是函数值。
|
||||
|
||||
我们可以通过 object.getOwnPropertyDescriptor() 来获取对象属性的属性描述,顾名思义,这个方法只适用于获取对象自身的属性,如果要查询继承属性的属性,就需要通过 Object.getPrototypeOf() 的方法来遍历原型链。如果要设置属性的属性或者使用指定的属性创建新属性,就需要用到 Object.defineProperty() 的方法。
|
||||
|
||||
2. 对象的可延展性
|
||||
|
||||
除了对对象的属性的获取和设置外,我们对对象本身也可以设置它的可延展性。我们可以通过 Object.isExtensible() 让一个对象可延展,同样的,我们也可以通过 Object.preventExtensions() 将一个对象设置为不可延展。
|
||||
|
||||
不过这里需要注意的是,一旦我们把一个对象设置为不可延展,我们不仅不可以在对象上再设置属性,而且我们也不可以再把对象改回为可延展。还有就是这个不可延展只影响对象本身的属性,对于对象的原型对象上的属性来说,是不受影响的。
|
||||
|
||||
对象的可延展性通常的作用是用于对像状态的锁定。通常我们把它和属性设置中的可写属性和可配置属性结合来使用。在 JavaScript 中,我们可以通过 Object.seal() 把不可延展属性和不可配置属性结合;通过 Object.freeze() 我们可以把不可延展、不可配置和不可写属性结合起来。
|
||||
|
||||
对于 JavaScript 第三方库的编写来说,如果库将对象传递给库的回调函数,那么我们就可以使用 Object.freeze() 来防止用户的代码对它们进行修改了。不过需要注意的是,这样的方法可能对 JavaScript 测试策略形成干扰。
|
||||
|
||||
3. 对象的原型对象
|
||||
|
||||
前面,我们介绍的 Object.freeze() 、 Object.seal() 和属性设置的方法一样,都是仅作用于对象本身的,都不会对对象的原型造成影响。我们知道,通过 new 创建的对象会使用创建函数的原型值作为自己的原型,通过 Object.create() 创建的对象会使用第一个参数作为对象的原型。
|
||||
|
||||
我们可以通过 Object.getPrototypeOf() 来获取对象的原型;通过 isPrototypeOf() 我们可以判断一个对象是不是另外一个对象的原型;同时,如果我们想要修改一个对象的原型,可以通过 Object.setPrototypeOf()。不过有一点需要注意的是,通常在原型已经设置后,就很少被改变了,使用 Object.setPrototypeOf() 有可能对性能产生影响。
|
||||
|
||||
用于DSL的模版标签
|
||||
|
||||
我们知道,在 JavaScript 中,在反引号内的字符串被称为模板字面量。当一个值为函数的表达式,并且后面跟着一个模板字面量时,它会变成一个函数被调用,我们将它称之为“带标签的模板字面量”。
|
||||
|
||||
为什么我们说定义一个新的标签函数,用于标签模板字面量可以被当做是一种元编程呢?因为标签模板通常用于定义 DSL,也就是域特定语言,这样定义新的标签函数就如同向 JavaScript 中添加了新的语法。标签模板字面量已被许多前端 JavaScript 库采用。GraphQL 查询语言通过使用 gql`` 标签函数,可以使查询被嵌入到 JavaScript 代码中。Emotion 库使用 css`` 标签函数,使 CSS 样式同样可以被嵌入到 JavaScript 中。
|
||||
|
||||
当函数表达式后面有模板字面量时,该函数将被调用。第一个参数是字符串数组,后面是零或多个其它参数,这些参数可以具有任何类型的值。参数的数量取决于插入到模板字面量的值的数量,模板字面量的值始终是字符串,但是标签模板字面量的值是标签函数返回的值。它可能是一个字符串,但当使用标签函数实现 DSL 时,返回值通常是一个非字符串数据结构,它是字符串的解析表示。
|
||||
|
||||
当我们想将一个值安全地插入到 HTML 字符串中时,模版会非常得有用。我们拿 html`` 为例,在使用标签构建最终字符串之前,标签会对每个值执行 HTML 转义。
|
||||
|
||||
function html(str, ...val) {
|
||||
var escaped = val.map(v => String(v)
|
||||
.replace("&", "&")
|
||||
.replace("'", "'"));
|
||||
var result = str[0];
|
||||
for(var i = 0; i < escaped.length; i++) {
|
||||
result += escaped[i] + str[i+1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var operator = "&";
|
||||
html`<b>x ${operator} y</b>` // => "<b>x & y</b>"
|
||||
|
||||
|
||||
下面,我们再来看看 Reflect 对象。Reflect 并不是一个类,和 Math 对象类似,它的属性只是定义了一组相关的函数。ES6 中添加的这些函数都在一个命名空间中,它们模仿核心语言的行为,并且复制了各种预先存在于对象函数的特性。
|
||||
|
||||
尽管 Reflect 函数没有提供任何新功能,但它们确实将这些功能组合在一个 API 中方便使用。比如,我们在上面提到的对象属性的设置、可延展性以及对象的原型对象在 Reflect 中都有对应的方法,如 Reflect.set()、Reflect.isExtensible() 和 Reflect.getPrototypeOf(),等等。下面,我们会看到 Reflect 函数集与 Proxy 的处理程序方法集也可以一一对应。
|
||||
|
||||
Proxy 和 Reflect
|
||||
|
||||
在 ES6 和更高版本中提供的 Proxy 类可以算是 JavaScript 中最强大的元编程功能了。它允许我们编写改变 JavaScript 对象基本行为的代码。我们在前面提到的 Reflect API 是一组函数,它使我们可以直接访问 JavaScript 对象上的一组基本操作。当我们创建 Proxy 对象时,我们指定了另外两个对象,目标对象和处理程序对象。
|
||||
|
||||
var target = {
|
||||
message1: "hello",
|
||||
message2: "world",
|
||||
};
|
||||
var handler = {};
|
||||
|
||||
var proxy = new Proxy(target, handler);
|
||||
|
||||
|
||||
生成的代理对象没有自己的状态或行为。无论何时对其执行操作(读取属性、写入属性、定义新属性、查找原型、将其作为函数调用),它都会将这些操作分派给处理程序对象或目标对象。代理对象支持的操作与 Reflect API 定义的操作相同。Proxy 的工作机制是,如果 handler 是空的,那么代理对象只是一层透明的装饰器。所以在上面的例子中,如果我们执行代理,那么它返回的结果就是目标对象上本来自有的属性。
|
||||
|
||||
console.log(proxy.message1); // hello
|
||||
console.log(proxy.message2); // world
|
||||
|
||||
|
||||
通常,我们会把 Proxy 和 Reflect 结合起来使用,这样的好处是,对于我们不想自定义的部分,我们可以使用 Reflect 来调用对象内置的方法。
|
||||
|
||||
const target = {
|
||||
message1: "hello",
|
||||
message2: "world",
|
||||
};
|
||||
|
||||
const handler = {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "message2") {
|
||||
return "Jackson";
|
||||
}
|
||||
return Reflect.get(...arguments);
|
||||
},
|
||||
};
|
||||
var proxy = new Proxy(target, handler);
|
||||
console.log(proxy.message1); // hello
|
||||
console.log(proxy.message2); // Jackson
|
||||
|
||||
|
||||
总结
|
||||
|
||||
通过今天这节课,我们看到了 JavaScript 对象的属性本身也带有可以查找和添加的属性。同时,我们学习了,JavaScript 定义的函数允许我们遍历对象的原型链,甚至更改对象的原型。
|
||||
|
||||
标签模板字面量是一种函数调用语法,我们可以用它来定义新的标签函数,这样做有点像向语言中添加新的字面量语法。通过定义一个解析其模板字符串参数的标签函数,我们就可以在JavaScript 代码中嵌入 DSL。标签函数还提供对字符串字面量的原始、非转义形式的访问,其中反斜杠不带有任何特殊含义。
|
||||
|
||||
最后,我们又看了 Proxy 类和相关的 Reflect API。Proxy 和 Reflect 允许我们对 JavaScript 中的对象的基本行为进行低级控制。Proxy 对象可以用作可选的、可撤销的包装器,以改进代码封装,还可以用于实现非标准对象行为(如早期 Web 浏览器定义的一些特殊情况下的 API)。
|
||||
|
||||
思考题
|
||||
|
||||
我们知道 Symbol 对象的属性的值可以用于定义对象和类的属性或方法的名称。这样做可以控制对象如何与 JavaScript 语言特性和核心库交互。那么,你觉得 Symbol 在这种使用场景下算不算是一种元编程呢?
|
||||
|
||||
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
67
专栏/JavaScript进阶实战课/结束语JavaScript的未来之路:源于一个以终为始的初心.md
Normal file
67
专栏/JavaScript进阶实战课/结束语JavaScript的未来之路:源于一个以终为始的初心.md
Normal file
@ -0,0 +1,67 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 JavaScript的未来之路:源于一个以终为始的初心
|
||||
你好,我是石川。
|
||||
|
||||
今年,是不太寻常的一年。这一年,我们的思想都是比较割裂的,可能大家很难对某件事情有很强烈的共识。但是,我觉得有一点,大家的感触都差不多,就是如果我们给今年一个关键词叫做“不确定性”的话,很少会有人否认。
|
||||
|
||||
每个人都有认知这个世界的方式,我觉得对于我而言,JavaScript 作为一门语言就是我用来认知这个世界、去适应不确定性的方式。我们从这个专栏一开始的时候,就提到说 JavaScript 是一个本身不太严谨的语言,甚至有很多缺陷,但是这不妨碍人们围绕着 JavaScript 创建出不同的框架和库来克服这些问题。我觉得它的这种特质就很像我们自己,我们每个人都不是完美的,我们周围的环境也充满变化,但是这反而造就了我们的多样性,形成了一个多元的世界。
|
||||
|
||||
在今年教师节的时候,我收到了极客时间寄来的一本好书,叫做《把自己作为方法》。这本书是与社会学家项飙的对话。我最早了解项飙老师,是从他的《跨越边界的社区:北京“浙江村”的生活史》论文和《全球猎身》这本书开始的。之所以会提到他的作品,是因为项飙老师能打动我的地方在于他所提出的每一个观点都产生于个人经验。我们今天的世界太过于中心化,我们可能更愿意相信基于中心意见领袖的人云亦云,或者一些假大空的概念,而忽略了我们自身的体验。
|
||||
|
||||
而对于我而言,从 JavaScript 作为一个锚点,就可以延伸出对世界的理解。因为它是一门我在很长一段时间内每天在用的语言,它是真实的,不需要我从其他人的思想中去抽象理解。而它的很多底层逻辑又是与很多事情相通的。换句话说,我认为我们越是能就身边的不起眼的小事着手,把它吃透、做精、做细,一竿子通到底,反而更容易触类旁通,去了解一些离我们很远的事情。
|
||||
|
||||
那怎么能吃透一件事情呢?就拿 JavaScript 来说,我认为首先需要做的是发现它的核心问题;再者是如何来解决这些问题。这两者的分量各占一半。换句话说,我们如果能清楚地定义问题本身,就等于解决了一半的问题。如果我们还能在这个基础上,想清楚它的解决方案,那么,我们就等于真正地掌握了它。
|
||||
|
||||
JavaScript 面临的不确定性
|
||||
|
||||
首先,我们看到 JavaScript 作为一种语言,从它的设计到实现,所用的时间非常短。这里面缺少非常严谨的思考,也就导致了我们在开发中,在处理各种异常的时候,会遇到很多的不可控因素。
|
||||
|
||||
其次,是它运行的环境也是充满着不确定性。当我们写好一段代码后,并不知道这段代码会在什么样的环境下运行,因为用户所使用的操作系统不同,浏览器的厂商也不同,JS 引擎的版本、屏幕的大小、移动还是桌面,这些都是不同的因素,叠加在一起,可能会产生成千上万种的组合方式。而且用户访问我们 Web 应用的网络、带宽和所在的地区,也都是不可控的。而我们所开发出来的程序,却要满足大多数环境下可以运行的需求,这本身就是一个面对巨大不确定性下的挑战。
|
||||
|
||||
同时,JavaScript 的不确定性还不仅源于环境,另外一种不确定性是来自我们的用户。很多时候,我们不可能控制用户的行为方式,我们不知道用户会先使用我们应用的哪个功能,后使用哪个功能,甚至有些功能是用户从未使用过的。而且,我们也无法完全控制用户的行为轨迹,就拿一个购票的应用来说,有些用户可能是通过搜索找到产品购买的,而有的用户可能是通过筛选条件找到产品购买的,有些用户可能是先加入购物车,而有的用户可能又选择直接购买。这些不同的行为轨迹都需要对应的系统流程来支持并且处理。
|
||||
|
||||
第四点是非功能性的,比如流量上的不可控。我们并不知道用户的数量和访问规模,我们只能通过预估和事后的分析来判断我们的系统访问量。而通常,访问量的不同又会直接影响我们系统的性能,反应在客户端,就是给用户带来的体验以及功能上的影响。
|
||||
|
||||
第五点就是业务需求上的不可控。因为我们的应用程序是直接面客的,这也就导致了我们的程序需要不断且快速地迭代来适应业务的需求。而越是复杂的业务场景,在激烈的竞争中,对变化的要求也就越快。
|
||||
|
||||
JavaScript 问题的解决方案
|
||||
|
||||
所以作为一个前端开发者来说,每天都要和大量的不确定性打交道。但是这并没有妨碍很多非常成熟的、可靠的系统在这种不确定的环境下被开发出来。那这些系统都有着什么样的特点呢?
|
||||
|
||||
第一是我们说的响应式编程思想。响应式编程的特点是要我们不断的感知和反应,比如面对用户行为的变化,我们就需要不断地基于事件来处理。同时,我们知道,越是面对复杂的未知的场景,我们就应该尽可能地确保我们对副作用的控制。我们需要知道在输入相同的情况下,应该如何保证在相同的函数操作下,结果也是相同的。同时,为了保证状态不可变,我们应当如何尽量在复制的数据或对象上进行操作,而不会改变状态本身。
|
||||
|
||||
第二是面向对象的编程思想和设计模式。只有当我们按照业务对象和领域来设计我们的系统和程序的时候,才能更好地应对业务的改变所带来的挑战,所以一个好的软件开发者,肯定同时也是一个很好的业务分析师,因为只有当我们掌握了深层次的业务逻辑后,才能更好地写出对应的程序。否则,我们就很容易陷入重复的开发、返工以及及大量的重构来满足业务变化。
|
||||
|
||||
第三是我们在开发中所运用的算法思想。在我们的程序所消耗的内存量、访问量、处理的数据量和访问的频率不高的情况下,也许算法并不会体现出它的价值。但是当我们的业务量、数据量增长,或者需要渲染的内容增加的时候,就会遇到各种性能瓶颈,这时,就是我们需要用算法思维来解决一些问题的时候了。而且往往越是复杂的问题,越是考验基本功。
|
||||
|
||||
第四是我们在开发的过程中所用到的工具,比如 Flow 就从很大程度上帮助我们解决了 JavaScript 自身的很多短板,让一个很随意的、缺乏严谨的类型系统的语言,也可以使用类型系统来做相关的类型检查。
|
||||
|
||||
第五是我们在 JavaScript 之势中介绍的域驱动的设计理念以及大前端,本质上都是强调业务驱动开发的重要性。这种设计理念,从某种角度来看,就是要求我们在开发中以终为始,不断地思考我们开发的目的。
|
||||
|
||||
为什么说知识可以触类旁通
|
||||
|
||||
我认为在解决 JavaScript 的不确定性的过程中,我所学到的东西,完全可以应用在生活的方方面面。举几个简单的例子,比如我们说响应式的设计,在生活中也是同样的,我们需要面对不确定性的解决方案。在面对未知的时候,我们几乎就可以把先行动、后感知、再反应作为一个模版。
|
||||
|
||||
再比如提到算法,我们就不能不提到动态规划,同样,在面对生活上的问题时,我们很多时候都应该时刻考虑全局的最优解,而不是局部的最优解。否则,我们很多时候可能都会因小失大。因为过分贪心,导致没有在大的问题上真正得到最优的解决方案。
|
||||
|
||||
再比如在开发的过程中,如果我们真的想达到事半功倍的效果,就必须要时刻了解业务的需求,细化深入地理解业务的流程,同时,为了提高开发的效率,我们也要思考如何能够优化开发流水线。这里,就又和软件工程有着密不可分的关系,所以开发到了最后,不仅是简单的开发问题,它延展出的是业务系统分析和项目管理的能力。
|
||||
|
||||
JavaScript 界的大佬道格拉斯·克拉特福德今年提出了一个非常具有争议的概念,就是 JavaScript 已死。虽然这样的观点看上去比较偏激,但是我认为 JavaScript 并不是完全不可以被取代的。不过,即使有一天,JavaScript 成为了历史,它在编程语言历史上的地位也是不会动摇的,它的很多思想也还会延续到任何一个要取代它的语言中。
|
||||
|
||||
其实,有很长一段时间,我很羡慕后端开发,他们就像“三好学生”一样。而且我认为更适合用来作为第一门计算机语言来学习的还是像 C++ 或 Java 这样的语言,因为它们有更加严谨的语法,一个好的基本功和扎实的基础是解决任何复杂问题的关键。
|
||||
|
||||
JavaScript 虽然很好用,但也正是因为它太容易上手了,所以导致了很多人没有能把它用好。而且在前端开发中,需要考虑的种种问题真是非常多。但是随着时间的推移,我渐渐接受了 JavaScript,因为正是它的种种问题和带来的不确定性,让我们在开发中锻炼了深度思考的能力,并且在克服这些问题的同时,也让我们自身变成了更有韧性的人。同时,相比后端的“一刀切”,前端对很多问题的“容忍”也造就了开发环境的包容。正是在不断的变化和不可控中,我们不断调整,优化系统稳定性,这个过程我们更加会注意如何减少副作用,提高在混乱中建立秩序的能力。
|
||||
|
||||
正好,这个结束语也写在了辞旧迎新的新年之际,在此,我也希望此时此刻读到这篇文章的你,也能够在新的一年,通过 JavaScript 这门语言更好地拥抱不确定性。
|
||||
|
||||
最后,我还给你准备了一份毕业问卷,希望你能花两三分钟填写一下,非常期待能听到你对这门课的反馈。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user