农村的师傅的博客

一个迫于生计,无法放飞自我,导致喜欢上了前端开发,并即将成长为强者(指头发)的程序猿。

0%

JavaScript回顾-3

最近几个月闲来无事,想着自从毕业那几年之外,已经好久没有沉下心来好好看看js这门语言了,趁着年底的这几个月,我又重新找了本《JavaScript权威指南》第7版好好回顾了一下JavaScript这门语法,并且在阅读的过程中,收集了一下书中的重点知识以及根据自身疑问找到的一些资料,用以后续可能的回顾。

前言

JavaScript权威指南第7版这本书我在微信读书上,大概花费了30个小时读完了,总共跨越12天,平均差不多每天2到3个小时吧,理论上,读完这本书的天数其实应该更少的,不过,我在读的过程中,很多时候都是读读停停,因为我在整理部分重要的知识点,并且对于其中有疑问的内容都会结合AI来进行梳理和扩展,所以整个花费的天数跨度比较久。

在读这本书时,我感觉还是又比较不错的收获的,除了对于已知的内容,有了大概的了解之外和知识点重新复习之外,对于之前还没有那么深入或者只是知其然而不知其所以然的内容,也有了更好、更深入的理解。顺便在学习的过程中,也对其语言中的一些额外知识点有了扩展,感觉也是非常有意思的内容。

不完全知识点总结

JavaScript语言

  • 核心规范
    • 类型
      • 数字
      • 字符
      • 对象类型
      • 引用类型和基本类型
      • 类型转换
      • 类型判断
      • 相等比较
    • 语句和表达式
    • 操作符
    • 变量
      • 变量声明
    • 作用域
      • 全局作用域
      • 函数作用域
      • 块级作用域
        • 特殊的for循环迭代作用域
      • 模块作用域
    • 作用域链
    • 词法环境
    • 执行上下文
    • 函数
      • 闭包
      • this
      • 箭头函数
      • 构造函数
      • 原型和原型继承
      • class
    • 对象
      • 原型链
    • 数组
      • 数组性能优化
    • 模块
    • js标准库
      • set和map
      • 类型数组:Typearray
      • buffer
      • 正则表达式
      • 日期和时间
      • js错误对象
      • 序列化
      • 定时器
    • 迭代器和生成器
    • 异步JavaScript
      • 回调
      • promise期约
      • async/await
    • 元编程
      • 对象的属性特征:defineProperty
      • 反射API
      • 代理Proxy
  • 浏览器(宿主环境、客户端)
    • 事件
    • dom
    • web API
      • canvas、svg
      • 历史、导航
      • 网络
      • 存储
      • 工作线程
      • 消息传递
    • 浏览器加载和运行js脚本
    • 事件循环
    • 垃圾回收
    • 通用中断机制
  • node(宿主环境、服务端)
    • 异步、非阻塞、单线程、IO密集型
      • 网络任务
      • 文件任务
      • 数据库任务

本书中有很多东西讲的我感觉也算比较深入了,或者说,其内容只有那么多,所以才能在一本书中讲的那么深入,而一些涉及很多的功能、特性、API啥的,讲的就没有那么细致了,可能局限于篇幅吧,而且这里主要讲的是JavaScript,但是有一些东西在我看来对于前端非常重要,但是他也没有提到的,例如事件循环、词法作用域、作用域链、垃圾回收等等,怎么说呢,这对于熟悉JavaScript可能不是必要的,因为也许太深入了,但是对于深入掌握JavaScript和前端来说,确感觉必不可少。

总之,对于大概了解大部分JavaScript内容和基础来说,其内容足够,但是还有部分内容,是需要自己去扩展和深入的。

下面则是我在阅读过程中,从书中或者AI中提炼出来的一些知识内容,仅供参考,在需要的时也可以稍微帮自己回忆一下。

下面则是提炼的知识内容。

元编程

对象的属性特征

  • 可写、可枚举、可配置:它们这几个特征都是布尔值
    • 可配置(configurable)特性指定是否可以删除属性,以及是否可以修改属性的特性。
    • writable特性控制对value属性的修改,而configurable特性控制对其他特性的修改(也控制是否可以删除某个属性)
  • 对象字面量中定义的属性,或者通过常规赋值方式给对象定义的属性都可写、可枚举和可配置。
  • 数据属性
    • 拥有一个属性名和一个值
    • 其拥有4个特征:value值、可写、可枚举,可配置
  • 访问器属性
    • 没有value,因为其value取决于get方法,而可写方法取决于是否拥有set方法
    • 其拥有4个特征:get、set、可枚举、可配置
  • 用于查询和设置属性特性的JavaScript方法使用一个被称为属性描述符(property descriptor)的对象,这个对象用于描述属性的4个特性。
  • 要获得特定对象某个属性的属性描述符,可以调用Object.getOwnPropertyDescriptor,它仅对自有属性有效,对继承属性无效
  • 要设置属性的特性或者要创建一个具有指定特性的属性,可以调用Object.defineProperty()方法,传入要修改的对象、要创建或修改的属性的名字,以及属性描述符对象
  • 传给Object.defineProperty()的属性描述符不一定4个特性都包含。如果是创建新属性,那么省略的特性会取得false或undefined值。如果是修改已有的属性,那么省略的特性就不用修改。注意,这个方法只修改已经存在的自有属性,或者创建新的自有属性,不会修改继承的属性
    • 如果想一次性创建或修改多个属性,可以使用Object.defineProperties()。
    • 在调用Object.defineProperty()或Object.defineProperties()时如果违反一些规则就会抛出TypeError,这规则并非简单明了。
  • Object.assign()只复制可枚举属性和属性值,但不复制属性的特性。这个结果通常都是我们想要的,但是也要清楚这个结果意味着什么。比如,它意味着如果源对象有一个访问器属性,那么复制到目标对象的是获取函数的返回值,而不是获取函数本身。

对象的可扩展能力

  • 对象的可扩展(extensible)特性控制是否可以给对象添加新属性,即是否可扩展。普通JavaScript对象默认是可扩展的,但可以使用本节介绍的方法修改。
  • 要确定一个对象是否可扩展,把它传给Object.isExtensible()即可。
  • 要让一个对象不可扩展,把它传给Object.preventExtensions()即可。如此,如果再给该对象添加新属性,那么在严格模式下就会抛出TypeErrror,而在非严格模式下则会静默失败。此外,修改不可扩展对象的原型始终都会抛出TypeError。
  • 注意,把对象修改为不可扩展是不可逆的(即无法再将其改回可扩展)。也要注意,调用Object.preventExtensions()只会影响对象本身的可扩展能力。如果给一个不可扩展对象的原型添加了新属性,则这个不可扩展对象仍然会继承这些新属性。
  • 封存对象
    • Object.seal()类似Object.preventExtensions(),但除了让对象不可扩展,它也会让对象的所有自有属性不可配置。这意味着不能给对象添加新属性,也不能删除或配置已有属性。
    • 不过,可写的已有属性依然可写。没有办法“解封”已被“封存”的对象。可以使用Object.isSealed()确定对象是否被封存。
  • 冻结对象
    • Object.freeze()会更严密地“锁定”对象。除了让对象不可扩展,让它的属性不可配置,该函数还会把对象的全部自有属性变成只读的(如果对象有访问器属性,且该访问器属性有设置方法,则这些属性不会受影响,仍然可以调用它们给属性赋值)。
    • 使用Object.isFrozen()确定对象是否被冻结。
  • 对于Object.seal()和Object.freeze(),关键在于理解它们只影响传给自己的对象,而不会影响该对象的原型。如果你想彻底锁定一个对象,那可能也需要封存或冻结其原型链上的对象。

公认符号(symbol)

这公认符号,让我看到了其对于js行为的自定义控制能力

  • 所谓“公认符号”,其实就是Symbol()工厂函数的一组属性,也就是一组符号值。通过这些符号值,我们可以控制JavaScript对象和类的某些底层行为。其实就是指一些内部定义的名字
  • Symbol.iterator和Symbol.asyncIterator符号可以让对象或类把自己变成可迭代对象和异步可迭代对象
  • Symbol.hasInstance:在ES6中,如果instanceof的右侧是一个有[Symbol.hasInstance]方法的对象,那么就会以左侧的值作为参数来调用这个方法并返回这个方法的值,返回值会被转换为布尔值,变成intanceof操作符的值。用处不大
  • Symbol.toStringTag:在ES6之前,Object.prototype.toString()这种特殊的用法只对内置类型的实例有效。如果你对自己定义的类的实例调用classof(),那只能得到“Object”。而在ES6中,Object.prototype.toString()会查找自己参数中有没有一个属性的符号名是Symbol.toStringTag。如果有这样一个属性,则使用这个属性的值作为输出。
  • Symbol.species:一个神奇的属性,用来控制你创建例如Array的子类时,其子类使用Array原型的一些方法,例如map这些返回的是Array类还是子类。
  • Symbol.isConcatSpreadable:在ES6之前,concat()只使用Array.isArray()确定是否将某个值作为数组来对待。在ES6中,这个算法进行了一些调整:如果concat()的参数(或this值)是对象且有一个Symbol.isConcatSpreadable符号属性,那么就根据这个属性的布尔值来确定是否应该“展开”参数。
  • Symbol.toPrimitive:在ES6中,公认符号Symbol.toPrimitive允许我们覆盖这个默认的对象到原始值的转换行为,让我们完全控制自己的类实例如何转换为原始值。定义该符号的方法,并接受一个参数:
    • 如果这个参数是”string”,则表示JavaScript是在一个预期或偏好(但不是必需)为字符串的上下文中做这个转换。比如,把对象作为字符串插值到一个模板字面量中。
    • 如果这个参数是”number”,则表示JavaScript是在一个预期或偏好(但不是必需)为数值的上下文中做这个转换。在通过<或>操作符比较对象,或者使用算术操作符-或*来计算对象时属于这种情况。
    • 如果这个参数是”default”,则表示JavaScript做这个转换的上下文可以接受数值也可以接受字符串。在使用+、==或!=操作符时就是这样。
    • 如果你希望自己类的实例可以通过<或>来比较,那么就需要给这个类定义一个[Symbol.toPrimitive]方法。

