first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,176 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 JavaScript的进阶之路
你好我是石川欢迎和我一起学习JavaScript。
JavaScript的前身Mocha是布兰登·艾克Brendan Eich在1995年用10天时间设计出来的。我第一次接触JavaScript是从它诞生算起的10年后在学校学习的那时的网站更多是静态的信息展示类网页。所以当时JavaScript和它刚发明出来时类似更多是一门脚本语言。可以说整个前端在那个年代和之前的很长时间都被认为是一个很业余的技能。
但随着AJAX在2005年诞生接着W3C在2006年第一次发布了XHRXMLHttpRequest规范草案后这种情况就改变了。基于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

View 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中的常量constconstant算不算不可变呢
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
延伸阅读
Beginning Functional JavaScript
Function Light JS
JavaScript世界的一等公民——函数
Mastering JavaScript Functional Programming
You Dont Know JS: this & Object Prototypes
JavaScript Patterns
Learning JavaScript Design Patterns

View 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

View 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

View 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)返回的结果是trueisOdd(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 ,那么你知道这背后的原理吗?欢迎在留言区分享你的答案,或者你如果对此并不十分了解,也希望你能找找资料,作为下节课的预习内容。
当然,你也可以在评论区交流下自己的疑问,我们一起讨论、共同进步。

View 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) // => truea里面有小于6的数字
arr.some(x => x % 2 === 1) // => true数组a里面有一些奇数
虽然some() 和 every() 都是 JavaScript自带的断言方法但是对比 filter() ,它们就显得没有那么“函数式”了,因为它们的返回值只是一个 true 或 false而没有像 filter 一样返回一组数据作为输出,继续用来进行后续一系列的函数式的操作。
reduce和缩减器
最后我们再来说说reduce。实际上缩减reduce主要的作用就是把列表中的值合成一个值。如下图所示
在reduce当中有一个缩减器reducer函数和一个初始值。比如在下面的例子中初始值是3reducer函数会计算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吗
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。

View 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这种方式经常用在长页面当中。比如产品详情页一般都是用讲故事的方式一步步介绍产品卖点用图说话最后再展示参数、一键购买以及加购物车的部分。所以也就是说我们不需要一上来就加载整个页面而是当用户滑动到了某个部分的时候再加载相关的内容。
交互时加载就是当用户和页面进行交互时,比如点击了某个按钮后,可能产生的加载。举个例子,有些应用中的日历,只有用户在进行特定操作的时候才会显示并且和用户交互。这样的模块,我们就可以考虑动态加载。
注意这里有几个重要的指标。在初始化的加载中我们关注的通常是首次渲染时间FCPFirst Contentful Paint和最大内容渲染时间LCPLargest Contentful Paint也就是页面首次加载的时候。在后续的动态加载中我们关注的是首次交互时间TTITime to Interactive也就是当用户开始从首屏开始往下滑动或者点击了某个按钮开启了日历弹窗的时候。
你可能会觉得这样的优化只能省下一两百KB或几个MB是否值得但是苍蝇腿也是肉而且积少成多当你在开发一个复杂的Web应用需要不断扩充模块的时候这样的操作可能就会产生质和量上的变化。
然后在这个时候我们通常会通过一些打包工具比如用Webpack先加载核心的组件渲染主程序之后根据交互的需要按需加载某个模块。
另外对于动态的加载其实也有很多三方的库可以支持其中一个例子就是React中的Suspense。如果是Node服务器端的加载渲染的话也有Loadable Components这样的库可以用来参考。当然如果你不使用这些三方的库自己开发也可以但是原理始终是类似的。
不过这里我还想说下在使用动态导入前一般应该先考虑预加载pre-load或预获取pre-fetch
它们两个的区别是,前者是在页面开始加载时就提前开始加载后面需要用到的元素;后者是在页面的主要功能都加载完成后,再提前加载后面需要用到的素材。除非没法做到预加载和预获取,或者你加载的是三方的内容,不可控,不然的话,这些方式都可以带来比动态加载更好的用户体验。
那么,有没有没法儿做到、或者不适合做预加载的例子呢?也是有的,比如要预加载的内容过大,而且用户不一定会使用预加载的内容的时候。
这个时候如果你事先加载一是会使用到用户的网络占用了用户手机的存储空间二是也会增加自己的CDN和服务器的资源消耗。这种情况下就要用到动态加载了。
另外在进一步看动态加载前,我们还要了解两个基础概念,就是页面渲染的两种基础渲染模式。一种是浏览器渲染,一种是服务器端渲染。
首先在客户端渲染CSRclient side rendering模式下我们是先下载HTML、JS和CSS包括数据所有的内容都下载完成然后再开始渲染。
而SSR服务器端渲染SSRserver side rendering模式下我们是先让用户能看到一个完整的页面但是无法交互。只有等相关数据从服务器端加载和hydrate后比如说一个按钮加上了的相关事件处理之后才能交互。
这个方案看上去比CSR会好一些但它也不是没有问题的。比如说我们作为用户使用一些应用有时候也会遇到类似的问题就是我们在加载和hydrate前点击某个按钮的时候就会发现某个组件没反应。
那么在交互驱动的动态加载中上面这种问题怎么解决呢比如Google他们会使用并且开源了一个叫JSAction 的小工具它的作用就是先加载一部分轻代码tiny code这部分代码可以“记住”用户的行为然后根据用户的交互来加载组件等加载完成再让组件执行之前“记住”的用户请求。这样就完美解决了上述问题。
总结
通过今天的学习,我们理解了函数式编程+响应式编程中时间是一个状态而且是一个最难管理的状态。而通过promise的概念我们可以消除时间并且可以通过同步的方式来处理异步事件。
另外,通过观察者模式我们也可以更好地应对未知,通过行动来感知和响应,这样的加载方式,在应用的使用者达到一定规模化的时候,可以减少不必要和大量的资源浪费。
思考题
我们说根据事件的动态加载可以起到降本增效的作用,那么你能说说你在前端开发中做资源加载设计、分析和优化的经验吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View 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这些更底层的方式实现同样的功能。在后面的两节课里我们会继续从单个对象延伸到对象间的“生产关系”来进一步理解面向对象的编程模式。
思考题
我们今天尝试通过去掉语法糖,用更底层的方式实现了对象中的私有属性,那么你能不能自己动手试试去掉静态属性的语法糖,来实现类似的功能?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,343 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 深入理解继承、Delegation和组合
你好,我是石川。
关于面向对象编程最著名的一本书就数GoFGang 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()吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课见!

