fecoder

fecoder 查看完整档案

深圳编辑  |  填写毕业院校  |  填写所在公司/组织 公众号:前端漫游指南 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

fecoder 赞了文章 · 4月7日

万字长文+图文并茂+全面解析微前端框架 qiankun 源码 - qiankun 篇

写在开头

微前端系列文章:

本系列其他文章计划一到两个月内完成,点个 关注 不迷路。

计划如下:

  • 生命周期篇;
  • IE 兼容篇;
  • 生产环境部署篇;
  • 性能优化、缓存方案篇;

引言

本文将针对微前端框架 qiankun 的源码进行深入解析,在源码讲解之前,我们先来了解一下什么是 微前端

微前端 是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。同时,它们也可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

qiankun(乾坤) 就是一款由蚂蚁金服推出的比较成熟的微前端框架,基于 single-spa 进行二次开发,用于将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。(见下图)

qiankun

那么,话不多说,我们的源码解析正式开始。

初始化全局配置 - start(opts)

我们从两个基础 API - registerMicroApps(apps, lifeCycles?) - 注册子应用start(opts?) - 启动主应用 开始,由于 registerMicroApps 函数中设置的回调函数较多,并且读取了 start 函数中设置的初始配置项,所以我们从 start 函数开始解析。

我们从 start 函数开始解析(见下图):

qiankun

我们对 start 函数进行逐行解析:

  • 第 196 行:设置 window__POWERED_BY_QIANKUN__ 属性为 true,在子应用中使用 window.__POWERED_BY_QIANKUN__ 值判断是否运行在主应用容器中。
  • 第 198~199 行:设置配置参数(有默认值),将配置参数存储在 importLoaderConfiguration 对象中;
  • 第 201~203 行:检查 prefetch 属性,如果需要预加载,则添加全局事件 single-spa:first-mount 监听,在第一个子应用挂载后预加载其他子应用资源,优化后续其他子应用的加载速度。
  • 第 205 行:根据 singularMode 参数设置是否为单实例模式。
  • 第 209~217 行:根据 jsSandbox 参数设置是否启用沙箱运行环境,旧版本需要关闭该选项以兼容 IE。(新版本在单实例模式下默认支持 IE,多实例模式依然不支持 IE)。
  • 第 222 行:调用了 single-spastartSingleSpa 方法启动应用,这个在 single-spa 篇我们会单独剖析,这里可以简单理解为启动主应用。

从上面可以看出,start 函数负责初始化一些全局设置,然后启动应用。这些初始化的配置参数有一部分将在 registerMicroApps 注册子应用的回调函数中使用,我们继续往下看。

注册子应用 - registerMicroApps(apps, lifeCycles?)

registerMicroApps 函数的作用是注册子应用,并且在子应用激活时,创建运行沙箱,在不同阶段调用不同的生命周期钩子函数。(见下图)

qiankun

从上面可以看出,在 第 70~71 行registerMicroApps 函数做了个处理,防止重复注册相同的子应用。

第 74 行 调用了 single-sparegisterApplication 方法注册了子应用。

我们直接来看 registerApplication 方法,registerApplication 方法是 single-spa 中注册子应用的核心函数。该函数有四个参数,分别是

  • name(子应用的名称)
  • 回调函数(activeRule 激活时调用)
  • activeRule(子应用的激活规则)
  • props(主应用需要传递给子应用的数据)

这些参数都是由 single-spa 直接实现,这里可以先简单理解为注册子应用(这个我们会在 single-spa 篇展开说)。在符合 activeRule 激活规则时将会激活子应用,执行回调函数,返回一些生命周期钩子函数(见下图)。

注意,这些生命周期钩子函数属于 single-spa,由 single-spa 决定在何时调用,这里我们从函数名来简单理解。(bootstrap - 初始化子应用,mount - 挂载子应用,unmount - 卸载子应用)

qiankun

如果你还是觉得有点懵,没关系,我们通过一张图来帮助理解。(见下图)

qiankun

获取子应用资源 - import-html-entry

我们从上面分析可以看出,qiankunregisterMicroApps 方法中第一个入参 apps - Array<RegistrableApp<T>> 有三个参数 name、activeRule、props 都是交给 single-spa 使用,还有 entryrender 参数还没有用到。

我们这里需要关注 entry(子应用的 entry 地址)render(子应用被激活时触发的渲染规则) 这两个还没有用到的参数,这两个参数延迟到 single-spa 子应用激活后的回调函数中执行。

那我们假设此时我们的子应用已激活,我们来看看这里做了什么。(见下图)

qiankun

从上图可以看出,在子应用激活后,首先在 第 81~84 行 处使用了 import-html-entry 库从 entry 进入加载子应用,加载完成后将返回一个对象(见下图)

qiankun

我们来解释一下这几个字段

字段解释
template将脚本文件内容注释后的 html 模板文件
assetPublicPath资源地址根路径,可用于加载子应用资源
getExternalScripts方法:获取外部引入的脚本文件
getExternalStyleSheets方法:获取外部引入的样式表文件
execScripts方法:执行该模板文件中所有的 JS 脚本文件,并且可以指定脚本的作用域 - proxy 对象

我们先将 template 模板getExternalScriptsgetExternalStyleSheets 函数的执行结果打印出来,效果如下(见下图):

qiankun

从上图我们可以看到我们外部引入的三个 js 脚本文件,这个模板文件没有外部 css 样式表,对应的样式表数组也为空。

然后我们再来分析 execScripts 方法,该方法的作用就是指定一个 proxy(默认是 window)对象,然后执行该模板文件中所有的 JS,并返回 JS 执行后 proxy 对象的最后一个属性(见下图 1)。在微前端架构中,这个对象一般会包含一些子应用的生命周期钩子函数(见下图 2),主应用可以通过在特定阶段调用这些生命周期钩子函数,进行挂载和销毁子应用的操作。

qiankun

qiankun

qiankunimportEntry 函数中还传入了配置项 getTemplate,这个其实是对 html 目标文件的二次处理,这里就不作展开了,有兴趣的可以自行去了解一下。

主应用挂载子应用 HTML 模板

我们回到 qiankun 源码部分继续看(见下图)

qiankun

从上图看出,在 第 85~87 行 处,先对单实例进行检测。在单实例模式下,新的子应用挂载行为会在旧的子应用卸载之后才开始。

第 88 行 中,执行注册子应用时传入的 render 函数,将 HTML Templateloading 作为入参,render 函数的内容一般是将 HTML 挂载在指定容器中(见下图)。

qiankun

在这个阶段,主应用已经将子应用基础的 HTML 结构挂载在了主应用的某个容器内,接下来还需要执行子应用对应的 mount 方法(如 Vue.$mount)对子应用状态进行挂载。

此时页面还可以根据 loading 参数开启一个类似加载的效果,直至子应用全部内容加载完成。

沙箱运行环境 - genSandbox

我们回到 qiankun 源码部分继续看,此时还是子应用激活时的回调函数部分(见下图)

qiankun

第 90~98 行qiankun 比较核心的部分,也是几个子应用之间状态独立的关键,那就是 js 的沙箱运行环境。如果关闭了 useJsSandbox 选项,那么所有子应用的沙箱环境都是 window,就很容易对全局状态产生污染。

我们进入到 genSandbox 内部,看看 qiankun 是如何创建的 (JS)沙箱运行环境。(见下图)

qiankun

从上图可以看出 genSandbox 内部的沙箱主要是通过是否支持 window.Proxy 分为 LegacySandboxSnapshotSandbox 两种。

扩展阅读:多实例还有一种 ProxySandbox 沙箱,这种沙箱模式目前看来是最优方案。由于其表现与旧版本略有不同,所以暂时只用于多实例模式。

ProxySandbox 沙箱稳定之后可能会作为单实例沙箱使用。

LegacySandbox

我们先来看看 LegacySandbox 沙箱是怎么进行状态隔离的(见下图)

qiankun

我们来分析一下 LegacySandbox 类的几个属性:

字段解释
addedPropsMapInSandbox记录沙箱运行期间新增的全局变量
modifiedPropsOriginalValueMapInSandbox记录沙箱运行期间更新的全局变量
currentUpdatedPropsValueMap记录沙箱运行期间操作过的全局变量。上面两个 Map 用于 关闭沙箱 时还原全局状态,而 currentUpdatedPropsValueMap 是在 激活沙箱 时还原沙箱的独立状态
name沙箱名称
proxy代理对象,可以理解为子应用的 global/window 对象
sandboxRunning当前沙箱是否在运行中
active激活沙箱,在子应用挂载时启动
inactive关闭沙箱,在子应用卸载时启动
constructor构造函数,创建沙箱环境

我们现在从 window.Proxysetget 属性来详细讲解 LegacySandbox 是如何实现沙箱运行环境的。(见下图)

qiankun

注意:子应用沙箱中的 proxy 对象(第 62 行)可以简单理解为子应用的 window 全局对象(代码如下),子应用对全局属性的操作就是对该 proxy 对象属性的操作,带着这份理解继续往下看吧。
// 子应用脚本文件的执行过程:
eval(
  // 这里将 proxy 作为 window 参数传入
  // 子应用的全局对象就是该子应用沙箱的 proxy 对象
  (function(window) {
    /* 子应用脚本文件内容 */
  })(proxy)
);

第 65~72 行中,当调用 set 向子应用 proxy/window 对象设置属性时,所有的属性设置和更新都会先记录在 addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox 中,然后统一记录到
currentUpdatedPropsValueMap 中。

第 73 行 中修改全局 window 的属性,完成值的设置。

当调用 get 从子应用 proxy/window 对象取值时,会直接从 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。

LegacySandbox 的沙箱隔离是通过激活沙箱时还原子应用状态,卸载时还原主应用状态(子应用挂载前的全局状态)实现的,具体实现如下(见下图)。

qiankun

从上图可以看出:

  • 第 37 行:在激活沙箱时,沙箱会通过 currentUpdatedPropsValueMap 查询到子应用的独立状态池(沙箱可能会激活多次,这里是沙箱曾经激活期间被修改的全局变量),然后还原子应用状态。
  • 第 44~45 行:在关闭沙箱时,通过 addedPropsMapInSandbox 删除在沙箱运行期间新增的全局变量,通过 modifiedPropsOriginalValueMapInSandbox 还原沙箱运行期间被修改的全局变量,从而还原到子应用挂载前的状态。

从上面的分析可以得知,LegacySandbox 的沙箱隔离机制利用快照模式实现,我们画一张图来帮助理解(见下图)

qiankun

多实例沙箱 - ProxySandbox

ProxySandbox 是一种新的沙箱模式,目前用于多实例模式的状态隔离。在稳定后以后可能会成为 单实例沙箱,我们来看看 ProxySandbox 沙箱是怎么进行状态隔离的(见下图)

qiankun

我们来分析一下 ProxySandbox 类的几个属性:

字段解释
updateValueMap记录沙箱中更新的值,也就是每个子应用中独立的状态池
name沙箱名称
proxy代理对象,可以理解为子应用的 global/window 对象
sandboxRunning当前沙箱是否在运行中
active激活沙箱,在子应用挂载时启动
inactive关闭沙箱,在子应用卸载时启动
constructor构造函数,创建沙箱环境

我们现在从 window.Proxysetget 属性来详细讲解 ProxySandbox 是如何实现沙箱运行环境的。(见下图)

qiankun

注意:子应用沙箱中的 proxy 对象可以简单理解为子应用的 window 全局对象(代码如下),子应用对全局属性的操作就是对该 proxy 对象属性的操作,带着这份理解继续往下看吧。
// 子应用脚本文件的执行过程:
eval(
  // 这里将 proxy 作为 window 参数传入
  // 子应用的全局对象就是该子应用沙箱的 proxy 对象
  (function(window) {
    /* 子应用脚本文件内容 */
  })(proxy)
);

当调用 set 向子应用 proxy/window 对象设置属性时,所有的属性设置和更新都会命中 updateValueMap,存储在 updateValueMap 集合中(第 38 行),从而避免对 window 对象产生影响(旧版本则是通过 diff 算法还原 window 对象状态快照,子应用之间的状态是隔离的,而父子应用之间 window 对象会有污染)。

当调用 get 从子应用 proxy/window 对象取值时,会优先从子应用的沙箱状态池 updateValueMap 中取值,如果没有命中才从主应用的 window 对象中取值(第 49 行)。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。

如此一来,ProxySandbox 沙箱应用之间的隔离就完成了,所有子应用对 proxy/window 对象值的存取都受到了控制。设置值只会作用在沙箱内部的 updateValueMap 集合上,取值也是优先取子应用独立状态池(updateValueMap)中的值,没有找到的话,再从 proxy/window 对象中取值。