模板标签

类似于这种写法的东西:"gql``"

  • 可以把定义使用标签化模板字面量的标签函数看成是元编程,因为标签化模板经常用于定义DSL(Domain-Specific Language,领域专用语言)。而定义新的标签函数类似于给JavaScript添加新语法。

反射API(Reflect)

  • 与Math对象类似,Reflect对象不是类,它的属性只是定义了一组相关函数。这些ES6添加的函数为“反射”对象及其属性定义了一套API。
  • Reflect对象在同一个命名空间里定义了一组便捷函数,这些函数可以模拟核心语言语法的行为,复制各种既有对象功能的特性。它并没有添加新特性和功能
  • Reflect函数虽然没有提供新特性,但它们用一个方便的API筛选出了一组特性。重点在于,这组Reflect函数一对一地映射了后续的Proxy处理器方法(即名称和函数签名都一模一样)这也是它的一个用处了。
  • Reflect和普通的方法行为存在一些差异
    • 行为差异,例如Reflect.defineProperty()与Object.defineProperty()非常类似,但在成功时返回true,失败时返回false
  • Reflect.ownKeys:这个函数返回包含对象o属性名的数组,如果o不是对象则抛出TypeError。返回数组中的名字可能是字符串或符号。调用这个函数类似于调用Object.getOwn PropertyNames()和Object.getOwnPropertySymbols()并将它们返回的结果组合起来。
  • 注意get和set的最后一个参数:receiver
    • 它的作用就是为了确保赋值上下文的正确绑定,否则你的代理可能会出现和普通赋值不一样的行为,即普通对象赋值:obj.title = xxx的默认行为,其本身的规范行为会为obj对象本身添加属性,而不是对原型上的title进行修改,而Reflect.set(target, key, newVal, receiver)中receiver就是为了确保能够和这个行为一致。
    • 因为规范有一个很神奇的东西就是,为普通对象的进行赋值时(对应Reflect.set(target, key, newVal, receiver))操作,如果对象这个上没有这个属性,那么会调用父对象内部的[[Set]]方法,当然,这个设计是有原因的。感兴趣可以了解一下。

代理(Proxy)

  • ES6及之后版本中的Proxy类是JavaScript中最强大的元编程特性。使用它可以修改JavaScript对象的基础行为,拦截和修改对象操作的大部分行为。
  • Proxy类提供了一种途径,让我们能够自己实现基础操作,并创建具有普通对象无法企及能力的代理对象。
  • 通过new Proxy得到的代理对象没有自己的状态或行为。每次对它执行某个操作(读属性、写属性、定义新属性、查询原型、把它作为函数调用)时,它只会把相应的操作发送给处理器对象或目标对象
    • 我们说代理是一种没有自己的行为的对象,因为它们只负责把所有操作转发给处理器对象和目标对象。其实这么说也不全对:转发完操作后,Proxy类会对结果执行合理性检查,以确保不违背重要的JavaScript不变式(invariant)。如果检查发现违背了,代理会抛出TypeError(不让操作继续)
    • 代理处理器API允许我们定义存在重要不一致的对象,但在这种情况下,Proxy类本身会阻止我们创建不一致得离谱的代理对象。这说法。。。
    • 例如对一个不可扩展的对象(isExtensible()返回为true)进行代理,你却在处理器对象中设置isExtensible返回一个true,此时就违背了不等式,从而抛出异常。
    • Proxy还遵循其他一些不变式,几乎都与不可扩展的目标对象和目标对象上不可配置的属性有关。
  • 代理对象支持的操作就是反射API定义的那些操作。即代理对象能代理的操作其实就是Reflect定义的那些方法,名字都一样。
  • 对所有基础操作,代理都这样处理:如果处理器对象上存在对应方法,代理就调用该方法执行相应操作(这里方法的名字和签名与反射函数完全相同)。如果处理器对象上不存在对应方法,则代理就在目标对象上执行基础操作。
    • 如果处理器对象是空的,那代理本质上就是目标对象的一个透明包装器
    • 假如代理对象没有指定 get() 拦截函数,那么当我们通过代理对象访问属性值时,代理对象的内部方法 [[Get]] 会调用原始对象的内部方法 [[Get]] 来获取属性值,这其实就是代理透明性质。
  • 创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身(proxy对象)的内部方法和行为的,而不是用来指定被代理对象(originObj)的内部方法和行为的
  • 创建可撤销代理不使用Proxy()构造函数,而要使用Proxy.revocable()工厂函数。这个函数返回一个对象,其中包含代理对象和一个revoke()函数。一旦调用revoke()函数,代理立即失效。
    • 这里的关键是可撤销代理充当了某种代码隔离的机制,而这可以在我们使用不信任的第三方库时派上用场。如果必须向一个不受自己控制的库传一个函数,则可以给它传一个可撤销代理,然后在使用完这个库之后撤销代理。这样可以防止第三方库持有对你函数的引用,在你不知道的时候调用它。
  • 通常使用代理的技术是为它定义处理器方法,拦截对象操作,但仍然把操作委托给目标对象。反射API的函数与处理器方法具有完全相同的签名,从而实现这种委托也很容易。
  • 可以对代理对象进行嵌套代理
  • 为一个代理对象设置原型时,例如Object.setPrototypeOf方法,那么设置的是代理对象代理的原始对象的原型(如果代理对象没有setPrototypeOf钩子的话)
    • Proxy实例本身是有原型的,不过对于开发者来说不可见(控制台也不会显示)

注意事项

  • 只有「复合赋值 / 自增自减」这类「先读再写」的操作,才会同时触发 get 和 set,纯赋值操作只触发 set。即普通的obj.a = 124这种,是不会触发obj代理对象的get钩子的
    • obj.a = 124 的核心是「向 obj 的 a 属性写入值 124」,引擎的执行步骤只有两步:
      • 找到 obj 这个对象的 a 属性的「赋值目标位置」;(并非读取obj.a的值)
      • 将 124 写入该位置。
  • 代理对象Proxy本身是不会嵌套的,即该对象的一个属性也是一个对象时,那么该对象本身没有被代理,还是一个普通对象

浏览器的js(Web API)

Node有自己唯一的实现,也有自己唯一的官方文档。相对而言,Web API则是通过主要浏览器厂商的共识来定义的。Web API的官方文档就是一系列规范,这些规范的目标读者是实现它们的C++程序员,而不是使用这些API的JavaScript程序员。

现在MDN web文档已经成为Web API的一个靠谱、全面的文档来源,如果你要了解或者查找一个Web API,去MDN要比翻一本书的效率要高得多,而且还能配合AI提问快速解答你心目中的疑惑

  • 这本书之前的版本一直在尝试全面介绍由浏览器定义的所有JavaScript API,结果造成这本书在十年前就已经非常厚了。Web API的数量和复杂性持续增长,作者不再认为有必要在一本书中全部介绍它们。
  • 除了其web API的数量和特性非常之多之外,其中还需要注意区分很多API已经被废弃或者过时了。
  • 其实很多API、功能特性之类的,其实都有很好的上位替代了,理论上如果你不需要的话,其实没有必要花费时间和精力去记忆或者学习其中的特性、功能、和其他方案的差异,其实关于方案的对比,理论上来说,如果不是各有优势,理论上没必要再去分析和学习了。

web编程基础

  • 在非模块脚本中,情况完全不同。如果在顶级脚本中定义了一个常量、变量、函数或类,则该声明将对同一文档中的所有脚本可见。如果一个脚本定义了函数f(),另一个脚本定义了类C,第三个脚本无须采取任何导入操作即可调用该函数和实例化该类。
  • 捕获全局错误:定义onerror和onunhandledrejection处理程序