View 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 Stringnew 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 效果一样吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View 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,00014后面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); // 返回 类型错误
// 方式2constructor
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”, age25}当我们将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。可以说除了有特殊需求会用到表达式以外声明式就是默认的写法下面是一个声明式函数的抽象语法树ASTabstract 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来创建那你觉得哪种方式在不同场景下更适合呢
欢迎在留言区分享你的答案和见解,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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()来对它进行调用,解决这个问题的办法就是使用一个立刻调用的函数表达 IIFEimmediately 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开始引进了块级作用域。希望你通过对原理的了解能够更加清楚它们的使用方法。
思考题
在讲到函数式编程时,我们说到了闭包可以作为和对象相比的数据存储结构,在讲到面向对象编程模式时,我们也说到了它可以用来创建对象的私有属性。那么除了这些例子外,你还能举例说明它的其它作用吗?
欢迎在留言区分享你的积累,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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。
11235813213455
如果用迭代的方式来写计算第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%算法都离不开它的影子请记住这个二八定律只要把迭代和递归的概念吃透搞明白对算法的学习可以说是有着事半功倍的效果
如果要对比迭代和递归的话从整体的性能来说迭代是优于递归的而如果从代码的整洁性来看递归看起来更简洁而且递归和作用域闭包结合起来形成的记忆函数和尾递归都能从一定程度上减少其副作用”。下面我们就结合这张图总结下针对这些副作用的解决方法吧
所以在算法中我们应该用迭代还是递归呢这里同样没有绝对应用上你可以根据它们的优劣势结合实际情况来应用我个人认为我们写的代码主要还是给人读的而不是最终的机器码所以我的建议是以代码的简洁可读性为先然后再针对机器无法替我们优化的副作用的部分所产生的问题做手动的优化
思考题
前面我们讲到了针对栈溢出的尾调用优化你知道尾递归调用优化是如何实现的吗
期待在留言区看到你的分享我们一起交流讨论另外也欢迎你把今天的内容分享给更多的朋友

View 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引擎用的是什么算法
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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-codegenCrankshaftIgnition和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分析器、栈的异常跟踪、分层的集成及热循环到优化代码的栈替换OSRon-stack replacement等工作。
那Sparkplug则用了一个比较聪明的方法简化了大多数问题就是它维护了“与解释器兼容的栈帧”。我们知道调用栈是代码执行存储函数状态的方式而每当我们调用一个新函数时它都会为该函数的本地变量创建一个新的栈帧。 栈帧由帧指针(标记其开始)和栈指针(标记其结束)定义。
当一个函数被调用时,返回地址也会被压入栈内的。返回地址在返回时由函数弹出,以便知道返回到哪里。当该函数创建一个新栈帧时,也会将旧的帧指针保存在栈中,并将新的帧指针设置为它自己栈帧的开头。因此,栈有一系列的帧指针 ,每个都标记指向前一个栈帧的开始。
除了函数的本地变量和回调地址外,栈中还会有传参和储值。参数(包括接收者)在调用函数之前以相反的顺序压入栈内,帧指针前面的几个栈槽是当前正在被调用的函数、上下文,以及传递的参数数量。这是“标准” JS 框架布局:
为了使我们在性能分析时,以最小的成本遍历栈,这种 JS 调用约定在优化和解释栈帧之间是共享的。
Ignition解释器会进一步让调用约定变得更加明确。Ignition是基于寄存器的解释器和机器寄存器的不同在于它是一个虚拟寄存器。它的作用是存储解释器的当前状态包括JavaScript函数局部变量var/let/const 声明)和临时值。这些寄存器存储在解释器的栈帧中。除此以外,栈帧中还有一个指向正在执行的字节码数组的指针,以及当前字节码在该数组中的偏移量。
后来V8团队对解释器栈帧做了一个小改动就是Sparkplug在代码执行期间不再保留最新的字节码偏移量改为了存储从Sparkplug代码地址范围到相应字节码偏移量的双向映射。因为Sparkplug代码是直接从字节码的线性遍历中发出的所以这是一个相对简单的编码映射。每当栈帧访问并想知道Sparkplug栈帧的“字节码偏移量”时都会在映射中查找当前正在执行的指令并返回相应的字节码偏移量。同样每当它想从解释器到Sparkplug进行栈替换OSRon-stack replacement都可以在映射中查找当前字节码偏移量并跳转到相应的Sparkplug指令。
Sparkplug特意创建并维护与解释器相匹配的栈帧布局每当解释器存储一个寄存器值时Sparkplug也会存储一个。它这样做有几点好处一是简化了Sparkplug的编译, Sparkplug可以只镜像解释器的行为而不必保留从解释器寄存器到Sparkplug状态的映射。二是它还加快了编译速度因为字节码编译器已经完成了寄存器分配的繁琐的工作。三是它与系统其余部分如调试器、分析器的集成是基本适配的。四是任何适用于解释器的栈替换OSRon-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
隔壁班宫文学老师的课程:《编译原理之美》

