最近写了一个 React 水印组件,为了支持水印防隐藏,采用了MutationObserver,主要思路如下:

  • 一个 MutationObserver 用于监测水印父节点(parentNode)的childList,在水印被手动删除之后,重新插入新的水印
  • 一个 MutationObserver 用于监测水印自身属性(attributes),在水印属性(displayopacityvisibility等)变化后重置属性值,防止通过修改 style 隐藏水印

我们只讨论如何监测自身属性来防止属性修改,主要有 2 个思路:

1. 设定一个初始值,任何属性变化时都将 style 属性重置为初始值

const callback = (mutationsList, observer) => {
    for (var mutation of mutationsList) {
        observer.disconnect()
        mutation.target.setAttribute('style', initStyle)
        observer.observe(node, options)
    }
}

演示 1

See the Pen React WaterMark Component by LiYan (@liyan) on CodePen.

2. 监测 oldValue,任何属性修改都将属性值重置为 oldValue

const callback = (mutationsList, observer) => {
    for (var mutation of mutationsList) {
        observer.disconnect()
        let { oldValue, attributeName } = mutation
        mutation.target.setAttribute(attributeName, oldValue)
        observer.observe(node, options)
    }
}

演示 2

See the Pen WaterMark use oldValue by LiYan (@liyan) on CodePen.

方法 2 与方法 1 相比,可以重置任何属性的改变,而方法 1 只能重置我们指定的几个属性。看起来方法 2 是更好的选项,事实如此吗?

注意演示 2 的 hideshow 按钮,点击发现,可以改变水印的  style 属性实现水印隐藏,其中 hide 按钮的事件处理函数为:

onClick = () => {
    let watermark = document.getElementById('watermark')
    watermark.style.display = 'none'
    watermark.style.display = 'block'
}

为什么这样会实现水印的隐藏呢?其实在于 JavaScript 的事件运行机制(任务队列)。

关于任务队列,此处不做详述,可以参看这篇文章Tasks, microtasks, queues and schedules。主要就是:

  • JavsScript 基于事件循环,每个循环会先处理当前循环的宏任务,然后会处理当前循环的微任务
  • 微任务处理完后会进入下一个事件循环
  • 如此循环往复

比如 setTimeout 就会产生一个宏任务,在当前任务运行完成之后再运行,而一个 fullfilled 的 Promise 、我们讨论的 MutationObserver 则是微任务的代表,基于此,我们来解释为什么上述代码会实现水印的隐藏。

当我们点击 hide 按钮时,就会产生一个任务,运行 onClick 回调函数

运行第一次属性修改

watermark.style.display = 'none'

style.displayblock 改变为none,这里会在微任务队列内推入一个微任务(MutationObserver callback, 其 mutationList 有一个 MutationRecord,其 oldValue 为 display: block)。

click 任务继续运行

watermark.style.display = 'block'

 style.displaynone 改变为block,这里和 Promise 不同的是,因为该 MutationObserver 已经有一个 callback 在微任务队列内了,不会再次推入一个新的微任务,而只是将一个新的 MutationRecord 推入 mutationList,该 MutationRecord 的 oldValue 为 display: none)。

click 任务运行完成,开始运行微任务

  • 微任务 mutation callback 开始运行, 循环 mutationList:
    • 第一个 MutationRecord 使 style 属性恢复到 oldValuedisplay 重置为 block
    • 第二个 MutationRecord 使 style 属性恢复到 oldValuedisplay 重置为 none

由此就实现了水印的隐藏。

具体过程如下:

style 初始值 display: block

click:

  • 任务开始
    • watermark.style.display = 'none'
      • styledisplay: block -> display: none,MutationObserver 入队微任务 callback, mutationList 有一个 MutationRecord,其 oldValue 为 display: block
    • watermark.style.display = 'block'
      • styledisplay: none -> display: block,MutationObserver callback 已存在,会推入一个新的 MutationRecord 到 mutationList,其 oldValue 为 display: none
  • 任务运行结束,开始微任务队列,执行 callback
    • mutationRecord 1:恢复 oldValue:display: block
    • mutationRecord 2:恢复 oldValue:display: none

所以我们的方法 1 虽然只能锁定几个指定的属性,但是在任何情况下属性都会重置为初始值,因此可以实现对属性的真正锁定。

参考

问题