事件

  • 浏览器会在文档、浏览器或者某些元素或与之关联的对象发生某些值得关注的事情时生成事件。
    • 这并非Web编程的专利,任意具有图形用户界面的应用都是这样设计的。换句话说,界面就在那里等待用户与之交互(也可以说,它们在等待事件发生),然后给出响应。
  • 事件模型
    • 事件类型:事件类型是一个字符串,表示发生了什么事件。
    • 事件对象:事件目标是一个对象,而事件就发生在该对象上或者事件与该对象有关。
      • 所有事件对象都有type和target属性,分别表示事件类型和事件目标。每种事件类型都为相关的事件对象定义了一组属性。如果有需要设计事件的,可以参考一下这个数据格式
    • 事件处理程序:注册的事件触发时的响应程序
    • 事件传播:冒泡和捕获
      • 第一阶段:事件捕获,捕获阶段持续到事件目标父元素的捕获事件处理程序被调用。
        • 注册在事件目标本身的捕获事件处理程序不会在这个阶段被调用
      • 第二阶段:调用目标对象本身的事件处理函数
      • 第三阶段:事件冒泡
    • 事件默认行为:例如a标签的跳转页面
  • 事件传播:事件捕获或者事件冒泡
    • 事件传播是一个过程,浏览器会决定在这个过程中哪些对象触发事件处理程序。对于Window对象上的“load”或Worker对象上的“message”等特定于一个对象的事件,不需要传播。但对于发生在HTML文档中的某些事件,则会“冒泡”(bubble)到文档根元素。
    • 如果事件的目标是Window或其他独立对象,浏览器对这个事件的响应就是简单地调用该对象上对应的事件处理程序(即Window上的事件或者其他js对象的事件,事件不会传播)。如果事件目标是Document或其他文档元素,则会触发事件传播
      • 多数在文档元素上发生的事件都会冒泡。明显的例外是“focus”“blur”和“scroll”事件。
      • 文档元素的“load”事件冒泡,但到Document对象就会停止冒泡,不会传播到Window对象(Window对象的“load”事件处理程序只会在整个文档加载完毕后才被触发)。所以不是所有事件都能冒泡到最上层的window对象的
    • 事件捕获提供了把事件发送到目标之前先行处理的机会。捕获事件处理程序可用于调试,或者使用事件取消技术过滤事件,让目标事件处理程序永远不会被调用。事件捕获最常见的用途是处理鼠标拖动,因为鼠标运动事件需要被拖动的对象来处理,而不是让位于其上的文档元素来处理。
  • 事件取消:浏览器对很多用户事件都会作出响应,无论你是否在代码中指定。你可以使用preventDefault()方法阻止其浏览器的默认行为
  • 阻止传播:可以调用事件对象的stopPropagation()方法,取消事件传播。stopPropagation()可以在捕获阶段、在事件目标本身,以及在冒泡阶段起作用。
    • stopImmediatePropagation()与stopPropagation()类似,只不过它也会阻止在同一个对象上注册的后续事件处理程序的执行。
  • addEventListener
    • 是否冒泡、是否只执行一次、是否会调用阻止默认程序(例如移动端的平滑滚动可能会用到)
  • 事件对象有一些有意思的属性,例如isTrusted判断是浏览器派发还是js派发的
  • 在事件处理程序的函数体中,this关键字引用的是注册事件处理程序的对象(哪个元素注册的事件,其this就是哪个元素,注意,不要用箭头函数去绑定事件,因为这样其this就不准了)
  • 一个事件目标可能会为一种事件注册多个处理程序。当这种事件发生时,浏览器会按照注册处理程序的顺序调用它们。有意思的是,即便混合使用addEventListener()注册的事件处理程序和在对象属性onclick上注册的事件处理程序,结果仍然如此。
  • window和document的addEventListener不是同一个,其中window和document都不是同一个对象
    • window是事件流的最顶层,而其下一层则是document,所以它们添加的事件处理函数,添加到的都是不同的dom节点
    • 它们支持的事件也有差距,例如load事件只有在window上
  • 派发自定义事件:如果一个JavaScript对象有addEventListener()方法,那它就是一个“事件目标”。这意味着该对象也有一个dispatchEvent()方法。可以通过CustomEvent()构造函数创建自定义事件对象,然后再把它传给dispatchEvent()
  • 事件优化
  • node的事件模型和浏览器的事件模型
    • 浏览器的事件模型,其触发时,占用的是主线程吗?和js互斥吗?理论上是互斥的,毕竟js执行时,浏览器页面无法响应页面
    • node的事件和浏览器的事件有差异吗?

dom

说实话,dom的相关的内容,前端很少问。但是可以考虑详细了解dom本身。

  • 查询dom
    • 与querySelectorAll()不同的是,这些老式选择方法返回的NodeList是“活的”。所谓“活的”,指的是这些NodeList的length属性和其中包含的元素会随着文档内容或结构的变化而变化。
  • 遍历dom:祖先、后代
  • dom属性
    • node和element
      • 所有 Element 都是 Node,但 Node 不一定是 Element;
      • Node 定义了所有 DOM 节点的通用属性 / 方法(如 nodeType、parentNode、appendChild);
      • Element 继承自 Node,并扩展了「标签专属」的能力(如 id、className、getAttribute)。
    • 有时候在HTML元素上附加一些信息很有用,因为JavaScript代码在选择并操作相应的元素时可以使用这些信息。在HTML中,任何以前缀“data-”开头的小写属性都被认为是有效的,可以将它们用于任何目的。这些“数据集”(dataset)属性不影响它们所在元素的展示,在保证文档正确性的前提下定义了一种附加额外数据的标准方式。
  • 获取和修改dom的内容
  • 修改文档结构:创建、插入、删除等
  • 操作css
    • DOM在所有Element对象上都定义了对应的style属性(等同于设置行内样式)。但与大多数镜像属性不同,这个style属性不是字符串,而是CSSStyleDeclaration对象,是对HTML中作为style属性值的CSS样式文本解析之后得到的一个表示:在读取元素的style属性时,应该知道它只表示元素的行内样式,而多数元素的多数样式都是在样式表中指定的,不是写在行内的。
    • 计算样式:元素的计算样式(computed style)是浏览器根据一个元素的行内样式和所有样式表中适用的样式规则导出(或计算得到)的一组属性值,浏览器实际上使用这组属性值来显示该元素
      • 计算样式同样以CSSStyleDeclaration对象表示。但与行内样式不同的是,计算样式是只读的,不能修改计算样式,但表示一个元素计算样式的CSSStyleDeclaration对象可以让你知道浏览器在渲染该元素时,使用了哪些属性和值。
      • 计算样式的属性是绝对值,百分比和点等相对单位都被转换成了绝对值。任何指定大小的属性(如外边距大小和字体大小)都将以像素度量。相应的值会包含“px”后缀,虽然还需要解析,但不用考虑解析或转换其他单位。值为颜色的属性将以“rgb()”或“rgb()”格式返回
      • 简写属性不会被计算,只有它们代表的基础属性会被计算
      • 计算样式比较难说,查询它们并一定总能得到想要的信息。后续介绍了一个更简单易用的替代方案。

重点

  • 什么是dom
    • 文档对象模型,有宿主环境提供的一组表示html页面内容的js API,js可以基于该文档对象模型来操作页面文档的内容。
    • html的页面本质是一个树结构,其根节点就是document,而文档对象模块中的API也是将页面视为一个树来进行操作的。
  • dom和js的关系
  • 安全:使用这些HTML API时,一定要注意永远不要把用户输入直接插到文档中。如果这样做,恶意用户可能会将他们的脚本插入你的应用。

为什么说操作dom的性能很慢?

  • 因为dom是对页面的描述,其中虽然也是一个js对象,但是其本身非常庞大(属性和方法),操作它本身就比一般的对象要慢不少。
  • 且它本身并非纯粹的js对象,而是页面元素的映射,他的API需要操作的是渲染引擎(JS 操作 DOM 本质是「跨模块通信(js和渲染引擎) + 线程阻塞 + 重排重绘」,三重性能损耗叠加,才让 DOM 操作显得「极慢」。)
  • 同时如果是大量dom,那么其垃圾回收除了js引擎还有渲染引擎的回收,相比普通js是双倍
  • 操作dom可能触发重绘重排:当 DOM 的几何属性发生变化(元素的宽高、位置、边距、尺寸、显示隐藏),或者页面布局发生变化时,浏览器需要重新计算所有受影响元素的「布局信息」(位置、大小),这个过程就是「重排」。
    • 渲染树的节点是相互关联的,一个元素的布局变化,会「连锁反应」影响其父元素、子元素、兄弟元素的布局,浏览器需要重新计算整个渲染树的布局信息,这个过程是 CPU 密集型操作,耗时毫秒级,是 DOM 操作中最大的性能开销来源。

文档几何和滚动

  • 今天的4K显示器和“视网膜”屏的分辨率已经非常高,因此又分出了软件像素与硬件像素的概念。那么一个CSS像素(也就是客户端JavaScript中的像素),实际上可能相当于多个设备像素。Window对象的devicePixelRatio属性表示多少设备像素对应一个软件像素。比如,设备像素比(dpr,device pixel ratio)为2,意味着每个软件像素实际上是一个2×2硬件像素的网格。

web组件

  • Web组件是在JavaScript中定义的,因此要在HTML中使用Web组件,需要包含定义该组件的JavaScript文件。
  • 主要涉及相对比较新的三个Web标准。这些Web标准允许JavaScript使用新标签扩展HTML,扩展后的标签就是自成一体的、可重用的UI组件。