View 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所以248位都可能导致相似的位之间交互而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)
HashMapJava是如何解决散列冲突的
先把JS放在一边其实我们也可以通过Java语言里一些更高阶的链式数据结构来更深入了解下哈希碰撞的解决方式如果你学过Java可能有用到过HashMapLinkedHashMap和TreeMap那么Java中的HashMap和LinkedHashMap那么Java中的这些数据结构有什么优势分别是如何实现的下面我们可以来看看
HashMap的底层逻辑是通过链表和红黑树实现的它最主要解决的问题就是哈希碰撞我们先来说说链表它的规则是当哈希函数生成的哈希值有冲突的时候就把有冲突的数据放到一个链表中以此来解决哈希碰撞那你可能会问既然链表已经解决了这个问题为什么还需要用到红黑树这是因为当链表中元素的长度比较小的时候链表性能还是可以的但是当冲突的数据过多的时候它就会产生性能上的问题这个时候用增删改查的红黑树来代替会更合适
散列加链表基于双链表存值排序
了解完HashMap再来看看LinkedHashMapLinkedHashMap是在HashMap的基础上内部维持了一个双向链表Doubly Linked List它利用了双向链表的性能特点可以起到另外一个非常重要的作用就是可以保持插入的数据元素的顺序
TreeMap基于红黑树的键值排序
除了HashMap和LinkedHashMapTreeMap也是Java一种基于红黑树实现的字典但是它和HashMap有着本质的不同它完全不是基于散列表的而是基于红黑树来实现的相比HashMap的无序和LinkedHashMap的存值有序TreeMap实现的是键值有序它的查询效率不如HashMap和LinkedHashMap但是相比前两者它是线程安全的
总结
通过这节课你应该了解了如何通过哈希查找JS对象的内存地址不过更重要的是希望通过今天的学习你也能更好地理解哈希散列表字典这些初学者都比较容易混淆的概念最后我们再来总结下吧我们说字典dictionary也被称为映射符号表或关联数组哈希表hash table是它的一种实现方式在ES6之后随着字典Map这种数据结构的引入可以用来实现字典集合Set和映射类似但是区别是集合只保存值不保存键举个例子这就好比一个只有单词没有解释的字典
思考题
今天的思考题是我们知道Map是在ES6之后才引入的在此之前人们如果想实现类似字典的数据结构和功能会通过对象数据类型那你能不能用对象手动实现一个字典的数据结构和相关的方法呢来动手试试吧
欢迎在留言区分享你的答案交流学习心得或者提出问题如果觉得有收获也欢迎你把今天的内容分享给更多的朋友我们下节再见

View File