如何在组件之间复用代码一直是困扰 React 开发者的一个问题,虽然通过 React 的 Higher-Order Components(HOC)Render Props 等技术可以解决  这些部分,但是同时也带来了很多其他问题:

  • 多层不必要的嵌套组件(HOC、Render Props)
  • 可读性差(Render Props)
  • 需要重构现有组件(HOC)
  • 复用状态性逻辑困难(Composition、HOC)

我们通过下边这个 Demo 来对其进行比较:

示例

比如我们有如下的组件,可用于试验考拉兹猜想。这个组件主要有两个处理逻辑:

  • 初始构建时设置一个随机的值为 n 的 state
  • 每次点击按钮计算下一个 n 的值并更新 state

代码如下:

class CollatzRandom extends Component {
    constructor(props) {
        super(props)
        let n = parseInt(Math.random() * 100, 10)
        this.state = { n }
    }
    calcNext = () => {
        let { n } = this.state
        if (n % 2) {
            n = 3 * n + 1
        } else {
            n = n / 2
        }
        this.setState({ n })
    }
    render() {
        return (
            <>
                <h1>{this.state.n}</h1>
                <button onClick={this.calcNext}>next</button>
            </>
        )
    }
}

组件结构如下图:

Comp

如果要复用 calcNext 逻辑呢

如果现在有另一个组件需要复用 calcNext 逻辑呢?

1. 使用 HOC

function withCalcNext(Comp, initN) {
    return class extends Component {
        constructor(props) {
            super(props)
            this.state = { n: initN }
        }
        calcNext = () => {
            let { n } = this.state
            if (n % 2) {
                n = 3 * n + 1
            } else {
                n = n / 2
            }
            this.setState({ n })
        }
        render() {
            return <Comp {...this.props} n={this.state.n} calcNext={this.calcNext} />
        }
    }
}

通过高阶组件,我们的组件  就可以共用 n、calcNext 等逻辑,只需要在组件内 n = this.props.n 以及 calcNext = this.props.calcNext

但是同时要对我们的 Collatz 组件进行重构:

export const CollatzHOC = withCalcNext(
    class Collatz extends Component {
        render() {
            return (
                <>
                    <h1>{this.props.n}</h1>
                    <button onClick={this.props.calcNext}>next</button>
                </>
            )
        }
    },
    parseInt(Math.random() * 100, 10),
)

以及增加了新的一层 Wrapper 包装层。

HOCComp

使用 Render Props

class CollatzWrapper extends Component {
    constructor(props) {
        super(props)
        this.state = { n: this.props.initN }
    }
    calcNext = () => {
        let { n } = this.state
        if (n % 2) {
            n = 3 * n + 1
        } else {
            n = n / 2
        }
        this.setState({ n })
    }
    render() {
        return this.props.children(this.state.n, this.calcNext)
    }
}

使用 Render Props,我们的组件将使用 CollatzWrapper 的内部状态进行渲染,数据 n 和处理函数 calcNext 都由 CollatzWrapper 进行处理,但是我们的代码可读性就变得很差,特别是如果有很多 Render Props 组建的话。

export class CollatzRenderProps extends Component {
    constructor(props) {
        super(props)
        this.initN = parseInt(Math.random() * 100, 10)
    }
    render() {
        return (
            <CollatzWrapper initN={this.initN}>
                {(n, calcNext, onNChange) => (
                    <>
                        <h1>{n}</h1>
                        <button onClick={calcNext}>next</button>
                    </>
                )}
            </CollatzWrapper>
        )
    }
}

而且,Render Props 也生成了一个 Wrapper 包装层。

RPComp

拥抱 Hooks

其实我们需要的只是 n 以及 calcNext,但是由于 state 与组件不能分离,要想共用这些 stateful logic,就只能把共用逻辑的 state 提升到 HOC,或者使用放到一个特殊组件内,使用 Render Props 进行渲染,但是这些方法在解决问题的带来了更多前述问题。Hooks则实现了 stateful logic 与组件的解耦,就像我们的组件和state是单独管理的一样,然后通过 Hooks 将它们 Hook(勾) 起来,如此,state 就不再依赖于单独的组件(虽然内部每个 Hooks 产生的内部状态依然作用于单个组件)。

采用 Hooks,我们的代码可以改写成下边的格式:

function useCalcNext(initN) {
    let [n, setN] = useState(initN)
    let calcNext = function() {
        if (n % 2) {
            n = 3 * n + 1
        } else {
            n = n / 2
        }
        setN(n)
    }
    return [n, calcNext]
}

使用 Hooks,我们定义了useCalcNext之后,就可以在各个组件之间使用,同时不用使用额外的 Wrapper 对组件进行包装,代码可读性也明显更好。