相比较而言,ProxySandbox 是最完备的沙箱模式,完全隔离了对 window 对象的操作,也解决了快照模式中子应用运行期间仍然会对 window 造成污染的问题。

我们对 ProxySandbox 沙箱画一张图来加深理解(见下图)

qiankun

SnapshotSandbox

在不支持 window.Proxy 属性时,将会使用 SnapshotSandbox 沙箱,我们来看看其内部实现(见下图)

qiankun

我们来分析一下 SnapshotSandbox 类的几个属性:

字段解释
name沙箱名称
proxy代理对象,此处为 window 对象
sandboxRunning当前沙箱是否激活
windowSnapshotwindow 状态快照
modifyPropsMap沙箱运行期间被修改过的 window 属性
constructor构造函数,激活沙箱
active激活沙箱,在子应用挂载时启动
inactive关闭沙箱,在子应用卸载时启动

SnapshotSandbox 的沙箱环境主要是通过激活时记录 window 状态快照,在关闭时通过快照还原 window 对象来实现的。(见下图)

qiankun

我们先看 active 函数,在沙箱激活时,会先给当前 window 对象打一个快照,记录沙箱激活前的状态(第 38~40 行)。打完快照后,函数内部将 window 状态通过 modifyPropsMap 记录还原到上次的沙箱运行环境,也就是还原沙箱激活期间(历史记录)修改过的 window 属性。

在沙箱关闭时,调用 inactive 函数,在沙箱关闭前通过遍历比较每一个属性,将被改变的 window 对象属性值(第 54 行)记录在 modifyPropsMap 集合中。在记录了 modifyPropsMap 后,将 window 对象通过快照 windowSnapshot 还原到被沙箱激活前的状态(第 55 行),相当于是将子应用运行期间对 window 造成的污染全部清除。

SnapshotSandbox 沙箱就是利用快照实现了对 window 对象状态隔离的管理。相比较 ProxySandbox 而言,在子应用激活期间,SnapshotSandbox 将会对 window 对象造成污染,属于一个对不支持 Proxy 属性的浏览器的向下兼容方案。

我们对 SnapshotSandbox 沙箱画一张图来加深理解(见下图)

qiankun

挂载沙箱 - mountSandbox

qiankun

我们继续回到这张图,genSandbox 函数不仅返回了一个 sandbox 沙箱,还返回了一个 mountunmount 方法,分别在子应用挂载时和卸载时的时候调用。

我们先看看 mount 函数内部(见下图)

qiankun

首先,在 mount 内部先激活了子应用沙箱(第 26 行),在沙箱启动后开始劫持各类全局监听(第 27 行),我们这里重点看看 patchAtMounting 内部是怎么实现的。(见下图)

qiankun

patchAtMounting 内部调用了下面四个函数:

  • patchTimer(计时器劫持)
  • patchWindowListener(window 事件监听劫持)
  • patchHistoryListener(window.history 事件监听劫持)
  • patchDynamicAppend(动态添加 Head 元素事件劫持)

上面四个函数实现了对 window 指定对象的统一劫持,我们可以挑一些解析看看其内部实现。

计时器劫持 - patchTimer

我们先来看看 patchTimer 对计时器的劫持(见下图)

qiankun

从上图可以看出,patchTimer 内部将 setInterval 进行重载,将每个启用的定时器的 intervalId 都收集起来(第 23~24 行),以便在子应用卸载时调用 free 函数将计时器全部清除(见下图)。

qiankun

我们来看看在子应用加载时的 setInterval 函数验证即可(见下图)

qiankun

从上图可以看出,在进入子应用时,setInterval 已经被替换成了劫持后的函数,防止全局计时器泄露污染。

动态添加样式表和脚本文件劫持 - patchDynamicAppend

patchWindowListenerpatchHistoryListener 的实现都与 patchTimer 实现类似,这里就不作复述了。

我们需要重点对 patchDynamicAppend 函数进行解析,这个函数的作用是劫持对 head 元素的操作(见下图)

qiankun

从上图可以看出,patchDynamicAppend 主要是对动态添加的 style 样式表和 script 标签做了处理。

我们先看看对 style 样式表的处理(见下图)

qiankun

从上图可以看出,主要的处理逻辑在 第 68~74 行,如果当前子应用处于激活状态(判断子应用的激活状态主要是因为:当主应用切换路由时可能会自动添加动态样式表,此时需要避免主应用的样式表被添加到子应用 head 节点中导致出错),那么动态 style 样式表就会被添加到子应用容器内(见下图),在子应用卸载时样式表也可以和子应用一起被卸载,从而避免样式污染。同时,动态样式表也会存储在 dynamicStyleSheetElements 数组中,在后面还会提到其用处。

qiankun

我们再来看看对 script 脚本文件的处理(见下图)

qiankun

对动态 script 脚本文件的处理较为复杂一些,我们也来解析一波:

第 83~101 行 处对外部引入的 script 脚本文件使用 fetch 获取,然后使用 execScripts 指定 proxy 对象(作为 window 对象)后执行脚本文件内容,同时也触发了 loaderror 两个事件。

第 103~106 行 处将注释后的脚本文件内容以注释的形式添加到子应用容器内。

第 109~113 行 是对内嵌脚本文件的执行过程,就不作复述了。

我们可以看出,对动态添加的脚本进行劫持的主要目的就是为了将动态脚本运行时的 window 对象替换成 proxy 代理对象,使子应用动态添加的脚本文件的运行上下文也替换成子应用自身。

HTMLHeadElement.prototype.removeChild 的逻辑就是多加了个子应用容器判断,其他无异,就不展开说了。

最后我们来看看 free 函数(见下图)

qiankun

这个 free 函数与其他的 patches(劫持函数) 实现不太一样,这里缓存了一份 cssRules,在重新挂载的时候会执行 rebuild 函数将其还原。这是因为样式元素 DOM 从文档中删除后,浏览器会自动清除样式元素表。如果不这么做的话,在重新挂载时会出现存在 style 标签,但是没有渲染样式的问题。

卸载沙箱 - unmountSandbox

我们再回到 mount 函数本身(见下图)

qiankun

从上图可以看出,在 patchAtMounting 函数中劫持了各类全局监听,并返回了解除劫持的 free 函数。在卸载应用时调用 free 函数解除这些全局监听的劫持行为(见下图)

qiankun

从上图可以看到 sideEffectsRebuildersfree 后被返回,在 mount 的时候又将被调用 rebuild 重建动态样式表。这块环环相扣,是稍微有点绕,没太看明白的同学可以翻上去再看一遍。

到这里,qiankun 的最核心部分-沙箱机制,我们就已经解析完毕了,接下来我们继续剖析别的部分。

在这里我们画一张图,对沙箱的创建过程进行一个总梳理(见下图)

qiankun

注册内部生命周期函数

在创建好了沙箱环境后,在 第 100~106 行 注册了一些内部生命周期函数(见下图)

qiankun

在上图中,第 106 行mergeWith 方法的作用是将内置的生命周期函数与传入的 lifeCycles 生命周期函数。

这里的 lifeCycles 生命周期函数指的是全子应用共享的生命周期函数,可用于执行多个子应用间相同的逻辑操作,例如 加载效果 之类的。(见下图)

qiankun

除了外部传入的生命周期函数外,我们还需要关注 qiankun 内置的生命周期函数做了些什么(见下图)

qiankun

我们对上图的代码进行逐一解析:

  • 第 13~15 行:在加载子应用前 beforeLoad(只会执行一次)时注入一个环境变量,指示了子应用的 public 路径。
  • 第 17~19 行:在挂载子应用前 beforeMount(可能会多次执行)时可能也会注入该环境变量。
  • 第 23~30 行:在卸载子应用前 beforeUnmount 时将环境变量还原到原始状态。

通过上面的分析我们可以得出一个结论,我们可以在子应用中获取该环境变量,将其设置为 __webpack_public_path__ 的值,从而使子应用在主应用中运行时,可以匹配正确的资源路径。(见下图)

qiankun

触发 beforeLoad 生命周期钩子函数

在注册完了生命周期函数后,立即触发了 beforeLoad 生命周期钩子函数(见下图)

qiankun

从上图可以看出,在 第 108 行 中,触发了 beforeLoad 生命周期钩子函数。

随后,在 第 110 行 执行了 import-html-entryexecScripts 方法。指定了脚本文件的运行沙箱(jsSandbox),执行完子应用的脚本文件后,返回了一个对象,对象包含了子应用的生命周期钩子函数(见下图)。

qiankun

第 112~121 行 对子应用的生命周期钩子函数做了个检测,如果在子应用的导出对象中没有发现生命周期钩子函数,会在沙箱对象中继续查找生命周期钩子函数。如果最后没有找到生命周期钩子函数则会抛出一个错误,所以我们的子应用一定要有 bootstrap, mount, unmount 这三个生命周期钩子函数才能被 qiankun 正确嵌入到主应用中。

这里我们画一张图,对子应用挂载前的初始化过程做一个总梳理(见下图)

qiankun

进入到 mount 挂载流程

在一些初始化配置(如 子应用资源、运行沙箱环境、生命周期钩子函数等等)准备就绪后,qiankun 内部将其组装在一起,返回了三个函数作为 single-spa 内部的生命周期函数(见下图)

qiankun

single-spa 内部的逻辑我们后面再展开说,这里我们可以简单理解为 single-spa 内部的三个生命周期钩子函数:

  • bootstrap:子应用初始化时调用,只会调用一次;
  • mount:子应用挂载时调用,可能会调用多次;
  • unmount:子应用卸载时调用,可能会调用多次;

我们可以看出,在 bootstrap 阶段调用了子应用暴露的 bootstrap 生命周期函数。

我们这里对 mount 阶段进行展开,看看在子应用 mount 阶段执行了哪些函数(见下图)

qiankun