@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 为什么环形队列适合做Node数据流缓存
你好,我是石川。
前面几讲讲完了栈这种数据结构我们再来看看队列queue。队列对于你来说可能不算是一种陌生的数据结构和栈相反列队通常遵循的是先进先出FIFOFirst 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的数据请求和反馈还是文件到目的地的传输这些数据的传输都会用到环形队列缓冲。
思考题
在我们用互斥锁的时候,会发现它有一个劣势,就是共享资源,也就是环形队列每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程,这在某种意义上是一个悲观锁。除此之外,还有另外的一种方式是原子操作,它是针对某个值的单个互斥操作。你知道如何通过原子操作来实现环形队列缓冲吗?
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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。循环链表顾名思义就是一个头尾相连的链表。
如果是双向循环列表的话,就是除了顺时针的头尾相接以外,从逆时针也可以循环的双循环链表。
如何通过链表来做缓存
了解了单双循环链表后现在我们回到题目那我们如何能通过链表来做缓存呢我们先了解下缓存的目的。缓存主要做的就是把数据放到临时的内存空间便于再次访问。比如数据库会有缓存目的是减少对硬盘的访问我们的浏览器也有缓存目的是再次访问页面的时候不用重新下载相关的文本、图片或其它素材。通过链表我们可以做到缓存。下面我们会看两种缓存的算法他们是最近最少使用LRUleast recently used和最不经常使用LFUleast 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的应用场景比较少但是它在某些特定场合还是很有用处的你能想到一些相关的场景和实现吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,189 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 TurboFan如何用图做JS编译优化
你好,我是石川。
今天我们来看下图graph这种数据结构图是一个抽象化的网络结构模型。你可能会问有什么算法应用会用到图这种结构吗其实很多像我们经常用的社交媒体比如国内的微博、微信或者国外的脸书、领英中的社交图谱都可以通过图来表达。另外图也能用来表达现实世界中的路网、空网以及虚拟的通信网络。
图在JS引擎的编译器中作用是非常大的。如果说整个V8的编译器TurboFan都基于图也毫不夸张。我们既可以通过图这种数据结构对编译的原理有更深的理解在我们了解编译的同时又可以对相关的数据结构和算法有更深入的认识。
图的结构
下面我们先来看一下图的结构吧。图就是由边edge连接的节点vertax任何一个二分的关系都可以通过图来表示。
那我们说的TurboFan是怎么利用图来做编译的呢在开始前我们先来了解下编译流程和中间代码。
编译流程:中间代码
IR也就是中间代码Intermediate Representation有时也称 Intermediate CodeIC。从概念层面看IR可以分为HIRHigher IR、MIRMiddle IR和LIRLower IR这几种高、中、低的中间代码的形式。
从上面的图中,我们可以看到在整个编译过程中,可以把它分成前中后三个端。前端主要做的是词法、语法和语义的分析,这个我们在之前的章节也有讲过。中端会做相关的优化工作,最后后端的部分就生成了目标代码。
前面我们说了中间代码可以分为高、中、低三个不同的抽象层次。HIR主要用于基于源语言做的一些分析和变换比较典型的例子就是用于语义分析的的ASTAbstract Syntax Parser语法树。MIR则是独立于源语言和 CPU 架构来做一些分析和优化,比较典型的例子就是三地址代码 TACThree Address Code和程序依赖图 PDGProgram Dependency Graph它们主要用于分析和优化算法的部分。LIR这一层则是依赖于 CPU 架构做优化和代码生成,其中比较典型的例子就是有向无环图 DAGDirected Ayclic Graph它的主要目的是帮助生成目标代码。
在编译流程中分析的这一段里有一个重要的分析就是控制流分析CFAControl Flow Analysis和数据流分析DFA, Data Flow Analysis而我们后面要说的节点之海SoNSea of Node就是可以同时反映数据流和控制流的一种数据结构节点之海可以减少CFA和DFA之间的互相依赖因而更有利于全局优化。
在编译流程中优化的这一段里,也有几个重要的概念,分别是通用优化、对象优化和函数式优化。比如循环优化就属于通用的优化;内联和逃逸属于关于对象的优化。对于函数式编程而言,比较重要的就是之前,我们在说到迭代和递归的时候,提到过的循环和尾递归的优化。
在流程中最后生成目标代码的这一段里,重点的任务是寄存器分配和指令的选择和排序。
今天,我们重点来看一下在分析过程中,图和节点之海的原理,以及为什么说节点之海有利于全局的优化。
中端优化:分析和优化
下面我们从中端的优化说起通过IR的结构你可能可以看出“中间代表”可能是比“中间代码”更准确的一种描述。因为IR更多的是以一种“数据结构”的形式表现也就是我们说的线性列表、树、图这些数据结构而不是“代码”。今天我们重点说的这种数据结构呢就是TurboFan用到的节点之海 SoNSea 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是符合静态单赋值SSAstatic single assignment格式的。什么是静态单赋值呢顾名思义就是每个变量只有唯一的赋值。比如下面的例子里第一个操作是x等于3乘以7之后我们给x加3。这时x变量就出现了两次。所以TurboFan在创建图的时候会给本地变量做重命名在改完之后就是3这个值的节点同时有两个操作符指向它一个是乘以7的结果一个是结果再加上3。在SoN中没有本地变量的概念所以变量被节点取代了。
数据流表达
我们前面说过在分析的过程中有一个重要的分析就是控制流分析CFAControl Flow Analysis和数据流分析DFA, Data Flow Analysis。在数据流中节点用来表达一切运算包括常量、参数、数学运算、加载、存储、调用等等。
关于数据流的边,有两个核心概念,一是实线边表达了数据流的方向,这个决定了输入和输出的关系,下面实线表达的就是运算输出的结果。二是虚线边影响了数据流中的相关数据的读写状态,下面虚线边表示运算读写状态的指定。
关于WAWWrite-After-Write写后再写、WARWrite-After-Read先读后写和RAWRead-After-Write先写后读的读写状态我们可以看看是如何实现的。我们可以看到SoN中的虚线边可以用来表达不同读写状态的影响。比下面obj对象的val属性状态是可变的完成加val加3的操作后通过先读再写存入之后的结果值先写后读。
控制流表达
我们前面说过,从数据流的角度来看,节点和边表达了一切。而在控制流中,这种说法也是同样成立的。通过下面的例子,我们可以看到,控制中的节点包括开始、分支、循环、合并以及结束等等,都是通过节点来表示。
在Turbolizer的可视化图中用黄色节点代表了控制流的节点。下面是几个单纯看数据流的例子我们可以看到从最简单的只包含开始结束的直线程序到分支到循环都可以通过节点之海来表达。
下面我们来看看在和数据流结合后的一个最简单的例子。这里我们看到了,为了区分不同类型的边,可以通过黑色实线、灰色实线和虚线边分别代表控制流、数据流和影响关系。
最后我们再加上合并可以看到一个更完整的例子。这里你会看到一个phi指令这个指令是做什么的呢它会根据分支判断后的实际情况来确定x的值。因为我们说过在SSA静态单赋值的规则中变量只能赋值一次那么我们在遇到分支后的合并就必须用到phi。
分析:变量类型和范围
我们讲了中间代码的重要作用是分析和优化为什么要做变量类型和范围的分析呢这里主要要解决三个问题。第一我们说JavaScript是动态而不是静态类型的所以虽然值有固定的数据类型可变量是没有固定的数据类型的。举个例子我们可以说 var a = 2525本身是不变的数字类型但是如果我们说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节点之海最后排序的结果会被放入一个CFGControl Flow Graph程序控制图这里有三个概念第一是控制支配第二是减少寄存压力第三是循环优化
首先我们来看第一点在这个过程中首先是把固定的节点比如phi参数等都放到CFG中
之后会用到支配树来计算支配关系接着会把剩余的节点按照支配关系从SoN节点之海输出输入到CFG程序控制图中
第二点那么在编译过程中如何缓解寄存压力呢我们前面说的SSA也是通过支配树来实现的排序后节点之海完全变成了一个程序控制图
最后我们来看第三点循环优化在这个过程里尽量对循环中的代码做提升这个过程叫做Loop Invariant Code Motion也就是我们常说的hoisting你可能问这个过程在哪儿其实这一步在分析优化的时候已经做了在这里只是纳入了优化后的结果
指令选择
下面我们再来看看指令选择从理论上讲指令选择可以用最大吞噬
但是实际上指令选择的顺序是从程序控制图中相反的顺序也就是从基本块儿中自下而上地移动标的位置
寄存器分配
前面我们说过在分析和优化结束后在编译过程的后端要最后生成机器码这个过程中一件主要的工作就是寄存器的分配TurboFan 使用线性扫描分配器和实时范围分割来分配寄存器和插入溢出代码SSA 形式可以在寄存器分配之前或之后通过显式移动进行解构
寄存器分配的结果是用真实寄存器代替虚拟寄存器的使用并在指令之间插入溢出代码
总结
这一讲可以算是比较硬核了但是如果你能读到这里证明你已经坚持下来了从V8对节点之海的运用我希望你不仅是了解了这种数据结构更是对整个编译的流程有了更深入的理解而且在这个过程中我们也可以看到我们写的代码在编译过程中最后被优化的代码不一定是一开始写的代码了这既说明了我们在写代码的时候可以更关注可读性因为我们没有必要为了机器会优化的内容而操心从另外一个角度来看呢有些开发者对性能有极度的追求也可能写出更接近优化后的代码但这样做是会牺牲一些可读性的
思考题
如果你有用过Java的Graal会发现这个编译器的IC用的也是基于SoN的数据结构那么你能说出HotPotGraal以及TurboFan在SoN上的相同和不同之处吗
期待在留言区看到你的分享我们一起交流讨论另外也欢迎你把今天的内容分享给更多的朋友我们下期再见
参考
TurboFan JIT Design by Ben L. Titzer from Google Munich