export function CollatzHooks(props) {
    let [n, calcNext] = useCalcNext(parseInt(Math.random() * 100, 10))
    return (
        <>
            <h1>{n}</h1>
            <button onClick={calcNext}>next</button>
        </>
    )
}

组件结构如下: HooksComp

 尝试实现一个简单的 useState

class Hooks {
    constructor() {
        this.cacheObj = null
        this.useState = this.useState.bind(this)
    }
    useState(initValue) {
        let cacheObj = this.cacheObj
        if (!cacheObj) {
            this.cacheObj = { value: initValue }
            cacheObj = this.cacheObj
        }
        function updater(newValue) {
            cacheObj.value = newValue
        }
        return [cacheObj.value, updater]
    }
}

let useState = new Hooks().useState

function render() {
    let [count, setCount] = useState(0)
    console.group(`render`)
    console.log('count ', count)
    setCount(count + 1)
    console.groupEnd()
}
render()
// render
//   count 0
render()
// render
//   count 1
render()
// render
//   count 2
render()
// render
//   count 3

该实现只是简单试验下,对于一个组件内多个 useState,还需要更多改进

参考:

上一篇文章中,我们了解到了,页面首次渲染需要DOM和CSSOM都构建完成才能实现,也就是CSS会阻塞页面的首次渲染。那么外部JS、CSS文件的加载和DOM构建之间是如何相互影响的呢,我们将对此一探究竟。

准备工作

我们将使用一个express服务器,并延时返回请求。文件名中带有delay-n.(css|js)的文件将会在 n * 100ms后返回。整个项目源文件见这里

纯HTML文本

纯html

可以看到,纯HTML文本会在(6ms)接收到返回后8ms(也就是14ms时)完成DOMContentLoad事件。

加载JS

<script src="delay-1.js"></script>

js-delay.png

JavaScript文件会延迟100ms后返回,在(6ms)HTML文件返回后,一直要等到JavaScript文件完全加载完成(124ms),DOMContentLoad事件才会触发,JavaScript会阻塞DOM的构建

加载CSS

<link rel="stylesheet" href="delay-1.css" />

css-delay.png

CSS文件会延迟100ms返回,不过我们可以看到,DOMContentLoad事件在15ms时就触发了,虽然onload事件要等到123ms才触发,也就是CSS不会阻塞DOM的构建

CSS 比 JavaScript 先加载

<link rel="stylesheet" href="delay-1.css" />
<script src="delay-2.js"></script>

css-faster.png

正如我们所见,由于JavaScript会阻塞DOM的构建DOMContentLoad事件在要等到JavaScript加载完成才能触发(226ms)。而同时由于CSS不会阻塞DOM的构建,那么我们是否可以推论:

DOM构建时间取决于JavaScript的加载时间?

JavaScript 比 CSS 先加载

<link rel="stylesheet" href="delay-2.css" />
<script src="delay-1.js"></script>

js-faster.png

在这种情况下,JavaScript文件在125ms时即加载完成,而DOMContentLoad事件仍然要等到226ms、CSS文件加载之后才出发。因此上述推论是错误的,DOM构建时间显然受到了CSS文件的加载时间影响。这是因为JavaScript存在修改CSSOM的可能性,因此JavaScript的解析、执行必须等到CSSOM构建完成之后才能执行,也就是说CSS文件的加载本身并未阻塞DOM的构建,但是CSS文件的加载阻塞了JavaScript的解析与执行,而JavaScript是会阻塞DOM构建的,因此CSS文件的加载就引起了DOM的阻塞。Perforcemance面板分析如下:(JavaScript在下载完成后并没有获得执行,而是等到CSS下载完成后才开始至此执行):

css-before-p.png

css-before-p-e.png

将script置于link标签前边

<script src="delay-1.js" defer></script>
<link rel="stylesheet" href="delay-2.css" />

js-before.png

这里我们会发现,JavaScript先于CSS进行加载,结果是CSS没有阻塞JavaScript的执行,JavaScript在加载完就获得了执行,之后就完成了DOM的构建。因此这种情况下,虽然CSS加载比JavaScript慢,CSS仍然没有阻塞DOM构建。Perforcemance面板分析如下:(JavaScript在下载完成后马上就获得了执行):

js-before-p.png

将JavaScript设置为defer

<link rel="stylesheet" href="delay-2.css" />
<script src="delay-1.js" defer></script>

defer.png