我们进行逐行解析:

  • 第 127~133 行:对单实例模式进行检测。在单实例模式下,新的子应用挂载行为会在旧的子应用卸载之后才开始。(由于这里是串行顺序执行,所以如果某一处发生阻塞的话,会阻塞所有后续的函数执行)
  • 第 134 行:执行注册子应用时传入的 render 函数,将 HTML Templateloading 作为入参。这里一般是在发生了一次 unmount 后,再次进行 mount 挂载行为时将 HTML 挂载在指定容器中(见下图)

    由于初始化的时候已经调用过一次 render,所以在首次调用 mount 时可能已经执行过一次 render 方法。

    在下面的代码中也有对重复挂载的情况进行判断的语句 - if (frame.querySelector("div") === null,防止重复挂载子应用。

qiankun

  • 第 135 行:触发了 beforeMount 全局生命周期钩子函数;
  • 第 136 行:挂载沙箱,这一步中激活了对应的子应用沙箱,劫持了部分全局监听(如 setInterval)。此时开始子应用的代码将在沙箱中运行。(反推可知,在 beforeMount 前的部分全局操作将会对主应用造成污染,如 setInterval
  • 第 137 行:触发子应用的 mount 生命周期钩子函数,在这一步通常是执行对应的子应用的挂载操作(如 ReactDOM.render、Vue.$mount。(见下图)

qiankun

  • 第 138 行:再次调用 render 函数,此时 loading 参数为 false,代表子应用已经加载完成。
  • 第 139 行:触发了 afterMount 全局生命周期钩子函数;
  • 第 140~144 行:在单实例模式下设置 prevAppUnmountedDeferred 的值,这个值是一个 promise,在当前子应用卸载时才会被 resolve,在该子应用运行期间会阻塞其他子应用的挂载动作(第 134 行);

我们在上面很详细的剖析了整个子应用的 mount 挂载流程,如果你还没有搞懂的话,没关系,我们再画一个流程图来帮助理解。(见下图)

qiankun

进入到 unmount 卸载流程

我们刚才梳理了子应用的 mount 挂载流程,我们现在就进入到子应用的 unmount 卸载流程。在子应用激活阶段, activeRule 未命中时将会触发 unmount 卸载行为,具体的行为如下(见下图)

qiankun

从上图我们可以看出,unmount 卸载流程要比 mount 简单很多,我们直接来梳理一下:

  • 第 148 行:触发了 beforeUnmount 全局生命周期钩子函数;
  • 第 149 行:这里与 mount 流程的顺序稍微有点不同,这里先执行了子应用的 unmount 生命周期钩子函数,保证子应用仍然是运行在沙箱内,避免造成状态污染。在这里一般是对子应用的一些状态进行清理和卸载操作。(如下图,销毁了刚才创建的 vue 实例)

qiankun

  • 第 150 行:卸载沙箱,关闭了沙箱的激活状态。
  • 第 151 行:触发了 afterUnmount 全局生命周期钩子函数;
  • 第 152 行:触发 render 方法,并且传入的 appContent 为空字符串,此处可以清空主应用容器内的内容。
  • 第 153~156 行:当前子应用卸载完成后,在单实例模式下触发 prevAppUnmountedDeferred.resolve(),使其他子应用的挂载行为得以继续进行,不再阻塞。

我们对 unmount 卸载流程也画一张图,帮助大家理解(见下图)。

qiankun

总结

到这里,我们对 qiankun 框架的总流程梳理就差不多了。这里应该做个总结,大家看了这么多文字,估计大家也看累了,最后用一张图对 qiankun 的总流程进行总结吧。

qiankun

彩蛋

qiankun

展望

传统的云控制台应用,几乎都会面临业务快速发展之后,单体应用进化成巨石应用的问题。我们要如何维护一个巨无霸中台应用?

上面这个问题引出了微前端架构理念,所以微前端的概念也越来越火,我们团队最近也在尝试转型微前端架构。

工欲善其事必先利其器,所以本文针对 qiankun 的源码进行解读,在分享知识的同时也是帮助自己理解。

这是我们团队对微前端架构的最佳实践(见下图),如果有需求的话,可以在评论区留言,我们会考虑出一篇《微前端框架 qiankun 最佳实践》来帮助大家搭建一套微前端架构。

架构图

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!

personal

查看原文

赞 95 收藏 52 评论 27

fecoder 赞了文章 · 2019-12-31

2020要用immer来代替immutable优化你的React项目

不可变数据

React的老手们早就知道为什么要用不可变数据了,但是为了防止新手们看不懂,所以还是要解释一下什么是不可变数据,不可变数据指的其实就是当你修改一个数据的时候,这个数据会给你返回一个新的引用,而自己的引用保持不变,有点像是经常用到的数组的map方法:

const arr1 = [1, 2, 3];
const arr2 = arr1.map(item => item * 10);

console.log(arr1 === arr2)
//false

这样的话每次修改数据,新返回的数据就和原来不相等了。

如果数据变更,节点类型不相同的时候会怎样呢?React 的做法非常简单粗暴,直接将 原 VDOM 树上该节点以及该节点下所有的后代节点 全部删除,然后替换为新 VDOM 树上同一位置的节点,当然这个节点的后代节点也全都跟着过来了。

这样的话非常浪费性能,父组件数据一变化,子组件全部都移除,再换新的,所以才有了shouldComponentUpdate这个生命周期(Vue的小伙伴请放心,Vue原理和React不太一样,所以没这毛病),这个函数如果返回false的话子组件就不会更新,但是每次在这个函数里面写对比会很麻烦,所以有了PureComponent和Memo,但是只提供了浅比较,所以这时候不可变数据就派上用场了,每次修改数据都和原数据不相等的话,就可以精确的控制更新。

immutable

Facebook早就知道React这一缺陷,所以历时三年打造了一个不可变数据的immutable.js。它内部实现了一套完整的 Persistent Data Structure,还有很多易用的数据类型。像Collection、List、Map、Set、Record、Seq。有非常全面的map、filter、groupBy、reduce``find函数式操作方法。同时 API 也设计的和JS对象、数组等类似。
不过功能虽全,但是如果我们仅仅只是为了优化浅对比防止子组件过度刷新的话,引入这么大的一个库就未免有些大材小用了,而且学习成本也是需要考虑在内的,所以要为大家介绍一下今天的主角:轻量、易用、简洁又可以快速上手的immer.js

immer

immer这玩意来头可不小,他的创造者就是大名鼎鼎的Mobx作者,听过Mobx的人应该都知道,它与Redux相比更简洁、更轻量、同时也更加易学,所以immer也同样的继承了这些优点:轻量、简洁、易上手、并且使用起来也非常的舒服,不会产生容易把immutable数据类型与原生JS数据类型搞混的情况。它的核心思想就是利用Vue3源码中大量运用的Proxy代理,几乎以最小的成本实现了JS的不可变数据结构,解决了许多日常开发中的棘手问题,相信看完我的文章你一定会喜欢上它的!
首先第一步就是先进行安装:

npm i -S immer

或者

yarn add immer
import produce from 'immer';

const array = [{value: 0}, {value: 1}, {value: 2}];
const arr = produce(array, draft => {
  draft[0].value = 10;
});

console.log(arr === array);
//false

解释一下:produce是生产的意思(你想起啥名都行,但是官网喜欢这么叫,我就跟着这么起名),这个函数第一个参数是你想要改变的数据对象,第二个参数是一个函数,这个函数的参数draft是草稿的意思,代表的就是你想要改变的那个数据对象,然后在函数体内你就正常想怎么改就怎么改,produce运行完的结果就是一个全新的对象啦!怎么样是不是超级简洁超级好用呢?

  • 注意:如果你什么也不返回或者并没有操作数据的话,并不会返回一个新的对象!
const array = [{value: 0}, {value: 1}, {value: 2}];
const arr = produce(array, draft => {});

console.log(array === arr);
// true


引用一张immutable的图,从图中可以看出来返回值并不是一份深拷贝内容,而是共享了未被修改的数据,这样的好处就是避免了深拷贝带来的极大的性能开销问题,并且更新后返回了一个全新的引用,即使是浅比对也能感知到数据的改变。

  • 如果把produce的第一个参数省略掉的话,只传入第二个参数返回值将会是一个函数👇
const array = [{value: 0}, {value: 1}, {value: 2}];
const producer = produce((draft) => {
  draft[0].value = 10;
});
const arr = producer(array);

console.log(array === arr);
// false

这样虽然结果一样,但是却增强了可复用性,甚至可以进行再次封装来形成一个高阶函数:

const array = [{value: 0}, {value: 1}, {value: 2}];
const producer = (state, fn) => produce(fn)(state);
const arr = producer(array, draft => { draft[0] = 666 });

console.log(array, arr);
// [{…}, {…}, {…}]
// [666, {…}, {…}]
  • 此时我们并没有任何返回值,那么如果有返回值的话会怎样呢?
const array = [{value: 0}, {value: 1}, {value: 2}];
const producer = (state, fn) => produce(fn)(state);
const arr = producer(array, draft => [666, ...draft]);

console.log(array, arr);
// [{…}, {…}, {…}]
// [666, {…}, {…}, {…}]

我们发现返回值就是新数据的结果!所以我们可以清楚的得知:在没有返回值时数据是根据函数体内对draft参数的操作生成的。有返回值的话返回值就会被当做新数据来返回。

使用use-immer来替代你的useState

由于React Hooks的异军突起,导致现在很多组件都使用函数来进行编写,数据就直接写在useState中,但是有了useImmer,你以后就可以用它来代替useState啦!
还是老规矩,先安装:

npm install immer use-immer

yarn add immer use-immer

用法

定义数据: const [xxx, setXxx] = useImmer(…)
修改数据: setXxx(draft => {})

可以看到用法和setState几乎没啥太大区别,接下来我们通过一个小案例来继续深入useImmer的用法:

import React from "react";
import { useImmer } from "use-immer";


export default function () {
  const [person, setPerson] = useImmer({
    name: "马云",
    salary: '对钱没兴趣'
  });

  function setName(name) {
    setPerson(draft => {
      draft.name = name;
    });
  }

  function becomeRicher() {
    setPerson(draft => {
      draft.salary += '$¥';
    });
  }

  return (
    <div className="App">
      <h1>
        {person.name} ({person.salary})
      </h1>
      <input
        onChange={e => {
          setName(e.target.value);
        }}
        value={person.name}
      />
      <br />
      <button onClick={becomeRicher}>变富</button>
    </div>
  );
}


这是一个改编自官网的小例子,可以看得出useImmer的用法和useState十分相似,在保持住了简洁性的同时还具备了immutable的数据结构,十分便捷。

useImmerReducer

use-immer对useReducer进行了加强封装,同样也几乎没什么学习成本,再改编一下官网小案例👇

import React from "react";
import { useImmerReducer } from "use-immer";

const initialState = { salary: 0 };

function reducer(draft, action) {
  switch (action.type) {
    case "reset":
      return initialState;
    case "increment":
      return void draft.salary++;
    case "decrement":
      return void draft.salary--;
  }
}

export default function () {
  const [state, dispatch] = useImmerReducer(reducer, initialState);
  return (
    <>
      期待工资: {state.salary}K
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "reset" })}>重置</button>
    </>
  );
}


怎么样?看完之后是不是感觉神清气爽,有这么一个东西轻量、简洁、易用又好学,看一篇文章的功夫就能学会,而且还能很好的解决你的React性能问题,那还等什么?赶紧npm install下载安装吧!

查看原文

赞 5 收藏 1 评论 1

fecoder 收藏了文章 · 2019-12-27

面试问题之——给你图片的url,你能知道它所占的字节空间吗?

从一个需求说起