View File

@ -0,0 +1,188 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 通过树和图看如何在无序中找到路径和秩序
你好,我是石川。
在算法中,最常见的两个操作莫过于排序和搜索了。前面我们通过数组了解了在线性的数据结构中不同的排序算法。之后,我们又通过哈希算法了解了散列表这种比较特殊的线性表,了解了它是如何与链表结合,用空间换时间地支持索引和查找的。
在现实的生活中,我们可以看到信息并不总是有序的,我们在讲到函数式编程的时候有提到过,函数思想的一个维度就是如何处理未知和混沌。那么函数中的数据结构和算法呢,作为现实世界的映射,也要能处理这样复杂的情况。今天,我们来看两种支持我们处理这种未知和混沌的非线性的数据结构。一种是树,另外一种是图。
我们知道一些大的咨询公司如麦肯锡,经常用到“金字塔原理”来做解决方案。它的核心原理呢,讲的就是我们平时接收的信息是杂乱无章的网状结构的,而我们要做的是在杂乱无章的信息中梳理出“金字塔”式的树形信息结构,最后呢,再用线性的“讲故事”的方式讲述出来。这就像极了我们在算法中用到的各种数据结构。
今天我们就深入来看看图和树这两种数据结构。我们先从图说起图就是一种非线性的数据结构。我们生活中有很多无序的网络组织都可以用图来表示比如社交网络我们的互联网通信、城市的道路、网站的链接。如果用我们前端开发的视角来看的话不同依赖资源本身也可以被看做是无序的而例如我们前端经常用到的webpack的模块加载功能就是一个在无序中建立秩序帮助我们厘清资源之间相关依赖关系的工具这种功能就可以通过拓扑排序来实现。我们可以先从图这种数据结构本身说起来一步步看下它是怎么做到的。
通过拓扑排序建立资源依赖
首先,我们来看下图的结构。
深入了解图的类型和结构
在上一节当中我们说过一个图就是由边edge 相连的节点node 组成的。如果延伸来看我们可以将通过一条线相连接的两个节点叫做相邻节点adjacent vertices。同时我们可以把一个节点连接的数量叫做度degree。一个边也可以加权weighted。一条路径path就是一组相邻节点的序列。
一条可以回到原点的路径是循环图cyclic graph。一个没有可以回到原点的路径的图被称作无环图acyclic graph。如果图之间的边是有指向的就会被称为是有向图directed graph反之如果图之间的边没有指向就被称之为无向图undirected graph。如果一个图是有向无环的就叫做我们上一讲提到过的有向无环图DAGdirected acyclic graph
一种用来存储图的方式是通过邻接矩阵adjacency matrix。如果一个图的相邻节点较少就被称为稀疏图sparse graph相反的如果一个图的相邻节点较多的话就被称为稠密图dense graph。对于稀疏图来说用邻接矩阵来存储可能就会造成资源的浪费因为这个矩阵中可能为了几个节点来维护一个较高的空间复杂度。同样邻接矩阵在支持节点的插入删除也具有劣势。所以另外一种用来存储图的方式就是通过邻接表adjacency list。这种数据结构既可以用数组也可以用链表、 哈希或者字典来表示,所以可以和邻接矩阵形成有效的互补。
对图的遍历有两种经典的方式一种是广度优先搜索BFSbreath first search另外一种是深度优先搜索DFSdepth 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和二叉查找树BSTbinary search tree)。虽然它们写法类似但代表的是不同的意思二叉树是最多只有2个子节点的树而二叉查找树是指一个左节点的值小于右节点的树二叉树里面分为满二叉树full binary tree和完全二叉树complete binary tree)。在二叉树的遍历中分为前中后三种顺序的遍历下面我们依次来看一下
在二叉查找树中有三种遍历的方式第一种是中序遍历in-order traversal这种方法是对节点展开的从小到大的遍历它的遍历顺序是左第二种是前序遍历pre-order traversal这种方法是在先访问根节点再访问子节点它的遍历顺序是根第三种是后序遍历post-order traversal这种方法是在访问子节点后访问它的父节点它的遍历顺序是右除了这三种以外还有层次遍历level order traversal这种方式就是上面我们讲到图的时候讲到的深度优先搜索DFSdepth first search的原理
二叉查找树有一个问题就是当这个树的一个分支过深的时候在增加减少和查找的时候可能会出现性能上的问题所以在二叉查找树的基础上有了AVL树AVL treeAdelson-Velskii and Landis 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前我们可以先看看暴力BFBrute Force)、BMBoyer-Moore)、RKRabinKarp和KMPKnuthMorrisPratt 这几种用于字符串的匹配的算法首先我们可以对比下暴力和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)\)因为每一个字符都是要被检查的如果插入的字符串的数量是mn是最长的字符串的话它的空间复杂度是\(O(m \* n)\)
所以从这里我们可以看出对比其它常见的字符串查询字典树更适合的是用来处理具有同样前缀的多个字符串而在一个字符串中匹配一个单独的模式串这种方式就不适合了因为这样就会占用大量的空间内存
总结
这一期我们就讲到这里那么通过图和树这两种数据结构呢我们可以看出它们最大的特点就是可以映射出现实世界中相对无序和未知的结构从具象中看我们可以发现它们可以用来解决web中的模块打包路由等等而抽象出来看我们也可以看到算法所解决的问题经常是在混沌和未知中建立秩序寻找出路这对在杂乱的信息中梳理出秩序或者我们在面临人生选择时也是很有帮助的同时我们看到在分析复杂度的问题时我们思考的维度也要随之扩展除了前几讲中提到的时间空间和最好最坏情况下的复杂度外我们也要考虑预处理不同应用场景下的复杂度
思考题
今天的思考题是我们在讲到拓扑实现的时候用了数组和unshift你觉得这种方式是最高效的吗从空间和时间复杂度或其它的角度的分析这里有没有什么优化空间
期待在留言区看到你的分享我们一起交流讨论另外也欢迎你把今天的内容分享给更多的朋友我们下期再见