带有defer属性的JavaScript将会在DOM构建完成、DOMContentLoad事件触发前执行,其加载和执行都不会阻塞DOM构建。如上图所示,DOMContentLoad事件在JavaScript加载执行完成之后(126ms)触发。那么我们如何确定DOM已经在JavaScript执行之前构建完成呢。打开Chrome的Performance面板,进行分析,可以看到脚本执行之后到DOMContentLoad,并没有ParseHTML活动。

defer-p.png

作为对比,下图是不带defer属性的脚本阻塞DOM构建的例子,可看到脚本执行后还伴随着ParseHTML活动,之后才是DOMContentLoad

normal-p.png

defer脚本的加载执行模式如下图所示(via MDN)。

defer-modal.png

将JavaScript设置为async

async.png

带有async属性的JavaScript的加载不会阻塞DOM的构建,由上图可见,DOMContentLoad时间在14ms即触发,而JavaScript文件要等到120ms才能加载完成。async脚本的执行会在onload事件触发前,值得注意的是,如果async脚本加载很快,加载完成时DOM正在构建,则其会马上执行,从而阻塞DOM构建。 async脚本的加载执行模式如下图所示(via MDN):

async-modal.png

结论

  • JavaScript的加载、执行会阻塞DOM构建
  • CSS自身不会阻塞DOM构建
  • CSS会阻塞JS执行,从而阻塞DOM构建
  • defer属性的JavaScript加载、执行不会阻塞DOM构建,但是会阻塞DOMContentLoad事件触发
  • async属性的JavaScript加载不会阻塞DOM构建,也不会阻塞DOMContentLoad事件触发,但是其执行可能会阻塞DOM构建(如果其加载够快)。

参考