svg

  • SVG(Scalable Vector Graphics,可伸缩矢量图形)是一种图片格式。名字中的“矢量”代表着它与GIF、JPEG、PNG等指定像素值矩阵的光栅(raster)图片格式有着根本的不同。SVG“图片”是一种对绘制期望图形的精确的、分辨率无关(因而“可伸缩”)的描述。
  • 要注意SVG本身的语法规则很多,还是比较复杂的。除了简单的图形绘制语法,SVG还支持任意曲线、文本和动画。SVG图形甚至可以与JavaScript脚本和CSS样式表组合,以添加行为和表现信息。

canvas

  • canvas API与SVG的主要区别在于使用画布(canvas)绘图要调用方法,而使用SVG创建图形则需要构建XML元素树。这两种绘图手段同样强大,而且可以相互模拟。
  • 两种绘图手段同样强大,而且可以相互模拟。但从表面上来看,这两种手段迥然不同,又有各自的优缺点。比如,修改SVG图形很简单,可能只需从描述中删除元素即可。而要从同样的<canvas>图形中删除元素通常需要先擦掉图形再重新绘制。由于画布绘图API是基于JavaScript的,而且相对比较简洁(不像SVG语法那么复杂)
  • HTML中指定canvas的width和height属性会确定画布的实际像素数。每个像素在内存里会分配4个字节,因此如果width和height都是100,则画布在内存中会用40000个字节来表示10000个像素。
  • 此外,HTML的width和height属性也指定了画布在屏幕上(以CSS像素)显示的默认大小。如果window.devicePixelRatio是2,则100×100 CSS像素实际上对应40000个硬件像素。当画布内容绘制到屏幕上时,内存中的10000个像素需要放大为屏幕上的40000个物理像素,这意味着你看到的图形会变模糊
    • 为优化图片质量,不要在HTML中使用width和height属性设置画布的屏幕大小。而要使用CSS的样式属性width和height来设置画布在屏幕上的预期大小。然后在通过JavaScript开始绘制前,再将画布对象的width和height属性设置为CSS像素数乘以window.devicePixelRatio。
    • 其实这里的本质是因为canvas标签的width会控制其css样式的大小,同时也会控制了canvas画布在在内存中分配的像素大小。

位置、历史、导航

位置:

  • Window和Document对象的location属性引用的都是Location对象,该对象表示当前窗口显示文档的URL,也提供了在窗口中加载新文档的API
  • Location对象与URL对象(参见11.9节)非常相似,可以使用protocol、hostname、port和path访问当前文档URL的不同部分。而href属性以字符串形式返回整个URL
  • URL对象有一个searchParams属性,是解析search属性之后的一种表示。Location对象没有searchParams属性,但如果想解析window.location.search,可以直接使用Location对象创建一个URL对象,然后访问URL对象的searchParams
  • 如果给window.location或document.location赋值一个字符串,则该字符串将被解释为一个URL,且浏览器会加载它,从而用新文档替换当前文档
  • Location对象的replace()方法倒是非常有用。在给replace()传入一个字符串时,字符串会被当作URL解析,并导致浏览器加载新页面,跟使用assign()一样。区别在于replace()会在浏览器的历史记录中替换当前文档。
    • 如果文档A中的脚本通过设置location属性或调用assign()加载了文档B,然后用户单击了浏览器的“后退”按钮,浏览器会返回到文档A。如果你使用的是replace(),则文档A会从浏览器历史中擦除。当用户单击“后退”按钮时,浏览器会返回显示文档A之前显示的文档。
  • Location对象也定义了reload()方法,调用该方法会让浏览器重新加载当前文档。

历史

  • Window对象的history属性引用的是窗口的History对象。History对象将窗口的浏览历史建模为文档和文档状态的列表。
  • History对象的back()和forward()方法就像浏览器的“后退”和“前进”按钮,可以让浏览器在浏览历史中后退或前进一步。另一个方法go()接收一个整数参数,可以在历史列表中前进(正整数)或后退(负整数)任意个页面
  • 如果窗口包含子窗口(如iframe元素),子窗口的浏览历史会按时间顺序与主窗口历史交替。这意味着在主窗口中调用history.back(),可能导致某个子窗口后退到前一个显示的文档,而主窗口则维持当前状态不变。
    • 也就是主窗口和子窗口共用同一个历史记录
  • 第一种管理浏览历史的技术是使用location.hash和“hashchange”事件。
    • location.hash属性用于设置URL的片段标识符,通常用于指定要滚动到的文档区域的ID。但location.hash不一定必须是元素ID,也可以将它设置为任意字符串。只要不是某个元素碰巧有该字符串ID,浏览器就不会在设置hash属性时滚动
    • 设置location.hash属性会更新地址栏中显示的URL,而且更重要的是,还会在浏览器历史列表中添加一条记录
    • 只要文档的片段标识符改变,浏览器就会在Window对象上触发“hashchange”事件。显式设置location.hash也会触发“hashchange”事件。
    • 对Location对象的这个修改会在浏览器的浏览历史中创建一条新记录。因此如果用户单击了“后退”按钮,浏览器会返回设置location.hash之前的URL。但这意味着片段标识符又改变了,因此又会触发另一个“hashchange”事件。换句话说,只要你可以为应用的每个可能的状态创建唯一的片段标识符,“hashchange”事件就能够在用户向后或向前导航浏览历史时给你发送通知。
    • 要使用这种历史管理机制,需要把渲染应用“页面”必需的状态信息编码为一个可以作为片段标识符的短字符串。
  • 第二种就是history API:这种更可靠的历史管理技术是建立在history.pushState()方法和“popstate”事件基础上的
    • 当Web应用进入一个新状态时,它会调用history.pushState(),向浏览器历史中添加一个表示该状态的对象。如果用户单击“后退”按钮,浏览器会触发携带该保存的状态对象的“popstate”事件,应用使用该对象重建其之前的状态。除了保存的状态对象,应用也可以为每个状态都保存一个URL,这样可以方便用户将URL加入书签和分享应用内部状态的链接。
    • 在用户使用“后退”或“前进”按钮导航到保存的历史状态时,浏览器会在Window对象上触发“popstate”事件。与之关联的事件对象有一个名为state的属性,其中包含当初你通过pushState()传入的状态对象的副本(通过结构化克隆)

网络

fetch

  • 基于期约的fetch()方法可以发送HTTP和HTTPS请求。尽量来说,可以替代掉XHR了,如果不是特别需要,则可以不需要XHR,不过理论上我们应该还是会使用axios之类的请求库的。
  • fetch()返回的期约解决为一个Response对象。这个对象的status属性是HTTP状态码,如表示成功的200或表示“Not Found”的404(statusText中则是与数值状态码对应的标准英文描述)。更方便的是Response对象的ok属性,它在status为200或在200和299之间时是true,在其他情况下是false。
  • 当服务器开始发送响应时,fetch()只要一收到HTTP状态码和响应头就会解决它的期约,但此时通常还没收到完整的响应体。
  • Response对象的headers属性是一个Headers对象。使用它的has()方法可以测试某个头部是否存在,使用它的get()方法可以取得某个头部的值。
  • 如果浏览器响应了fetch()请求,那么返回的期约就会以一个Response对象兑现,包括响应404 Not Found和500 Internal Server Error。fetch()只在自己根本联系不到服务器时才会拒绝自己返回的期约。如果用户的计算机断网了、服务器不响应了,或者URL指定的主机不存在,才会发生这种情况。
  • 使用Response对象的json()或text()方法,并返回它们返回的期约对象,从而获取请求的数据。除了这个还有其他API获取blob或者arrayBuffer
  • 除了分别以某种形式返回完整响应体的异步响应方法,还可以流式访问响应体。在需要分块处理通过网络接收到的响应时可以采取这种方式。不过,流式访问响应体也可以用于显示进度条,以便用户看到下载进度。
  • Response对象的body属性是一个ReadableStream对象。如果已经调用了text()或json()等读取、解析和返回响应体的方法,那么bodyUsed属性会变成true,表示body流已经读完了。如果bodyUsed属性是false,那就意味着该流尚未被读取。
    • 所以这些返回完整响应体的异步响应方法返回的都是一个期约。其本质上是在读取一个流
    • 可以在response.body上调用getReader()获取流读取器对象,然后通过这个读取器对象的read()方法异步从流中读取文本块。
  • 跨域:在通过fetch()请求跨源URL时,浏览器会为请求添加一个“Origin”头部(且不允许通过headers属性覆盖它的值)以告知服务器这个请求来自不同源的文档。如果服务器对这个请求的响应中包含恰当的“Access-Control-Allow-Origin”头部,则请求可以继续。否则,如果服务器没有明确允许请求,则fetch()返回的期约会被拒绝。
  • 中断请求
    • fetch API支持使用AbortControllerAbortSignal类来中断请求(这两个类定义了通用的中断机制,也能在其他API中使用)。