View 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变成11变成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的原理学好算法不仅仅是纸上谈兵而是可以在工作和生活的方方面面帮助我们运筹帷幄之中决胜千里之外”。
思考题
今天的思考题是关于找零的问题除了贪心和动态规划的实现你能不能也用文中提到的递归和回溯来动手实现下找零
期待在留言区看到你的分享我们一起交流讨论另外也欢迎你把今天的内容分享给更多的朋友我们下期再见

View 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也有很多后端的用例使用这些创建型模式你能分享下你的应用场景、遇到的相关问题和一些解决方案吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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模式我们也可以看出任何设计模式都不是绝对的而是可以互相结合形成新的模式来解决问题。
思考题
我们说响应式设计用到了很多函数式编程的思想,但是又不完全是函数式编程,你能说出它们的一些差别吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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来实现你能说说它是怎么实现的吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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 就是我们在一个函数操作完时把结果作为参数传递给另外一个函数的这样一个操作。在函数式编程中这种传递结果的方式称为连续传递样式CPScontinous 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吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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 的话,应该用过它的开发者工具中的时间旅行式调试,它可以将应用程序的状态向前、向后或移动到任意时间点。你知道这个功能的实现用到了今天学到的哪(些)种行为型设计模式吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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 }) {
...
}
说完了渲染属性下面我们再来看看高阶组件模式HOCHigher 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.statethis.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都是通过前端渲染开发的这样的模式不需要浏览器的刷新就可以让用户在不同的页面间进行切换。这样在带来了方便的同时也会造成性能上问题比如它的FCPFirst Contentful Paint首次内容绘制时间、 LCPLargest Contentful Paint最大内容绘制时间、TTITime to Interactive首次可交互时间 会比较长,遇到这种情况,通过初始最小化代码、预加载、懒加载、代码切割和缓存等手段,性能上的问题可以得到一些解决。
但是相比后端渲染前端渲染除了性能上的问题还会造成SEO的问题。通常为了解决SEO的问题一些网站会在SPA的基础上再专门生成一套供搜索引擎检索的后端页面。但是作为搜索的入口页面后端渲染的页面也会被访问到它最大的问题就是到第一字节的时间TTFB会比较长。
为了解决前端和后端渲染的问题静态渲染static rendering的概念便出现了。静态渲染使用的是一种预渲染pre-render的方式。也是说在服务器端预先渲染出可以在CDN上缓存的HTML页面当前端发起请求的时候直接将渲染好了的文件发送给后端通过这种方式就降低了TTFB。相比静态的页面内容而言JS的文件通常较小所以在静态渲染的情况下页面的FCP和TTI也不会太高。
静态渲染一般被称之为静态生成SSGstatic generation而由此又引出了静态渐进生成iSSGincremental 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来做相关组件或资源的加载。
除此之外另外一个值得了解的模式是PRPLPush Render, Pre-Cache, Lazy-load。PRPL模式的核心思想是在初始化的时候先推送渲染最小的初始化内容。之后在背后通过service worker缓存其它经常访问的路由相关的内容之后当用户想要访问相关内容时就不需要再请求而直接从缓存中懒加载相关内容。
PRPL的思想是如何实现的呢这里就要说到HTTP2的特点了。相比HTTP1.1HTTP2中提供的服务器推送可以一次把初始化所需要的资源以外的额外素材都一并推送给客户端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-virtualizedreact-window结合了上面讲到的摇树优化同时它也比react-virtualized更加轻量化。感兴趣的同学可以在Github上了解更多。
总结
通过今天的学习我们更系统化地了解了在前端强交互的场景下响应式编程中的几种设计模式。我们学到了可以通过Hooks来减少组件间嵌套关系更高效地建立数据、状态和行为上的联系。在加载和渲染的模式我们看到了为了提高展示和交互的速度降低资源的消耗如何渐进式地提供内容和交互。最后在性能优化模式中我们可以看到更多通过优化资源及节省算力的方式来提高性能的模式。
思考题
我们前面说到了通过Hooks可以减少嵌套那么你觉得这种方式可以直接取代上下文提供者context provider、渲染属性render props、高阶组件HOC的这些模式吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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是单核还是多核线程都是被全局解释器锁GILglobal 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的例子中传递的只是字符串这么看来可能也没什么问题。但是当我们要传递更复杂的数据结构时就会出现问题了。
比如如果我们需要传递的是下面这样一个带有参数的函数调用在这个时候我们需要先将函数调用转化为一个序列也就是一个对应的是我们的本地调用远程过程调用叫做PRCRemote 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也可以解决数据传递中性能的问题但是函数functionclass类是不能通过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来提高性能你知道它是如何做到的以及背后的原理吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View File

