问题

如何在组件之间复用代码一直是困扰 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>

点击下面按钮:

imliyan.com终于启用了HTTP/2了。

为什么之前没支持

好吧,其实HTTP/2在本站是一直开启的,只是由于Google在去年不再支持被OpenSSL 1.0.2以下版本广泛支持的NPN,而转向了ALPN,但ALPN则最低需要OpenSSL 1.0.2版本,而目前的linux服务器,除了Ubuntu 16.04 LTS以及Debian 9.0,其他OpenSSL版本都低于1.0.2。由于OpenSSL是系统服务,直接升级OpenSSL版本可能导致不可预知的系统问题。

不升级OpenSSL怎么实现

  1. 最直接的: 升级系统到Ubuntu 16.04 LTSDebian 9.0

  2. 使用OpenSSL 1.0.2之后的版本从源码重新编译一个Nginx版本。(具体可参考Supporting HTTP/2 for Website Visitors - What Can You Do?

HTTP/2 的好处

升级HTTP/2,一大特性就是单个TCP连接并行处理多个请求的多路复用特性,有效提升网站速度。现在升级完,试了下,好像也没有快很多啊。 :-(

不过在国内连国外的服务器能快到那里呢。

:)