SSE:Server-Sent Events:为HTTP“轮询”技术提供的基于事件的便利接口,让Web服务器可以一直保持连接打开,以便随时向客户端发送数据。

  • 此特性在webWorker中可用
  • 注意,sse并不是基于轮询的,而是可以做到和轮询类似的工作,但是其性能、实时性、API易用性都比轮询要好。
  • 底层基于 HTTP 「持久连接 + 流式响应」实现,是比轮询更高效的单向服务端推送方案;很早就有的方案了,基本所有主流浏览器都支持(除了IE)
  • 轮询
    • 短轮询:客户端定时(如 1s)发送 HTTP 请求 → 服务端立即响应 → 连接关闭 → 下次定时重复
    • 长轮询:客户端发送请求 → 服务端 HOLD 连接(不立即响应)→ 有数据时响应 / 超时关闭 → 客户端立即重连;例如60秒一个循环这种
    • 核心问题:每次请求 / 响应都有 HTTP 头开销,连接频繁建立 / 关闭,服务端压力大。
  • SSE:利用 HTTP 1.1 的「持久连接(Keep-Alive)」和「文本流(text/event-stream)」特性,实现单条 HTTP 连接的持续流式推送。
    • 客户端发起1 次HTTP GET 请求,请求头指定 Accept: text/event-stream
    • 服务端返回响应头 Content-Type: text/event-stream; charset=utf-8,并设置 Cache-Control: no-cache、Connection: keep-alive;
    • 连接不关闭,服务端可通过「特定格式的文本流」持续向客户端推送数据(一行行发送,无需等待响应完成);
    • 客户端通过 EventSource API 监听流数据自动处理重连(默认断连后 3 秒重试)
  • 与 WebSocket 的对比,不是替代,而是补充
    • SSE 适合「服务端单向推送、无需客户端回传数据、轻量级、低实时性要求」的场景
    • 在「服务端单向推送」场景下,SSE 比 WebSocket 开发成本低、资源占用少,仍是当前前端实时开发的重要选择(与 WebSocket 互补,而非替代)。
    • 告警、实时数据更新(只读)
    • SSE的一个典型应用是类似在线聊天一样的多用户协作。聊天客户端可以使用fetch()把消息发送到聊天室,通过EventSource对象订阅聊天信息流。这个也有点意思,感觉也是一个不错的方案。
  • 只要客户端首次建立 SSE 连接后,没有出现网络中断、服务端主动关闭、客户端手动关闭等 “意外”,这条基于 HTTP 的持久连接会一直保持,客户端可长期等待并接收服务端的流式数据推送 —— 这正是 SSE 区别于 HTTP 轮询的核心优势(单次连接、持续通信)。
    • SSE 的持久连接依赖服务端主动 “hold 住连接”
      • 服务端在数据推送完后,也不关闭连接
      • 部分服务端 / 网关会有 “空闲连接超时” 机制(如 Nginx 默认 60s 空闲断开),需手动配置长超时(如 proxy_read_timeout 86400s;),或服务端定时发送 “心跳消息”(空数据 / 注释消息)维持连接
    • 客户端标签页进入 “后台休眠”(如浏览器最小化、移动端息屏),可能暂停 SSE 连接,恢复后 EventSource 会自动重连
  • SSE方案的服务器端的成本可能较高,因为服务器必须对它的所有客户端都维护一个活动连接

websocket

  • WebSocket是一个网络协议,不是HTTP但设计时考虑了与HTTP互操作。它定义了一个异步消息传递API,即客户端和服务器可以通过与TCP网络套接口类似的方式相互发送和接收消息。
  • 虽然WebSocket API是传统的低级网络套接口,但标识连接端点的并不是IP地址和端口。在使用WebSocket协议连接服务时,要通过URL指定该服务,就像使用Web服务一样。
    • 浏览器通常会限制只能在安全的https连接加载的页面中使用WebSocket
  • 特点
    • 全双工:双方可以同时发送和接收,就像打电话或使用现代网络聊天
    • 双向通信
    • 长连接
    • 文本数据或者二进制数据
  • 连接(http协议升级)
    • 要建立WebSocket连接,浏览器首先要建立一个HTTP连接
    • 并向服务器发送Upgrade: websocket请求头,请求把连接从HTTP协议切换为WebSocket协议。
      • 第一步:基于已建立的 TCP 连接(与普通 HTTP 共享 TCP 三次握手),和普通的http接口的tcp握手一致;
      • 在 HTTP 请求 - 响应的握手阶段完成(通过 Upgrade 头申请,101 响应确认);
        • 发送特殊的http get请求,用来表示该请求是http升级,此时这仍是标准的 HTTP 请求,但头信息明确告知服务端 “我要升级为 WebSocket”。
        • 服务端处理成功后,返回HTTP 101 Switching Protocols 响应,表明升级成功
      • 升级后复用 TCP 连接,替换上层协议为 WebSocket,原 HTTP 连接终止(无 “HTTP 连接完成后再升级” 的过程)。
    • 创建WebSocket时,连接过程会自动开始。连接是异步的,需要根据下面的状态和事件进行判断
    • webSocket的接口对象的readyState属性表明了当前的连接状态。
      • WebSocket.CONNECTING:WebSocket正在连接。
      • WebSocket.OPEN:WebSocket已经连接,可以通信了。
      • WebSocket.CLOSING:WebSocket正在关闭。
      • WebSocket.CLOSED:WebSocket已经关闭,不能再通信了。初始连接失败时也是这个状态。
    • 如果WebSocket连接发生了协议错误或其他错误,WebSocket对象会触发“error”事件。可以通过设置onerror来定义事件处理程序
  • 这意味着,要在客户端JavaScript中使用WebSocket,服务器必须遵循WebSocket协议,按照该协议发送和接收数据。
  • 发送和接受消息
    • send()方法会把要发送的消息保存在缓冲区,并在实际发送前返回。WebSocket对象的bufferedAmount属性保存着还在缓冲区未发送的字节数(奇怪的是,当这个值变成0时WebSocket居然不触发任何事件)。
    • 要通过WebSocket从服务器接收消息,注册“message”事件处理程序,可以设置WebSocket对象的onmessage属性,也可以调用addEventListener
    • 与“message”事件关联的事件对象是MessageEvent的实例,其data属性包含服务器的消息。
  • 协议协商
    • WebSocket协议支持文本和二进制消息交换,但并未规定这些消息的结构或含义。使用WebSocket的应用必须在其提供的简单消息交换机制基础上自行协商通信协议

存储

  • Web应用可以使用浏览器API在用户计算机上本地存储数据。客户端存储的目的是让浏览器能够记住一些信息。
  • 客户端存储是按照来源隔离的,因此来自一个站点的页面不能读取来自另一个站点的页面存储的数据。但来自同一站点的两个页面可以共享存储的数据,并将其作为一种通信机制。
  • Web应用可以选择它们存储数据的生命期。可以临时存储,只保留到窗口关闭或浏览器退出;也可以保存在用户计算机上,持久化存储数月甚至数年
  • 安全:应该假设Web应用会以未加密的形式将数据保存在用户的设备上。因此这些保存下来的数据可以被使用设备的其他用户或者被潜入设备的恶意软件(如后门程序)访问到。为此,任何形式的客户端存储技术都不能用来保存密码、财务账号或其他类似的敏感信息

web Storage

  • Web Storage API包含localStorage和sessionStorage对象,本质上是映射字符串键和值的持久化对象。Web Storage很容易使用,适合存储大量(不是巨量)数据。
  • Window对象的localStorage和sessionStorage属性引用的是Storage对象。Storage对象与普通JavaScript对象非常类似,只不过
    • Storage对象的属性值必须是字符串;
    • Storage对象中存储的属性是持久化的。如果你设置了localStorage对象的一个属性,然后用户刷新了页面,你的程序仍然可以访问在该属性中保存的值。
  • localStorage和sessionStorage的差异主要体现在生命期和作用域上。
    • 通过localStorage存储的数据是永久性的,除非Web应用或用户通过浏览器(特定的界面)删除,否则数据会永远保存在用户设备上。
    • 所有同源文档都共享相同的localStorage数据,与实际访问localStorage的脚本的来源无关
  • sessionStorage数据的生命期与存储它的脚本所属的顶级窗口或浏览器标签页相同
    • 窗口或标签页永远关闭后,通过sessionStorage存储的所有数据都会被删除
    • 不过要注意,现代浏览器有能力再次打开最近关闭的标签页并恢复用户上次浏览的会话,此时会重新注入sessionStorage数据,因此这些标签页以及与之关联的sessionStorage的生命期有可能比看起来更长
    • sessionStorage的作用域与localStorage类似,都是文档来源。换句话说,不同来源的文档永远不会共享sessionStorage。
    • 但是,sessionStorage的作用域也在窗口间隔离(这一点和localstorage不同)。如果用户在两个浏览器标签页中打开了同一来源的文档,这两个标签页的sessionStorage数据也是隔离的。
  • 存储事件
    • 存储在localStorage中的数据每次发生变化时,浏览器都会在该数据可见的其他Window对象(不包括导致该变化的窗口)上触发“storage”事件。
  • localStorage和“storage”事件可以作为一种广播机制,即浏览器向所有当前浏览同一网站的窗口发送消息。