@ -0,0 +1,168 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 性能如何理解JavaScript中的并行、并发
你好,我是石川。
在上一讲中我们初步介绍了并发和并行的概念对比了不同语言对多线程开发的支持。我们也通过postMessage学习了用信息传递的方式在主线程和Worker线程间实现交互。但是我们也发现了JavaScript对比其它语言在多线程方面还有不足似乎信息传递本身不能让数据在不同的线程中真正做到共享而只是互传拷贝的信息。
所以今天,我们再来看看如何能在信息互传的基础上,让数据真正地在多线程间共享和修改。不过更重要的是,这种修改是不是真的有必要呢。
SAB+Atomics模式
前面我们说过对象的数据结构在线程间是不能共享的。如果通过postMessage来做信息传递的话需要数据先被深拷贝。那有没有什么办法能让不同的线程同时访问一个数据源呢答案是有要做到数据的共享也就是内存共享我们就需要用到 SABSharedArrayBuffer和 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叫做快速用户空间互斥体futexfast 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你觉得可以实现对象的共享吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 GCscavenger的作用就是回收新生代中生命周期较短的对象并且将生命周期较长的对象移动到老年代的半空间。年轻代空间里又包含对象区域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 GCScavenger用到的并行回收Scavenger Parallel。平行回收顾名思义就是垃圾回收的工作是在多线程间平行完成的。相比较并发它更容易处理因为在回收的时候主线程上的工作是全停顿的stop the world
V8用并行回收在工作线程间分配工作。每个线程会被分到一定数量的指针线程会根据指针把存活的对象疏散到对象空间。因为不同的任务都有可能通过不同的路径找到同一个对象并且做疏散和移动的处理所以这些任务是通过原子性的读写、对比和交换操作来避免竞争条件的。成功移动了对象的线程会再更新指针供其它线程参考更新。
早期V8所用到的是单线程的切尼半空间复制算法Cheneys semispace copying algorithm。后来把它改成多线程。与单线程一样这里的收集工作主要分3步扫描根、在年轻代中复制、向老年代晋升以及更新指针。
这三步是交织进行的。这是一个类似于霍尔斯特德半空间复制回收器Halsteads 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的垃圾回收是自动的但是我们在写代码的时候有没有什么“手工”优化内存的方法呢
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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次握手。显然这么操作是很费时的。
持久HTTPKeep 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 spriteJS或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中这类元数据是纯文本的形式每次传输会增加500800字节的开销而如果使用的是HTTP cookie有时还会增加到上千字节。
为了减少这样的开销并提高性能HTTP/2使用HPACK压缩格式压缩请求和响应标头的数据该格式使用了两种看上去很简单但强大的技术一是它允许通过静态霍夫曼代码对传输的头部字段进行编码从而减小了它们各自传输的大小二是它要求客户端和服务器维护和更新先前看到的标头字段的索引列表即建立共享压缩上下文可以作为更加高效编码的参考。
HPACK有静态和动态两个表。静态的是一些常用的标头元素动态表开始是空的后面根据实际的请求来增加。如果想更深入理解霍夫曼算法的同学也可以参考隔壁班黄清昊老师算法课中的《哈夫曼树HTTP2.0是如何更快传输协议头的?》。
请求优先级
因为上面我们看到HTTP/2中传递的信息可以被分割成多个单独的帧并且允许来自多个流的帧被复用那么考虑到客户和服务器端对帧进行交叉和传递的顺序HTTP/2标准允许每个流定义1256之间的权重和依赖性。
这里面流的依赖性和权重是一个“优先级树”型的结构这个树表达了客户端希望如何接收响应。反过来服务器可以通过控制CPU、内存和其他资源的分配使用这些信息来确定流处理的优先级并且一旦响应数据可用就可以分配带宽以最优的方式向客户端传递高优先级响应。
比如在上面的例1中流A和流B平级A的权重为8B的权重为4因此A应分配2/3可用资源B应获得剩余的1/3。在例2中C依赖于D因此D应该在C之前获得全部资源分配。以此类推在第3个例子中D应先于C获得全部资源分配C应先于A和B获得全部资源分配A应获得可用资源的2/3B应获得剩余的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互联网连接QUICQuick UDP Internet Connections是由谷歌于2012年推出的。它不再通过TCP而是通过用户数据报协议UDPUser Datagram Protocol进行传输。
同为传输层的协议相比较TCPUDP更加简便和快速。你可能会觉得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而起到了性能优化的效果那么你知道它是通过改进哪些算法来实现性能优化吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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可以通过什么来代替吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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有对不同类型的测试相对更广泛的支持。这也就避免了我们在不同的测试场景之间切换工具的情况。
除了功能性的测试,还有非功能性测试。非功能性测试又包含了性能测试、安全测试和辅助功能测试等等。这些,我们也会进一步在“测试三部曲”中的第三讲来看。
思考题
我们今天从红绿重构的角度了解了测试驱动的开发,这里我们主要看的是测试的深度(嵌套),除此之外,可能我们也要关注覆盖率和复杂度(圈数),那么你能分享下平时在开发中你是否有测试驱动开发的习惯?通常测试的覆盖率能达到多少呢?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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({price21});
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
下面我们再来看看promise/then以及async/await的使用。同样如我们在异步时提到过的实际上async/await也是promise/then基语法糖的实现。我们也可以将await与resolve和reject结合起来使用。在下面的例子中我们可以看到当获取数据后我们可以通过对比期待的值来得到测试的结果。
// 例子1promise then
test('数据是价格为21', () => {
return fetchData().then(data => {
expect(data).toBe({price21});
});
});
// 例子2async await
test('数据是价格为21', async () => {
var data = await fetchData();
expect(data).toBe({price21});
});
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对比其它工具有着对快照和多线程的支持你能想到它们的使用场景和实现方式吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View File