这标题起得有点标题党,实际情况是我最近在做一个公司内部工具时,遇到了这么一个需求,给定一个静态资源站点上某张图片的url(比如https://a.xxxcdn.com/demo.jpg),如何获取其存储大小并计算出加载该资源的平均网速呢?注意,是存储大小,而不是图片的宽高尺寸大小。接下来就是我在实现这个需求中总结的一些方式。

一、通过Ajax请求获取

这种方式涉及到XMLHttpRequest的一个属性:responseType,这是属于XMLHttpRequest Level2中的标准属性。
responseType有几种类型:textblobarraybufferdocument。默认是使用text类型,也就是我们可以从返回结果中的responseText取到服务端返回的主体数据。而如果你设置为其他类型(比如blob),会发现responseText无法正常获取了,如下图:

既然XMLHttpRequest对象可以帮我们把返回结果进行转换,那我们就可以借助它来实现这个需求了,先看看转为arraybuffer会返回什么结果:

转为ArrayBuffer类型

从上图可以看到,通过XMLHttpRequest实例对象的response字段,我们可以取到一个ArrayBuffer类型的对象,它是用来表示通用的、固定长度的原始二进制数据缓冲区。它暴露出的byteLength,代表了数组的字节大小,也就是B(1KB = 1024B),从而可知该文件的存储大小为14896B

如果仔细观察,会发现byteLength与上图打印出来的[[Int8Array]][[Uint8Array]]的长度是一致的,这里稍微解释一下它的原理:

首先,我们可以使用Uint8Array(request.response),将这个ArrayBuffer对象转换为Uint8Array类型数组。那么,为何两者的长度值是一样的呢?因为ArrayBuffer的byteLength表示的是其字节大小,众所周知一个字节(1B)是由8个bit组合的,比如01010101。而Uint8Array数组中的每一个值,都代表8个二进制位转换为十进制后的值,所以ArrayBuffer中有多少个字节,那么对应的Uint8Array就有多少个值。Int8Array也同理,区别在于Int8Array是有符号整数,Uint8Array是无符号的。

转为Blob类型

如果将responseType设置为blob,那么可想而知XMLHttpRequest对象会将结果转为Blob类型,如下图:

通过它的size,我们也可以正确获取到该文件的字节大小。并且,我们应该知道,Blob和ArrayBuffer之间是可以通过HTML5的FileReader互相转化的,有兴趣的读者可以通过这篇文章进行了解。

小结

至此,这个需求已经可以实现了,但是,通过Ajax的手段去请求一个文件,还得服务器的CORS配置允许跨域,但一般服务器不会设置Access-Control-Allow-Origin*,否则随意哪个域名都可以请求它的资源了。于是,我们可以联想到,通过img发起的请求不就可以支持跨域吗?但可惜,img的onload事件的回调参数中不会为我们提供图片文件的大小信息,它只会提供图片元素本身的一些信息,比如宽、高等HTMLImageElement中的属性。

后来,经过stackoverflow、MDN搜寻一番之后,发现了一个更值得推荐的做法,那就是:img加载图片配合Performance API进行获取,接着往下看:

二、通过Performance API

通常,我们可以使用performance的timing来测量一个页面的各项性能指标,比如DNS查询时间、HTTP连接时间、首字节时间、可交互式时间等等,但除此之外,performance还提供了一项能力,可以让我们探测某个被加载的资源的各项指标:

通过MDN文档,我们可以了解到,Performace API在浏览器进行资源加载时,会自动生成每个资源的PerformanceEntry对象,自动生成的PerformanceEntry对象,其entryType一般有三种:resource、navigation和paint。

而对于图片、css、脚本等资源文件,其entryType是resource,与之相对应的是扩展了PerformanceEntry的PerformanceResourceTiming接口,其中的属性提供有关获取资源大小的数据以及初始化时获取的资源类型。比如,一个图片资源对应的PerformanceResourceTiming对象中,会包含以下属性(部分):

可以看到,最后的三个属性,都可以表示该资源的大小,我们选取encodedBodySize来表示作为该资源的存储大小,因为该值与浏览器Network面板中显示的该资源大小是一致的。让我们通过代码实践一下。

我们使用img加载一个资源文件,并在其onload回调中使用performance的API来获取它的PerformanceResourceTiming:

var img = new Image();
var resource = 'https://a.xx.com/xxx.png';
img.src = resource;
img.onload = function() {
    console.log(performance.getEntriesByName(resource));
}

结果如下:

跟预期的不同,三个可以标识资源大小的属性,都返回了0,而当我换了其他服务器上的另一张图片时,三个属性却返回了预期的值:

为何有些行得通有些却不行呢?经过仔细对比,我发现了它们之间的区别,凡是可以被检测出大小的资源,在Response Header中都有一个字段:timing-allow-origin: *。那么,timing-allow-origin的作用是啥呢?这里我给出MDN文档的解释:

响应头Timing-Allow-Origin用于指定特定站点,以允许其访问Resource Timing API提供的相关信息,否则这些信息会由于跨源限制将被报告为零

原来,只有设置了该响应头的资源,才能被指定域名的脚本进行performance的相关检测。

小结

到这里我们已经可以对这两种方法进行比对了:

两种方法都可以顺利获取到资源准确的存储字节大小,但前者通过ajax进行请求,需要服务器配合设置CORS的Access-Control-Allow-Origin,但对于一个服务器来讲,出于安全考虑,将这个字段设置为*是不太可能的。

然而,第二种方法也需要服务器配合设置timing-allow-origin字段,但区别在于,这个字段的设置只用于performance的检测,几乎不需要付出安全成本。不过,在谷歌上我也找到了一个利用 timing-allow-origin: * 来检测接口返回时间从而推断接口状态的漏洞,所以,最好的方式就是,只对专门放置资源文件的服务器设置该响应头,或者在主服务器中,针对资源文件的请求加入该响应头,就可以避免这种漏洞了。

并且,同理可得,对于其他资源,比如字体、样式文件、脚本文件等,也可以通过以上方式进行存储大小的检测。

引申(凑字数)——关于图片的跨域能力

众所周知,img和script都是支持跨域访问的,这是一般浏览器都会提供的能力,但大家有没有想过,图片发出的请求,和XMLHttpRequest发出的ajax请求,其实都是正常的http请求,为啥img和script就可以绕过同源策略呢?我们可以观察一下两个跨域的资源请求的请求头。

一个是用img发出的请求:

一个是XMLHttpRequest的get请求:

这里有两个差别,一个是Accept,一个是Origin,Accept只是告诉服务器客户端可以接受的返回内容类型,而Origin,才是决定是否触发浏览器同源策略的关键。浏览器接收到请求的响应后,通过判断响应头中是否有Access-Control-Allow-Origin字段并验证它与当前请求的Origin是否匹配,来决定是否让用户读取到返回值,如果没有Access-Control-Allow-Origin字段,那么我们会看到一个很常见的浏览器报错:

反之,像img发起的请求,没有携带Origin字段,那么对于这个请求,浏览器就会忽略这层判断,自然就绕过了同源策略的限制。

ps:欢迎关注微信公众号——前端漫游指南,会定期发布优质原创文章和译文,关注公众号福利:回复666可以获得精选前端进阶电子书,感谢~

图片描述

查看原文

fecoder 发布了文章 · 2019-12-27

面试问题之——给你图片的url,你能知道它所占的字节空间吗?

从一个需求说起

这标题起得有点标题党,实际情况是我最近在做一个公司内部工具时,遇到了这么一个需求,给定一个静态资源站点上某张图片的url(比如https://a.xxxcdn.com/demo.jpg),如何获取其存储大小并计算出加载该资源的平均网速呢?注意,是存储大小,而不是图片的宽高尺寸大小。接下来就是我在实现这个需求中总结的一些方式。

一、通过Ajax请求获取

这种方式涉及到XMLHttpRequest的一个属性:responseType,这是属于XMLHttpRequest Level2中的标准属性。
responseType有几种类型:textblobarraybufferdocument。默认是使用text类型,也就是我们可以从返回结果中的responseText取到服务端返回的主体数据。而如果你设置为其他类型(比如blob),会发现responseText无法正常获取了,如下图:

既然XMLHttpRequest对象可以帮我们把返回结果进行转换,那我们就可以借助它来实现这个需求了,先看看转为arraybuffer会返回什么结果:

转为ArrayBuffer类型

从上图可以看到,通过XMLHttpRequest实例对象的response字段,我们可以取到一个ArrayBuffer类型的对象,它是用来表示通用的、固定长度的原始二进制数据缓冲区。它暴露出的byteLength,代表了数组的字节大小,也就是B(1KB = 1024B),从而可知该文件的存储大小为14896B

如果仔细观察,会发现byteLength与上图打印出来的[[Int8Array]][[Uint8Array]]的长度是一致的,这里稍微解释一下它的原理:

首先,我们可以使用Uint8Array(request.response),将这个ArrayBuffer对象转换为Uint8Array类型数组。那么,为何两者的长度值是一样的呢?因为ArrayBuffer的byteLength表示的是其字节大小,众所周知一个字节(1B)是由8个bit组合的,比如01010101。而Uint8Array数组中的每一个值,都代表8个二进制位转换为十进制后的值,所以ArrayBuffer中有多少个字节,那么对应的Uint8Array就有多少个值。Int8Array也同理,区别在于Int8Array是有符号整数,Uint8Array是无符号的。

转为Blob类型

如果将responseType设置为blob,那么可想而知XMLHttpRequest对象会将结果转为Blob类型,如下图:

通过它的size,我们也可以正确获取到该文件的字节大小。并且,我们应该知道,Blob和ArrayBuffer之间是可以通过HTML5的FileReader互相转化的,有兴趣的读者可以通过这篇文章进行了解。

小结

至此,这个需求已经可以实现了,但是,通过Ajax的手段去请求一个文件,还得服务器的CORS配置允许跨域,但一般服务器不会设置Access-Control-Allow-Origin*,否则随意哪个域名都可以请求它的资源了。于是,我们可以联想到,通过img发起的请求不就可以支持跨域吗?但可惜,img的onload事件的回调参数中不会为我们提供图片文件的大小信息,它只会提供图片元素本身的一些信息,比如宽、高等HTMLImageElement中的属性。

后来,经过stackoverflow、MDN搜寻一番之后,发现了一个更值得推荐的做法,那就是:img加载图片配合Performance API进行获取,接着往下看:

二、通过Performance API

通常,我们可以使用performance的timing来测量一个页面的各项性能指标,比如DNS查询时间、HTTP连接时间、首字节时间、可交互式时间等等,但除此之外,performance还提供了一项能力,可以让我们探测某个被加载的资源的各项指标:

通过MDN文档,我们可以了解到,Performace API在浏览器进行资源加载时,会自动生成每个资源的PerformanceEntry对象,自动生成的PerformanceEntry对象,其entryType一般有三种:resource、navigation和paint。

而对于图片、css、脚本等资源文件,其entryType是resource,与之相对应的是扩展了PerformanceEntry的PerformanceResourceTiming接口,其中的属性提供有关获取资源大小的数据以及初始化时获取的资源类型。比如,一个图片资源对应的PerformanceResourceTiming对象中,会包含以下属性(部分):

可以看到,最后的三个属性,都可以表示该资源的大小,我们选取encodedBodySize来表示作为该资源的存储大小,因为该值与浏览器Network面板中显示的该资源大小是一致的。让我们通过代码实践一下。

我们使用img加载一个资源文件,并在其onload回调中使用performance的API来获取它的PerformanceResourceTiming:

var img = new Image();
var resource = 'https://a.xx.com/xxx.png';
img.src = resource;
img.onload = function() {
    console.log(performance.getEntriesByName(resource));
}

结果如下:

跟预期的不同,三个可以标识资源大小的属性,都返回了0,而当我换了其他服务器上的另一张图片时,三个属性却返回了预期的值:

为何有些行得通有些却不行呢?经过仔细对比,我发现了它们之间的区别,凡是可以被检测出大小的资源,在Response Header中都有一个字段:timing-allow-origin: *。那么,timing-allow-origin的作用是啥呢?这里我给出MDN文档的解释:

响应头Timing-Allow-Origin用于指定特定站点,以允许其访问Resource Timing API提供的相关信息,否则这些信息会由于跨源限制将被报告为零

原来,只有设置了该响应头的资源,才能被指定域名的脚本进行performance的相关检测。

小结

到这里我们已经可以对这两种方法进行比对了:

两种方法都可以顺利获取到资源准确的存储字节大小,但前者通过ajax进行请求,需要服务器配合设置CORS的Access-Control-Allow-Origin,但对于一个服务器来讲,出于安全考虑,将这个字段设置为*是不太可能的。

然而,第二种方法也需要服务器配合设置timing-allow-origin字段,但区别在于,这个字段的设置只用于performance的检测,几乎不需要付出安全成本。不过,在谷歌上我也找到了一个利用 timing-allow-origin: * 来检测接口返回时间从而推断接口状态的漏洞,所以,最好的方式就是,只对专门放置资源文件的服务器设置该响应头,或者在主服务器中,针对资源文件的请求加入该响应头,就可以避免这种漏洞了。

并且,同理可得,对于其他资源,比如字体、样式文件、脚本文件等,也可以通过以上方式进行存储大小的检测。

引申(凑字数)——关于图片的跨域能力

众所周知,img和script都是支持跨域访问的,这是一般浏览器都会提供的能力,但大家有没有想过,图片发出的请求,和XMLHttpRequest发出的ajax请求,其实都是正常的http请求,为啥img和script就可以绕过同源策略呢?我们可以观察一下两个跨域的资源请求的请求头。

一个是用img发出的请求:

一个是XMLHttpRequest的get请求:

这里有两个差别,一个是Accept,一个是Origin,Accept只是告诉服务器客户端可以接受的返回内容类型,而Origin,才是决定是否触发浏览器同源策略的关键。浏览器接收到请求的响应后,通过判断响应头中是否有Access-Control-Allow-Origin字段并验证它与当前请求的Origin是否匹配,来决定是否让用户读取到返回值,如果没有Access-Control-Allow-Origin字段,那么我们会看到一个很常见的浏览器报错:

反之,像img发起的请求,没有携带Origin字段,那么对于这个请求,浏览器就会忽略这层判断,自然就绕过了同源策略的限制。

ps:欢迎关注微信公众号——前端漫游指南,会定期发布优质原创文章和译文,关注公众号福利:回复666可以获得精选前端进阶电子书,感谢~

图片描述

查看原文

赞 17 收藏 14 评论 5

fecoder 收藏了文章 · 2019-09-30

探索HTTP传输中gzip压缩的秘密

为什么要开启gZip

图片描述

我们给某人发送邮件时,我们在传输之前把自己的文件压缩一下,接收方收到文件后再去解压获取文件。这中操作对于我们来说都已经司空见惯。我们压缩文件的目的就是为了把传输文件的体积减小,加快传输速度。我们在 http 传输中开启 gZip 的目的也是如此,但是一般文章介绍 gZip 时候总是结合一些服务端配置(nginx)或者构建工具插件(webpack)来说,列出一大堆配置让人看的云里雾里,以至于到最后还没搞懂 为什么用怎么用 这些问题。

http 与 gZip

我们下面去探讨一下这些问题

gZip 文件怎么通讯

我们传输压缩文件给别人时候一般都带着后缀名 .rar, .zip之类,对方在拿到文件后根据相应的后缀名选择不同的解压方式然后去解压文件。我们在 http 传输时候解压文件的这个角色的扮演者就是我们使用的浏览器,但是浏览器怎么分辨这个文件是什么格式,应该用什么格式去解压呢?

http/1.0 协议中关于服务端发送的数据可以配置一个 Content-Encoding 字段,这个字段用于说明数据的压缩方法

Content-Encoding: gzip
Content-Encoding: compress
Content-Encoding: deflate

客户端在接受到返回的数据后去检查对应字段的信息,然后根据对应的格式去做相应的解码。客户端在请求时,可以用 Accept-Encoding 字段说明自己接受哪些压缩方法。

Accept-Encoding: gzip, deflate


我们在浏览器的控制台中可以看到请求的相关信息

图片描述

兼容性

提到浏览器作为一个前端就不由自主的会想一个问题,会不会有浏览器不支持呢。HTTP/1.0 是1996年5月发布的。好消息是基本不用考虑兼容性的问题,几乎所有浏览器都支持它。值得一提的是 ie6的早起版本中存在一个会破坏 gZip的错误,后面 ie6本身在 WinXP SP2 中修复了这个问题,而且用这个版本的用户数量也很少。

谁去压缩文件

这件事看起来貌似只能服务端来做,我们在网上看到最多的也是诸如 nginx 开启 gZip 配置之类的文章,但是现在前端流行 spa 应用, 用 react, vue 之类的框架时候总伴随这一套自己的脚手架,一般用 webpack 作为打包工具,其中可以配置插件 如compression-webpack-plugin 可以让我们把生成文件进行 gZip 等压缩并生成对应的压缩文件,而我们应用在构架时候有可能也会在服务区和前端文件中放置一层 node 应用来进行接口鉴权和文件转发。nodejs中我们熟悉的express 框架中也有一个compression 中间件,可以开启gZip,一时间看的人眼花缭乱,到底应该用谁怎么用呢?

服务端响应请求时候压缩

其实 nginx 压缩和 node 框架中用中间件去压缩都是一样的,当我们点击网页发送一个请求时候,我们的服务端会找到对应的文件,然后对文件进行压缩返回压缩后的内容【当然可以利用缓存减少压缩次数】,并配置好我们上面提到的 Content-Encoding 信息。对于一些应用在构架时候并没有上游代理层,比如服务端就一层 node 就可以直接用自己本身的压缩插件对文件进行压缩,如果上游配有有 nginx 转发处理层,最好交给 nginx 来处理这些,因为它们有专门为此构建的内容,可以更好的利用缓存并减小开销(很多使用c语言编写的)。

我们看一些 nginx 中开启 gZip 压缩的一部分配置

# 开启gzip
gzip on;
# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;
# gzip 压缩级别,1-10,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
gzip_comp_level 2;
# 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript;
应用构建时候压缩

既然服务端都可以做了为什么 webpack 在打包前端应用时候还有这样一个压缩插件呢,我们可以在上面 nginx 配置中看到 gzip_comp_level 2 这个配置项,上面也有注释写道 1-10 数字越大压缩效果越好,但是会耗费更多的CPU和时间,我们压缩文件除了减少文件体积大小外,也是为了减少传输时间,如果我们把压缩等级配置的很高,每次请求服务端都要压缩很久才回返回信息回来,不仅服务器开销会增大很多,请求方也会等的不耐烦。但是现在的 spa 应用既然文件都是打包生成的,那如果我们在打包时候就直接生成高压缩等级的文件,作为静态资源放在服务器上,接收到请求后直接把压缩的文件内容返回回去会怎么样呢?

webpackcompression-webpack-plugin 就是做这个事情的,配置起来也很简单只需要在装置中加入对应插件,简单配置如下

const CompressionWebpackPlugin = require('compression-webpack-plugin');

webpackConfig.plugins.push(
    new CompressionWebpackPlugin({
      asset: '[path].gz[query]',
      algorithm: 'gzip',
      test: new RegExp('\\.(js|css)$'),
      threshold: 10240,
      minRatio: 0.8
    })
)

webpack 打包完成后生成打包文件外还会额外生成 .gz 后缀的压缩文件

图片描述

那么这个插件的压缩等级是多少呢,我们可以在源码中看到默认的 level9

...
const zlib = require('zlib');
this.options.algorithm = zlib[this.options.algorithm];
...
this.options.compressionOptions = {
    level: options.level || 9,
    flush: options.flush
    ...
}

可以看到压缩使用的是 zlib 库,而 zlib 分级来说,默认是 6 ,最高的级别就是9 Best compression (also zlib.Z_BEST_COMPRESSION),因为我们只有在上线项目时候才回去打包构建一次,所以我们在构建时候使用最高级的压缩方式压缩多耗费一些时间对我们来说根本没任何损耗,而我们在服务器上也不用再去压缩文件,只需要找到相应已经压缩过的文件直接返回就可以了。

服务端怎么找到这些文件

在应用层面解决这个问题还是比较简单的,比如上述压缩文件会产生index.css, index.js的压缩文件,在服务端简单处理可以判断这两个请求然后给予相对应的压缩文件。以 nodeexpress 为例

...
app.get(['/index.js','/index.css'], function (req, res, next) {
  req.url = req.url + '.gz'
  res.set('Content-Encoding', 'gzip')
  res.setHeader("Content-Type", generateType(req.path)) // 这里要根据请求文件设置content-type
  next()
})

上面我们可以给请求返回 gZip 压缩后的数据了,当然上面的局限性太强也不可取,但是对于处理这个方面需求也已经有很多库存在,expressexpress-static-gzip 插件 koakoa-static 则默认自带对 gZip 文件的检测,基本原理就是对请求先检测 .gz后缀的文件是否存在,再去根据结果返回不同的内容。

哪些文件可以被 gZip 压缩

gZip 可以压缩所有的文件,但是这不代表我们要对所有文件进行压缩,我们写的代码(css,js)之类的文件会有很好的压缩效果,但是图片之类文件则不会被 gzip 压缩太多,因为它们已经内置了一些压缩,一些文件(比如一些已经被压缩的像.zip文件那种)再去压缩可能会让生成的文件体积更大一些。当然已经很小的文件也没有去压缩的必要了。

实践

能开启 gZip 肯定是要开启的,具体使用在请求时候实时压缩还是在构建时候去生成压缩文件,就要看自己具体业务情况。

参考资料

查看原文

fecoder 赞了文章 · 2019-09-30

探索HTTP传输中gzip压缩的秘密

为什么要开启gZip

图片描述

我们给某人发送邮件时,我们在传输之前把自己的文件压缩一下,接收方收到文件后再去解压获取文件。这中操作对于我们来说都已经司空见惯。我们压缩文件的目的就是为了把传输文件的体积减小,加快传输速度。我们在 http 传输中开启 gZip 的目的也是如此,但是一般文章介绍 gZip 时候总是结合一些服务端配置(nginx)或者构建工具插件(webpack)来说,列出一大堆配置让人看的云里雾里,以至于到最后还没搞懂 为什么用怎么用 这些问题。

http 与 gZip

我们下面去探讨一下这些问题

gZip 文件怎么通讯

我们传输压缩文件给别人时候一般都带着后缀名 .rar, .zip之类,对方在拿到文件后根据相应的后缀名选择不同的解压方式然后去解压文件。我们在 http 传输时候解压文件的这个角色的扮演者就是我们使用的浏览器,但是浏览器怎么分辨这个文件是什么格式,应该用什么格式去解压呢?

http/1.0 协议中关于服务端发送的数据可以配置一个 Content-Encoding 字段,这个字段用于说明数据的压缩方法

Content-Encoding: gzip
Content-Encoding: compress
Content-Encoding: deflate

客户端在接受到返回的数据后去检查对应字段的信息,然后根据对应的格式去做相应的解码。客户端在请求时,可以用 Accept-Encoding 字段说明自己接受哪些压缩方法。

Accept-Encoding: gzip, deflate


我们在浏览器的控制台中可以看到请求的相关信息

图片描述

兼容性

提到浏览器作为一个前端就不由自主的会想一个问题,会不会有浏览器不支持呢。HTTP/1.0 是1996年5月发布的。好消息是基本不用考虑兼容性的问题,几乎所有浏览器都支持它。值得一提的是 ie6的早起版本中存在一个会破坏 gZip的错误,后面 ie6本身在 WinXP SP2 中修复了这个问题,而且用这个版本的用户数量也很少。

谁去压缩文件

这件事看起来貌似只能服务端来做,我们在网上看到最多的也是诸如 nginx 开启 gZip 配置之类的文章,但是现在前端流行 spa 应用, 用 react, vue 之类的框架时候总伴随这一套自己的脚手架,一般用 webpack 作为打包工具,其中可以配置插件 如compression-webpack-plugin 可以让我们把生成文件进行 gZip 等压缩并生成对应的压缩文件,而我们应用在构架时候有可能也会在服务区和前端文件中放置一层 node 应用来进行接口鉴权和文件转发。nodejs中我们熟悉的express 框架中也有一个compression 中间件,可以开启gZip,一时间看的人眼花缭乱,到底应该用谁怎么用呢?

服务端响应请求时候压缩

其实 nginx 压缩和 node 框架中用中间件去压缩都是一样的,当我们点击网页发送一个请求时候,我们的服务端会找到对应的文件,然后对文件进行压缩返回压缩后的内容【当然可以利用缓存减少压缩次数】,并配置好我们上面提到的 Content-Encoding 信息。对于一些应用在构架时候并没有上游代理层,比如服务端就一层 node 就可以直接用自己本身的压缩插件对文件进行压缩,如果上游配有有 nginx 转发处理层,最好交给 nginx 来处理这些,因为它们有专门为此构建的内容,可以更好的利用缓存并减小开销(很多使用c语言编写的)。

我们看一些 nginx 中开启 gZip 压缩的一部分配置

# 开启gzip
gzip on;
# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;
# gzip 压缩级别,1-10,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
gzip_comp_level 2;
# 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript;
应用构建时候压缩

既然服务端都可以做了为什么 webpack 在打包前端应用时候还有这样一个压缩插件呢,我们可以在上面 nginx 配置中看到 gzip_comp_level 2 这个配置项,上面也有注释写道 1-10 数字越大压缩效果越好,但是会耗费更多的CPU和时间,我们压缩文件除了减少文件体积大小外,也是为了减少传输时间,如果我们把压缩等级配置的很高,每次请求服务端都要压缩很久才回返回信息回来,不仅服务器开销会增大很多,请求方也会等的不耐烦。但是现在的 spa 应用既然文件都是打包生成的,那如果我们在打包时候就直接生成高压缩等级的文件,作为静态资源放在服务器上,接收到请求后直接把压缩的文件内容返回回去会怎么样呢?

webpackcompression-webpack-plugin 就是做这个事情的,配置起来也很简单只需要在装置中加入对应插件,简单配置如下

const CompressionWebpackPlugin = require('compression-webpack-plugin');

webpackConfig.plugins.push(
    new CompressionWebpackPlugin({
      asset: '[path].gz[query]',
      algorithm: 'gzip',
      test: new RegExp('\\.(js|css)$'),
      threshold: 10240,
      minRatio: 0.8
    })
)

webpack 打包完成后生成打包文件外还会额外生成 .gz 后缀的压缩文件

图片描述

那么这个插件的压缩等级是多少呢,我们可以在源码中看到默认的 level9

...
const zlib = require('zlib');
this.options.algorithm = zlib[this.options.algorithm];
...
this.options.compressionOptions = {
    level: options.level || 9,
    flush: options.flush
    ...
}

可以看到压缩使用的是 zlib 库,而 zlib 分级来说,默认是 6 ,最高的级别就是9 Best compression (also zlib.Z_BEST_COMPRESSION),因为我们只有在上线项目时候才回去打包构建一次,所以我们在构建时候使用最高级的压缩方式压缩多耗费一些时间对我们来说根本没任何损耗,而我们在服务器上也不用再去压缩文件,只需要找到相应已经压缩过的文件直接返回就可以了。

服务端怎么找到这些文件

在应用层面解决这个问题还是比较简单的,比如上述压缩文件会产生index.css, index.js的压缩文件,在服务端简单处理可以判断这两个请求然后给予相对应的压缩文件。以 nodeexpress 为例

...
app.get(['/index.js','/index.css'], function (req, res, next) {
  req.url = req.url + '.gz'
  res.set('Content-Encoding', 'gzip')
  res.setHeader("Content-Type", generateType(req.path)) // 这里要根据请求文件设置content-type
  next()
})

上面我们可以给请求返回 gZip 压缩后的数据了,当然上面的局限性太强也不可取,但是对于处理这个方面需求也已经有很多库存在,expressexpress-static-gzip 插件 koakoa-static 则默认自带对 gZip 文件的检测,基本原理就是对请求先检测 .gz后缀的文件是否存在,再去根据结果返回不同的内容。

哪些文件可以被 gZip 压缩

gZip 可以压缩所有的文件,但是这不代表我们要对所有文件进行压缩,我们写的代码(css,js)之类的文件会有很好的压缩效果,但是图片之类文件则不会被 gzip 压缩太多,因为它们已经内置了一些压缩,一些文件(比如一些已经被压缩的像.zip文件那种)再去压缩可能会让生成的文件体积更大一些。当然已经很小的文件也没有去压缩的必要了。

实践

能开启 gZip 肯定是要开启的,具体使用在请求时候实时压缩还是在构建时候去生成压缩文件,就要看自己具体业务情况。

参考资料

查看原文

赞 39 收藏 38 评论 3

fecoder 赞了文章 · 2019-09-17

CSS中用 opacity、visibility、display 属性将 元素隐藏 的 对比分析

说明

opacity 用来设置透明度
display 定义建立布局时元素生成的显示框类型
visibility 用来设置元素是否可见。
opacity、visibility、display 这三个属性分别取值 0、hidden、none 都能使元素在页面上看不见,但是他们在方方面面都还是有区别的。

是否占据页面空间

举个例子

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
    }
    .red{
        width:100px;
        height:100px;
        background:red;
    }
  </style>
 </head>
 <body>
    <div class="yellow"></div>
    <div class="red"></div>
 </body>
</html>

最开始的样子

图片描述

黄色块div元素 使用 opacity:0;

图片描述

黄色块div元素 使用 visibility:hidden;

图片描述

黄色块div元素 使用 display:none;

图片描述

可以看出,使用 opacity 和 visibility 属性时,元素还是会占据页面空间的,而使用 display 属性时,元素不占据页面空间。

对子元素的影响

如果子元素什么都不设置的话,都会受父元素的影响,和父元素的显示效果一样,我们就来举例看看,如果子元素设置的值 和 父元素设置的值不同会有什么效果。
例子 (opacity属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        opacity:0;  /* 父元素的 opacity属性取值为0 */
    }
    .blue{
        width:50px;
        height:50px;
        background:blue;
        opacity:1;   /* 子元素的 opacity属性取值为1 */
    }
  </style>
 </head>
 <body>
    <div class="yellow">
        <div class='blue'></div>
    </div>
 </body>
</html>

图片描述

例子 (visibility属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        visibility:hidden;  /* 父元素的 visibility属性取值为hidden */
    }
    .blue{
        width:50px;
        height:50px;
        background:blue;
        visibility:visible;  /* 子元素的 visibility属性取值为visible */
    }
  </style>
 </head>
 <body>
    <div class="yellow">
        <div class='blue'></div>
    </div>
 </body>
</html>

图片描述

例子 (display属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        display:none;  /* 父元素的 display属性取值为none */
    }
    .blue{
        width:50px;
        height:50px;
        background:blue;
        display:block;  /* 子元素的 display属性取值为block */
    }
  </style>
 </head>
 <body>
    <div class="yellow">
        <div class='blue'></div>
    </div>
 </body>
</html>

图片描述

可以看出,使用 opacity 和 display 属性时,父元素对子元素的影响很明显,子元素设置的 opacity 和 display 属性是不起作用的,显示的效果和父元素一样,而使用 visibility 属性时,子元素如果设置为 visibility:visible; 并没有受父元素的影响,可以继续显示出来。

自身绑定的事件是否能继续触发

这里说的触发事件,是指用户人为的触发的事件,不包括使用 JavaScript 模拟触发的事件。
例子 (opacity属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        opacity:0;
    }
 
  </style>
 </head>
 <body>
    <div class="yellow" onmouseenter="alert(0)"></div>
 </body>
</html>

图片描述

例子 (visibility属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        visibility:hidden;
    }
 
  </style>
 </head>
 <body>
    <div class="yellow" onmouseenter="alert(0)"></div>
 </body>
</html>

图片描述

使用 display:none; 就不用举例子了,因为使用 display 属性的话,元素不仅看不见,而且也不占据页面空间,所有不会触发事件。

总的来说,使用 visibility 和 display 属性,自身的事件不会触发,而使用 opacity 属性,自身绑定的事件还是会触发的。

是否影响其他元素触发事件

例子(opacity属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
       .red{
         width:400px;
         height:40px;
         background:red;
         position:relative;
       }
       
       .yellow{
        position:absolute;     
        top:0;
        left:0;
        width:200px;
        height:300px;
        background:yellow;
        opacity:0;            
       }
        
       .blue{
         width:200px;
         height:200px;
         background:blue;
       }
       .red:hover .yellow{
         opacity:1;          
       }
  </style>
 </head>
 <body>
      <div  class='red'>
         <div class='yellow'></div>
      </div>

      <p  class='blue' onmouseenter=alert(0)></p>
 </body>
</html>

图片描述

黄色块div元素设置 opacity:0; ,通过定位,遮挡住了 蓝色的p元素,当鼠标移到蓝色p元素上时,并没有触发蓝色p元素的事件。

例子(visibility属性)

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
       .red{
         width:400px;
         height:40px;
         background:red;
         position:relative;
       }
       
       .yellow{
        position:absolute;     
        top:0;
        left:0;
        width:200px;
        height:300px;
        background:yellow;
        visibility:hidden;          
       }
        
       .blue{
         width:200px;
         height:200px;
         background:blue;
       }
       .red:hover .yellow{
         visibility:visible;       
       }
  </style>
 </head>
 <body>
      <div  class='red'>
         <div class='yellow'></div>
      </div>

      <p  class='blue' onmouseenter=alert(0)></p>
 </body>
</html>

图片描述

黄色块div元素设置 visibility:hidden; ,通过定位,虽然遮挡住了 蓝色的p元素,但是当鼠标移到蓝色p元素上时,还是触发了蓝色p元素绑定的事件。

和上边一样,display 属性就不举例子了,因为他不会占据页面空间,也就不会遮挡其他元素,就不会影响其他元素触发事件了。
所以,visibility 和 display 属性是不会影响其他元素触发事件的,而 opacity 属性 如果遮挡住其他元素,其他的元素就不会触发事件了。

是否产生回流(reflow)

回流

当页面中的一部分(或全部)因为元素的规模尺寸,布局,隐藏等改变而需要重新构建。这就称为回流(也有人会把回流叫做是重布局或者重排)。
每个页面至少需要一次回流,就是在页面第一次加载的时候。

dispaly 属性会产生回流,而 opacity 和 visibility 属性不会产生回流。

是否产生重绘(repaint)

重绘

当页面中的一些元素需要更新属性,而这些属性只是影响元素的外观,风格,而不会影响布局的时候,比如background-color。则称为重绘。

dispaly 和 visibility 属性会产生重绘,而 opacity 属性不一定会产生重绘。

元素提升为合成层后,transform 和 opacity 不会触发 repaint,如果不是合成层,则其依然会触发 repaint。
在 Blink 和 WebKit 内核的浏览器中,对于应用了 transition 或者 animation 的 opacity 元素,浏览器会将渲染层提升为合成层。
也可以使用 translateZ(0) 或者 translate3d(0,0,0) 来人为地强制性地创建一个合成层。

举个例子

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
    <div id="target">重绘 repaint</div>

    <script>
        var flag = false;
        setInterval(function () {
            flag = !flag;
            target.style.opacity = flag ? 0 : 1;
        },1000)
    </script>
</body>
</html>

我们可以用 Chrome DevTools 的 Rendering 来看看,
先打开 Rendering

图片描述

把第一个选项勾选,这个选项会 高亮显示需要重绘的区域。

图片描述

看看效果

图片描述

改改代码,增加上个 transition

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    div{
        transition:1s;
    }
  </style>
</head>
<body>
    <div id="target">重绘 repaint</div>

    <script>
        var flag = false;
        setInterval(function () {
            flag = !flag;
            target.style.opacity = flag ? 0 : 1;
        },1000)
    </script>
</body>
</html>

再看看效果

图片描述

加上 transition 后就没有 高亮显示了,这时候 opacity 不会触发重绘。

实际上透明度改变后,GPU 在绘画时只是简单的降低之前已经画好的纹理的 alpha 值来达到效果,并不需要整体的重绘。不过这个前提是这个被修改的 opacity 本身必须是一个图层,如果图层下还有其他节点,GPU 也会将他们透明化。

注意:回流必将引起重绘,而重绘不一定会引起回流。

是否支持transition

opacity 是支持 transition的,一般淡入淡出的效果就是这样实现的。

图片描述

visibility 也是支持 transition 的。

visibility: 离散步骤,在0到1数字范围之内,0表示“隐藏”,1表示完全“显示”

visibility : hidden; 可以看成 visibility : 0;
visibility : visible; 可以看成 visibility : 1;

只要 visibility 的值大于0就是显示的,所以
visibility:visible 过渡到 visibility:hidden,看上去不是平滑的过渡,而是进行了一个延时。

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
  .blue{
    width:200px;
    height:200px;
    background:blue;
    transition:1s;
    visibility:visible;
  }
  .blue:hover{
    visibility:hidden;
  }
  </style>
 </head>
 <body>
    <div class='blue'></div>
 </body>
</html>

图片描述

而如果 visibility:hidden 过渡到 visibility:visible ,则是立即显示,没有延时。
注意
上面这个例子只能是从 visibility:visible 过渡到 visibility:hidden,不能从 visibility:hidden 过渡到 visibility:visible
当元素是 visibility:hidden; 时,自身的事件不会触发,所以像上面这个例子中,直接在蓝色块div元素 上加 hover 事件,要去将自身的 visibility:hidden 过渡到 visibility:visible 是不会起作用的。
但是在其他元素上加事件,来将该元素的 visibility:hidden 过渡到 visibility:visible 是可以的,看例子。

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
  .red{
    width:200px;
    height:200px;
    background:red;
  }
  .blue{
    width:200px;
    height:200px;
    background:blue;
    transition:1s;
    visibility:hidden;
  }
  .red:hover+.blue{
    visibility:visible;
  }
  </style>
 </head>
 <body>
        <div class='red'></div>
        <div class='blue'></div>
 </body>
</html>

图片描述

display 不仅不支持transition,它还会使 transition 失效。举个例子看看

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .blue{
        width:200px;
        height:200px;
        background:blue;
    }
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        opacity:0;
        display:none;
        transition:1s
    }
    .blue:hover .yellow{
        opacity:1;
        display:block;
    }
  </style>
 </head>
 <body>
    <div class='blue'>
        <div class='yellow'></div>
    </div>
 </body>
</html>

图片描述

可以看出用了display,支持 transition 的 opacity 属性也没起作用。

这是因为display:none; 的元素,是不会渲染在页面上的,而 transition 要起作用,元素必须是已经渲染在页面上的元素,我们可以再来看个例子

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .blue{
        width:200px;
        height:200px;
        background:blue;
    }
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        opacity:0;
        transition:1s
    }
  </style>
 </head>
 <body>
    <span>渲染到页面</span>
    <div class='blue'></div>
    
    <script>    
        var span = document.querySelector('span');
        span.addEventListener('click',function(){
            var yellowDiv = document.createElement('div');
            yellowDiv.classList.add('yellow');

            var blue = document.querySelector('.blue');
            blue.appendChild(yellowDiv);

            yellowDiv.style.opacity = '1';
        })
    </script>
 </body>