cookie

  • Cookie是一种古老的客户端存储机制,是专门为服务端脚本使用而设计的。浏览器也提供了一种笨拙的JavaScript API,可以在客户端操作cookie。但这个API很难用,而且只适合保存少量数据。
  • 通常来说,客户端已经不再操作cookie了,只不过需要了解其是什么,有什么特点,用来做什么的,以及一些安全相关的机制
  • 生命周期:cookie默认的生命期很短,它们存储的值只在浏览器会话期间存在,用户退出浏览器后就会丢失。如果想让cookie的生命期超过单个浏览会话,必须告诉浏览器你希望保存它们多长时间(以秒为单位)。为此要指定cookie的max-age属性。
  • 作用域:与localStorage和sessionStorage类似,cookie的可见性由文档来决定,但也由文档路径决定
    • 你可以通过domain设置其cookie适用的域名或者其子域名,不过不能将cookie的域设置为服务器父域名之外的其他域名
    • 协议需要一致,例如https和http其cookie不能互相携带
  • cookie遵循同源策略,只不过是一种宽松同源策略
    • Cookie 既遵循同源策略的核心约束,又通过 domain/path 属性实现了「同源策略的灵活扩展」
    • Cookie 的同源规则是 “基于 domain + path 的宽松同源”,而非浏览器 /ajax 那样的 “严格同源”
      • domain是为了扩展 Cookie 的访问范围
    • Cookie 的同源判断基于「协议 + domain + path」
    • 注意:父域名(如 example.com)无法设置 domain=a.example.com(浏览器会忽略),只有子域名可设置 domain 为父域名,实现共享。
    • 默认严格同源(未设 domain),仅当前域名可以访问,避免 Cookie 被无关域名窃取;
  • 浏览器通常允许大大超过300个cookie,但某些浏览器仍然限制4 KB大小。
  • 设置cookie通常在响应时返回set-Cookie来实现,而删除cookie,则是通过设置某个cookie的过期时间为0来告诉浏览器该cookie过期了。

IndexedDB

  • IndexedDB是一种异步API,可以访问支持索引的对象数据库。
  • IndexedDB是一个对象数据库,不是关系型数据库,比支持SQL查询的数据库更简单。而且比localStorage提供的键/值对存储机制更强大、高效和可靠。
  • 与localStorage类似,IndexedDB数据库的作用域限定为包含文档的来源。换句话说,两个同源的网页可以互相访问对方的数据,但不同源的网页则不能相互访问。
    • 同源是指:域名、协议和端口都一致时(子域名和父域名、不同子域名之间都不是同源的)
  • 每个来源可以有任意数量的IndexedDB数据库。每个数据库的名字必须在当前来源下唯一
  • IndexedDB提供了原子保证,即查询和更新数据库会按照事务进行分组,要么全部成功,要么全部失败,永远不会让数据库处于未定义、部分更新的状态。
  • IndexedDB是在期约得到广泛支持之前定义的,因此这个API是基于事件而非基于期约的。这意味着不能对它使用async和await。

工作线程(worker)和消息传递

worker

  • 线程是JavaScript的一个基本特性,因此浏览器绝不会同时运行两个事件处理程序,也不会在一个事件处理程序运行的时候触发其他计时器。然而一个必然的结果就是JavaScript函数不能运行太长时间,否则它们就会阻塞事件循环,而浏览器也会变得不能响应用户输入。
  • 浏览器通过Worker类非常谨慎地放松了这种单线程的限制。
    • Worker运行于独立的运行环境,有着完全独立的全局对象,不能访问Window或Document对象。Worker与主线程只能通过异步消息机制通信。
  • 工作线程适合执行计算密集型任务,比如图像处理。使用工作线程把这类任务从主线程转移走可以避免浏览器卡顿。而工作线程也提供了把任务分给多个线程的可能。除此之外,工作线程也适合频繁执行较密集的计算
  • worker内的运行环境
    • worker运行其中的代码会在一个新的、干净的JavaScript执行环境中执行,与创建工作线程的js环境完全隔离。
    • 这个新执行环境中的全局对象是一个WorkerGlobalScope对象。WorkerGlobalScope比核心JavaScript全局对象多一些东西,但又比客户端中完整的Window对象少一些东西。(浏览器环境的全局对象,比js的全局对象要多,例如dom这些)
    • WorkerGlobalScope对象也有postMessage()方法和onmessage事件处理程序,只是方向与Worker对象上的恰好相反。在工作线程内部调用postMessage()会在外部生成消息事件
    • 因为WorkerGlobalScope是工作线程的全局对象,postMessage()和onmessage在工作线程的代码中看起来就像一个全局函数和一个全局变量。
    • 如果给Worker()构造函数传入对象作为第二个参数,而该对象有一个name属性,则这个属性的值就会成为工作线程中全局对象的name属性的值。在通过console.warn()或console.error()打印的任何消息中,工作线程都包含这个名字(name)。
    • close()函数可以让工作线程终止自己,效果与调用Worker对象的terminate()方法一样。
    • WorkerGlobalScope对象还包含重要的客户端JavaScript API,比如Console对象、fetch()函数和IndexedDB API
    • WorkerGlobalScope也包含Worker()构造函数,这意味着工作线程也可以创建自己的工作线程
  • 在worker中导入代码
    • 浏览器支持Worker的时候JavaScript还不支持模块系统,因此工作线程有自己一套独特的系统用于导入外部代码。
    • importScripts()接收一个或多个URL参数,每个URL引用一个JavaScript代码文件。相对URL的解析相对于传给Worker()构造函数的URL(而不是相对于包含文档)。importScripts()按照传入顺序一个接一个地同步加载并执行这些文件。
    • 如果加载某个脚本时出现网络错误,或者如果执行某个脚本时抛出了任何错误,则后续脚本都不会再加载或执行。
    • 通过importScripts()加载的脚本自身也可以调用importScripts()加载自己的依赖文件。
    • importScripts()是同步函数,即它会在所有脚本都加载并执行完毕后返回。importScripts()返回后,就可以立即使用它所加载的脚本,不需要回调、事件处理程序、then()方法或await。
  • worker中使用模块
    • 为了在工作线程中使用模块,必须给Worker()构造函数传入第二个参数。这个参数必须是一个有type属性且值为module的对象。
    • 如果工作线程加载的是模块而非常规脚本,WorkerGlobalScope上不会再定义importScripts()函数。
  • worker的异常处理
    • 如果工作线程中出现了异常,而且没有被catch子句捕获,则会在全局对象上触发“error”事件。
    • 如果这个事件有处理程序,而且处理程序调用了事件对象的preventDefault(),则错误会停止传播。
    • 否则,“error”事件会在Worker对象上触发。如果这里调用了preventDefault(),则传播停止。
    • 否则,开发者控制台会打印出错误消息,并调用Window对象的onerror处理程序
    • 与在window上类似,工作线程也可以注册一个事件处理程序,以便期约被拒绝又没有.catch()函数处理它时调用。为此,可以在工作线程内定义一个self.onunhand-ledrejection函数,或者使用addEventListener()为全局事件“unhandledrejection”注册一个全局处理程序。

worker的执行模型

  • 工作线程自上而下地同步运行自己的代码(和所有导入的脚本及模块),之后就进入了异步阶段,准备对事件和定时器作出响应。
  • 如果注册了“message”事件处理程序,只要有收到消息事件的可能,则工作线程就不会退出。
  • 而如果工作线程没有监听消息事件,它会运行直到没有其他待决的任务(如fetch()期约和定时器),且所有任务相关的回调都被调用。在所有注册的回调都被调用后,工作线程已经不可能再启动新任务了,此时线程可以安全退出,而且是自动的。
  • 注意,Worker对象上没有任何属性或方法可以告诉我们工作线程是否还在运行,因此除非与父线程协商一致,否则工作线程不应该主动终止自己。

消息传递:MessageChannel

  • Worker对象的postMessage()方法和工作线程内部的全局postMessage()函数,都是通过调用在创建工作线程时一起创建的一对MessagePort(消息端口)对象的postMessage()方法来实现通信的。客户端JavaScript无法直接访问这两个自动创建的MessagePort对象,但可以通过MessageChannel()构造函数创建一对新的关联端口
  • MessageChannel是一个对象,有两个属性port1和port2,引用一对关联的MessagePort对象。MessagePort对象有一个postMessage()方法和一个onmessage事件处理程序属性。
  • 发送到一个端口的消息在该端口定义onmessage属性或调用start()方法之前会被放在一个队列中。这样可以防止信道一端发送的消息被另一端错过。如果调用了MessagePort的addEventListener(),不要忘了调用start(),否则可能永远看不到发过来的消息。
  • 使用MessageChannel也可以实现两个工作线程间直接通信,从而避免通过主线程代为转发消息。
    • 通过postMessage()方法第二个参数,可以将一个MessagePort对象转移到一个worker中去
  • postMessage()的第二个参数还可以用来在工作线程间转移而非复制ArrayBuffer。