@ -0,0 +1,122 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 测试(三):非功能性测试
你好,我是石川。
上节课我们学习了功能类测试。今天我们来看一下非功能性测试中的性能、安全和辅助功能测试。对于一个Web应用而言性能测试的复杂程度并不低于后端或端到端的测试。导致前端性能测试复杂度很高的主要原因是影响Web应用性能的因素有很多并且很多是应用本身不完全可控的。所以今天我们重点来看一下关于性能测试都有哪些指标、影响性能的因素和性能调优的方式。
性能测试
在任何测试前我们都需要对要达到的目标有清晰的认识针对性能测试也不例外。所以首先我们要来看看对于Web应用来说都有哪些性能指标。
性能指标
之前我们说过JS虚机开发中做内存管理和优化的时候会关注浏览器和应用的流畅度smoothness和响应度responsiveness。其实对于JS应用的开发者来说也可以参考类似的 Rail 指标。Railresponse, amination, idleload是响应、动效、空闲和加载这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。那么你知道如何通过浏览器提供的工具或独立的开发者工具来分析、排查和解决多线程开发中的性能问题吗
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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而是自行创建了一个新的解析器呢
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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遵循的是哪种思想吗你觉得哪种思想更适合实现
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View File

@ -0,0 +1,100 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 包管理和分发通过NPM做包的管理和分发
你好,我是石川。
在前面几讲中我们看到无论是响应式编程框架React还是测试用的Jest、Puppeteer工具亦或是做代码检查和样式优化的ESLint、Prettier工具都离不开第三方库而我们在之前的例子中都是通过NPM下载和安装工具的。
所以今天我们就来深入了解下NPM以及包的管理和发布。
包的发布
NPMNode 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 吗?除了我们讲到的方法外,你还能想到其他的安全措施吗?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 我们也可以解决功能兼容的问题,那么你知道它俩使用场景上的区别吗?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 模版引擎有什么区别吗?各自的优劣势是怎样的?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 的转译,我们可以在提前用到新功能的同时,保证较老版本浏览器的支持;
第二个方法,就是使用 PolyfillPolyfill 作为一个插件,它提供了较新浏览器的功能,但也提供了较旧版本的功能。
前面,我们讲过了代码转译的工具 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 的场景吗你主要使用它来解决哪些问题呢
欢迎在留言区分享你的经验交流学习心得或者提出问题如果觉得有收获也欢迎你把今天的内容分享给更多的朋友我们下节课再见

View 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。微服务的到来对业务架构、软件开发和软件工程都有着不同程度的影响。
从业务架构的角度来看它是基于域驱动的设计这样的设计更多关注的是面向业务对象而不是面向过程的设计。第二个是从软件开发的角度传统的服务开发模式大多是基于贫血的域模型ADMAnemic Domain Model的开发而随着微服务和 DDD 的兴起逐渐转为了充血的域模型RDMRich 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 会结合 CDNContent Delivery Network一起使用通过缓存加快页面和资源加载时间。当然这种经典的 CMS 方法至今仍被广泛使用,例如现在使用 WordPress 创建个人博客的人还有很多。CMS 工具流行的一个原因是,选择用 CMS 来开发主要是着重信息更新,不需要大量的业务逻辑交互的页面。
随着 Ajax 的兴起,以及后来的 HTML5 和 CSS3 技术的出现网站的互动性越来越强丰富的互联网应用程序也不断涌现。由于更多的用户交互SPA单页应用程序的技术就随之兴起了在 Web 开发中得到了越来越广泛的应用。虽然在单页应用程序中,仍然可以使用 CMS但它的功能将仅限于管理内容而不是创建HTML页面。在这种情况下前端应用程序承载了页创建和展示的任务而 CMS 只负责内容的提供。通常这样的 CMS 被称为无头 CMSheadless CMS
SPA 不仅限于 PC 端,还可以用于移动端和其他嵌入式系统,并且可能会根据客户端类型对内容进行一些更改,在这种情况下,还可能需要添加一个 BFF 的适配层。当然 BFF 的作用不仅在适配,它也可以用于 API 层的复合或聚合、服务的认证或授权,或者在遗留系统之上构建微服务。
单页应用程序的另一个问题是搜索引擎优化。搜索引擎通常基于 URL 链接搜索内容。然而,对于单页应用程序,搜索引擎不再能够轻松找到页面,因为当用户从一个部分导航到另一个部分时,没有 URL 更新。谷歌开发了强大的算法,甚至可以抓取 SPA 页面。然而,谷歌并不是世界上唯一使用的搜索引擎,仍然有许多搜索引擎无法抓取服务器端渲染和客户端渲染的 HTML 页面。
针对这个问题,目前最常用的解决方案是同时使用客户端和服务器端渲染,这意味着前端和后端将共享相同的模板文件,并使用单独的模板引擎来渲染包含数据的页面。这种与 Node.js 结合的模式被称为同构 JavaScriptisomorphic 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 的注册和发现,路由以及生命周期的管理。而且越是去中心化,我们越是需要团队间有一套“共识机制”保证开发和部署流程的管理。但是这样带来的好处是,我们可以在子团队间有更大的开发自由,同时,在规模化的情况下,可以保证协作的效率。
思考题
在微前端中,每个组件都是独立封装的,你知道如何实现它们之间的通信吗?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 一种核心技术,满足了前中后台的开发需求,也等于起到了降本增效的作用。但除此之外,你还能想到它的其它优势或者一些短板吗?
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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("&", "&amp;")
.replace("'", "&#39;"));
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 &amp; 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 在这种使用场景下算不算是一种元编程呢?
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 这门语言更好地拥抱不确定性。
最后,我还给你准备了一份毕业问卷,希望你能花两三分钟填写一下,非常期待能听到你对这门课的反馈。