</html>

图片描述

给 span 元素绑定事件,点击它的时候,才会把黄色块div元素,渲染到DOM树上,然后改变黄色块div元素的 opacity 属性,opacity 是支持 transition 的,而在这段代码中,并没有起作用。
更详细的关于 transition 是否成功 的解读看这里
渲染树决定 transtion 能否成功

要想解决这个问题,我们可以这样做。
1、把 display 属性换成 visibility 属性

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .blue{
        width:200px;
        height:200px;
        background:blue;
    }
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        opacity:0;
        visibility:hidden;
        transition:1s
    }
    .blue:hover .yellow{
        opacity:1;
        visibility:visible;
    }

  </style>
 </head>
 <body>
    <div class='blue'>
        <div class='yellow'></div>
    </div>
 </body>
</html>

图片描述

2、如果必须要用 display 属性,我们可以加上定时器来解决这个问题

<!doctype html>
<html lang="en">
 <head>
  <meta charset="UTF-8">
  <style>
    .blue{
        width:200px;
        height:200px;
        background:blue;
    }
    .yellow{
        width:100px;
        height:100px;
        background:yellow;
        display:none;
        opacity:0;
        transition:1s;
    }
  </style>
 </head>
 <body>
    <div class='blue'>
        <div class='yellow'></div>
    </div>
    <script>    
        var blue = document.querySelector('.blue');
        blue.addEventListener('mouseenter',function(){
            var yellowDiv = document.querySelector('.yellow');
            yellowDiv.style.display = 'block';
            setTimeout(function(){
                yellowDiv.style.opacity = '1';
            },0);
        })
    </script>
 </body>