页面性能优化

  • 加载优化
    • 文本(代码)优化(大小)
      • 压缩代码(minify)
      • 压缩传输文件(Gzip)
      • 减少库的使用(只使用库里的一两个小功能,完全可以用原生自己开发)
      • 删除无用代码
    • 图片优化(大小)
      • 合适的图片格式
        • PNG:插图、需要透明度的图片
        • JPG:照片
        • GIF:动画(最新的Safari支持src为 video的<img />
      • 删除图片的元数据(metadata)以减少大小
      • 缩放图片(在不需要高清图片的地方)
      • 减少图片质量
      • 压缩图片
    • HTTP 请求优化(频率,在HTTP2中将不再那么重要)
      • 合并图片(雪碧图)
      • 图片/视频懒加载
      • 合并Script/CSS代码
      • <script><style>代替外部文件
    • HTTP缓存
      • Cache-control
        • no-cache 每次请求必须验证缓存是否过期
        • no-store 禁止缓存存储
        • max-age 最大缓存时间(相对时间)
        • must-revalidate 在最大缓存时间过期后 必须重新验证是否过期
      • Expire 缓存过期时间(优先级低于 max-age
      • If-Modified-Since 如果文件自指定日期后修 改过,返回新文件
      • If-None-Match 配合E-Tag,如果没有匹配的 E-Tag,则返回新文件
    • 了解渲染流程
    • 使用HTTP/2
  • 运行优化

关键渲染路径

  1. DOM构建(骨架)
    1. 字节解析为位版本
    2. 词法、语法分析
    3. 构建DOM对象
  2. CSSOM构建(样式描述)
    1. 字节解析为位版本
    2. 词法、语法分析
    3. 构建CSSOM对象
  3. Render-Tree构建(带样式描述的骨架)
    1. 从DOM树根节点便利每个可见节点
    2. 对每个节点匹配CSSOM并应用它们
  4. Layout(布局)
    1. 从渲染树的根节点开始,遍历节点,输出一个“盒模型”,精确捕获每个元素在视口内的确切位置和尺寸
  5. Paint
    1. 绘制各个图层
    2. 合并图层
    3. 显示到屏幕

CSS阻塞渲染

因为Render-Tree的构建依赖于CSSOM,因此CSS将会阻塞页面的渲染。 使用媒体查询来实现CSS不阻塞渲染。

<link href="style.css"    rel="stylesheet"> <!-- 阻塞渲染 -->
<link href="style.css"    rel="stylesheet" media="all"> <!-- 阻塞渲染 -->
<link href="portrait.css" rel="stylesheet" media="orientation:portrait"> <!-- 根据设备方向决定是否阻塞渲染 -->
<link href="print.css"    rel="stylesheet" media="print"> <!-- 不会阻塞渲染 -->

JavaSctipt阻塞DOM构建与页面渲染

因为JavaScript可以查询、修改DOM及CSSOM,其执行将阻止CSSOM,除非声明为异步,它将阻止构建DOM。

当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。

如果浏览器尚未完成 CSSOM 的下载和构建,而我们却想在此时运行修改CSSOM的脚本,会怎样?答案很简单,对性能不利:浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。

默认情况下,所有 JavaScript 都会阻止解析器。由于浏览器不了解脚本计划在页面上执行什么操作,它会作最坏的假设并阻止解析器。向浏览器传递脚本不需要在引用位置执行的信号既可以让浏览器继续构建 DOM,也能够让脚本在就绪后执行;例如,在从缓存或远程服务器获取文件后执行。

———— 使用 JavaScript 添加交互

将JavaScript标记为异步async将防止其阻塞DOM构建。

优化关键渲染路径

优化关键渲染路径手段主要有:

  • 分析关键资源,将其删除/延迟/异步加载。
  • 减少关键资源的大小。
  • 优化其加载顺序。

关键资源指的是与网页首次渲染相关的资源

优化建议

  • 优化JavaScript的使用
    • 尽量异步加载JavaScript资源
    • 延迟解析对首次加载无关的脚本
    • 避免长时间运行的JavaScript
  • 优化CSS的使用
    • 将CSS置于<head>内,尽早构建CSSOM
    • 避免使用@import或内联阻塞渲染的CSS,防止网络时延阻止CSSOM构建以及页面渲染

关于requestAnimationFrame

requestAnimationFrame将会在下一次页面重绘之前调用其callback,那么它和微任务以及下一个宏任务之间的执行顺序如何呢。通常宏任务需要等待本次宏任务的微任务执行完成之后执行,看以下代码:

let main = () => {
    console.log('begin')
    requestAnimationFrame(() => console.log('animationFrame')   )
    setTimeout(() => {console.log('macro')}, 0)
    setTimeout(() => {console.log('macro')}, 100)
    Promise.resolve('micro').then(result => {console.log    (result)})
    console.log('end')
}

main()

不同的浏览器会有不同的输出:

Chrome

begin
end
micro
animationFrame
macro
macro

FireFox & Safari

begin 
end
micro 
macro 
animationFrame 
macro

本文主要介绍一些新的Web特性。所谓新,要么是以前很难实现或者实现不了,现在可以了;或者是以前的方法太过于复杂,或者影响性能。

Intersection Observer API

一个常见的需求是滚动到底部自动加载,目前一个常见的解决方案是对文档创建scroll事件监听,并计算scrollTop等值来判断页面滚动到了底部,从而触发事件。这样实现的缺点是:scroll事件触发非常频繁,在主线程上运行很影响性能。

Intersection Observer通过注册一个回调,每当被监控的元素进入或者退出另一个元素、或者只是两元素相交部分大小变化时,回调就会执行。如此就避免了在主线程内监听元素交集变化。

Intersection Observer Demo(尝试滚动Demo到底部查看效果)

Chrome >= 55, FireFox >= 55

See the Pen IntersectionObserver scroll by LiYan (@liyan) on CodePen.

backdrop-filter

如何在网页上实现毛玻璃效果?

Filter Effects Module Level 1定义了CSS filter属性,可以对文档元素施加各种视觉效果。blur函数可以对元素实现高斯模糊效果,但是该函数只对它作用于的元素施加效果,该元素后边的元素是没有模糊效果的。可以看下边的演示:

See the Pen filter by LiYan (@liyan) on CodePen.

backdrop-filter则可以实现将指定元素后边的元素都应用模糊效果,实现毛玻璃效果。只需将上述CSS代码的filter改为backdrop-filter。效果如下(目前在Chrome中需在chrome://flags/找到Experimental Web Platform Features并启用来查看效果):

See the Pen backdrop-filter by LiYan (@liyan) on CodePen.

dynamic import

如何按需加载js文件?

一个方法是动态创建<script>标签,或者使用类似requirejs这样的库。但是ecmascript标准是有import关键字的,正常是不应该绕这么多路的。好在现在一些浏览器(如Chrome)已经实现了dynamic import(目前处于ecmascript proposal stage 3,也就是即将成为正式标准)。

示例

<button id="dynamicImport">点击我动态加载</button>

<script>
    document.getElementById('dynamicImport').addEventListener('click', async (event) => {
        event.preventDefault()
        const module = await impor(`https://raw.githubusercontent.com/clumsyme/learn/d6f1db41122ca3b36068ba08ab8ed0e8686355b3/js/dynamicImport.js`);
        module.introduce()
    })
</script>

点击下面按钮: