JavaScript 简明教程
一、什么是 JavaScript 语言?
JavaScript 是一种轻量级的脚本语言。所谓“脚本语言”(script language),指的是它不具备开发操作系统的能力,而是只用来编写控制其他大型应用程序(比如浏览器)的“脚本”。
JavaScript 也是一种嵌入式(embedded)语言。它本身提供的核心语法不算很多,只能用来做一些数学和逻辑运算。JavaScript 本身不提供任何与 I/O(输入/输出)相关的 API,都要靠宿主环境(host)提供,所以 JavaScript 只合适嵌入更大型的应用程序环境,去调用宿主环境提供的底层 API。
目前,已经嵌入 JavaScript 的宿主环境有多种,最常见的环境就是浏览器,另外还有服务器环境,也就是 Node 项目。
JavaScript 的核心语法部分相当精简,只包括两个部分:基本的语法构造(比如操作符、控制结构、语句)和标准库(就是一系列具有各种功能的对象比如Array
、Date
、Math
等)。除此之外,各种宿主环境提供额外的 API(即只能在该环境使用的接口),以便 JavaScript 调用。以浏览器为例,它提供的额外 API 可以分成三大类。
- 浏览器控制类:操作浏览器
- DOM 类:操作网页的各种元素
- Web 类:实现互联网的各种功能
如果宿主环境是服务器,则会提供各种操作系统的 API,比如文件操作 API、网络通信 API 等等。这些你都可以在 Node 环境中找到。
二、学习 JavaScript 语言的好处
1、广泛的使用领域
近年来,JavaScript 的使用范围,慢慢超越了浏览器,正在向通用的系统语言发展。
(1)、浏览器的平台化
随着 HTML5 的出现,浏览器本身的功能越来越强,不再仅仅能浏览网页,而是越来越像一个平台,JavaScript 因此得以调用许多系统功能,比如操作本地文件、操作图片、调用摄像头和麦克风等等。这使得 JavaScript 可以完成许多以前无法想象的事情。
(2)、Node
Node 项目使得 JavaScript 可以用于开发服务器端的大型项目,网站的前后端都用 JavaScript 开发已经成为了现实。有些嵌入式平台(Raspberry Pi)能够安装 Node,于是 JavaScript 就能为这些平台开发应用程序。
(3)、数据库操作
JavaScript 甚至也可以用来操作数据库。NoSQL 数据库这个概念,本身就是在 JSON(JavaScript Object Notation)格式的基础上诞生的,大部分 NoSQL 数据库允许 JavaScript 直接操作。基于 SQL 语言的开源数据库 PostgreSQL 支持 JavaScript 作为操作语言,可以部分取代 SQL 查询语言。
(4)、移动平台开发
JavaScript 也正在成为手机应用的开发语言。一般来说,安卓平台使用 Java 语言开发,iOS 平台使用 Objective-C 或 Swift 语言开发。许多人正在努力,让 JavaScript 成为各个平台的通用开发语言。
PhoneGap 项目就是将 JavaScript 和 HTML5 打包在一个容器之中,使得它能同时在 iOS 和安卓上运行。Facebook 公司的 React Native 项目则是将 JavaScript 写的组件,编译成原生组件,从而使它们具备优秀的性能。
Mozilla 基金会的手机操作系统 Firefox OS,更是直接将 JavaScript 作为操作系统的平台语言,但是很可惜这个项目没有成功。
(5)、内嵌脚本语言
越来越多的应用程序,将 JavaScript 作为内嵌的脚本语言,比如 Adobe 公司的著名 PDF 阅读器 Acrobat、Linux 桌面环境 GNOME 3。
(6)、跨平台的桌面应用程序
Chromium OS、Windows 8 等操作系统直接支持 JavaScript 编写应用程序。Mozilla 的 Open Web Apps 项目、Google 的 Chrome App 项目、GitHub 的 Electron 项目、以及 TideSDK 项目,都可以用来编写运行于 Windows、Mac OS 和 Android 等多个桌面平台的程序,不依赖浏览器。
(7)、小结
可以预期,JavaScript 最终将能让你只用一种语言,就开发出适应不同平台(包括桌面端、服务器端、手机端)的程序。早在 2013 年 9 月的统计之中,JavaScript 就是当年 GitHub 上使用量排名第一的语言。
著名程序员 Jeff Atwood 甚至提出了一条 “Atwood 定律”:
“所有可以用 JavaScript 编写的程序,最终都会出现 JavaScript 的版本。”(Any application that can be written in JavaScript will eventually be written in JavaScript.)
2、易学性
相比学习其他语言,学习 JavaScript 有一些有利条件。
(1)、学习环境无处不在
只要有浏览器,就能运行 JavaScript 程序;只要有文本编辑器,就能编写 JavaScript 程序。这意味着,几乎所有电脑都原生提供 JavaScript 学习环境,不用另行安装复杂的 IDE(集成开发环境)和编译器。
(2)、简单性
相比其他脚本语言(比如 Python 或 Ruby),JavaScript 的语法相对简单一些,本身的语法特性并不是特别多。而且,那些语法中的复杂部分,也不是必需要学会。你完全可以只用简单命令,完成大部分的操作。
(3)、与主流语言的相似性
JavaScript 的语法很类似 C/C++ 和 Java,如果学过这些语言(事实上大多数学校都教),JavaScript 的入门会非常容易。
必须说明的是,虽然核心语法不难,但是 JavaScript 的复杂性体现在另外两个方面。
首先,它涉及大量的外部 API。JavaScript 要发挥作用,必须与其他组件配合,这些外部组件五花八门,数量极其庞大,几乎涉及网络应用的各个方面,掌握它们绝非易事。
其次,JavaScript 语言有一些设计缺陷。某些地方相当不合理,另一些地方则会出现怪异的运行结果。学习 JavaScript,很大一部分时间是用来搞清楚哪些地方有陷阱。Douglas Crockford 写过一本有名的书,名字就叫《JavaScript: The Good Parts》,言下之意就是这门语言不好的地方很多,必须写一本书才能讲清楚。另外一些程序员则感到,为了更合理地编写 JavaScript 程序,就不能用 JavaScript 来写,而必须发明新的语言,比如 CoffeeScript、TypeScript、Dart 这些新语言的发明目的,多多少少都有这个因素。
尽管如此,目前看来,JavaScript 的地位还是无法动摇。加之,语言标准的快速进化,使得 JavaScript 功能日益增强,而语法缺陷和怪异之处得到了弥补。所以,JavaScript 还是值得学习,况且它的入门真的不难。
3、开放性
JavaScript 是一种开放的语言。它的标准 ECMA-262 是 ISO 国际标准,写得非常详尽明确;该标准的主要实现(比如 V8 和 SpiderMonkey 引擎)都是开放的,而且质量很高。这保证了这门语言不属于任何公司或个人,不存在版权和专利的问题。
语言标准由 TC39 委员会负责制定,该委员会的运作是透明的,所有讨论都是开放的,会议记录都会对外公布。
不同公司的 JavaScript 运行环境,兼容性很好,程序不做调整或只做很小的调整,就能在所有浏览器上运行。
三、JavaScript 的历史
JavaScript 因为互联网而生,紧跟着浏览器的出现而问世。回顾它的历史,就要从浏览器的历史讲起。
1990 年底,欧洲核能研究组织(CERN)科学家 Tim Berners-Lee,在全世界最大的电脑网络——互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。最早的网页只能在操作系统的终端里浏览,也就是说只能使用命令行操作,网页都是在字符窗口中显示,这当然非常不方便。
1992 年底,美国国家超级电脑应用中心(NCSA)开始开发一个独立的浏览器,叫做 Mosaic。这是人类历史上第一个浏览器,从此网页可以在图形界面的窗口浏览。
1994 年 10 月,NCSA 的一个主要程序员 Marc Andreessen 联合风险投资家 Jim Clark,成立了 Mosaic 通信公司(Mosaic Communications),不久后改名为 Netscape。这家公司的方向,就是在 Mosaic 的基础上,开发面向普通用户的新一代的浏览器 Netscape Navigator。
1994 年 12 月,Navigator 发布了 1.0 版,市场份额一举超过 90%。
Netscape 公司很快发现,Navigator 浏览器需要一种可以嵌入网页的脚本语言,用来控制浏览器行为。当时,网速很慢而且上网费很贵,有些操作不宜在服务器端完成。比如,如果用户忘记填写“用户名”,就点了“发送”按钮,到服务器再发现这一点就有点太晚了,最好能在用户发出数据之前,就告诉用户“请填写用户名”。这就需要在网页中嵌入小程序,让浏览器检查每一栏是否都填写了。
管理层对这种浏览器脚本语言的设想是:功能不需要太强,语法较为简单,容易学习和部署。那一年,正逢 Sun 公司的 Java 语言问世,市场推广活动非常成功。Netscape 公司决定与 Sun 公司合作,浏览器支持嵌入 Java 小程序(后来称为 Java applet)。但是,浏览器脚本语言是否就选用 Java,则存在争论。后来,还是决定不使用 Java,因为网页小程序不需要 Java 这么“重”的语法。但是,同时也决定脚本语言的语法要接近 Java,并且可以支持 Java 程序。这些设想直接排除了使用现存语言,比如 Perl、Python 和 TCL。
1995 年,Netscape 公司雇佣了程序员 Brendan Eich 开发这种网页脚本语言。Brendan Eich 有很强的函数式编程背景,希望以 Scheme 语言(函数式语言鼻祖 LISP 语言的一种方言)为蓝本,实现这种新语言。
1995 年 5 月,Brendan Eich 只用了 10 天,就设计完成了这种语言的第一版。它是一个大杂烩,语法有多个来源。
- 基本语法:借鉴 C 语言和 Java 语言。
- 数据结构:借鉴 Java 语言,包括将值分成原始值和对象两大类。
- 函数的用法:借鉴 Scheme 语言和 Awk 语言,将函数当作第一等公民,并引入闭包。
- 原型继承模型:借鉴 Self 语言(Smalltalk 的一种变种)。
- 正则表达式:借鉴 Perl 语言。
- 字符串和数组处理:借鉴 Python 语言。
为了保持简单,这种脚本语言缺少一些关键的功能,比如块级作用域、模块、子类型(subtyping)等等,但是可以利用现有功能找出解决办法。这种功能的不足,直接导致了后来 JavaScript 的一个显著特点:对于其他语言,你需要学习语言的各种功能,而对于 JavaScript,你常常需要学习各种解决问题的模式。而且由于来源多样,从一开始就注定,JavaScript 的编程风格是函数式编程和面向对象编程的一种混合体。
Netscape 公司的这种浏览器脚本语言,最初名字叫做 Mocha,1995 年 9 月改为 LiveScript。12 月,Netscape 公司与 Sun 公司(Java 语言的发明者和所有者)达成协议,后者允许将这种语言叫做 JavaScript。这样一来,Netscape 公司可以借助 Java 语言的声势,而 Sun 公司则将自己的影响力扩展到了浏览器。
之所以起这个名字,并不是因为 JavaScript 本身与 Java 语言有多么深的关系(事实上,两者关系并不深,详见下节),而是因为 Netscape 公司已经决定,使用 Java 语言开发网络应用程序,JavaScript 可以像胶水一样,将各个部分连接起来。当然,后来的历史是 Java 语言的浏览器插件失败了,JavaScript 反而发扬光大。
1995 年 12 月 4 日,Netscape 公司与 Sun 公司联合发布了 JavaScript 语言,对外宣传 JavaScript 是 Java 的补充,属于轻量级的 Java,专门用来操作网页。
1996 年 3 月,Navigator 2.0 浏览器正式内置了 JavaScript 脚本语言。
相关阅读:
四、Javascript 大事记
JavaScript 伴随着互联网的发展一起发展。互联网周边技术的快速发展,刺激和推动了 JavaScript 语言的发展。下面,回顾一下 JavaScript 的周边应用发展。
1996 年,样式表标准 CSS 第一版发布。
1997 年,DHTML(Dynamic HTML,动态 HTML)发布,允许动态改变网页内容。这标志着 DOM 模式(Document Object Model,文档对象模型)正式应用。
1998 年,Netscape 公司开源了浏览器,这导致了 Mozilla 项目的诞生。几个月后,美国在线(AOL)宣布并购 Netscape。
1999 年,IE 5 部署了 XMLHttpRequest 接口,允许 JavaScript 发出 HTTP 请求,为后来大行其道的 Ajax 应用创造了条件。
2000 年,KDE 项目重写了浏览器引擎 KHTML,为后来的 WebKit 和 Blink 引擎打下基础。这一年的 10 月 23 日,KDE 2.0 发布,第一次将 KHTML 浏览器包括其中。
2001 年,微软公司时隔 5 年之后,发布了 IE 浏览器的下一个版本 Internet Explorer 6。这是当时最先进的浏览器,它后来统治了浏览器市场多年。
2001 年,Douglas Crockford 提出了 JSON 格式,用于取代 XML 格式,进行服务器和网页之间的数据交换。JavaScript 可以原生支持这种格式,不需要额外部署代码。
2002 年,Mozilla 项目发布了它的浏览器的第一版,后来起名为 Firefox。
2003 年,苹果公司发布了 Safari 浏览器的第一版。
2004 年,Google 公司发布了 Gmail,促成了互联网应用程序(Web Application)这个概念的诞生。由于 Gmail 是在 4 月 1 日发布的,很多人起初以为这只是一个玩笑。
2004 年,Dojo 框架诞生,为不同浏览器提供了同一接口,并为主要功能提供了便利的调用方法。这标志着 JavaScript 编程框架的时代开始来临。
2004 年,WHATWG 组织成立,致力于加速 HTML 语言的标准化进程。
2005 年,苹果公司在 KHTML 引擎基础上,建立了 WebKit 引擎。
2005 年,Ajax 方法(Asynchronous JavaScript and XML)正式诞生,Jesse James Garrett 发明了这个词汇。它开始流行的标志是,2 月份发布的 Google Maps 项目大量采用该方法。它几乎成了新一代网站的标准做法,促成了 Web 2.0 时代的来临。
2005 年,Apache 基金会发布了 CouchDB 数据库。这是一个基于 JSON 格式的数据库,可以用 JavaScript 函数定义视图和索引。它在本质上有别于传统的关系型数据库,标识着 NoSQL 类型的数据库诞生。
2006 年,jQuery 函数库诞生,作者为 John Resig。jQuery 为操作网页 DOM 结构提供了非常强大易用的接口,成为了使用最广泛的函数库,并且让 JavaScript 语言的应用难度大大降低,推动了这种语言的流行。
2006 年,微软公司发布 IE 7,标志重新开始启动浏览器的开发。
2006 年,Google 推出 Google Web Toolkit 项目(缩写为 GWT),提供 Java 编译成 JavaScript 的功能,开创了将其他语言转为 JavaScript 的先河。
2007 年,Webkit 引擎在 iPhone 手机中得到部署。它最初基于 KDE 项目,2003 年苹果公司首先采用,2005 年开源。这标志着 JavaScript 语言开始能在手机中使用了,意味着有可能写出在桌面电脑和手机中都能使用的程序。
2007 年,Douglas Crockford 发表了名为《JavaScript: The good parts》的演讲,次年由 O’Reilly 出版社出版。这标志着软件行业开始严肃对待 JavaScript 语言,对它的语法开始重新认识,
2008 年,V8 编译器诞生。这是 Google 公司为 Chrome 浏览器而开发的,它的特点是让 JavaScript 的运行变得非常快。它提高了 JavaScript 的性能,推动了语法的改进和标准化,改变外界对 JavaScript 的不佳印象。同时,V8 是开源的,任何人想要一种快速的嵌入式脚本语言,都可以采用 V8,这拓展了 JavaScript 的应用领域。
2009 年,Node.js 项目诞生,创始人为 Ryan Dahl,它标志着 JavaScript 可以用于服务器端编程,从此网站的前端和后端可以使用同一种语言开发。并且,Node.js 可以承受很大的并发流量,使得开发某些互联网大规模的实时应用变得容易。
2009 年,Jeremy Ashkenas 发布了 CoffeeScript 的最初版本。CoffeeScript 可以被转换为 JavaScript 运行,但是语法要比 JavaScript 简洁。这开启了其他语言转为 JavaScript 的风潮。
2009 年,PhoneGap 项目诞生,它将 HTML5 和 JavaScript 引入移动设备的应用程序开发,主要针对 iOS 和 Android 平台,使得 JavaScript 可以用于跨平台的应用程序开发。
2009,Google 发布 Chrome OS,号称是以浏览器为基础发展成的操作系统,允许直接使用 JavaScript 编写应用程序。类似的项目还有 Mozilla 的 Firefox OS。
2010 年,三个重要的项目诞生,分别是 NPM、BackboneJS 和 RequireJS,标志着 JavaScript 进入模块化开发的时代。
2011 年,微软公司发布 Windows 8 操作系统,将 JavaScript 作为应用程序的开发语言之一,直接提供系统支持。
2011 年,Google 发布了 Dart 语言,目的是为了结束 JavaScript 语言在浏览器中的垄断,提供更合理、更强大的语法和功能。Chromium 浏览器有内置的 Dart 虚拟机,可以运行 Dart 程序,但 Dart 程序也可以被编译成 JavaScript 程序运行。
2011 年,微软工程师Scott Hanselman提出,JavaScript 将是互联网的汇编语言。因为它无所不在,而且正在变得越来越快。其他语言的程序可以被转成 JavaScript 语言,然后在浏览器中运行。
2012 年,单页面应用程序框架(single-page app framework)开始崛起,AngularJS 项目和 Ember 项目都发布了 1.0 版本。
2012 年,微软发布 TypeScript 语言。该语言被设计成 JavaScript 的超集,这意味着所有 JavaScript 程序,都可以不经修改地在 TypeScript 中运行。同时,TypeScript 添加了很多新的语法特性,主要目的是为了开发大型程序,然后还可以被编译成 JavaScript 运行。
2012 年,Mozilla 基金会提出 asm.js 规格。asm.js 是 JavaScript 的一个子集,所有符合 asm.js 的程序都可以在浏览器中运行,它的特殊之处在于语法有严格限定,可以被快速编译成性能良好的机器码。这样做的目的,是为了给其他语言提供一个编译规范,使其可以被编译成高效的 JavaScript 代码。同时,Mozilla 基金会还发起了 Emscripten 项目,目标就是提供一个跨语言的编译器,能够将 LLVM 的位代码(bitcode)转为 JavaScript 代码,在浏览器中运行。因为大部分 LLVM 位代码都是从 C / C++ 语言生成的,这意味着 C / C++ 将可以在浏览器中运行。此外,Mozilla 旗下还有 LLJS (将 JavaScript 转为 C 代码)项目和 River Trail (一个用于多核心处理器的 ECMAScript 扩展)项目。目前,可以被编译成 JavaScript 的语言列表,共有将近 40 种语言。
2013 年,Mozilla 基金会发布手机操作系统 Firefox OS,该操作系统的整个用户界面都使用 JavaScript。
2013 年,ECMA 正式推出 JSON 的国际标准,这意味着 JSON 格式已经变得与 XML 格式一样重要和正式了。
2013 年 5 月,Facebook 发布 UI 框架库 React,引入了新的 JSX 语法,使得 UI 层可以用组件开发,同时引入了网页应用是状态机的概念。
2014 年,微软推出 JavaScript 的 Windows 库 WinJS,标志微软公司全面支持 JavaScript 与 Windows 操作系统的融合。
2014 年 11 月,由于对 Joyent 公司垄断 Node 项目、以及该项目进展缓慢的不满,一部分核心开发者离开了 Node.js,创造了 io.js 项目,这是一个更开放、更新更频繁的 Node.js 版本,很短时间内就发布到了 2.0 版。三个月后,Joyent 公司宣布放弃对 Node 项目的控制,将其转交给新成立的开放性质的 Node 基金会。随后,io.js 项目宣布回归 Node,两个版本将合并。
2015 年 3 月,Facebook 公司发布了 React Native 项目,将 React 框架移植到了手机端,可以用来开发手机 App。它会将 JavaScript 代码转为 iOS 平台的 Objective-C 代码,或者 Android 平台的 Java 代码,从而为 JavaScript 语言开发高性能的原生 App 打开了一条道路。
2015 年 4 月,Angular 框架宣布,2.0 版将基于微软公司的 TypeScript 语言开发,这等于为 JavaScript 语言引入了强类型。
2015 年 5 月,Node 模块管理器 NPM 超越 CPAN,标志着 JavaScript 成为世界上软件模块最多的语言。
2015 年 5 月,Google 公司的 Polymer 框架发布 1.0 版。该项目的目标是生产环境可以使用 WebComponent 组件,如果能够达到目标,Web 开发将进入一个全新的以组件为开发基础的阶段。
2015 年 6 月,ECMA 标准化组织正式批准了 ECMAScript 6 语言标准,定名为《ECMAScript 2015 标准》。JavaScript 语言正式进入了下一个阶段,成为一种企业级的、开发大规模应用的语言。这个标准从提出到批准,历时 10 年,而 JavaScript 语言从诞生至今也已经 20 年了。
2015 年 6 月,Mozilla 在 asm.js 的基础上发布 WebAssembly 项目。这是一种 JavaScript 引擎的中间码格式,全部都是二进制,类似于 Java 的字节码,有利于移动设备加载 JavaScript 脚本,执行速度提高了 20+ 倍。这意味着将来的软件,会发布 JavaScript 二进制包。
2016 年 6 月,《ECMAScript 2016 标准》发布。与前一年发布的版本相比,它只增加了两个较小的特性。
2017 年 6 月,《ECMAScript 2017 标准》发布,正式引入了 async 函数,使得异步操作的写法出现了根本的变化。
2017 年 11 月,所有主流浏览器全部支持 WebAssembly,这意味着任何语言都可以编译成 JavaScript,在浏览器运行。
相关阅读:
五、JavaScript 基本语法
1、数据类型
JavaScript 语言的每一个值,都属于某一种数据类型。JavaScript 的数据类型,共有六种。(最新的规范又新增了第七种 Symbol 类型和 BigInt,这里暂不提及。)
- 数值(number):整数和小数(比如
1
和3.14
) - 字符串(string):文本(比如
Hello World
)。 - 布尔值(boolean):表示真伪的两个特殊值,即
true
(真)和false
(假) undefined
:表示“未定义”或不存在,即由于目前没有定义,所以此处暂时没有任何值null
:表示空值,即此处的值为空。- 对象(object):各种值组成的集合。
对象是最复杂的数据类型,又可以分成三个子类型。
- 狭义的对象(object)
- 数组(array)
- 函数(function)
2、字符串
1、字符串就是零个或多个排在一起的字符,放在单引号或双引号或者反引号中。
|
|
2、模板字符串(template string)是增强版的字符串,用反引号(`)标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。
|
|
3、字符串常见函数和属性
|
|
3、数组
和 python 中的 list
很类似;
数组实例常见函数和属性:
|
|
数组常见静态方法:
|
|
4、对象
和 python 中的 dict
很类似,但是表示方式有一些区别:
python 是:
|
|
js 是:
|
|
也可以说是:
|
|
对象常见静态方法:
|
|
5、解构
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
|
|
现在可以写成:
|
|
同样,多层嵌套也是可以的:
|
|
解构赋值允许指定默认值:
|
|
同样的,对象也可以解构(对象的解构没有顺序要求):
|
|
6、扩展运算符
扩展运算符用三个点表示(…),可以将用逗号分隔的参数序列转为一个数组。
|
|
类似于 python 和 golang 中的可变参数:
|
|
|
|
它还可以反过来,可以将一个数组转为用逗号分隔的参数序列:
|
|
类似于 golang 中将 ...
放在变量的后面:
|
|
同样的,扩展运算符对 对象 也同样有效:
|
|
扩展运算符可以做很多事情,下面是例子:
(1)、可变参数:
|
|
(2)、对象的浅克隆, 等价于 Object.assign({ hello : 'world' })
|
|
(3)、合并数组:
|
|
(4)、数组的浅克隆:
|
|
(5)、可以和扩展运算符配合使用:
|
|
7、函数
(1)、表示形式
|
|
(2)、默认值
|
|
六、JavaScript 进阶语法
1、词法作用域
JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。
|
|
python
用的也是词法作用域:
|
|
与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
bash
就是 动态作用域 的典型代表,参考下面的 bash 脚本:
|
|
golang
用的也是 动态作用域 :
|
|
2、常用的运算符
1、三目运算符
|
|
2、!!
|
|
3、??
|
|
4、可选链 ?.
|
|
3、闭包
当一个函数 A 返回一个函数 B,并且在函数 B 使用函数 A 的内部作用域的时候(存在函数 B 对函数 A 内部对象的引用),JS 引擎的垃圾回收器因此无法释放函数 A 的内部作用域,因此形成闭包。
举个例子:
|
|
七、异步编程风格
异步编程的方式有多种
- 回调函数
- Promise
- Generator
- Async
下面结合一个例子依次演示:
写一个方法来 查找指定目录下的最大文件
。
我们基本的实现思路就是:
- 用 fs.readdir 获取指定目录的内容信息
- 循环遍历内容信息,使用 fs.stat 获取该文件或者目录的具体信息
- 将具体信息储存起来
- 当全部储存起来后,筛选其中的是文件的信息
- 遍历比较,找出最大文件
- 获取并返回最大文件
1、回调函数
|
|
使用方式为:
|
|
2、Promise
|
|
使用方式为:
|
|
3、Generator
|
|
使用方式为:
|
|
4、Async
|
|
使用方式为:
|
|
相关阅读:
七、JavaScript 为什么是单线程?
JavaScript 只在一个线程上运行,不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。
JavaScript 之所以采用单线程,而不是多线程,跟历史有关系。JavaScript 从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。**如果 JavaScript 同时有两个线程,一个线程在网页 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?是不是还要有锁机制?**所以,为了避免复杂性,JavaScript 一开始就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
1、如何做到异步执行?
单线程好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。JavaScript 语言本身并不慢,慢的是读写外部数据,比如等待 Ajax 请求返回结果。这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞。
JavaScript 语言的设计者意识到,这时 CPU 完全可以不管 IO 操作,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是 JavaScript 内部采用的“事件循环”机制(Event Loop)。
事件循环有三个关键点:
- 第一点引入了循环机制,具体实现方式是在线程语句最后添加了一个 for 循环语句,线程会一直循环执行。
- 第二点是引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。
- 第三点维护了一个消息队列,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等,都会放在消息队列里依次执行。
2、JS 如何多线程并发执行?
为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。
这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。
实现方式:
主线程采用 new 命令,调用 Worker()构造函数,新建一个 Worker 线程。
|
|
主线程 与 worker 线程的通信方式:
|
|
|
|
node
中也有相应的模块可以使用 worker
,参考一个例子:
|
|
八、Node 常见模块
Node 是 JavaScript 语言的服务器运行环境。
所谓“运行环境”有两层意思:首先,JavaScript 语言通过 Node 在服务器运行,在这个意义上,Node 有点像 JavaScript 虚拟机;其次,Node 提供大量工具库,使得 JavaScript 语言与操作系统互动(比如读写文件、新建子进程),在这个意义上,Node 又是 JavaScript 的工具库。
Node 内部采用 Google 公司的 V8 引擎,作为 JavaScript 语言解释器;通过自行开发的 libuv 库,调用操作系统资源。
1、Node 模块加载
(1)、模块加载加载方式
目前主流是两种模块化方案, ES Module
和 CommonJs
。ES Module
是 JavaScript 官方的标准化模块系统。
CommonJs
是 node 的模块化方案。现在两者在 node 中都可以使用。
两者使用上的区别:
ES Module
使用export
关键词导出, 用import
关键词导入;CommonJs
使用module.exports
关键词导出, 用require
关键词导入;
举个例子:
ES Module 模块方式:
|
|
|
|
CommonJs 模块方式:
|
|
|
|
(2)、模块加载规则
-
如果 X 是内置模块(比如
require('http')
)- 返回该模块。
- 不再继续执行。
-
如果 X 以 “./” 或者 “/” 或者 “../” 开头
-
根据 X 所在的父模块,确定 X 的绝对路径。
-
将 X 当成文件,依次查找下面文件,只要其中有一个存在,就返回该文件,不再继续执行。
1 2 3 4
X X.js X.json X.node
-
将 X 当成目录,依次查找下面文件,只要其中有一个存在,就返回该文件,不再继续执行。
1 2 3 4
X/package.json(main字段) X/index.js X/index.json X/index.node
-
-
如果 X 不带路径
- 根据 X 所在的父模块,确定 X 可能的安装目录。
- 依次在每个目录中,将 X 当成文件名或目录名加载。
-
抛出 “not found”
对于不带路径的情况,再举一个详细的例子。
当前脚本文件 /home/ry/projects/foo.js
执行了 require('bar')
,Node 内部运行过程如下:
首先,确定 x 的绝对路径可能是下面这些位置,依次搜索每一个目录。
|
|
搜索时,Node 先将 bar 当成文件名,依次尝试加载下面这些文件,只要有一个成功就返回。
|
|
如果都不成功,说明 bar 可能是目录名,于是依次尝试加载下面这些文件。
|
|
如果在所有目录中,都无法找到 bar 对应的文件或目录,就抛出一个错误。
2、fs
fs 是 filesystem 的缩写,该模块提供本地文件的读写能力。这个模块几乎对所有操作提供 异步 和 同步 两种操作方式,供开发者选择。
(1)、readFile(),readFileSync()
readFile
方法用于异步读取数据:
|
|
readFile
方法的第一个参数是文件的路径,可以是绝对路径,也可以是相对路径。注意,如果是相对路径,是相对于当前进程所在的路径(process.cwd()),而不是相对于当前脚本所在的路径。
readFile
方法的第二个参数是读取完成后的回调函数。该函数的第一个参数是发生错误时的错误对象,第二个参数是代表文件内容的 Buffer
实例。
readFileSync 方法用于同步读取文件,返回一个字符串:
|
|
(2)、writeFile(),writeFileSync()
writeFile
方法用于异步写入文件:
|
|
上面代码中,writeFile 方法的第一个参数是写入的文件名,第二个参数是写入的字符串,第三个参数是回调函数。
回调函数前面,还可以再加一个参数,表示写入字符串的编码(默认是 utf8):
|
|
writeFileSync 方法用于同步写入文件:
|
|
(3)、exists(path, callback)
exists 方法用来判断给定路径是否存在,然后不管结果如何,都会调用回调函数:
|
|
(4)、mkdir(), mkdirSync()
mkdir 方法用于新建目录:
|
|
mkdir 接受三个参数,第一个是目录名,第二个是权限值,第三个是回调函数。
mkdirSync 用于同步创建文件夹:
|
|
(5)、readdir(),readdirSync()
readdir 方法用于读取目录,返回一个所包含的文件和子目录的数组。
(6)、stat()
stat 方法的参数是一个文件或目录,它产生一个对象,该对象包含了该文件或目录的具体信息。我们往往通过该方法,判断正在处理的到底是一个文件,还是一个目录。
|
|
其他
- fs.access(): 检查文件是否存在,以及 Node.js 是否有权限访问。
- fs.appendFile(): 追加数据到文件。如果文件不存在,则创建文件。
- fs.chmod(): 更改文件(通过传入的文件名指定)的权限。相关方法:fs.lchmod()、fs.fchmod()。
- fs.chown(): 更改文件(通过传入的文件名指定)的所有者和群组。相关方法:fs.fchown()、fs.lchown()。
- fs.copyFile(): 拷贝文件。
- fs.createReadStream(): 创建可读的文件流。
- fs.createWriteStream(): 创建可写的文件流。
- fs.link(): 新建指向文件的硬链接。
- fs.rename(): 重命名文件或文件夹。
- fs.rmdir(): 删除文件夹。
- fs.stat(): 返回文件(通过传入的文件名指定)的状态。相关方法:fs.fstat()、fs.lstat()。
- fs.watchFile(): 开始监视文件上的更改。相关方法:fs.watch()。
- fs.unwatchFile(): 停止监视文件上的更改。
3、os
os 模块提供与操作系统相关的方法
(1)、os.EOL
os.EOL 属性是一个常量,返回当前操作系统的换行符(Windows 系统是\r\n,其他系统是\n)
|
|
(2)、os.arch
os.arch 方法返回当前计算机的架构。
|
|
(3)、os.cpus
os.cpus 方法返回当前计算机的 cpu 信息。
(4)、os.freemem
os.freemem 返回代表系统中可用内存的字节数。
(5)、os.homedir
返回到当前用户的主目录的路径。
(6)、os.hostname
返回主机名。
(7)、os.platform
返回为 Node.js 编译的平台,例如:darwin、linux、win32
4、path
path 模块提供了一些用于处理文件路径的小工具
(1)、path.join()
path.join 方法用于连接路径。该方法的主要用途在于,会正确使用当前系统的路径分隔符,Unix 系统是”/“,Windows 系统是”\“。
|
|
上面代码在 Unix 系统下,会返回路径 mydir/foo
。
(2)、path.resolve()
path.resolve 方法用于将相对路径转为绝对路径。
它可以接受多个参数,依次表示所要进入的路径,直到将最后一个参数转为绝对路径。如果根据参数无法得到绝对路径,就以当前所在路径作为基准。除了根目录,该方法的返回值都不带尾部的斜杠。
|
|
上面代码的实例,执行效果类似下面的命令:
|
|
更多例子:
|
|
(3)、path.parse()
path.parse()
方法可以返回路径各部分的信息。
|
|
5、child_process
child_process
模块用于新建子进程。
(1)、exec()
exec
方法用于执行 bash 命令,它的参数是一个命令字符串:
|
|
上面代码的 exec
方法用于新建一个子进程,子进程的运行结果储存在系统缓存之中(最大 200KB),等到子进程运行结束以后,主进程再用回调函数读取子进程的运行结果。
由于标准输出和标准错误都是流对象(stream),可以监听 data 事件,因此上面的代码也可以写成下面这样:
|
|
这样的写法有一个好处,监听 data 事件以后,可以实时输出结果,否则只有等到子进程结束,才会输出结果。所以,如果子进程运行时间较长,或者是持续运行,第二种写法更好。
exec 方法会直接调用 bash 来解释命令,所以如果有用户输入的参数,exec
方法是不安全的。例如:
|
|
上面代码表示,在 bash 环境下,ls -l; user input
会直接运行。如果用户输入恶意代码,将会带来安全风险。因此,在有用户输入的情况下,最好不使用 exec
方法,而是使用 execFile
方法。
(2)、execFile()
execFile
方法直接执行特定的程序,参数作为数组传入,不会被 bash 解释,因此具有较高的安全性。
|
|
上面代码中,假定 path 来自用户输入,如果其中包含了分号或反引号,ls
程序不理解它们的含义,因此也就得不到运行结果,安全性就得到了提高。
(3)、spawn()
spawn
方法创建一个子进程来执行特定命令,用法与 execFile
方法类似,但是没有回调函数,只能通过监听事件,来获取运行结果。它属于异步执行,适用于子进程长时间运行的情况。
|
|
exec
和 spawn
方法的区别?
exec
返回整个子进程处理时产生的 buffer,这个 buffer 默认大小是 200K。 当子进程返回的数据超过默认大小时,程序就会产生 “Error: maxBuffer exceeded” 异常。 调大 exec
的 maxBuffer
选项可以解决这个问题,不过当子进程返回的数据太过巨大的时候,这个问题还会出现。 因此当子进程返回的数据超过默认大小时,最好的解决方法是使用 spawn
方法。
spawn
返回 stdout 和 stderr 流对象。 程序可以通过 stdout 的 data
、end
或者其他事件来获取子进程返回的数据。 使用 spawn
方法时,子进程一开始执行就会通过流返回数据,因此 spawn
适合子进程返回大量数据的情形。
与 exec
相比,spawn
还可以对子进程进行更详细的设置,例如使子进程在后台运行,成为一个 daemon
程序,不随着父进程的退出而退出:
|
|
(4)、fork()
fork
方法直接创建一个子进程,执行 Node 脚本,fork('./child.js')
相当于 spawn('node', ['./child.js'])
。与 spawn 方法不同的是,fork 会在父进程与子进程之间,建立一个通信管道,用于进程之间的通信。
|
|
上面代码中,fork 方法返回一个代表进程间通信管道的对象,对该对象可以监听 message 事件,用来获取子进程返回的信息,也可以向子进程发送信息。
child.js 脚本的内容如下:
|
|
6、stream
”数据流“(stream)是处理系统缓存的一种方式。操作系统采用数据块(chunk)的方式读取数据,每收到一次数据,就存入缓存。Node 应用程序有两种缓存的处理方式,第一种是等到所有数据接收完毕,一次性从缓存读取,这就是传统的读取文件的方式;第二种是采用“数据流”的方式,收到一块数据,就读取一块,即在数据还没有接收完成时,就开始处理它。
第一种方式先将数据全部读入内存,然后处理,优点是符合直觉,流程非常自然,缺点是如果遇到大文件,要花很长时间,才能进入数据处理的步骤。第二种方式每次只读入数据的一小块,像“流水”一样,每当系统读入了一小块数据,就会触发一个事件,发出“新数据块”的信号。应用程序只要监听这个事件,就能掌握数据读取的进展,做出相应处理,这样就提高了程序的性能。
举个例子,用流复制文件:
|
|
7、process
process
对象是 Node 的一个全局对象,提供当前 Node 进程的信息。它可以在脚本的任意位置使用,不必通过 require 命令加载。
process 对象提供一系列属性,用于返回系统信息:
process.argv
:返回一个数组,成员是当前进程的所有命令行参数。process.env
:返回一个对象,成员为当前 Shell 的环境变量,比如 process.env.HOME。process.pid
:返回一个数字,表示当前进程的进程号。process.platform
:返回一个字符串,表示当前的操作系统,比如 Linux。
process 对象提供以下方法:
process.cwd()
:返回运行当前脚本的工作目录的路径。process.exit()
:退出当前进程。
8、util
util
模块提供了一些工具方法供开发者使用,举一个例子 promisify
:
promisify
可以传入一个遵循常见的错误优先的回调风格的函数(即以 (err, value) => … 回调作为最后一个参数),并返回一个返回 promise 的版本。
|
|
上面的例子:
|
|
可以改写为:
|
|
九、Deno 简介
Deno 是基于 V8 JavaScript
引擎和 Rust
编程语言的 JavaScript 和 TypeScript 运行时,默认使用安全环境执行代码。
它是 Node.js 的替代品。有了它,将来可能就不需要 Node.js 了。
1、安装
Shell (Mac, Linux):
|
|
PowerShell (Windows):
|
|
Homebrew (Mac):
|
|
Chocolatey (Windows):
|
|
Scoop (Windows):
|
|
Build and install from source using Cargo
|
|
2、例子
可以直接运行:
|
|
或者运行一个相对复杂的例子:
|
|
3、历史
Deno 是 Ryan Dahl 在 2017 年创立的。
Ryan Dahl 也是 Node.js 的创始人,从 2007 年一直到 2012 年,他后来把 Node.js 移交给了其他开发者,不再过问了,转而研究人工智能。
他始终不是很喜欢 Python 语言,久而久之,就想搞一个 JavaScript 语言的人工智能开发框架。等到他再回过头捡起 Node.js,发现这个项目已经背离了他的初衷,有一些无法忽视的问题。
node 有如下一些问题:
首先,过去五六年,JavaScript
语言脱胎换骨,ES6 标准引入了大量新的语法特性。其中,影响最大的语法有两个:Promise
接口(以及 async 函数)和 ES
模块。
Node.js
对这两个新语法的支持,都不理想。由于历史原因,Node.js
必须支持回调函数(callback),导致异步接口会有 Promise
和回调函数两种写法;同时,Node.js
自己的模块格式 CommonJS
与 ES
模块不兼容,导致迟迟无法完全支持 ES
模块。
其次,Node.js
的模块管理工具 npm
,逻辑越来越复杂;模块安装目录 npm_modules
极其庞杂,难以管理。Node.js 也几乎没有安全措施,用户只要下载了外部模块,就只好听任别人的代码在本地运行,进行各种读写操作。
再次,Node.js
的功能也不完整,导致外部工具层出不穷,让开发者疲劳不堪:webpack
,babel
,typescript
、eslint
、prettier
……
由于上面这些原因,Ryan Dahl 决定放弃 Node.js,从头写一个替代品,彻底解决这些问题。deno 这个名字就是来自 Node 的字母重新组合(Node = no + de),表示"拆除 Node.js"(de = destroy, no = Node.js)。
跟 Node.js 一样,Deno 也是一个服务器运行时,但是支持多种语言,可以直接运行 JavaScript、TypeScript 和 WebAssembly 程序。
它内置了 V8 引擎,用来解释 JavaScript。同时,也内置了 tsc 引擎,解释 TypeScript。它使用 Rust 语言开发,由于 Rust 原生支持 WebAssembly,所以它也能直接运行 WebAssembly。它的异步操作不使用 libuv 这个库,而是使用 Rust 语言的 Tokio 库,来实现事件循环(event loop)。
4、Deno 的特点
Deno 有如下特性:
(1)、Deno 打包后只有一个可执行文件,所有操作都通过这个文件完成。它支持跨平台(Mac、Linux、Windows)。
(2)、Deno 具有安全控制,默认情况下脚本不具有读写权限。如果脚本未授权,就读写文件系统或网络,会报错。
(3)、Deno 支持 Web API,尽量跟浏览器保持一致。
它提供 window 这个全局对象,同时支持 fetch、webCrypto、worker 等 Web 标准,也支持 onload、onunload、addEventListener 等事件操作函数。
此外,Deno 所有的异步操作,一律返回 Promise。
(4)、Deno 只支持 ES 模块,跟浏览器的模块加载规则一致。没有 npm,没有 npm_modules 目录,没有 require()命令(即不支持 CommonJS 模块),也不需要 package.json 文件。
所有模块通过 URL 加载,比如 import { bar } from “https://foo.com/bar.ts"(绝对 URL)或 import { bar } from ‘./foo/bar.ts’(相对 URL)。因此,Deno 不需要一个中心化的模块储存系统,可以从任何地方加载模块。
(5)、Deno 内置了开发者需要的各种功能,不再需要外部工具。打包、格式清理、测试、安装、文档生成、linting、脚本编译成可执行文件等,都有专门命令。
执行 deno -h 或 deno help,就可以显示 Deno 支持的子命令。
|
|
十、TypeScript
TypeScript 是 JavaScript 的一个超集,主要提供了类型系统和对 ES6 的支持,它由 Microsoft 开发,代码开源于 GitHub 上。
它可以编译成纯 JavaScript。编译出来的 JavaScript 可以运行在任何浏览器上。TypeScript 编译工具可以运行在任何服务器和任何系统上。
1、TypeScript 的优势
(1)、TypeScript 增加了代码的可读性和可维护性
- 类型系统实际上是最好的文档,大部分的函数看看类型的定义就可以知道如何使用了
- 可以在编译阶段就发现大部分错误,这总比在运行时候出错好
- 增强了编辑器和 IDE 的功能,包括代码补全、接口提示、跳转到定义、代码重构等
(2)、TypeScript 非常包容
- TypeScript 是 JavaScript 的超集,.js 文件可以直接重命名为 .ts 即可
- 即使不显式的定义类型,也能够自动做出类型推论
- TypeScript 的类型系统是图灵完备的,可以定义从简单到复杂的几乎一切类型(后面会举一个复杂类型推断的例子)
- 兼容第三方库,即使第三方库不是用 TypeScript 写的,也可以编写单独的类型文件供 TypeScript 读取
2、安装 TypeScript
|
|
编译一个 TypeScript 文件:
|
|
3、TS 数据类型
(1)、基础类型
|
|
(2)、任意值
|
|
声明一个变量为任意值之后,对它的任何操作,返回的内容的类型都是任意值。
(3)、类型推论
以下代码虽然没有指定类型,但是会在编译的时候报错:
|
|
事实上,它等价于:
|
|
TypeScript 会在没有明确的指定类型的时候推测出一个类型,这就是类型推论。
(4)、联合类型
联合类型(Union Types)表示取值可以为多种类型中的一种。
|
|
(5)、接口
在 TypeScript 中,使用接口(Interfaces)来定义对象的类型。
|
|
接口少一些或者多一些属性会提示报错:
|
|
如果希望不要完全匹配一个形状,那么可以用可选属性:
|
|
(6)、数组的类型
可以使用「类型 + 方括号」来表示数组:
|
|
数组的项中不允许出现其他的类型:
|
|
数组的一些方法的参数也会根据数组在定义时约定的类型进行限制:
|
|
也可以使用数组泛型 Array<elemType>
来表示数组:
|
|
(7)、函数的类型
|
|
函数类型的重载
重载允许一个函数接受不同数量或类型的参数时,作出不同的处理。
比如,我们需要实现一个函数 reverse,输入数字 123 的时候,输出反转的数字 321,输入字符串 ‘hello’ 的时候,输出反转的字符串 ‘olleh’。
利用联合类型,我们可以这么实现:
|
|
然而这样有一个缺点,就是不能够精确的表达,输入为数字的时候,输出也应该为数字,输入为字符串的时候,输出也应该为字符串。
这时,我们可以使用重载定义多个 reverse 的函数类型:
|
|
4、类型断言
类型断言(Type Assertion)可以用来手动指定一个值的类型。
语法 值 as 类型
或 <类型>值
:
(1)、将一个联合类型断言为其中一个类型
|
|
(2)、将一个父类断言为更加具体的子类
|
|
(3)、将任何一个类型断言为 any
|
|
上面的例子中,我们需要将 window 上添加一个属性 foo,但 TypeScript 编译时会报错,提示我们 window 上不存在 foo 属性。
|
|
(4)、将 any 断言为一个具体的类型
|
|
5、字符串字面量类型
字符串字面量类型用来约束取值只能是某几个字符串中的一个。
|
|
6、枚举
枚举(Enum)类型用于取值被限定在一定范围内的场景,比如一周只能有七天,颜色限定为红绿蓝等;
|
|
7、泛型
泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。
(1)、简单的例子
实现一个函数 createArray,它可以创建一个指定长度的数组,同时将每一项都填充一个默认值:
|
|
这段代码编译不会报错,但是一个显而易见的缺陷是,它并没有准确的定义返回值的类型;
利用泛形可以解决这个问题:
|
|
(2)、多个类型参数
定义泛型的时候,可以一次定义多个类型参数:
|
|
我们定义了一个 swap 函数,用来交换输入的元组。
(3)、泛型约束
在函数内部使用泛型变量的时候,由于事先不知道它是哪种类型,所以不能随意的操作它的属性或方法:
|
|
上例中,泛型 T 不一定包含属性 length,所以编译的时候报错了。
这时,我们可以对泛型进行约束,只允许这个函数传入那些包含 length 属性的变量。这就是泛型约束
|
|
8、TypeScript 实用工具类型
(1)、Partial<T>
将 T 中所有属性转换为可选属性,返回的类型可以是 T 的任意子集。 这在需要支持接受部分属性的场景下非常有用:
|
|
源码:
|
|
(2)、Required<T>
通过将 T 的所有属性设置为必选属性来构造一个新的类型,与 Partial 相对。
|
|
源码:
|
|
(3)、Readonly<T>
将 T 中所有属性设置为只读:
|
|
源码:
|
|
(4)、Pick<T,K>
通过在 T 中抽取一组属性 K 构建一个新类型:
|
|
源码:
|
|
(5)、ReturnType<T>
返回 function 的返回值类型:
|
|
源码:
|
|
9、一个题目
TypeScript 的可玩性很高,特别是它的类型推断特别强大,参考 leetcode 的 一个题目:
|
|
答案:
|
|
十一、Node 包管理工具
node 有很多包管理工具,例如:npm
、yarn
、cnpm
、pnpm
,在这里主要用 npm
举例:
1、全局安装与本地安装
每个模块可以“全局安装”,也可以“本地安装”。“全局安装”指的是将一个模块安装到系统目录中,各个项目都可以调用。一般来说,全局安装只适用于工具模块,比如 eslint
和 typescript
。“本地安装”指的是将一个模块下载到当前项目的 node_modules 子目录,然后只有在项目目录之中,才能调用这个模块。
|
|
npm install 也支持直接输入 Github 代码库地址:
|
|
2、安装不同版本
install 命令总是安装模块的最新版本,如果要安装模块的特定版本,可以在模块名后面加上@和版本号。
|
|
3、更新与卸载 npm update,npm uninstall
npm update 命令可以更新本地安装的模块:
|
|
npm uninstall 命令,卸载已安装的模块:
|
|
4、node-modules 的困境
(1)、嵌套结构
npm
v3 之前的版本用的是嵌套结构,举个例子:
我们的模块 my-app
现在依赖了两个模块:buffer
、ignore
:
|
|
ignore
是一个纯 JS 模块,不依赖任何其他模块,而 buffer
又依赖了下面两个模块:base64-js
、 ieee754
。
|
|
那么,执行 npm install
后,得到的 node_modules
中模块目录结构就是下面这样的:
嵌套结构的优点很明显:
node_modules
的结构和package.json
结构一一对应,每次安装的目录结构都一致- 不会有版本冲突问题
但是坏处也显而易见:
- 在不同层级的依赖中,可能引用了同一个模块,导致大量冗余
- 嵌套可能非常深,在
Windows
系统中,文件路径最大长度为 260 个字符,嵌套过深可能导致不可预知的问题
在一个大型项目中,就会发现第三方库共同依赖了一些很基础的第三方库,如 lodash。你会发现你的 node_modules 里充满了各种重复版本的 lodash,造成了极大的空间浪费,也导致 npm install
很慢,这既是臭名昭著的 node_modules hell
为了解决上面的问题,npm
后来采用了扁平结构。
(2)、扁平结构
npm
在 3.x
版本做了一次较大更新。其将早期的嵌套结构改为扁平结构:
- 安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在
node_modules
根目录
还是上面的依赖结构,我们在执行 npm install 后将得到下面的目录结构:
此时我们若在模块中又依赖了 base64-js@1.0.1
版本:
|
|
- 当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的
node_modules
下安装该模块。
此时,我们在执行 npm install 后将得到下面的目录结构:
对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:
- 在当前模块路径下搜索
- 在当前模块 node_modules 路径下搜素
- 在上级模块的 node_modules 路径下搜索
- …
- 直到搜索到全局路径中的 node_modules
假设我们又依赖了一个包 buffer2@^5.4.3
,而它依赖了包 base64-js@1.0.3
,则此时的安装结构是下面这样的:
所以 npm 3.x
版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。
试想一下,你的 APP 假设没有依赖 base64-js@1.0.1
版本,而你同时依赖了依赖不同 base64-js
版本的 buffer
和 buffer2
。由于在执行 npm install
的时候,按照 package.json
里依赖的顺序依次解析,则 buffer
和 buffer2
在 package.json
的放置顺序则决定了 node_modules
的依赖结构。
为了解决 npm install
的不确定性问题,在 npm 5.x
版本新增了 package-lock.json
文件,而安装方式还沿用了 npm 3.x
的扁平化的方式。
(3)、package-lock.json 的作用
package-lock.json
的作用是锁定依赖结构,即只要你目录下有 package-lock.json
文件,那么你每次执行 npm install
后生成的 node_modules
目录结构一定是完全相同的。
例如,我们有如下的依赖结构:
|
|
在执行 npm install
后生成的 package-lock.json
如下:
|
|
另外,由于 package-lock.json
中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查,大大减少了网络请求。
但是潜在的冗余问题并没有完全解决。那参考一下别的语言是怎么做包管理的。
(3)、其他语言的包管理方式
实际上除了 node 的 npm,很少有其他的语言是需要每个项目都维护一个 node_modules 这种依赖(听说过其他语言有 node_modules hell 的问题吗),其他语言也很少有这种递归查找依赖的做法,所以其他语言很多都采用了全局 store 的管理系统。我们可以看一下 rust 是如何进行包管理的。
新建一个 rust 项目很简单,只需要运行:
|
|
其生成目录结构如下:
|
|
其中的 Cargo.toml 和 package.json 的功能几乎一致(相比 json,tom 支持注释),包括如下一些信息:
|
|
添加一个第三方依赖看看,与 npm 类似,cargo 的 dependencies 也支持 git 协议和 file 协议:
|
|
执行 build 安装依赖, 此时发现多了个 Cargo.lock
, 其类似于 package-lock.json
文件,里面包含了第三方库的及其依赖的确定性版本:
|
|
我们发现项目里并没有类似 node_modules
存放项目所有依赖的东西。事实上,cargo 将所有的第三方依赖的代码,都存放在了称为 cargo home
的目录里, 默认为 ~/.cargo
,它承担了类似中央仓库的功能。
(4)、解决方法
每个仓库都有一个中央仓库,管理项目的所有依赖。然后通软链接映射到目标文件目录,事实上, pnpm 包管理工具就是这么做的,参考如下代码:
package.json
|
|
使用 pnpm 安装相关依赖后,我们发现项目中存在 debug 的两个版本:
|
|
查看 node_modules 里的版本,我们发现区别于 npm, pnpm 是将不同版本放在同一层级里通过软链选择加载版本,而 npm 则是放在不同层级,依赖递归查找算法来选择版本:
这样即使出现版本冲突,只需要将各个模块进行链接即可,并不需要每个模块再进行重复安装模块。因为彻底的避免了包的重复问题,其节省了大量的空间和加快了安装速度。
以一个大型项目为例:
对比一下:
- pnpm: node_modules 大小 359M,安装耗时 20s
- yarn: node_modules 大小 1.2G,安装耗时 173s
差别非常显著。