</html>

图片描述

总结

图片描述

前端简单说

查看原文

赞 20 收藏 8 评论 0

fecoder 收藏了文章 · 2019-09-06

译文:JS事件循环机制(event loop)之宏任务、微任务

译文:JS事件循环机制(event loop)之宏任务、微任务

原文标题:《Tasks, microtasks, queues and schedules》

这是一篇谷歌大神文章,写得非常精彩。译者想借这次翻译深入学习一下,由于水平有限,英文好的同学建议直接阅读原文。
原文地址:Tasks, microtasks, queues and schedules
下面正文开始:

Tasks, microtasks, queues and schedules

首先看一段代码:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

打印顺序是什么?
正确答案是
script start, script end, promise1, promise2, setTimeout
但是在不同浏览器上的结果却是让人懵逼的。

Microsoft Edge, Firefox 40, iOS Safari 和 desktop Safari 8.0.8在promise1,promise2之前打印了setTimeout,--虽然看起来像竞态条件。
但让人懵逼的是Firefox 39 , Safari 8.0.7会打印出正确顺序。
译者注:译者的Microsoft Edge 38.14393.2068.0,Firefox 59.0.2 版本会打印出正确顺序,应该已经支持了吧,其他浏览器未验证。

为什么会出现这样打印顺序呢?

