React Fiber 浅析

react-fiber

1. Background

我们知道从广义上来讲,浏览器是单线程的,它将 GUI 描绘,时间器处理,事件处理,js 执行,远程资源加载统统放在一起。在 React 15 及之前的版本,React 在对组件进行更新时,如果需要渲染更新的组件过于庞大,js 执行就会长时间占据主线程,导致页面的响应变慢。当然 React 也提供了优化的手段(shouldComponentUpdate),但是这种优化方式更多是依赖于使用者自身,这种单纯的人肉优化并没有很好地改善这种情况。

2. 一些前置概念

2-1. Renderers(渲染器)和 Reconcilers(协调器)

React 最开始服务于 DOM,后来又有了支持原生平台的 React Native,为了区分开这两者,React 内部提出了“**渲染器 (Renderers)**”的概念。

渲染器用于管理一棵 React 树,使其根据底层平台进行不同的调用。

即使 React DOM 和 React Native 渲染器区别很大,但也需要共享一些逻辑。特别是协调 (Reconciliation) 算法需要尽可能相似,这样可以让声明式渲染,自定义组件,state,生命周期方法和 refs 等特性,保持跨平台工作一致。

为了解决这个问题,不同的渲染器彼此共享一些代码。我们称 React 的这一部分为 “reconciler(协调器)”。当处理类似于 setState() 这样的更新时,reconciler 会调用树中组件上的 render(),然后决定是否进行挂载,更新或是卸载操作。

2-2. Reconciliation 协调

React 是一个用于构建用户界面的 JavaScript 库,一个核心的机制是跟踪组件的状态变化,并将更新后的状态映射到新的界面。这个过程被称为 Reconciliation(协调)。我们调用 setState 方法来改变状态,而框架本身会去检查 state 或 props 是否已经更改来决定是否重新渲染组件。

在开始了解 Fiber 架构前,先简单看一下协调过程中的 “Diffing” 算法的设计决策:

React 的 render 方法在组件 state 和 props 变更时计算返回新的树,React 需要基于这新旧两棵树之间的差别来判断如何有效率的更新 UI 以保证当前 UI 与最新的树保持同步。

这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。