窗口中的postMessage

  • 在客户端JavaScript中,postMessage()方法还有另一个使用场景。这个场景涉及窗口而不是工作线程
  • 对于工作线程,postMessage()为两个独立的线程提供了无须共享内存就能通信的安全机制。对于窗口,postMessage()也为两个独立的来源提供了安全交换消息的受控机制。

未来阅读建议

  • html和css技术
  • 另外两个额外需要关注的领域
    • 无障碍
    • 国际化
  • 性能:Performace API
  • 安全
    • 常见的web安全
    • csp
    • cors
  • WebAssembly
  • 浏览器支持一套复杂的API,以支持拖放UI和与浏览器外部应用程序的数据交换。
  • PWA
  • Service Worker
    • 例如像博客这种较为静态的数据网站,可以考虑使用这种技术以支持本地浏览

浏览器加载和运行js脚本

  • js执行
    • 我们可以把JavaScript程序的执行想象成发生在两个阶段。在第一阶段,文档内容加载完成,<script>元素指定的(内部和外部)代码运行。脚本通常按照它们在文档中出现的顺序依次执行,不过也可以使用前面介绍过的async和defer属性来修改。
    • 当文档加载完毕且所有脚本都运行之后(可以看做事load事件触发后)JavaScript执行就进入了第二阶段。这个阶段是异步的、事件驱动的。如果脚本要在第二阶段执行,那么它在第一阶段必须要做一件事,就是至少要注册一个将被异步调用的事件处理程序或其他回调函数。在事件驱动的第二阶段,作为对异步事件的回应,浏览器会调用事件处理程序或其他回调。
  • script标签
    • async
    • defer
  • 脚本动态加载
  • 脚本阻塞
  • 客户端js的线程模型
    • js是单线程语言,其好处自然就是简单,逻辑清晰,不需要关系资源竞争的问题
    • 单线程执行意味着浏览器会在脚本和事件处理程序执行期间停止响应用户输入(同时页面也会无响应)
      • 为什么js是单线程语言那浏览器的UI和事件也会被卡主呢?因为它们在同一个事件循环中,那为什么要将其放到同一个事件循环中呢?
      • 也许是因为UI的变化和js本身是互斥的,js可能获取UI的信息(通过文档对象模型),如果不是互斥的,那么可能同一个上下文中,对于UI的信息获取就会出现数据不一致。
      • web Worker工作线程中运行的代码无权访问文档内容,不会与主线程或其他工作线程共享任何状态。你也可以想想为啥worker不能访问文档对象模型。

node中的js

异步和非阻塞

  • 在 Node.js 中,非阻塞(Non-blocking)和异步(Asynchronous)并非同一个概念—— 它们是从「不同维度描述 I/O 操作特性」的术语,虽高度关联(Node.js 异步 I/O 几乎都基于非阻塞实现),但核心定义、关注维度、底层逻辑完全不同。
  • Node.js 的异步 I/O 是「非阻塞系统调用 + 事件循环 + 回调」的组合
  • 非阻塞:
    • 操作系统的 I/O 操作(如读文件、网络请求)分为「阻塞」和「非阻塞」两种模式。
    • 非阻塞系统调用:调用非阻塞 read() 时,内核会立即返回 若数据未准备好,返回 “EAGAIN/EWOULDBLOCK”(表示 “暂时没数据,你稍后再问”);若数据已准备好,返回读取到的数据。
    • Node.js 对所有 I/O 操作(文件、网络)都使用「非阻塞系统调用」 这是 Node.js 单线程不被卡死的基础,但仅靠非阻塞还不够:如果每次调用非阻塞 read() 后都轮询状态,会浪费 CPU 资源。
  • 异步:上层事件驱动的封装
    • 这里的异步可能是指的是对于非阻塞的调用,将其结果通知给主线程的方式。主动轮询结果状态是一种方式,基于事件的异步也是一种方式
    • Node.js 在非阻塞系统调用的基础上,增加了「事件循环 + IOCP/kqueue/epoll(I/O 多路复用)」,封装为 “异步 API”
      • 调用 fs.readFile 时,Node.js 会发起非阻塞的文件读取系统调用,然后将这个操作交给操作系统的 I/O 多路复用器(如 Linux 的 epoll);
      • 主线程继续执行后续代码,无需轮询读取状态;
      • 当文件读取完成后,I/O 多路复用器会通知事件循环,事件循环再调用 fs.readFile 的回调函数,返回数据。
  • 非阻塞是异步的 “底层基础”,异步是对非阻塞的 “上层封装” Node.js 的异步 API 利用非阻塞系统调用避免线程阻塞,再通过事件循环实现 “结果异步通知”,两者结合才实现了 “单线程高效处理大量 I/O”。
  • 异步 API 基于非阻塞系统调用实现,两者强关联但不等价 —— 非阻塞是异步的必要条件(Node.js 层面),但非阻塞≠异步,异步也不必然依赖非阻塞。
  • 非阻塞:描述「调用后是否阻塞执行流程」(底层交互方式);
  • 异步:描述「结果是否异步通知」(上层结果处理方式)。