要理解这些你首先需要对事件循环机制处理宏任务和微任务的方式有了解。
如果是第一次接触信息量会有点大。深呼吸……

每个线程都会有它自己的event loop(事件循环),所以都能独立运行。然而所有同源窗口会共享一个event loop以同步通信。event loop会一直运行,来执行进入队列的宏任务。一个event loop有多种的宏任务源(译者注:event等等),这些宏任务源保证了在本任务源内的顺序。但是浏览器每次都会选择一个源中的一个宏任务去执行。这保证了浏览器给与一些宏任务(如用户输入)以更高的优先级。好的,跟着我继续……

宏任务(task)

浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染 (task->渲染->task->...)
鼠标点击会触发一个事件回调,需要执行一个宏任务,然后解析HTMl。还有下面这个例子,setTimeout

setTimeout的作用是等待给定的时间后为它的回调产生一个新的宏任务。这就是为什么打印‘setTimeout’在‘script end’之后。因为打印‘script end’是第一个宏任务里面的事情,而‘setTimeout’是另一个独立的任务里面打印的。

微任务(Microtasks )

微任务通常来说就是需要在当前 task 执行结束后立即执行的任务,比如对一系列动作做出反馈,或或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。只要执行栈中没有其他的js代码正在执行且每个宏任务执行完,微任务队列会立即执行。如果在微任务执行期间微任务队列加入了新的微任务,会将新的微任务加入队列尾部,之后也会被执行。微任务包括了mutation observe的回调还有接下来的例子promise的回调。

一旦一个pormise有了结果,或者早已有了结果(有了结果是指这个promise到了fulfilled或rejected状态),他就会为它的回调产生一个微任务,这就保证了回调异步的执行即使这个promise早已有了结果。所以对一个已经有了结果的promise调用.then(yey, nay)会立即产生一个微任务。这就是为什么‘promise1’,'promise2'会打印在‘script end’之后,因为所有微任务执行的时候,当前执行栈的代码必须已经执行完毕。‘promise1’,'promise2'会打印在‘setTimeout’之前是因为所有微任务总会在下一个宏任务之前全部执行完毕。

逐步执行demo:译者注,这里作者实现了一个类似于debug,逐步执行的demo,其中还加入了执行栈的动画还有讲解,建议大家去原文观看
原文

是的,我弄了一个逐步的图标。你怎么度过你的周六?和你的朋友出去享受阳光?emmmm,如果对我惊艳的ui交互设计看不懂,点击左右箭头试试吧。

那为什么那些浏览器打印顺序不一样咧?

有些浏览会会打印出:
script start, script end, setTimeout, promise1, promise2。
他们会在setTimeout之后执行promise的回调,就好像这些浏览器会把promise的回调视作一个新的宏任务而不是微任务。

其实无可厚非,因为promises 来自于ECMAScript 的标准而不是HTML标准。
ECMAScript 有个关于jobs的概念和微任务挺类似的,但是否明确具有关联关系却尚未定论(相关讨论)。然而,普遍的观点是promise应该属于微任务。

如果说把 promise 当做一个新的 task 来执行的话,这将会造成一些性能上的问题,因为 promise 的回调函数可能会被延迟执行,因为在每一个 task 执行结束后浏览器可能会进行一些渲染工作。由于作为一个 task 将会和其他任务来源(task source)相互影响,这也会造成一些不确定性,同时这也将打破一些与其他 API 的交互,这样一来便会造成一系列的问题。

这里有一个关于让Edge把promise加入微任务的提议,其实WebKit 早已悄悄正确实现。所以我猜Safari最终会修复,Firefox 43好像已修复。

如何分辨宏任务和微任务?

实际测试是一种方法,观察日志打印顺序与promise和setTimeout的关系,但是首先浏览器对这两者的实现要正确。
还有一个稳妥方法就是看文档,比如setTimeout是宏任务,mutation是微任务。
正如上文提到的,ECMAScript 中把微任务叫做jobs,EnqueueJob
是微任务。
接下来,让我们看一些复杂的例子吧

一级boss战

写这篇文章前我就犯了这个错。来看代码

<div class="outer">
  <div class="inner"></div>
</div>

在看接下来的js代码,如果我点击div.inner会打印什么?

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
//监听element属性变化
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

偷看答案前先试一试啊,tips:日志可能出现多次哦。
结果如下:
click
promise
mutate
click
promise
mutate
timeout
timeout
你猜对了吗。你可能猜对了,但是许多浏览器却不这样觉得。
图片描述

译者注:译者本机测试
Chrome( 64.0.3282.167(正式版本) (64 位))相同,
Edge(Edge 38.14393.2068.0)不同(与Chrome顺序相同)
Firefox 32位 59.0.2

  • click
  • mutate
  • click
  • mutate
  • promise
  • promise
  • timeout
  • timeout

哪个是对的?

分发click event是一个宏任务,Mutation observer和promise都会进入微任务队列,setTimeout回调是一个宏任务,所以来看demo
作者演示demo,建议原文观看demo
所以chrome是对的,我之前也不知道只要执行栈中没有js代码在执行,微任务会在回调后立即执行,我之前认为它只会在宏任务结束后执行(Although we are mid-task,microtasks are processed after callbacks if the stack is empty).这个规则来自于HTML标准中关于回调调用的部分

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

如果js执行栈空了,立即执行microtask checkpoint
—— HTML: Cleaning up after a callback
microtask checkpoint 会检查整个微任务队列,除非正在执行这个检查动作。ECMAScript 标准中说到

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
— ECMAScript: Jobs and Job Queues

HTML环境下,必须执行。

浏览器哪里出错了?

Firefox和Safari在click监听器回调之间正确执行了mutation 回调的微任务,但promise打印结果却出现在了错误的位置。
无可厚非的是jobs和微任务的关系太含糊不清,不过我仍认为应该在click监听器回调之间执行。
Edge我们早就知道会把promise回调放进错误的队列,但他也也没在click监听器回调之间执行微任务队列,而是在所有监听器回调后执行,这打印click之后只打印了一次muteta,为此我给它提了个bug。

一级boss愤怒的大哥来了

用刚才的代码,如果我们这样执行会发生什么。

inner.click();

这依旧会开始分发事件,但这次是使用脚本而不是交互点击。
click
click
promise
mutate
promise
timeout
timeout
图片描述

我发誓我从chrome得到的答案一直不一样- -。我已经更新了这个表许许多次了。我觉得我是错误地测试了Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。

为什么不一样呢?

来看demo发生了什么,原作者的演示demo
所以正确的顺序是click, click, promise, mutate, promise, timeout, timeout,看来chrome是对的。
在每个监听器回调调用之后

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

之前的例子,微任务会在监听器回调之间执行。但这里的例子,click()会导致事件同步分发,所以在监听器回调之间Js执行栈不为空,而上述的这个规则保证了微任务不会打断正在执行的js.这意味着我们不能在监听器回调之间执行微任务,微任务会在监听器之后执行。

这能影响到什么?

译者注:对IndexedDB 理解不深入,这段就不翻译了- -
Yeah, it'll bite you in obscure places (ouch). I encountered this while trying to create a simple wrapper library for IndexedDB that uses promises rather than weird IDBRequest objects. It almost makes IDB fun to use.

When IDB fires a success event, the related transaction object becomes inactive after dispatching (step 4). If I create a promise that resolves when this event fires, the callbacks should run before step 4 while the transaction is still active, but that doesn't happen in browsers other than Chrome, rendering the library kinda useless.

You can actually work around this problem in Firefox, because promise polyfills such as es6-promise use mutation observers for callbacks, which correctly use microtasks. Safari seems to suffer from race conditions with that fix, but that could just be their broken implementation of IDB. Unfortunately, things consistently fail in IE/Edge, as mutation events aren't handled after callbacks.

Hopefully we'll start to see some interoperability here soon.

干得不错!

总结一下:

  • 宏任务按顺序执行,且浏览器在每个宏任务之间渲染页面
  • 所有微任务也按顺序执行,且在以下场景会立即执行所有微任务

    • 每个回调之后且js执行栈中为空。
    • 每个宏任务结束后。

希望你已经熟悉了eventloop.

查看原文

fecoder 赞了文章 · 2019-09-06

译文:JS事件循环机制(event loop)之宏任务、微任务

译文:JS事件循环机制(event loop)之宏任务、微任务

原文标题:《Tasks, microtasks, queues and schedules》

这是一篇谷歌大神文章,写得非常精彩。译者想借这次翻译深入学习一下,由于水平有限,英文好的同学建议直接阅读原文。
原文地址:Tasks, microtasks, queues and schedules
下面正文开始:

Tasks, microtasks, queues and schedules

首先看一段代码:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

打印顺序是什么?
正确答案是
script start, script end, promise1, promise2, setTimeout
但是在不同浏览器上的结果却是让人懵逼的。

Microsoft Edge, Firefox 40, iOS Safari 和 desktop Safari 8.0.8在promise1,promise2之前打印了setTimeout,--虽然看起来像竞态条件。
但让人懵逼的是Firefox 39 , Safari 8.0.7会打印出正确顺序。
译者注:译者的Microsoft Edge 38.14393.2068.0,Firefox 59.0.2 版本会打印出正确顺序,应该已经支持了吧,其他浏览器未验证。

为什么会出现这样打印顺序呢?

要理解这些你首先需要对事件循环机制处理宏任务和微任务的方式有了解。
如果是第一次接触信息量会有点大。深呼吸……

每个线程都会有它自己的event loop(事件循环),所以都能独立运行。然而所有同源窗口会共享一个event loop以同步通信。event loop会一直运行,来执行进入队列的宏任务。一个event loop有多种的宏任务源(译者注:event等等),这些宏任务源保证了在本任务源内的顺序。但是浏览器每次都会选择一个源中的一个宏任务去执行。这保证了浏览器给与一些宏任务(如用户输入)以更高的优先级。好的,跟着我继续……

宏任务(task)

浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染 (task->渲染->task->...)
鼠标点击会触发一个事件回调,需要执行一个宏任务,然后解析HTMl。还有下面这个例子,setTimeout

setTimeout的作用是等待给定的时间后为它的回调产生一个新的宏任务。这就是为什么打印‘setTimeout’在‘script end’之后。因为打印‘script end’是第一个宏任务里面的事情,而‘setTimeout’是另一个独立的任务里面打印的。

微任务(Microtasks )

微任务通常来说就是需要在当前 task 执行结束后立即执行的任务,比如对一系列动作做出反馈,或或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。只要执行栈中没有其他的js代码正在执行且每个宏任务执行完,微任务队列会立即执行。如果在微任务执行期间微任务队列加入了新的微任务,会将新的微任务加入队列尾部,之后也会被执行。微任务包括了mutation observe的回调还有接下来的例子promise的回调。

一旦一个pormise有了结果,或者早已有了结果(有了结果是指这个promise到了fulfilled或rejected状态),他就会为它的回调产生一个微任务,这就保证了回调异步的执行即使这个promise早已有了结果。所以对一个已经有了结果的promise调用.then(yey, nay)会立即产生一个微任务。这就是为什么‘promise1’,'promise2'会打印在‘script end’之后,因为所有微任务执行的时候,当前执行栈的代码必须已经执行完毕。‘promise1’,'promise2'会打印在‘setTimeout’之前是因为所有微任务总会在下一个宏任务之前全部执行完毕。

逐步执行demo:译者注,这里作者实现了一个类似于debug,逐步执行的demo,其中还加入了执行栈的动画还有讲解,建议大家去原文观看
原文