(引用自协调 - React

React 主要是基于以下的两个假设,对 “Difffing“ 算法进行了优化

  1. 两个不同类型的元素会产生出不同的树;
  2. 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定;

2-3. Fiber 架构的目标

上文已经提到,React 15 及更早的版本在更新组件时,会持续占用主线程,这样主线程上的布局、动画等周期性任务以及交互响应就无法立即得到处理,导致掉帧。Stack reconciler 是指 React 15 及更早的 reconciler 解决方案,它其实是自顶向下的递归 mount/update,这种自顶向下递归的方法在节点较多的时候会需要很长的处理时间。

stack-reconciler

于是就有人提出来,如果仅依靠浏览器自己去调用堆栈,它将一直工作到堆栈为空为止……如果我们能够随意中断堆栈的调用并手动操作堆栈帧,那不是很好吗?通过设置固定的时间分片,在每个分片内灵活地处理堆栈的任务,并且进行一次页面的渲染,这样就可以保证页面的刷新频率。

fiber-reconciler

但是除了需要对任务进行切片,还需要增加一个任务的优先级,因为对于用户体验来说,用户输入的响应事件要优先于请求填充内容,并且高优先级的任务可以打断低优先级的任务。

Fiber 架构的主要目标是(后两个其实属于附带的目标):

  • 能够把可中断的任务切片处理。
  • 能够调整优先级,重置并复用任务。
  • 能够在父元素与子元素之间交错处理,以支持 React 中的布局。
  • 能够在 render() 中返回多个元素。
  • 更好地支持错误边界。

3. 数据结构及算法

3-1. 从 React 元素到 Fiber 节点

在讲 Fiber 架构之前,我们先看一下 React 在 V15 及之前是怎么处理虚拟 DOM 以及实际节点的。在 React 运行的时候,会存在有以下三种实例:

DOM - 真实的 DOM 节点
Instances - react 维护的虚拟 DOM 节点
Elements - 对 UI 进行描述 eg. type, props

显然要做到上文提到的增量更新,按照这些实例是很难满足的,所以 React 在原有的基础上进行扩展:

DOM - 真实的 DOM 节点
effect - 即副作用 (side effect),包括 DOM change 等操作
workInProgress - workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,用于断点恢复
fiber - fiber tree与vDOM tree类似,用来描述增量更新所需的上下文信息
Elements - 对 UI 进行描述 eg. type, props

fiber tree上各节点的主要结构(每个节点称为fiber):

1
2
3
4
5
6
7
8
// fiber tree节点结构
{
stateNode,
child,
return,
sibling,
...
}

return表示当前节点处理完毕后,应该向谁提交自己的成果(effect list),在这里 fiber tree 使用的是链表结构

3-2. Fiber 节点

在协调期间,从 render 方法返回的每个 React 元素的数据都会被合并到 Fiber 节点树中。每个 React 元素都有一个相应的 Fiber 节点。与 React 元素不同,不会在每次渲染时重新创建这些 Fiber 。这些是持有组件状态和 DOM 的可变数据结构。

根据不同 React 元素的类型,框架需要执行不同的活动。对于类组件,它调用生命周期方法和 render 方法,而对于 span 宿主组件(DOM 节点),它进行得是 DOM 修改。因此,每个 React 元素都会转换为 相应类型 的 Fiber 节点,用于描述需要完成的工作。

您可以将 Fiber 视为表示某些要做的工作的数据结构,或者说,是一个工作单位。Fiber 的架构还提供了一种跟踪、规划、暂停和销毁工作的便捷方式。

当 React 元素第一次转换为 Fiber 节点时,React 在 createFiberFromTypeAndProps 函数中使用元素中的数据来创建 Fiber。在随后的更新中,React 会再次利用 Fiber 节点,并使用来自相应 React 元素的数据更新必要的属性。如果不再从 render 方法返回相应的 React 元素,React 可能还需要根据 key 属性来移动或删除层级结构中的节点。

3-3. Fiber Tree

上文提到 fiber tree 是链表结构,链表结构跟以往的 reactNode tree 相比,有着更高的遍历效率,并且是可暂停、撤销、重新开始的。

fiber-tree

3-4. current tree 及 workInProgress tree

在第一次渲染之后,React 最终得到一个 Fiber 树,它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current 树(当前树)。当 React 开始处理更新时,它会构建一个所谓的 workInProgress 树(工作过程树),它反映了要刷新到屏幕的未来状态。

所有工作都在 workInProgress 树的 Fiber 节点上执行。当 React 遍历 current 树时,对于每个现有 Fiber 节点,React 会创建一个构成 workInProgress 树的备用节点,这一节点会使用 render 方法返回的 React 元素中的数据来创建。处理完更新并完成所有相关工作后,React 将准备好一个备用树以刷新到屏幕。一旦这个 workInProgress 树在屏幕上呈现,它就会变成 current 树。这种方法我们称之为双缓冲技术 (double buffering)

React 的核心原则之一是一致性。 React 总是一次性更新 DOM - 它不会显示部分中间结果。workInProgress 树充当用户不可见的「草稿」,这样 React 可以先处理所有组件,然后将其更改刷新到屏幕。
在源代码中,您将看到很多函数从 current 和 workInProgress 树中获取 Fiber 节点。这是一个这类函数的签名:

1
2
3
function updateHostComponent(current, workInProgress, renderExpirationTime) {
...
}

每个Fiber节点持有备用域在另一个树的对应部分的引用。来自 current 树中的节点会指向 workInProgress 树中的节点,反之亦然。

这样做的好处:

  • 能够复用内部对象(fiber)
  • 节省内存分配、GC的时间开销

3-5. 优先级策略

每个工作单元运行时有6种优先级:

  • synchronous 与之前的Stack reconciler操作一样,同步执行
  • task 在next tick之前执行
  • animation 下一帧之前执行
  • high 在不久的将来立即执行
  • low 稍微延迟(100-200ms)执行也没关系
  • offscreen 下一次render时或scroll时才执行

synchronous 首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程。animation 通过requestAnimationFrame 来调度,这样在下一帧就能立即开始动画过程;后3个都是由requestIdleCallback回调执行的;offscreen 指的是当前隐藏的、屏幕外的(看不见的)元素

高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等。另外,紧急的事件是允许插队的。

3-6. requestIdleCallback 和 requestAnimationFrame

一些较新的浏览器提供了这个 API,我们可以看一下 MDN 上的介绍:

window.requestIdleCallback() 方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。

函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。值得留意的是,为了兼顾更多的浏览器和确定空闲时间间隔等原因,React 是自己内部实现了一个 requestIdleCallback。

3-7. 工作循环

所有的 Fiber 节点都会在 工作循环 中进行处理。如下是该循环的同步部分的实现:

1
2
3
4
5
6
7
function workLoop(isYieldy) {
if (!isYieldy) {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {...}
}

nextUnitOfWork 持有 workInProgress 树中的 Fiber 节点的引用,这个树有一些工作要做。当 React 遍历 Fiber 树时,它会使用这个变量来知晓是否有任何其他 Fiber 节点具有未完成的工作。处理过当前 Fiber 后,变量将持有树中下一个 Fiber 节点的引用或 null。在这种情况下,React 退出工作循环并准备好提交更改。
遍历树、初始化或完成工作主要用到 4 个函数:

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

为了演示他们的使用方法,我们可以看看如下展示的遍历 Fiber 树的动画。我已经在演示中使用了这些函数的简化实现。每个函数都需要对一个 Fiber 节点进行处理,当 React 从树上下来时,您可以看到当前活动的 Fiber 节点发生了变化。从视频中我们可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成子节点的工作,然后才转移到父节点进行处理。

wookloop

3-7. reconciliation

以 fiber tree 为蓝本,把每个 fiber 作为一个工作单元,自顶向下逐节点构造 workInProgress tree(构建中的新 fiber tree)

具体过程如下(以组件节点为例):

  1. 如果当前节点不需要更新,直接把子节点 clone 过来,跳到5;要更新的话打个 tag
  2. 更新当前节点状态(props, state, context等)
  3. 调用 shouldComponentUpdate(),false 的话,跳到5
  4. 调用 render()获得新的子节点,并为子节点创建 fiber(创建过程会尽量复用现有 fiber,子节点增删也发生在这里)
  5. 如果没有产生 child fiber,该工作单元结束,把 effect list 归并到 return,并把当前节点的 sibling 作为下一个工作单元;否则把 child 作为下一个工作单元
  6. 如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做
  7. 如果没有下一个工作单元了(回到了 workInProgress tree 的根节点),第 1 阶段结束,进入pendingCommit 状态

际上是1-6的工作循环,7是出口,工作循环每次只做一件事,做完看要不要喘口气。工作循环结束时, workInProgress tree 的根节点身上的 effect list 就是收集到的所有 side effect(因为每做完一个都向上归并)

所以,构建 workInProgress tree 的过程就是 diff 的过程,通过 requestIdleCallback 来调度执行一组任务,每完成一个任务后回来看看有没有插队的(更紧急的),每完成一组任务,把时间控制权交还给主线程,直到下一次 requestIdleCallback 回调再继续构建 workInProgress tree

4. 参考资料

  1. Inside Fiber: in-depth overview of the new reconciliation algorithm in React
  2. React Fiber Architecture
  3. Codebase Overview - React
  4. Reconciliation - React
  5. The how and why on React’s usage of linked list in Fiber to walk the component’s tree
  6. React Fiber架构 - 司徒正美
  7. 完全理解React Fiber
  8. window.requestAnimationFrame - Web API 接口参考 | MDN
  9. requestIdleCallback - Web API 接口参考 | MDN

前端地图基本原理

1. 基本概念

1-1. 经纬度的描述

地球是一个椭球,Datum 是一组用于描述这个椭球的数据集合。最常用的一个 Datum 是 WGS84 (World Geodetic System 1984),它的主要参数有:

  • 坐标系的原点是地球质心(center of mass)
  • 子午线(meridian),即零度经线,位于格林威治子午线 Royal Observatory 所在纬度往东 102.5米 所对应的的经线圈
  • 椭球截面长轴为 a=6378137米
  • 椭圆截面短轴为 b=6356752.3142米,可选参数
  • 扁平比例(flattening)f=(a−b)/a=1/298.257223563
  • geoid,即海平面,用于定义高度

1-2. 像素坐标系

像素坐标系,也可以成为屏幕坐标系,像素坐标系和地图的经纬度坐标系存在对应关系,屏幕上的每一个像素都对应一个经纬度点位置。 不同缩放级别下,像素坐标系和经纬度坐标系的对应关系是不同的。

1-3. 投影

地图投影是利用一定数学法则把地球表面的经、纬线转换到平面上的理论和方法。由于地球是一个赤道略宽两极略扁的不规则的梨形球体,故其表面是一个不可展平的曲面,所以运用任何数学方法进行这种转换都会产生误差和变形,为按照不同的需求缩小误差,就产生了各种投影方法。

map_projection

关于投影我们只需要了解墨卡托投影(正轴等角圆柱投影)

1-4. 墨卡托投影

墨卡托投影,是正轴等角圆柱投影。由荷兰地图学家墨卡托 (G.Mercator) 于 1569 年创立。假想一个与地轴方向一致的圆柱切或割于地球,按等角条件,将经纬网投影到圆柱面上,将圆柱面展为平面后,即得本投影。等角条件是使地球面上微分区域内两个方向的夹角投影到平面以后,保持角度不变的条件。

Web 墨卡托投影(又称球体墨卡托投影)是墨卡托投影的变种,它接收的输入是 Datum 为 WGS84 的经纬度,但在投影时不再把地球当做椭球而当做半径为 6378137 米的标准球体,以简化计算。

mercator_projection

Web 墨卡托投影的两个投影标准:

  • EPSG4326:Web 墨卡托投影后的平面地图,但仍然使用 WGS84 的经度、纬度表示坐标;
  • EPSG3857:Web 墨卡托投影后的平面地图,坐标单位为米。

这两个投影标准在我们调用 geoserver 的服务时可能会用到,所以简单了解即可。

1-5. 瓦片

经过投影后,地图就变为平面的一张地图。考虑到有时候我们需要看宏观的地图信息(如世界地图里每个国家的国界),有时候又要看很微观的地图信息(如导航时道路的路况信息)。为此,我们对这张地图进行等级切分。在最高级 (zoom=0),需要的信息最少,只需保留最重要的宏观信息,因此用一张 256x256 像素的图片表示即可;在下一级 (zoom=1),信息量变多,用一张 512x512 像素的图片表示;以此类推,级别越低的像素越高,下一级的像素是当前级的4倍。这样从最高层级往下到最低层级就形成了一个金字塔坐标体系。

对每张图片,我们将其切分为 256x256 的图片,称为瓦片(Tile)。这样,在最高级 (zoom=0) 时,只有一个瓦片;在下一级 (zoom=1) 时有4个瓦片;在下一级 (zoom=2) 时有16个瓦片,以此类推。

conversion_of_coordinates

1-6. 瓦片的编号

瓦片生成后,就是一堆图片。怎么对这堆图片进行编号,是目前主流互联网地图商分歧最大的地方。总结起来分为四个流派:

  • 谷歌 XYZ:Z 表示缩放层级,Z=zoom;XY 的原点在左上角,X 从左向右,Y 从上向下。
  • TMS:开源产品的标准,Z 的定义与谷歌相同;XY 的原点在左下角,X 从左向右,Y 从下向上。
  • QuadTree:微软 Bing 地图使用的编码规范,Z 的定义与谷歌相同,同一层级的瓦片不用 XY 两个维度表示,而只用一个整数表示,该整数服从四叉树编码规则
  • 百度 XYZ:Z 从 1 开始,在最高级就把地图分为四块瓦片;XY 的原点在经度为 0 纬度位 0 的位置,X 从左向右,Y 从下向上

tile_coding

2. Geoserver

先看一段官方的介绍:

GeoServer 是基于 Java 的软件服务器,允许用户查看和编辑地理空间数据。使用开放地理空间联盟(OGC)提出的开放标准,GeoServer 在地图创建和数据共享方面具有极大的灵活性。
GeoServer 允许您向世界显示您的空间信息。实施Web地图服务 (WMS) 标准,GeoServer 可以创建各种输出格式的地图。一个免费的地图库 OpenLayers 已集成到 GeoServer 中,从而使地图生成快速简便。
GeoServer 符合 Web Feature Service(WFS)标准和 Web Coverage Service(WCS)标准,该标准允许共享和编辑用于生成地图的数据。GeoServer 还使用 Web Map Tile Service 标准将您发布的地图拆分为图块,以方便Web地图和移动应用程序使用。

GeoServer 使用的是图层与图层组的概念。将在服务器上准备发布为服务的数据定义为一组数据集,然后规定在发布为Web服务时的一些参数。Geoserve 提供了操作界面来管理各种配置:

geoserver_page

3. leaflet 和 openlayers

上文说到的 openlayersleaflet 都是用于在 Web 上创建交互式地图,可以显示从任何来源加载的地图图块、矢量数据和标记。

这里的两者都是开源的,相比起来 leaflet 更加轻量,适合移动端的场景,如果只是相对简单的地图需求,使用 leaflet 会是一个合适的选择。而 openlayers 具有数量更大,更复杂的 API。而 geoserver 提供的服务,对 leaflet 和 openlayers 都是支持的。

4. 调用 geoserver 的服务

4-1. 调用 WMS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const wmsLayer = Map.tileLayer.wms(
'http://gsmap.sf-express.com/geoserver/sfmap/wms',
{
type: 'tile',
layers: 'map:example_layer',
version: '1.1.1',
format: 'image/png',
request: 'GetMap',
transparent: 'true',
zoom: [3, 20],
zIndex: 5,
sld_body: getSld('sfmap:dept_3', this.cateMapData),
},
)

this.mapIns.addLayer(wmsLayer)

此处使用 Map.tileLayer.wms(url, params) 调用来自 geoserver 的 WMS 服务,使用的参数如下:

  • url:请求的 geoserver 服务地址
  • params:请求的目标图层相关配置参数,layers 为请求的图层名,在 geoserver 服务中不同维度的地图图层对应不同的名字,在这里的 dept_3 表示业务区的地图图层;request 表示此处调用的是 geoserver 的获取地图图层的服务;formattranparent表示请求的瓦片图文件格式及背景是否透明; 而 sld_body 则是需要自定义的 SLD 样式

4-2. 配置 SLD

对于 WMS 返回的地图图层,可以针对之进行样式的自定义,geoserver 支持多种方法定义图层样式,SLD 就是其中一种。

SLD (Style Layer Descriptor) 是2005年OGC提出的一个标准,这个标准在一定条件下允许WMS服务器对地图可视化的表现形式进行扩展。该 SLD 规范是采用XML定义地图显示样式,通过自定义SLD来配置地图图层渲染的可视化风格,可以设置过滤器,自定义图例等。

SLD_structure

如果我们想要自定义图层的样式,肯定会用到的元素包括:

  • FeatureTypeStyle:这一部分是整个样式文档的根节点,并说明什么是它的样式将被应用的特征类型。FeatureTypeStyle 包含一个或者多个 Rule 元素,Rule 元素允许有条件的映射。

  • RULE(规则):规则是根据属性条件和地图比例尺来对要素进行分组渲染,一般 RULE 中只允许渲染一种类型的要素,即点,线,面等其中的一种,但是可以和注记同时使用。

  • Symbolizer(符号):Symbolizer 指定数据应该如何可视化,在1.0的标准中包含五忠类型的Symbolizer,分别是PointSymbolizer(点符号)、LineSymbolizer(线符号)、PloygonSymbolizer(面符号)、TextSymbolizer(注记)、RasterSymbolizer(栅格)。

除了一些预设的匹配规则,SLD 还提供了一些 Function 可应用于较为负责的筛选。

以实际应用举例,见以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<Rule>
<ogc:Filter>
<ogc:PropertyIsEqualTo>
<ogc:Function name="in">
<ogc:PropertyName>code</ogc:PropertyName>
<ogc:Literal>591Y</ogc:Literal>
<ogc:Literal>592Y</ogc:Literal>
<ogc:Literal>595Y</ogc:Literal>
<ogc:Literal>791Y</ogc:Literal>
</ogc:Function>
<ogc:Literal>true</ogc:Literal>
</ogc:PropertyIsEqualTo>
</ogc:Filter>
<PolygonSymbolizer>
<Fill>
<CssParameter name="fill">#7ba7ee</CssParameter>
<CssParameter name="fill-opacity">0.7</CssParameter>
</Fill>
<Stroke>
<CssParameter name="stroke">#727D71</CssParameter>
<CssParameter name="stroke-width">0.4</CssParameter>
</Stroke>
</PolygonSymbolizer>
</Rule>

这段 SLD 表示对数据对象进行筛选匹配,当对象的 code 属性为 591Y、592Y、595Y 和 791Y 四者的其一时,适用后续的样式。后续的样式表示对应的 PolygonSymbolizer 元素适用了 Fill 和 Stroke 的样式,具体的样式属性分别是填充颜色、填充透明度、边界颜色和边界宽度。

这里需要留意的是任何 Function 元素都会有一个返回值, <ogc:Function name="in"></ogc:Function> 对应的返回值是一个 Boolean 值,所以需要在后面紧跟 <ogc:Literal>true</ogc:Literal> 来对应返回值的匹配。

4-3. 获取点击位置所属业务区

还是看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 监听地图的点击事件
this.mapIns.on('click', getClickFeature)

getClickFeature(e) {
const vm = this
const { containerPoint } = e
const bbox = this.mapIns.getBounds().toBBoxString()
const size = this.mapIns.getSize()
const url =
`http://gsmap.sf-express.com/geoserver/sfmap/wms` +
`?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetFeatureInfo&` +
`FORMAT=image%2Fpng&TRANSPARENT=true&QUERY_LAYERS=map%3Aexample_layer` +
`&LAYERS=sfmap%3Adept_3&CRS=EPSG%3A4326&` +
`INFO_FORMAT=text%2Fjavascript&FEATURE_COUNT=1000` +
`&X=${containerPoint.x}&Y=${containerPoint.y}` +
`&WIDTH=${size.x}&HEIGHT=${size.y}` +
`&SRS=EPSG%3A4326&STYLES=&BBOX=${bbox}`

function getResult(res) {
if (res) {
// 根据返回的数据结构区域编码
const { features } = res
if (features.length) {
const {
properties: { code },
} = features[0]
vm.$emit('areaClick', code) // 获取到点击位置所属的业务区
}
}
}
this.jsonp(url, getResult) // 通过 jsonp 的方式跨域
}

geoserver 的 GetFeatureInfo 服务支持通过 jsonp 的方式调用,通过组装地址的方式就可以实现请求获取点击位置的所属业务区了,简单看一下请求参数:

  • CRS Coordinate Reference System, 即上文提到的投影标准,这里使用的是 EPSG4326
  • INFO_FORMAT 使用 jsonp 调用所需要的传参
  • FEATURE_COUNT 查询命中的数据对象取数上限
  • X, Y 在地图上的点击坐标,以地图容器左上为原点的坐标值
  • WIDTH, HEIGHT 地图容器的尺寸
  • BBOX Bounds Box, 当前所显示地图的四个角对应的经纬度

geoserver 主要是通过 X, Y, WIDTH, HEIGHT 和 BBOX 来定位到具体的点击位置,需要注意受限于地图缩放等级和容器大小等因素,最终的返回值可能不止一个。

扩展参考:
Leaflet - a JavaScript library for interactive maps
OpenLayers - A high-performance, feature-packed library for all your mapping needs.
SLD Styling — GeoServer 2.19.x User Manual
GeoServer中使用SLD样式
瓦片地图原理

E2E 测试与自动化测试工具 Nightmare

1. 前端的自动化测试

1.2 单元测试

单元测试往往只关注于一个代码片段,通常是一个模块或函数。实际应用中会将代码拆分成若干个小的组件,这也意味着你会写很多的单元测试用例来保证代码的功能正常。

前端开发中的单元测试工具有 jestmochajasminequnit

1.3. E2E 测试

E2E(end to end)测试即端到端测试是,也称冒烟测试,用于测试真实浏览器环境下前端应用的流程和表现,相当于代替人工去操作应用。E2E 测试是一个边界比较模糊的概念,有以下几个特点:

  • 把整个系统当作一个黑盒
  • 测试人员模拟真实用户在浏览器中操作UI
  • 测试出的问题可能是前端也可能是后端导致的

常见的 E2E 测试工具有 nightmare、nightwatch 和 puppeteer

1.4. 单元测试和 E2E 测试的对比

单元测试的概念出现已久,相关的工具体系也已经十分完善,但是单元测试的维护成本较高,并且对于前端测试来说有很多需求无法满足。此外,单元测试需要较多的角度完善的测试用例支持,且这些测试用例都是较为简单的,针对每一个小模块或者组建的输入与输出。

E2E 测试针对具体的测试环境条件来编写测试用例,一般情况下测试用例不会太多.

1.5. TDD 和 BDD

TDD(Test Drive Development)即测试驱动开发。简单的说就是先根据需求写测试用例,再代码实现,接着测试,循环此过程直到产品的实现。可以看出来,TDD 的基本思路就是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析,设计,质量控制量化的过程。

BDD(Behavior Drive Development)即行为驱动开发,BDD 可以看作是对 TDD 的一种补充,或者说是 TDD 的一个分支。在TDD中,我们并不能完全保证根据设计所编写的测试就是用户所期望的功能。BDD将这一部分简单和自然化,用自然语言来描述,让开发、测试、BA以及客户都能在这个基础上达成一致。BDD 更加依赖于需求行为和文档来驱动开发,这些文档的描述跟测试代码很相似。

通过对比,TDD 通过测试来推动整个开发的进行。这有助于编写简洁可用和高质量的代码,并加速实际开发过程,可能导致的问题是需求和开发脱节,实际产品与用户所需要的功能并不匹配。而 BDD 通过鼓励项目中的开发者、QA 和非技术人员或商业参与者之间进行协作,确保程序实现效果与用户需求一致。

可以看出 E2E 测试更多的是换和 BDD 的开发模式进行结合,实际应用用会将 E2E 的测试工具和 BDD 测试框架进行结合。

2. Nightmare

2.1.基本介绍

Nightmare 是一个基于 electron 的浏览器自动化代码库,用于实现爬虫或自动化测试。相较于传统的爬虫框架(scrapy/pyspider),或者dom操作库(cheerio/jsdom),或者基于浏览器的自动化框架(selenium/phantomjs),他的优势在于提供了一个简洁有效的编程模型。

官网给出的实现一个向yahoo自动提交关键词并搜索的功能如下:

1
2
3
4
yield Nightmare()
.goto('http://yahoo.com')
.type('input[title="Search"]', 'github nightmare')
.click('.searchsubmit');

2.2. 相关 API

基本的交互 API 包括:

  • goto(url[, headers]) 跳转到url
  • viewport(width, height) 浏览器窗口大小
  • wait(selector) 等待某个dom元素出现
  • click(selector) 点击某个dom元素
  • type(selector[, text]) 在某个dom元素中输入
  • inject(type, file) 在页面上挂载 js/css 文件内容
  • evaluate(fn[, arg1, arg2,...]) 在客户端注入JS脚本并执行,从而实现electron模型下的C/S互动及数据交换
  • ……

2.3. 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Nightmare from 'nightmare';

describe('Login', () => {
let page;
beforeEach(() => {
page = Nightmare();
page.goto('http://localhost:8000/#/user/login');
});

it('should login with failure', async () => {
await page.type('#userName', 'mockuser')
.type('#password', 'wrong_password')
.click('button[type="submit"]')
.wait('.ant-alert-error') // should display error
.end();
});

it('should login successfully', async () => {
const text = await page.type('#userName', 'admin')
.type('#password', '888888')
.click('button[type="submit"]')
.wait('.ant-layout-sider h1') // should display error
.evaluate(() => document.body.innerHTML)
.end();
expect(text).toContain('<h1>Ant Design Pro</h1>');
});
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const expect = require('chai').expect;
const Nightmare = require('nightmare');
const assert = require('assert');

describe('simulate search', function () {
  this.timeout('30s')

  let nightmare = null
  beforeEach(() => {
    // 设置显示模拟弹框
    nightmare = new Nightmare({
    show: true,
   })
  })

  it('模拟用户搜索', done => {
    nightmare
    .goto('https://www.baidu.com/') //设置搜索引擎
    .viewport(1200, 672)   //设置弹框视口宽高        
    .type('form[action*="/s"] [name=wd]', 'github nightmare') //获取搜索框,自动填充搜索内容
    .click('form[action*="/s"] [type=submit]') //获取点击按钮,模拟点击
    .wait(5000) //等待5s(可为dom节点),获取第一条的信息的内容
    .evaluate(() =>
     document.querySelector('#content_left .c-container a em').innerHTML
    )
    .end()
    .then(content => {
     console.log(content === 'Nightmare','----->true') //输出结果
done();
    })
    .catch(error => {
     console.error('Search failed:', error); //输出捕捉到的错误
done();
    });
  })
})

参考:

Base64 图片数据转换上传

1. base64

1.1. 背景介绍

Base64是网络上最常见的用于传输 8Bit 字节码的编码方式之一,Base64 就是一种基于 64 个可打印字符来表示二进制数据的方法。

用记事本打开exe、jpg、pdf这些文件时,我们都会看到一大堆乱码,因为二进制文件包含很多无法显示和打印的字符,所以,如果要让记事本这样的文本处理软件能处理二进制数据,就需要一个二进制到字符串的转换方法。Base64 就是一种最常见的二进制编码方法。

简单的说,Base64 将 8 位的非英语字符转化为 7 位的 ASCII 字符。最初为了解决电子邮件中不能直接使用非 ASCII 码字符的问题而被提出,除此之外还有别的意义:

  • 所有的二进制文件,都可以因此转化为可打印的文本编码,使用文本软件进行编辑
  • 能够对文本进行简单的加密

1.2. 原理简析

Base64,就是说选出64个字符:

  • 小写字母 a-z
  • 大写字母 A-Z
  • 数字 0-9
  • 符号 “+”、”/“

实际上还有作为垫字的“=”,所以在一段 base64 文本中会看到 65 个字符。将除了ASCII字符以外的其他所有符号都转换成这个字符集中的字符。

Base64 对二进制数据进行处理,每 3 个字节一组,一共是 3 x 8 = 24bit,划为4组,每组正好 6 个 bit:

得到四个 6 位的数据之后给每一个开头加上 00,这样 3 个字节的数据就转成了 4 个字节。正是因此经过 Base64 编码过的数据会在大小上增加 33% 左右。

如果要编码的二进制数据不是 3 的倍数,实际编码可能会在最后剩下 1 个或 2 个字节,Base64用 \x00 字节在末尾补足后,再在编码的末尾加上 1 个或 2 个 = 号,表示补了多少字节,在解码的时候,会自动去掉。

2. 相关方法

2.1. window.atob()window.btoa()

在JavaScript中,有 2 个函数分别用来处理解码和编码 Base64 字符串:

  • window.atob():对用 Base64 编码过的字符串进行解码
  • window.btoa():从 String 对象中创建一个 Base64 编码的 ASCII 字符串,其中字符串中的每个字符都被视为一个二进制数据字节

2.2. Uint8Array

Uint8Array 数组类型表示一个 8 位无符号整型数组,创建时内容被初始化为 0。

创建完后,可以以对象的方式或使用数组下标索引的方式引用数组中的元素。

2.3. Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是 JavaScript 原生格式的数据。File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

要构建一个 Blob 对象可以使用 Blob() 构造函数:

1
const aBlob = new Blob( array, options );

Blob() 构造函数返回的 Blob 对象内容由参数数组中给出的值的串联组成。参数如下:

  • array 是一个由ArrayBuffer, ArrayBufferView, Blob, DOMString 等对象构成的 Array ,或者其他类似对象的混合体,它将会被放进 Blob。DOMStrings会被编码为UTF-8
  • options 是一个可选的 BlobPropertyBag 字典,它可能会指定如下两个属性:key 和 ending

3. 具体实现

为了将 Base64 数据转换成二进制的数据进行上传,可以分成以下几个步骤:

  1. 去掉 Base64 图片数据字串中开头一些类型说明的内容
  2. 取出 Base64 图片数据字串中的 MIME 说明
  3. window.atob() 解码 Base64 转换成图片原生的数据字串
  4. 将得到的图片原生字串放入 Uint8Array
  5. 转换成 Blob 格式
  6. 调用 FormData 的 append() 插入 Blob 数据,连同 form 中的数据一同发送
1
2
3
4
5
6
7
8
9
10
11
// 将base64转换成二进制图片(Blob)
function dataURItoBlob (base64Data) {
const byteString = window.atob(base64Data.split(',')[1]); // 去掉url的头,是一段类型说明
const mimeString = base64Data.split(',')[0].split(':')[1].split(';')[0]; // 取出 MIME类型

let ia = new Uint8Array(byteString.length); //
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ia], { type: mimeString });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import request from 'superagent';

function submit () {
const blob = dataURItoBlob(base64Data);
const fd = new FormData(document.querySelector('form'));
fd.append(fileName, blob, file.file.name);

request.post(action)
.send(fd)
.end(function(err, res) {
// eg.成功回调的处理逻辑
if (err || !res.ok) {
// 处理 request 返回的请求错误
console.log("not 200 error msg:" + err);
// ...
} else {
// 处理后端返回的请求错误
if (res.body.errorCode === 0) {
// do something ...
} else {
// do something ...
}
}
});
}

参考:

iOS 11 及 iPhone X 爬坑姿势

1 序言

2017 年 9 月 12 日凌晨,苹果在乔布斯剧院发布了 iPhone X。iPhone X 正面的全面屏上方有一条刘海,对于如何适配 iPhone X,苹果的 Human Interface Guidelines 文档有给出相关的说明,但是对于前端开发者来说,我们还应该关注到更多的东西。

iOS 11 新引入了若干个 Webkit API 来解决带来的适配问题,使用这些新的 API 我们可以更好的利用 iPhone X 屏幕的特性。

2 相关概念

在了解新 API 之前需要先了解苹果设备屏幕的相关概念,接下来简单的对之进行说明。

2.1 安全区域(Safe Area)

在苹果的 UIKit文档 中,提到了安全区域。

safe_area_1

safe_area_2

简单的说,将内容放在这样的 Safe area 可以确保内容不会被设备圆角(corners),传感器外壳(sensor housing,齐刘海)以及底部的 Home Indicator 遮挡。

3 相关 API

3.1 viewport-fit

3.1.1 基础介绍

iOS 11 与早期的版本有个不同的地方,Webview 内容将会尊重所谓的安全区域。这意味着,如果你有一个标题栏固定在顶部 position: fixed; top: 0 。它将会在屏幕顶部下面的 20px 开始渲染。当你向下滚动时,它会移动到状态栏的后面。当你向上滚动时,它会再次下降到状态栏下面,而且在 20px 的间隙中,内容会透出。在 Understanding the WebView Viewport in iOS 11 这篇文章中的视频可以看到实际是怎么样的。不过这个恼人的问题通过给 viewport-fit 进行配置即可解决。

viewport-fit 可以设置可视窗口(Visual Viewport)的大小。在设备的物理屏幕上看到的初始布局视窗。在圆形屏幕上,当前屏幕上显示的部分是圆形的,但是 Viewport 却是矩形的。因此,根据 Viewport 的大小,页面的某些部可能被剪切。

clipped_area

viewport-fit 可以设置可视视窗的大小,也就是裁剪区域,viewport-fit 接收以下三个值:

  • contain:viewport 中可以显示整个页面的内容,也就是说那些 position 设置为 fixed 的元素会相对于 iOS 11 的安全区域定位。

    注意: 设置该属性值时,border-boundary: displayshape-inside: display 会失效。

  • cover:页面的内容完全覆盖整个 viewport。也就是说那些 position: fixed 的元素会相对于 viewport 定位,这就可能导致页面中有的内容会被遮盖。
  • auto:默认值,表现与 contain 一致。

CSS Round Display Level 1 中有给出了一些对于这个属性的建议:

当在非矩形显示器上设置 viewport 的边界框(viewport Bounding Box)大小时,必须考虑以下因素:

  • 由于 viewport 边界框(viewport Bounding Box)的面积大于显示区域,导致剪切区域
  • 在 Viewport 边界框和显示区域之间存在间隙

开发人员可以决定哪一个因素比较重要。如果必须确保 web 页面的没有任何一部分被隐藏,那么避免发生剪裁的优先级要高于处理在 viewport 的边界框和屏幕的边框之间的间隙。如果不想让 web 页面被缩小导致可读性上降低,那么最好将 viewport-fit 设置为 cover ,但是要注意页面被裁剪的部分。

我们直接看规范中给出的例子:

1
2
3
@viewport (viewport-fit: contain) {
/* CSS for the rectangular design */
}

viewport_fit_contain

当将 viewport-fit 设置为 contain 时,viewport 会以显示完整的、最大的矩形页面为依据,去展示 web 内容。在 iPhone X 中可视区域内多余的区域会以白色的背景色显示。

1
2
3
4
5
6
7
8
9
@viewport {
viewport-fit: cover;
}
@media (shape: round){
/* styles for the round design */
}
@media (shape: rect){
/* styles for the rectangular design */
}

viewport_fit_cover

当将 viewport-fit 设置为 cover 时,初始的 viewport 会受限于非矩形的展示区域,以填满展示区域为依据进行展示。

3.1.2 实际应用

使用 viewport-fit 只需在 viewport 的声明中增加 viewport-fit 的配置即可。

1
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

通过给 viewport-fit 设置 cover 可以页面内容充满 iPhone X 的屏幕。下面是不作处理的 iPhone X 打开页面,可以看到有两条白边。

iphone_x_with_white_wings

设置了 viewport-fit 为 cover 之后:

iphone_x_with_viewport_fit_cover

3.2 safe-area-inset-*

基本介绍

新出的 iPhone X 形状不规则,其状态栏的高不再是 20px,而且在摄像头和扬声器的设置下,你的标题栏内容将会完全无法访问到。需要注意的是,这也适用于固定在屏幕底部的页脚,它将被麦克风阻塞。在一开始的关于安全区域的图中就可以看到,底部是有一定区域在 safe area 之外。

在上面一个小节,通过设置 viewport-fit 我们将页面的内容铺满了整个屏幕,但是可以看到“齐刘海”会将一部分的内容遮挡,如果我们知道屏幕边缘到 safe area 边缘的距离,那就可以通过设置 padding 属性来解决这个问题了。

在设置 viewport-fit=cover 之后,Web 中会新增四个常量:

  • safe-area-inset-top:可视区域顶部边缘到安全区域的距离(以CSS像素为单位)

  • safe-area-inset-right:可视区域最右边到安全区域的距离(以CSS像素为单位)

  • safe-area-inset-left:可视区域最左边到安全区域的距离(以CSS像素为单位)

  • safe-area-inset-bottom:可视区域底部边缘到安全区域的距离(以CSS像素为单位)

对应地苹果添加了一个方法,将安全区域布局指南暴露给 CSS 。他们添加类似一个CSS的变量概念,叫作 CSS constants 。通过 constant() 就可以获取到上面的几个常量。
通过给页面的容器元素增加以下的样式属性:

1
padding: constant(safe-area-inset-top) constant(safe-area-inset-right) constant(safe-area-inset-bottom) constant(safe-area-inset-left);

可以解决内容被遮挡的问题。

iphone_x_with_viewport_fit_cover_constant

要注意在 Darryl Pogue 的文章中说到:

Note: iOS 11.0 uses the constant() syntax, but future versions will only support env()!

在 iOS 11 中仍使用 constant(),但是在未来的版本将会抛弃 constant() 改用 env()。对此 Darryl Pogue 给出的一个关于 header 的样式例子如下,简单做了渐进增强。

1
2
3
4
5
6
7
8
9
10
11
12
13
header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;

/* Status bar height on iOS 10 */
padding-top: 20px;

/* Status bar height on iOS 11+ */
padding-top: constant(safe-area-inset-top);
}

3.3 max() & min ()

3.3.1 基本介绍

max() 和 min() 未当前版本未包含的功能

如果你在网站设计中采用了 safe-area-inset-*,你可能会注意到,除了安全区域插入之外,你还需要指定最小的内填充。在上面页面中,如果我们使用了 constant(safe-area-inset-left),当设备旋转回垂直方向时,左边的安全插入距离变成了 0px,文本便紧贴着屏幕边缘了。

min()max() 来实现,这两个函数会在未来的 Safari 技术预览版中可用。这两个函数都接受任意数量的参数,并返回最小值或最大值。它们能用在calc()里面,或者彼此嵌套。并且这两个函数都允许使用calc()作为参数,就像使用数字一样。

3.3.2 实际应用

在处理左右边距时使用max():

1
2
3
4
5
6
@supports(padding: max(0px)) {
.post {
padding-left: max(12px, constant(safe-area-inset-left));
padding-right: max(12px, constant(safe-area-inset-right));
}
}

使用 @support() 做功能检测很必要。

3.4 theme color

iPhone X 在 portrait 模式下(竖屏)状态栏和 URL 地址栏会有一定的透明度,所以他们会显示半透明的网页背景色。

theme_color

通过声明 theme-color 即可:

1
<meta name="theme-color" content="#676767">

4 简单的总结

本文提及的 API:

  • viewport-fit:有 containcoverauto 三个取值。
  • safe-area-inset-*:与 safe area 有关的几个常量,在设置 viewport-fit=cover 后可以使用
  • max()min():在 CSS 中便利地获取最大值和最小值

5 补充扩展

显然除了使用以上的 API 去适配 iPhone X,还会有其他情况要使用别的适配方式。

sharp_outside

在张鑫旭大佬的这篇文章中有讲到这种效果如何实现,可以了解一下。

参考资料

装饰器及其相关实例

1. 装饰器的概念

1-0. 前言

装饰器 (Decorator) 是 ES2017 中的一个提案,装饰器的出现,给我们在多个不同类之间共享或者扩展一些方法或者行为的时候,提供了一种更加优雅的方法。

1-1. 什么是装饰器

简单的说,修饰器就是一个对类进行处理的函数,可以给类以及类的方法添加一些行为,而又不改变其代码。
在 Python 中,对于 装饰器 (Decorator) 是这样定义的:

A Python decorator is a function that takes another function, extending the behavior of the latter function without explicitly modifying it.
Python 装饰器是接受另一个函数作为参数的函数,扩展参数函数行为的同时并没有修改参数函数本身。

我们通过一个简单的例子来说明装饰器是什么,以下代码是一个给类添加属性 isTestable 的装饰器,通过 @testable 这样的调用,可以给类增加一个 isTestable = true 的属性。

1
2
3
4
5
6
7
8
9
10
@testable
class MyTestableClass {
// ...
}

function testable(target) {
target.isTestable = true;
}

MyTestableClass.isTestable // true

1-2. 设计模式: 装饰模式

装饰模式是 包装模式 (Wrapper Pattern) 的一种,同为包装模式的还有适配器模式。装饰模式以对客户端透明的方式扩展对象的功能,是继承关系的一个替代方案。

装饰模式的设计模式是为了动态地给一个对象增加额外的行为,单就增加功能来说,装饰模式要比生成子类的方式更加灵活。遇到符合下面描述的情况时,可以考虑使用装饰模式:

  • 需要扩展一个类的功能,或者给一个类增加属性
  • 需要动态的给一个对象增加功能,这些功能需要动态的撤销
  • 需要增加一些基本功能的排列组合而产生的非常大量的功能,从而使继承变得不现实

2. ES6 中的装饰器

ES6 中装饰器使用特殊的语法,使用 @ 作为标识符,且放置在被装饰代码之前。一个类可以被多个不同的装饰器装饰,在代码进行编译的时候,按照顺序相应执行。

1
2
3
4
5
6
7
8
@log()
@immutable()
class Example {
@time('demo')
doSomething() {

}
}

此外,装饰器对于类的行为的改变,是在代码编译的时候发生的,并不是在运行时。这意味着,修饰器能在编译阶段运行代码。也就是说,修饰器本质就是编译时执行的函数。

2-1. 关于 Object.defineProperty

ES6 中的装饰器依赖于 ES5 中的 Object.definePropertyObject.defineProperty 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

Object.defineProperty 的语法如下:

Object.defineProperty(obj, prop, descriptor)

参数 obj 为要在其上定义属性的对象,prop 为要定义或者修改的属性的名称,descripor 为将被定义或修改的属性描述符,对于属性描述符有哪些,可以看这里
该方法返回被传递给函数的对象。

Object.defineProperty允许精确添加或修改对象的属性。通过赋值来添加的普通属性会创建在属性枚举期间显示的属性( for...inObject.keys 方法), 这些值可以被改变,也可以被删除。这种方法允许这些额外的细节从默认值改变。默认情况下,使用 Object.defineProperty() 添加的属性值是不可变的。

2-2. 装饰器的使用

接下来我们通过几个简单的例子来看看,如何使用 ES6 提供的语法糖。

2-2-1. 对类的装饰器

像我们在 1-1 中举的例子 testable 就是一个对类进行装饰的例子,这个例子可以用带参数的方法实现,类似于工厂方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
function testable(isTestable) {
return function(target) {
target.isTestable = isTestable;
}
}

@testable(true)
class MyTestableClass {}
MyTestableClass.isTestable // true

@testable(false)
class MyClass {}
MyClass.isTestable // false

前面的例子是为类添加一个静态属性,如果想添加实例属性,可以通过目标类的prototype对象操作。

1
2
3
4
5
6
7
8
9
function testable(target) {
target.prototype.isTestable = true;
}

@testable
class MyTestableClass {}

let obj = new MyTestableClass();
obj.isTestable // true

2-2-2. 对类属性的装饰器

类属性装饰器适用于类的单独成员,接收三个参数:

  • target 被修饰的类
  • name 要修饰的类成员的名字
  • descriptor 要修饰的类成员的描述符

可以看到接收参数与 Object.defineProperty 完全类似。

对于类属性的装饰,可以先看一个非常常见的只读实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function readonly(target, name, descriptor) {
discriptor.writable = false;
return discriptor;
}
class Cat {
@readonly
say() {
console.log("meow");
}
}
var wangcai = new Cat();
wangcai.say = function() {
console.log("woof");
}

// Exception: Attempted to assign to readonly property

可以看到对 wangcaisay 方法进行修改并没有生效,此时 Cat 对象的 say 方法是只读的,不可被赋值表达式改变。

2-2-3. 装饰器用于函数

从上文中我们知道,实际上装饰器在代码编译时就运行了,存在函数提升,可以看下面的例子:

1
2
3
4
5
6
7
8
9
var counter = 0;

var add = function () {
counter++;
};

@add
function foo() {
}

由于函数提升的存在,实际执行的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@add
function foo() {
}

var counter;
var add;

counter = 0;

add = function () {
counter++;
};

我们想让 counter 输出为 1 ,而counter 在最后给赋值了 0。

3. 应用实例

3-1. debounce (去抖动)

函数执行次数过于频繁导致性能问题的时候,debounce (去抖动) 可以节约性能提高用户体验。debounce (去抖动)的定义是:

如果用手指一直按住一个弹簧,它将不会弹起直到你松手为止。

去抖动通过限制函数执行次数,来提高用户体验。当调用动作 n 毫秒后,才会执行该动作,若在这 n 毫秒内又调用此动作则将重新计算执行时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// core-decorators/src/debounce.js
import { decorate, metaFor, internalDeprecation } from './private/utils';

const DEFAULT_TIMEOUT = 300;

function handleDescriptor(target, key, descriptor, [wait = DEFAULT_TIMEOUT, immediate = false]) {
const callback = descriptor.value;

if (typeof callback !== 'function') {
throw new SyntaxError('Only functions can be debounced');
}

return {
...descriptor,
value() {
const { debounceTimeoutIds } = metaFor(this); // 每个 debounce 使用独立的计时器
const timeout = debounceTimeoutIds[key];
const callNow = immediate && !timeout;
const args = arguments;

clearTimeout(timeout);

debounceTimeoutIds[key] = setTimeout(() => {
delete debounceTimeoutIds[key];
if (!immediate) {
callback.apply(this, args);
}
}, wait);

if (callNow) {
callback.apply(this, args);
}
}
};
}

export default function debounce(...args) {
internalDeprecation('@debounce is deprecated and will be removed shortly. Use @debounce from lodash-decorators.\n\n https://www.npmjs.com/package/lodash-decorators');
return decorate(handleDescriptor, args);
}

3-2. 混入 (Mixin)

混入 (Mixin) 所作的事情即枚举出一个或者多个对象的所有属性,然后将这些属性添加到另一个对象上去。

而实际上 jQuery 中的 jQuery.extend和 lodash 中的 _.mixin 通过不同的调用形式都实现了混入 (Mixin)。

依赖于 Object.assign 我们可以用装饰器的方式实现实现混入 (Mixin):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function mixins(...list) {
return function (target) {
Object.assign(target.prototype, ...list);
};
}

const Foo = {
foo() { console.log('foo') }
};

@mixins(Foo)
class MyClass {}

let obj = new MyClass();
obj.foo() // "foo"

3-3. 简单的日志系统

通过在给类执行前后增加输出的处理,可以实现监控类行为的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let log = (type) => {
return (target, name, descriptor) => {
const method = descriptor.value;
descriptor.value = (...args) => {
console.info(`(${type}) 正在执行: ${name}(${args}) = ?`);
let ret;
try {
ret = method.apply(target, args);
console.info(`(${type}) 成功 : ${name}(${args}) => ${ret}`);
} catch (error) {
console.error(`(${type}) 失败: ${name}(${args}) => ${error}`);
}
return ret;
}
}
}
class IronMan {
@log('IronMan 自检阶段')
check(){
return '检查完毕';
}
@log('IronMan 攻击阶段')
attack(){
return '击倒敌人';
}
@log('IronMan 机体报错')
error(){
throw 'Something is wrong!';
}
}

var tony = new IronMan();
tony.check();
tony.attack();
tony.error();

// (IronMan 自检阶段) 正在执行: check() = ?
// (IronMan 自检阶段) 成功 : check() => 检查完毕
// (IronMan 攻击阶段) 正在执行: attack() = ?
// (IronMan 攻击阶段) 成功 : attack() => 击倒敌人
// (IronMan 机体报错) 正在执行: error() = ?
// (IronMan 机体报错) 失败: error() => Something is wrong!

4. 通过 Babel 使用

使用 npm 安装 babel-plugin-transform-decorators-legacy 插件:

1
npm install babel-plugin-transform-decorators-legacy --save-dev

配置 .babelrc 文件:

1
"plugins": ["transform-decorators-legacy"]

参考资料:

简析 javascript 中的深拷贝和浅拷贝

1 深拷贝和浅拷贝的区别

1.1 JavaScript 的变量类型

JavaScript 中的变量类型可以分为两类:

  • 基本类型
  • 引用类型

基本类型有5种:

Undefined、Null、Boolean、Number 和 String

引用类型即我们所说的对象,存放在堆内存。

实际上引用类型保存的是一个指针,指向引用类型的值。当需要访问引用类型的值时,首先从栈中获得该对象的地址指针,然后再从堆内存中取得所需的数据。

1.2 JavaScript 的深拷贝和浅拷贝

深拷贝和浅拷贝都是对于应用类型而言的,简单的说,浅拷贝只复制了引用类型的子级的属性,而浅拷贝不止复制子级的属性,还递归复制了所有层级的属性。

所以可以知道,如果复制的对象中有饮用对象,那么在只复制了引用的情况下,复制产生的新对象和原对象引用的是同一个栈里的值。

diff_copy

废话不多说,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
var zhang = {
name: "san",
age: 25,
gender: "male"
};
var zhang1 = zhang;
console.log(zhang); //Object {name: "san", age: 25, gender: "male"}
console.log(zhang1); //Object {name: "san", age: 25, gender: "male"}
zhang1.name = "si";
console.log(zhang); //Object {name: "si", age: 25, gender: "male"}
console.log(zhang1); //Object {name: "si", age: 25, gender: "male"}

明明是修改的 zhang1 的 name ,连 zhang 的 name 也改变了厚,这就是浅拷贝。
根据定义我们来实现深拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var zhang = {
name: "san",
age: 25,
gender: "male"
};
var zhang1 = {
name: zhang.name,
age: zhang.age,
gender: zhang.gender
};
console.log(zhang); //Object {name: "san", age: 25, gender: "male"}
console.log(zhang1); //Object {name: "san", age: 25, gender: "male"}
zhang1.name = "si";
console.log(zhang); //Object {name: "san", age: 25, gender: "male"}
console.log(zhang1); //Object {name: "si", age: 25, gender: "male"}

2 浅拷贝的实现方法

2.1 引用复制

浅拷贝只复制第一层的子级属性,所以只要遍历对象的子级属性进行复制就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function shallowClone(copyObj) {
var obj = {};
for ( var i in copyObj) {
obj[i] = copyObj[i];
}
return obj;
}
var x = {
a: 1,
b: { f: { g: 1 } },
c: [ 1, 2, 3 ]
};
var y = shallowClone(x);
console.log(y.b.f === x.b.f); // true

2.2 Object.assign() 函数

ES2015中提供了 Object.assign() 函数,用于将指定对象和目标对象合并。 MDN 上对于该函数的说明是:

Object.assign() 方法用于将所有可枚举的属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

因为 Object.assign() 拷贝的是属性值,加入源对象的属性值是一个指向对象的引用,它也只拷贝那个引用值。

1
2
3
4
5
6
7
let a = { b: {c:4} , d: { e: {f:1}} }
let g = Object.assign({},a)
console.log(g.d) //Object { e: { f: 1 } }
g.d.e = 32
console.log(g) //Object { b: { c: 4 }, d: { e: 32 } }
console.log(a) //Object { b: { c: 4 }, d: { e: 32 } }
console.log(h) //Object { b: { c: 4 }, d: { e: { f: 1 } } }

3 深拷贝的实现方法

3.1 JSON.parse() 和 JSON.stringify()

通过 JSON.stringify() 获得对象的 json 字符串,然后再通过 JSON.parse()将该字符串转化成实际对象。

1
let copya = JSON.parse(JSON.stringify(a));

不过这个方法的缺点也很明显:

  • json 不支持 NaN ,Infinity 和精确的浮点数
  • 不支持 function

所以使用这个方法并不好,还考虑到这个方法的效率,虽然简单,但是一般情况下都不会用。

3.2 通过递归解析的拷贝

这个方法其实就是递归地去解析对象的属性,将解析到的属性一条一条赋值给新的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function deepCopy(o, c) {
var c = c || {}
for (var i in o) {
if (typeof o[i] === 'object') {
if (o[i].constructor === Array) { //数组
c[i] = []
} else { //对象
c[i] = {}
}
deepCopy(o[i], c[i])
} else {
c[i] = o[i]
}
}
return c
}
var a = { b: {c:4} , d: { e: {f:1}} }
var g = deepCopy(a, {});
console.dir(a.b.c); //4
console.dir(g.b.c); //4
g.b.c = 5;
console.dir(a.b.c); //4
console.dir(g.b.c); //5

3 jQuery.extend() 函数的对象拷贝

3.1 jQuery.extend() 的基本用法

jQuery.extend() 可以实现深拷贝和浅拷贝,这个函数大多情况下用来扩展 jQuery / jQuery.fn 对象的方法,jQuery官网的解释是:

Description: Merge the contents of two or more objects together into the first object.

个人翻译就是将多个对象的属性合并到第一个对象中,如果有同名的属性,则将第一个对象中的属性覆盖,规则上是靠后(右)的对象属性覆盖靠前(左)的对象属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var object1 = {
apple: 0,
banana: { weight: 52, price: 100 },
cherry: 97
};
var object2 = {
banana: { price: 200 },
durian: 100
};
var object3 = {
banana: { price: 300 },
watermalon: 50
};

// Merge object2 and object3 into object1
$.extend( object1, object2, object3 );
console.log(JSON.stringify(object1)); //{"apple":0,"banana":{"price":300},"cherry":97,"durian":100,"watermalon":50}

3.2 jQuery.extend() 实现浅拷贝和深拷贝

根据 jQuery.extend() 的定义,只需要将要拷贝的对象合并到一个空对象,就可以实现拷贝了。而将 jQuery.extend() 的第一个参数设置成 true ,就可设置成深拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var obj = { b: {c:4} , d: { e: {f:1}} }
var shallowc = $.extend({}, a)
var deepc = $.extend(true, {}, a)
console.dir(obj.b.c); //4
console.dir(shallowc.b.c); //4
console.dir(deepc.b.c); //4
deepc.b.c = 5;
console.dir(obj.b.c); //4
console.dir(shallowc.b.c); //4
console.dir(deepc.b.c); //5
shallowc.b.c = 6;
console.dir(obj.b.c); //6
console.dir(shallowc.b.c); //6
console.dir(deepc.b.c); //5

可以看到 $.extend({}, a)$.extend(true, {}, a) 得到的对象分别就是浅拷贝和深拷贝得到的对象。文末附上 jQuery.extend() 的源码。

4 简单的总结

说了那么多,需要来简单的概括一下。

其实深拷贝的需求在实际开发中会出现的频次并不高,对于深拷贝,最好的方法就是抛弃需要深拷贝的代码


参考资料:
Object.assign() - JavaScript|MDN
jQuery.extend() | jQuery API Documentation
javaScript中浅拷贝和深拷贝的实现
[Javascript] 關於 JS 中的淺拷貝和深拷貝 · Larry

附上 $.extend() 源码(是不是跟楼上的递归很像)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
jQuery.extend = jQuery.fn.extend = function() {
var options, name, src, copy, copyIsArray, clone,
target = arguments[ 0 ] || {},
i = 1,
length = arguments.length,
deep = false;

// Handle a deep copy situation
if ( typeof target === "boolean" ) {
deep = target;

// Skip the boolean and the target
target = arguments[ i ] || {};
i++;
}

// Handle case when target is a string or something (possible in deep copy)
if ( typeof target !== "object" && !jQuery.isFunction( target ) ) {
target = {};
}

// Extend jQuery itself if only one argument is passed
if ( i === length ) {
target = this;
i--;
}

for ( ; i < length; i++ ) {

// Only deal with non-null/undefined values
if ( ( options = arguments[ i ] ) != null ) {

// Extend the base object
for ( name in options ) {
src = target[ name ];
copy = options[ name ];

// Prevent never-ending loop
if ( target === copy ) {
continue;
}

// Recurse if we're merging plain objects or arrays
if ( deep && copy && ( jQuery.isPlainObject( copy ) ||
( copyIsArray = Array.isArray( copy ) ) ) ) {

if ( copyIsArray ) {
copyIsArray = false;
clone = src && Array.isArray( src ) ? src : [];

} else {
clone = src && jQuery.isPlainObject( src ) ? src : {};
}

// Never move original objects, clone them
target[ name ] = jQuery.extend( deep, clone, copy );

// Don't bring in undefined values
} else if ( copy !== undefined ) {
target[ name ] = copy;
}
}
}
}

// Return the modified object
return target;
};

ES6中的let、const和var

1 细数 var 的种种原罪

1.1 var 使用时的变量提升

总所周知,JavaScript中的作用域并不是块级作用域,而是函数作用域。所谓的作用域就是可以访问到某一个变量的代码范围,那么函数作用域又是怎么样的呢?
简单的来说,在我们用var声明变量时,这个变量的作用域是向声明的上下两个方向同时延伸,直到到达函数边界,这个函数边界包围的代码范围就是var的作用域。
也因此js引擎会将每一个var声明和函数声明都提升到封闭的函数顶部,这样才可以在变量的使用前把变量先声明,这种js引擎的处理就是“变量提升(hoisting)”。
可以看一个简单的例子:

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

输出结果为:

console1

变量提升固然有其必要性,但是有的情况下会出现undefinedReferenceError,还有相同变量名时产生的错误,都让人感觉困惑难以追查问题。

1.2 闭包的困惑

MDN上对于闭包(Closure)的定义:

闭包是指那些能够访问独立(自由)变量的函数 (变量在本地使用,但定义在一个封闭的作用域中)。换句话说,这些函数可以“记忆”它被创建时候的环境

简单的说,闭包就是指内部函数可以访问外部函数的一种机制,但外部函数并不能访问内部函数的变量。
以下是一段前端面试很喜欢用到的代码:

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

console.log(new Date(), 1);

结果应该是立即输出一个时间点和5,而在短暂的时间间隔后同时连续5次输出同一个时间点和5(考虑到js的定时机制并不是完全准确的,这里的时间间隔应该是大约在1秒后输出)。
那为什么不是输出不同的时间和0到5呢?
因为这里其实共用了同一个变量i,五个超时回调同时使用一个i,亦即构成了闭包,就会造成在循环完成时,i的值被赋为5,此时所有的超时回调还没有被回调到。
那么应该如何去修改成输出不同的结果呢?在这里可以选择一个比较简单的修改,就是用IIFE(Immediately Invoked Function Expression:声明即刻执行的函数表达式)来解决闭包造成的问题。

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

console.log(new Date(), 1);

可以看出来其实var的这些不便,都是早期的 JavaScript 设计导致的,很多编程语言都是向后兼容的,所以这种设计上的失误无法被修复。所以在 ES6 中推出了新的变量声明关键字let,来让作用域规则更加合理。

2 新 var: let

2.1 let 和 var 的区别

MDN上对于let语句的定义如下:

let 语句声明一个块级作用域的本地变量,并且可选的赋予初始值。

用法和 var 一样,进一步的描述为:

let允许你声明一个作用域被限制在块级中的变量、语句或者表达式。与var关键字不同的是,var声明的变量只能是全局或者整个函数块的

简单的说,let 的作用域是块,而 var 的作用域是函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = 5;
var b = 10;

if (a === 5) {
let a = 4; // The scope is inside the if-block
var b = 1; // The scope is inside the function

console.log(a); // 4
console.log(b); // 1
}

console.log(a); // 5
console.log(b); // 1

所以可以知道在程序或者函数的顶层,let 并不会想 var 一样在全局对象上创造一个属性:

1
2
3
4
var x = 'global';
let y = 'global';
console.log(this.x); // "global"
console.log(this.y); // undefined

此外还要注意一点,let 变量的重复声明是语法错误,所以如果有一些脚本声明了相同的全局变量,那么在二次加载的时候会出现报错。

let 是一个严格模式下的保留词。在非严格模式的代码中,为了向后兼容,你仍然可以声明变量、函数和名为 let 的实参——你可以写 var let = 'q';,而不是你原本想写的那样。let let; 也是不允许的。

2.2 Const

const 声明创建一个只读的常量。这不意味着常量指向的值不可变,而是变量标识符的值只能赋值一次。

1
2
3
4
const PI = 3.14;

PI // 3.14
PI = 3; // TypeError: Assignment to constant variable.

const 的声明需要立即进行赋值,否则会报错。

1
const FOO; // SyntaxError: missing = in const declaration

在给一个常量定义成对象时需要注意区分这里的常量指的是这个对象的地址,因为复合类型的变量,变量名不指向数据,而是指向数据所在的地址(这里跟C里的指针十分类似)。

可以简单地理解成存储数据的地址是常量,这个地址是不变的,而该存储单元存储的数据是可以改变的。

1
2
3
4
5
6
const MY_OBJECT = {"key": "value"};

MY_OBJECT = {"OTHER_KEY": "value"}; // TypeError: Assignment to constant variable.

// 对象属性并不在保护的范围内,下面这个声明会成功执行
MY_OBJECT.key = "otherValue";

还有个关于数组的例子:

1
2
3
4
const a = [];
a.push('Hello'); // ['Hello']
a.length = 0; // []
a = ['Dave']; // Assignment to constant variable.

参考资料:
let 和 const
80% 应聘者都不及格的 JS 面试题
let - JavaScript|MDN

自制简易日历

1. 日历插件的需求分析

在官网电商部分的开发中,遇到了需要自定义日历插件的情况,日历中的每一天都需要显示库存or价格,而此前使用的日历插件仅仅只有选择的功能,在这种情况下简单地研究了下日历的生成以及相关的一些兼容性问题。

所以经过简单的分析,简单总结:

  • 正确的日期显示以及排版
  • 响应某一个日期的点击事件
  • 随时对日历的月份进行修改(月份上移or下移),即重新渲染日历

2. 实际代码编写

2.1 参数约定

首先我们用一个对象来存储传入的参数,调出一个基本的当月日历,需要传入年、月份。
此外根据实际情况需要在每一天显示的内容,增加一个模板的参数,传入每一天所要显示的内容,传入HTML内容即可。
除了以上的最基本内容,我这里加入了日历的调整范围,即通过日历的上一月下一月按钮可以到达的最早的月份和最晚的月份。
得到的对象如下,这里我们在方法中先声明默认的值,在不传入任何参数的情况下,显示当前月份,并且将对日历月份没有任何限制。

1
2
3
4
5
6
var defaultSeeting = {
"year": new Date().getFullYear(),//年
"month": new Date().getMonth() + 1,//月
"template": "{{date}}",//日历表格中的模板
"refresh": false//是否非页面初始化,用以控制事件的绑定
}

这里面的模板template参数使用以下的规则进行替换:

1
2
3
4
5
6
7
8
9
10
/**
* @template 表格中一天的模板
* 模板替换规则:
* {{year}} -> 年
* {{month}} -> 月份
* {{date}} -> 日期
* {{fulldate}} -> 完整的日期
* {{day}} -> 周几
*
*/

2.2 日历的HTML渲染

我们要渲染的日历是一个table元素,所以本质上要处理的就是在一个7*57*4或者7*6的表格中,显示某月的每一个日期,根据表格是否属于该月来控制背景色,从单纯的展示功能来看,只要每一个td中显示的日期和背景颜色正确就可以了。所以我们预计效果图如下:

calendar_preview

先考虑要拼的HTML内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<table cellpadding="0">
<tbody>
<tr class="month">
<th colspan="7">
<div class="clearfix">
<div class="prevMonth">
<a class="prev0" href="javascript:;" title="上个月">上个月</a>
</div>
<div class="dates" data-fullmonth="2017-2">
<em>2017</em>年<em>2</em>月
</div>
<div class="nextMonth">
<a class="next" href="javascript:;" title="下个月">下个月</a>
</div>
</div>
</th>
</tr>
<tr class="week">
<th class="weekEnd"><span>星期日</span></th>
<th><span>星期一</span></th>
<th><span>星期二</span></th>
<th><span>星期三</span></th>
<th><span>星期四</span></th>
<th><span>星期五</span></th>
<th class="weekEnd"><span>星期六</span></th>
</tr>
<tr>
<td></td>
<td></td>
<td></td>
<td><p>1</p></td>
<td><p>2</p></td>
<td><p>3</p></td>
<td><p>4</p></td>
</tr>
//……
</tbody>
</table>

首先我们要获取这个月份有多少天,先以指定月份的次月1号为日期新建一个Date对象,再用这个对象的毫秒数减去一天的毫秒数(86400000)即为该月的最后一天,获取该天的日期就是我们指定的月份有多少天了。

1
2
var nextMonth1st = new Date(year, month, 1),//指定月份的次月1号
maxdays = (new Date(Date.parse(nextMonth1st) - 86400000)).getDate();

接下来就是日期td的渲染,需要判断对应的td是否为该月中的日期,其实只有以下的条件:

  • 小于当前月第一天的
  • 大于当前月最后一天的

对于日历来说,其实类似于一个二维的数组结构,所以我们只需要两层遍历去生成日历模板。

1
2
3
4
5
6
for (var j = 1; j <= 6; j++) {
//...
for (var k = 1; k <= 7; k++) {
//...
}
}

在判断的条件中我们用一个变量来存储当前处于星期几,即

1
2
3
4
5
6
7
8
var i1 = (new Date(y, m - 1 + i)).getDate(), i2 = 1;
for (var j = 1; j <= 6; j++) {
//...
for (var k = 1; k <= 7; k++) {
i2 = (j - 1) * 7 + k -i1;

}
}

// 断更待续

window.history.pushState引发的血案

1. pushState是什么?

这两天在实习中,要将官网的后台系统扩展,其中的一个点就是将页面的主体内容用iframe来引用,在被iframe的高度坑过一遍之后,一个问题又浮现在我的脑海中:

使用iframe时,如果用户点击了浏览器的后退按钮,因为使用的是iframe,那么这时候整个页面都会后退,而不是用户所预料的只是iframe中页面的后退。

针对于这个问题,首先想到的就是H5的新方法history.pushState()history.replaceState()window.onpopstate事件,但是当时由于在忙别的,所以在这个问题没有细想。

简单的说,history对象下的pushState是H5提出的一个新方法,可以将浏览器窗口的URL修改为自己想要的路径,并且将这个路径push到当前页面的页面浏览历史中。

要注意的是,这种URL的修改不会让页面刷新,亦即不会让页面发生跳转

1.1 history.pushState(state, title, url);

pushState的参数一共有三个:

  • state:可以是一个null,也可以是一个完整的对象,用来表示要push的url对应的状态信息。
  • title:字符串,声明要push的url对象的title,实际使用时并没有什么鸟用。
  • url':要push的history栈里的url,同时也是显示在浏览器地址栏中的地址。要注意该url不能跨域

根据该方法传入的state,我们可以通过控制台输出history查看其state,如果该url不是通过pushStatereplaceState产生的,那么history.state就是一个null。当然,如果你在pushState时传的state本来就是null,那么当我没说吼

在Chrome控制台测试该接口,可以看到调用了pushState方法之后,historylength增加了1,即将指定的url添加到了history栈中。

pushState

1.2 history.replaceState(state, title, url)

replaceState的参数与pushState的参数一致。

调用了replaceState方法之后,historylength不变,指定的url没有被添加到history栈中,仅仅只是浏览器地址栏的url改变。

replaceState

2 window.onpopstate

window下的popstate事件是在history发生变化时触发,也就是说调用history.go()history.back()都会触发该事件。
但是要注意,我们上面提到的history.pushState()history.replaceState()并不会触发该事件。

3 仓促结尾的说明

本来想延伸开来讲一下API的具体应用,但是由于种种原因,在这里就只是简单介绍API的使用方法,具体应用的话等到以后有机会再来介绍(其实就是还没有在生产环节用过…XD)。