node说明

  • 使用js在服务端运行代码,实现功能
  • node的生命周期
    • Node基本上是自顶向下执行指定文件中的JavaScript代码。很多Node程序会在执行完文件的最后一行代码时退出。
    • Node程序在运行完初始文件、调用完所有回调、不再有未决事件之前不会退出。基于Node的服务器程序监听到来的网络连接,理论上会永远运行,因为它始终要等待下一个事件。
    • 如果程序中的代码抛出异常,也没有catch子句捕获该异常,程序会打印栈追踪信息并退出。
    • 由于Node天生异步,发生在回调或事件处理程序中的异常必须局部处理,否则根本得不到处理。这意味着处理异步逻辑中的异常是一件麻烦事。如果你不想让这些异常导致程序崩溃,可以注册一个全局处理程序,以备调用,防止崩溃
    • 期约也是类似的(貌似不同版本其不会导致node退出)
  • Node是针对I/O密集型程序(如网络服务器)进行设计和优化的,特别地,Node的设计让实现高并发(同时处理大量请求的)服务器非常容易。
    • I/O密集型:常见例子有文件读写、网络请求、数据库查询(因为大部分时间都花在磁盘上了)流媒体处理等
  • Node的进程和Worker避免了典型多线程编程的复杂性。因为它的进程或线程间通信是通过消息传递实现的,相互之间很难共享内存
    • worker这些技术对于CPU耗用大的操作很有用,但对于服务器这种I/O密集型程序并不常用
  • 异步、非阻塞、单线程,I/O密集型任务
    • node通过异步和非阻塞来优化I/O密集型任务
    • node通过单线程,优化了多线程编程的复杂性
    • Node通过让其API默认异步和非阻塞实现了高层次的并发,同时保持了单线程的编程模型。Node很严格地采用非阻塞并将其运用到了极致,令人惊讶。你可能觉得读写网络的函数应该是异步的,但可能想不到在Node中就连读写本地文件系统的函数也是异步非阻塞的。
    • Node也为其很多函数定义了阻塞、同步的版本,特别是文件系统模块中的函数。这些函数的名字最后通常都有明确的Sync字样。
  • node的并发
    • Node内置的非阻塞函数使用了操作系统的回调和事件处理程序。在你调用一个异步函数时,Node会启动操作,然后在操作系统中注册某种事件处理程序。这样,当操作完成时,它就能收到通知。你传给Node函数的回调会被保存在内部,当操作系统向Node发送相应事件时,Node就可以调用你的回调。
    • 这种并发通常被称为基于事件的并发。其核心是Node用单线程运行一个“事件循环”。当Node程序启动时,它会运行你让它运行的代码。这些代码很可能会至少调用一个非阻塞函数,导致一个回调或事件处理程序被注册到操作系统中(如果没有调用非阻塞函数,那么你写的就是同步Node程序,Node在执行完毕后直接退出)
    • 当Node执行到程序的末尾时,它会一直阻塞直到有事件发生,此时操作系统会再启动并运行它。Node把操作系统事件映射到你注册的JavaScript回调,然后调用该函数。你的回调函数可能会调用更多非阻塞Node函数,导致注册更多事件处理程序。当你的回调函数运行完毕,Node又会进入睡眠状态,如此循环。
    • 对于Web服务器和其他主要把时间花在等待输入和输出的I/O密集型应用,这种基于事件的并发效率又高、效果又好。
  • buffer:缓冲区
    • Node是在核心JavaScript支持定型数组(参见11.2节)之前诞生的,因此没有表示无符号字节的Uint8Array。Node的Buffer类就是为了满足这个需求而设计的。
    • 在JavaScript语言支持Uint8Array之后,Node的Buffer类就成为Uint8Array的子类。Buffer与其超类Uint8Array的区别在于,它是设计用来操作JavaScript字符串的。因此缓冲区里的字节可以从字符串初始化而来,也可以转换为字符串。
    • 字符编码(例如utf-8、utf-16这种)将某个字符集中的每个字符映射为一个整数。只要有字符串和字符编码,就可以将该字符串中的字符编码为字节序列。
    • 而只要有(正确编码的)字节序列和字符编码,就可以将这些字节解码为字符串。Node的Buffer类有执行编码和解码的方法,这些方法都接收一个encoding参数,用于指定要使用的编码。(默认utf-8)
    • hex编码,类似base64编码这种,将字符串转换另外仅包含特定字符的组合。只不过hex是将二进制数据转换为16进制字符(0-9和a-f)
      • Hex 编码就是把二进制拆成 4 位一组,用 0-9/a-f 表示,目的是让二进制数据 “看得懂、传得稳”。
      • 本质是二进制的 “可读化转换”,无加密 / 压缩作用
      • 编码后数据长度翻倍,字符集简单无歧义,因为一个字节8为,而hex将一个字节拆分为2个8位的字符,所以翻倍了。
      • hex编码转换流程:字符的二进制数据(指定二进制编码格式,例如utf-8、utf-16) -> 将二进制数据转换为hex字符
      • 所以要注意,不同字符编码的二进制数据,其转换为hex的结果也不一样,hex编码本身不关心其二进制数据是什么字符编码的二进制数据
  • 事件和EventEmitter
    • 在Node中,发送事件的对象都是EventEmitter或其子类的实例
    • 当某个EventEmitter对象上发生特定的事件时,Node会调用在该EventEmitter上针对该事件类型注册的所有处理程序。调用顺序是注册的顺序。如果有多个处理程序,它们会在一个线程上被顺序调用。
    • 注意,Node没有并行调用。更重要的,事件处理程序会被同步调用,而非异步调用。这意味着emit()方法不会把事件处理程序排队到将来某个时刻再调用。emit()会调用所有注册的处理程序(一个接一个),并且会在最后一个事件处理程序返回之后返回。
      • 也就是emit的影响会是同步的,直接占用当前js线程
      • 同时也意味着,一个事件处理函数如果被执行了,那么改事件的其他处理函数也会被同步的执行,不会被异步分散
      • 事件处理程序返回的任何值都会被忽略。不过,如果某个事件处理程序抛出异常,则该异常会从emit()调用中传播出来,从而阻止在抛出异常的事件处理程序之后注册的其他处理程序的执行。
      • error错误事件:EventEmitter类对这个“错误”事件进行了特殊处理。如果调用emit()发送的是“错误”事件,且如果该事件没有注册处理程序,那么就会抛出一个异常。通常的异步API中emit是异步触发的,无法在catch块中处理这个异常,所以这种错误通常会导致程序退出。
    • 这意味着当某个内置Node API发送一个事件时,该API基本上会阻塞执行你的事件处理程序。如果你的事件处理程序调用了fs.readFileSync()之类的阻塞函数,那么在该函数同步读完文件之前,不会执行后续的事件处理程序。如果你的程序需要实时响应(类似网络服务器),那么关键在于要让事件处理程序不阻塞,可以快速执行完。
      • 除非这里的发送一个事件会自行封装为一个异步触发,不过所有事件处理函数都会在一个事件循环中被执行吧。
  • 流-stream
    • 使用基于流的算法,让数据“流入”你的程序,经过处理之后再“流出”你的程序。基于流的算法,其本质就是把数据分割成小块,内存中不会保存全部数据。如果能够使用基于流的方案,则这种方案的内存利用率更高,处理速度也更快。
    • Node的网络API是基于流的,Node的文件系统模块也定义了流API用于读写文件。因此你在写Node程序时很有可能用到流API。
    • 双工流把可读流和可写流组合为一个对象。比如,net.connect()和其他Node网络API返回的Socket对象就是双工流。如果你写入套接口,你的数据就会通过网络发送到该套接口连接的计算机上。如果你读取套接口,则可以访问由其他计算机写入的数据。
    • 转换流也是可读和可写的,但与双工流有一个重要区别:写入转换流的数据在同一个流会变成可读的(通常是某种转换后的形式)。比如,zlib.createGzip()函数返回一个转换流,可以(使用gzip算法)对写入其中的数据进行压缩。
    • Node的流API也支持“对象模式”,即流会读写比缓冲区和字符串更复杂的对象(例如处理js对象或者数组数据,按照流的方式)。Node的核心API都不使用这种对象模式,但你可能会在其他库中遇到这种模式。
    • 可读流必须从某个地方读取数据,而可写流必须把数据写到某个地方。因此每个流都有两端:输入端和输出端(或称来源和目标)。使用基于流的API,最难的地方是流的这两端几乎总是以不同的速度流动。
      • 流的实现几乎总会包含一个内部缓冲区,用于保存已经写入但尚未读取的数据。缓冲有助于保证在读取时有数据,而在写入时有空间保存数据。但这两点都无法绝对保证,基于流编程的本质决定了读取器有时候必须要等待数据写入(因为缓冲区空了),而写入器有时候必须等待数据读取(因为缓冲区满了)。
      • 在使用基于线程并发的编程环境中,流API通常存在阻塞调用。换句话说,读取数据的调用在数据到达流之前不会返回,而写入数据的调用在流的内部缓冲区有足够空间容纳新数据时才会终止阻塞。不过在基于事件的并发模型中,阻塞调用就没有意义了,而Node的流API是基于事件和回调的。
      • 由于要通过事件来协调流的读取能力(缓冲区不空)和写入能力(缓冲区不满),Node的流API比较复杂。而这些API随着时间的推移不断演进和变化让问题更加严重了。
    • 管道
      • 有时候,我们需要把从流中读取的数据写入另一个流。此时可以使用管道把这两个接口连接为一个“管道”,让Node帮你实现复杂的操作。
    • 异步迭代器
      • Node 12及之后,可读流是异步迭代器,这意味着在一个async函数中可以使用for/await循环从流中读取字符串或Buffer块
    • write()方法返回false值是一种背压(backpressure)的表现(可写流中)。背压是一种消息,表示你向流中写入数据的速度超过了它的处理能力
      • 这个write()方法有一个非常重要的返回值。在调用一个流的write()方法时,它始终会接收并缓冲传入的数据块。如果内部缓冲区未满,它会返回true。如果内部缓冲区已满或太满,它会返回false。这个返回值是建议性的,你可以忽略它
    • 手工处理背压面临一些问题:有时候可以连续调用write(),而有时候每次调用write()都要先等待事件。这个问题导致很难编写通用的算法。而这也是很多人宁愿选择pipe()方法的一个原因。使用pipe()时,Node会自动为你处理背压。
    • 注意:如果不能对背压作出反应,则在可写流的内部缓冲区溢出时,会导致你的程序占用过多内存,而且占用的内存会越来越多。如果你写的是一个网络服务器,那么这可能就是一个可以被远程利用的安全问题。
      • 写流会随着你不断调用write()而按需增大它的内部缓冲区。但是别忘了,使用流API的原因首先就是避免一次性在内存中保存过多数据。
    • Node可读流有两种模式,每种模式都有自己的读取API。如果你不能在程序中使用管道或异步迭代,那就需要从这两种基于事件的API中选择一种来处理流。关键在于只能使用其中一种API,不能两种混用。
      • 流动模式:在流动模式(flowing mode)下,当可读数据到达时,会立即以“data”事件的形式发送。
      • 暂停模式:可读流的另一种模式是“暂停模式”。这个模式是流开始时所处的模式。如果你不注册“data”事件处理程序,也不调用pipe()方法,那么可读流就一直处于暂停模式。在暂停模式下,流不会以“data”事件的形式向你推送数据。相反,你需要显式调用其read()方法从流中拉取数据。
  • 文件模块fs
  • http服务模块:Node的http、https和http2模块是功能完整但相对低级的HTTP协议实现。这些模块定义了实现HTTP客户端和服务器的所有API
    • 了解即可,基本上不会用到,都会用上层封装,例如nestjs等
  • node中的通信
    • worker中的通信
    • MessageChannel

其他

通用中断机制

AbortControllerAbortSignal类来实现中断请求,这两个类定义了通用的中断机制,也能在其他API中使用,甚至你可以自行封装一个API使其支持AbortSignal。

  • AbortSignal是一个对象,其拥有一个abort事件,你可以监听该对象的中止事件,来识别该信号是否被中止
  • AbortController实例的signal属性返回一个AbortSignal对象,同时你可以调用AbortController实例的abort()方法来中止signal属性的AbortSignal。
  • AbortSignal 已成为「异步操作取消」的标准接口,核心支持场景
  • 使用时只需遵循「创建控制器 → 传入 signal → 调用 abort 取消」的统一范式,即可实现跨 API 的一致取消体验。
  • 注意:通常来说针对自定义的中断机制,这里的取消,只是针对异步事件或者对象本身的,例如自定义的promise,它只是被拒绝决议,但是其异步正在执行中的处理,如果没有额外的中止机制,那么仍然会继续执行。

例如如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function myCoolPromiseAPI(/* …, */ { signal }) {
return new Promise((resolve, reject) => {
// 如果信号已经被中止,立即抛出错误,以拒绝 promise
if (signal.aborted) {
reject(signal.reason);
}

// 执行 API 主要的目标
// 当完成时调用 resolve(result)

// 监听 'abort' 信号
signal.addEventListener("abort", () => {
// 停止主要任务
// 以中止理由拒绝 promise
reject(signal.reason);
});
});
}