是的,我弄了一个逐步的图标。你怎么度过你的周六?和你的朋友出去享受阳光?emmmm,如果对我惊艳的ui交互设计看不懂,点击左右箭头试试吧。

那为什么那些浏览器打印顺序不一样咧?

有些浏览会会打印出:
script start, script end, setTimeout, promise1, promise2。
他们会在setTimeout之后执行promise的回调,就好像这些浏览器会把promise的回调视作一个新的宏任务而不是微任务。

其实无可厚非,因为promises 来自于ECMAScript 的标准而不是HTML标准。
ECMAScript 有个关于jobs的概念和微任务挺类似的,但是否明确具有关联关系却尚未定论(相关讨论)。然而,普遍的观点是promise应该属于微任务。

如果说把 promise 当做一个新的 task 来执行的话,这将会造成一些性能上的问题,因为 promise 的回调函数可能会被延迟执行,因为在每一个 task 执行结束后浏览器可能会进行一些渲染工作。由于作为一个 task 将会和其他任务来源(task source)相互影响,这也会造成一些不确定性,同时这也将打破一些与其他 API 的交互,这样一来便会造成一系列的问题。

这里有一个关于让Edge把promise加入微任务的提议,其实WebKit 早已悄悄正确实现。所以我猜Safari最终会修复,Firefox 43好像已修复。

如何分辨宏任务和微任务?

实际测试是一种方法,观察日志打印顺序与promise和setTimeout的关系,但是首先浏览器对这两者的实现要正确。
还有一个稳妥方法就是看文档,比如setTimeout是宏任务,mutation是微任务。
正如上文提到的,ECMAScript 中把微任务叫做jobs,EnqueueJob
是微任务。
接下来,让我们看一些复杂的例子吧

一级boss战

写这篇文章前我就犯了这个错。来看代码

<div class="outer">
  <div class="inner"></div>
</div>

在看接下来的js代码,如果我点击div.inner会打印什么?

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
//监听element属性变化
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

偷看答案前先试一试啊,tips:日志可能出现多次哦。
结果如下:
click
promise
mutate
click
promise
mutate
timeout
timeout
你猜对了吗。你可能猜对了,但是许多浏览器却不这样觉得。
图片描述

译者注:译者本机测试
Chrome( 64.0.3282.167(正式版本) (64 位))相同,
Edge(Edge 38.14393.2068.0)不同(与Chrome顺序相同)
Firefox 32位 59.0.2

  • click
  • mutate
  • click
  • mutate
  • promise
  • promise
  • timeout
  • timeout

哪个是对的?

分发click event是一个宏任务,Mutation observer和promise都会进入微任务队列,setTimeout回调是一个宏任务,所以来看demo
作者演示demo,建议原文观看demo
所以chrome是对的,我之前也不知道只要执行栈中没有js代码在执行,微任务会在回调后立即执行,我之前认为它只会在宏任务结束后执行(Although we are mid-task,microtasks are processed after callbacks if the stack is empty).这个规则来自于HTML标准中关于回调调用的部分

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

如果js执行栈空了,立即执行microtask checkpoint
—— HTML: Cleaning up after a callback
microtask checkpoint 会检查整个微任务队列,除非正在执行这个检查动作。ECMAScript 标准中说到

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
— ECMAScript: Jobs and Job Queues

HTML环境下,必须执行。

浏览器哪里出错了?

Firefox和Safari在click监听器回调之间正确执行了mutation 回调的微任务,但promise打印结果却出现在了错误的位置。
无可厚非的是jobs和微任务的关系太含糊不清,不过我仍认为应该在click监听器回调之间执行。
Edge我们早就知道会把promise回调放进错误的队列,但他也也没在click监听器回调之间执行微任务队列,而是在所有监听器回调后执行,这打印click之后只打印了一次muteta,为此我给它提了个bug。

一级boss愤怒的大哥来了

用刚才的代码,如果我们这样执行会发生什么。

inner.click();

这依旧会开始分发事件,但这次是使用脚本而不是交互点击。
click
click
promise
mutate
promise
timeout
timeout
图片描述

我发誓我从chrome得到的答案一直不一样- -。我已经更新了这个表许许多次了。我觉得我是错误地测试了Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。

为什么不一样呢?

来看demo发生了什么,原作者的演示demo
所以正确的顺序是click, click, promise, mutate, promise, timeout, timeout,看来chrome是对的。
在每个监听器回调调用之后

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

之前的例子,微任务会在监听器回调之间执行。但这里的例子,click()会导致事件同步分发,所以在监听器回调之间Js执行栈不为空,而上述的这个规则保证了微任务不会打断正在执行的js.这意味着我们不能在监听器回调之间执行微任务,微任务会在监听器之后执行。

这能影响到什么?

译者注:对IndexedDB 理解不深入,这段就不翻译了- -
Yeah, it'll bite you in obscure places (ouch). I encountered this while trying to create a simple wrapper library for IndexedDB that uses promises rather than weird IDBRequest objects. It almost makes IDB fun to use.

When IDB fires a success event, the related transaction object becomes inactive after dispatching (step 4). If I create a promise that resolves when this event fires, the callbacks should run before step 4 while the transaction is still active, but that doesn't happen in browsers other than Chrome, rendering the library kinda useless.

You can actually work around this problem in Firefox, because promise polyfills such as es6-promise use mutation observers for callbacks, which correctly use microtasks. Safari seems to suffer from race conditions with that fix, but that could just be their broken implementation of IDB. Unfortunately, things consistently fail in IE/Edge, as mutation events aren't handled after callbacks.

Hopefully we'll start to see some interoperability here soon.

干得不错!

总结一下:

  • 宏任务按顺序执行,且浏览器在每个宏任务之间渲染页面
  • 所有微任务也按顺序执行,且在以下场景会立即执行所有微任务

    • 每个回调之后且js执行栈中为空。
    • 每个宏任务结束后。

希望你已经熟悉了eventloop.

查看原文

赞 111 收藏 80 评论 7

fecoder 收藏了文章 · 2019-09-05

寻根问底之——元素隐藏你知多少?

老生常谈之display: none

相信小伙伴们都被问过这样一个问题:让一个元素隐藏起来,有多少种方法呢?常规来讲,我们有三种方法display: noneopacity: 0visibility: hidden ,基于display: none的副作用,已经是个被说烂的问题,主要是有以下缺点:

一、切换显隐时会导致reflow(回流),从而引起repaint(重绘),当页面中reflow增多至一定程度时,会导致cpu使用率飙高。

二、无法对元素设置过渡动画,也无法进行方位测量(包括clientWidth, clientHeight, offsetWidth, offsetHeight, scrollWidth, scrollHeight, getBoundingClientRect(), getComputedStyle())

原因是:浏览器会解析HTML标签生成DOM Tree,解析CSS生成CSSOM,然后将DOM Tree和CSSOM合并生成Render Tree,最后才根据Render Tree的信息布局来渲染界面,但设置了display: none的元素,是不会被加入Render Tree中的,自然也无法渲染过渡动画。

三、用它来设置显隐切换时,会因为与display: flexdisplay: grid冲突而使人困扰。

你真的了解opacity和visibility吗

如此说来,我们设置元素显隐时,使用opacity或visibility似乎是更好的选择,但小伙伴们有没有考虑过,opacity: 0visibility: hidden 这两者又有何具体区别呢?
既然标题写着寻根问底,那么我们就通过几轮PK来深挖一下这两者的具体区别:

第一轮:动画属性

常见的动画效果中,使用最广泛的应该就属淡入和淡出了,这时候,我们应该只有一种选择:opacity配合animation,因为visibility这个属性是无法进行动画过渡的,要满足动画过渡,必须在两个值之间存在连续不断的值,即连续区间,visibility显然不满足,因为在可见/不可见两个状态之间不存在中间态,它是“布尔隐藏”的。

第二轮:子元素的表现

设置了opacity: 0visibility: hidden 的元素,它们的子元素会受到怎样的不同影响呢?

首先,opacity属性是不可以被子元素继承的,而visibility属性可以被继承,详见CSS3规范opacityvisibility中的属性介绍。

其次,一旦父级元素设置了opacity,那么子元素的最大透明度将无法超过父级,意味着,父级的opacity为0.5,那么子级的opacity就算设置为1,其实际透明度也会是0.5 * 1 = 0.5,所以,只要父级透明度为0,那么子级没有任何办法可以重新设置为可见;

但visibility的子级却仍有“翻身”的机会,即使父级元素设置了visibility: hidden,子元素仍可通过visibility: visible重新设置为可见。

第三轮:层叠上下文(Stacking Context)

HTML中的元素都有自身的层叠水平,但是某些情况下,元素会形成层叠上下文(接下来用SC代替),直接“拔高”自身以及子元素的层叠水平。而元素间不同的层叠水平,在它们发生重叠的时候,就会决定谁将在Z轴上更高一筹,也就是谁离用户的眼睛更近。

至于什么情况下元素会形成SC,可以参考MDN文档的详细说明。而在这份文档中我们可以看到:当元素的opacity属性值小于1时,会形成SC。我们可以观察如下代码:

<div style="position: relative;">
    <div style="position: absolute;background: green;
                top: 0;width: 200px;height: 200px">
    </div>
    <div style="background: red;width: 100px;height: 100px"></div>
</div>

这种情况下,设置了绝对定位的绿色方块形成了SC,所以其层叠水平自然比红色方块高,所以此时我们看不到红色方块:

而当我们为红色方块设置了opacity属性后,比如:

<div style="position: relative;">
    <div style="position: absolute;background: green;
                top: 0;width: 200px;height: 200px">
    </div>
    <div style="opacity: 0.5;background: red;width: 100px;height: 100px">
    </div>
</div>

此时,红色方块会层叠在绿色方块之上。因为红色方块的opacity小于1,形成了SC,且两者都未设置z-index,属于相同层叠水平,所以按照后来居上的原则,红色方块就会叠在上方,如图所示:

同理,opacity为0的元素也会创建SC,而visibility属性则不会创建SC,也不会影响到元素的层叠水平。

说了半天,有人可能会问,既然元素都隐藏了,看不见了,谁还管它在上在下呢?通常情况下是如此,但经过第四轮的PK后,你就会知道,有时候你的确不能忽视这个问题。

第四轮:可交互性/可访问性

这一轮我们比较的是可交互性/可访问性,先说visibility: hidden,设置了这个属性的元素,其绑定的监听事件将会忽略event.target为自身的事件触发。这句话比较拗口,通俗点说就是,这个元素会接收到子元素的事件冒泡,但无法触发自身的事件,可以通过这个在线demo体验一下这个效果。

当然,除了无法触发自身的事件之外,它还无法通过tab键访问到,也就是无法focus;此外,它还会失去accessibility,也就是不能进行无障碍访问,比如屏幕阅读软件将无法访问到这个元素。

反观设置了opacity: 0的元素,则完全没有以上的限制。现在你知道我们为啥不能忽视上一轮提出的问题了,因为设置了opacity: 0的元素即使看不见了,它仍然可以被点击被访问,有时会产生意料之外的bug。

取长补短

既然两者都有各自的优缺点,我们能否将其结合,并取长补短呢?

答案是当然可以。但首先要明确我们想取什么长,补什么短。一般来讲,我们既希望元素可以使用淡入淡出的动画效果,又希望在消失后不要保留可交互性/可访问性,其实做法很简单:

.box {
  animation: fade 0.5s linear 0s forwards;
}
@keyframes fade {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    visibility: hidden;
  }
}

我们仍然使用opacity来做动画过渡,但在最后一个动画帧,我们把visibility: hidden加上,就可以达到我们想要的效果了。此时,当元素淡出后,也不会意外地触发事件了。并且,在使用opacity属性进行动画效果时,浏览器还会将该元素提升为composite layer(合成层),使用gpu进行硬件加速渲染,两全其美~

当然,如果你的确需要这个元素保留页面中的占位,就不能这样做了。

总结

总而言之,如果你没有动画需求,使用visibility进行显隐切换可能更省心,但如果有动画需求,则最好使用两者结合的方式。另外,以后会有更多的寻根问底系列的文章,目的就是要对小的知识点也进行深入剖析,从而获得更加系统性的认识,而不是停留在表面。

ps:欢迎关注微信公众号——前端漫游指南,会定期发布优质原创文章和译文,关注公众号福利:回复666可以获得精选前端进阶电子书,感谢~

图片描述

查看原文

认证与成就

  • 获得 146 次点赞
  • 获得 4 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-05-11
个人主页被 429 人浏览