Git Product home page Git Product logo

blog's Introduction

blog's People

Contributors

campcc avatar

Stargazers

wsadczh avatar fadeaway avatar Xiaolin Huang avatar Zhazha_JiaYiZhen avatar kmfe avatar Weigang Jin avatar wuyangfan avatar  avatar kke avatar x311 avatar  avatar 黎子 avatar Oyyko avatar 郭子鑫 avatar Twisted avatar Rain avatar Copy avatar JX_PPP avatar  avatar chongmiao avatar xujintai avatar weeshin avatar  avatar hjz avatar zbchen avatar CodeDreamfy avatar  avatar seho avatar MXPPXM avatar QuentinHsu avatar Jack traveler avatar Danil avatar Dewey Ou avatar peanut avatar GeekLuo avatar renhongxu123 avatar xueyunfeng avatar Will it end? avatar  avatar EthanShen avatar rengar avatar  avatar Chenby-26 avatar 韩嘉旺 avatar  avatar

Watchers

James Cloos avatar

blog's Issues

面试题解JavaScript(三): call, apply 及 bind 函数的内部实现是怎么样的

call,apply 及 bind 函数的内部实现是什么样的 ? 你能手写模拟实现吗 ?

区别

callapplybind都可以改变函数的执行上下文,也就是函数运行时 this 的指向。

区别在于,callapply 改变了函数的执行上下文后会执行该函数,而 bind 不会执行该函数而是返回一个新函数。在参数的处理上,callapply 的第一个参数都是函数运行时的 this 值,区别在于,call 方法接受的是参数列表,apply 接受的是一个参数数组或类数组对象

call

call 接受一个函数运行时的 this 值和一个参数列表,如果不指定第一个参数时,默认执行上下文为 window,改变 this 指向后,需要让新对象可以执行该函数,并能接受参数:

Function.prototype.call = function(context = window) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  context.fn = this  // 给context创建一个临时的fn属性,将值设置为需要调用的函数
  const args = [...arguments].slice(1) // call接受的是一个参数列表,第一个参数是函数执行时的this指向,去掉第一个参数,后面的就是我们需要传递给函数调用的参数
  const result = context.fn(...args)
  delete context.fn // 删除临时属性
  return result
}

apply

apply 接受一个函数运行时的 this 值和一个参数数组或类数组对象,与 call 的区别在于对参数处理不同:

Function.prototype.apply = function(context = window) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  context.fn = this
  let result
  if (arguments[1]) {
    result = context.fn(...arguments[1])
  } else {
    result = context.fn()
  }
  delete context.fn
  return result
}

bind

bind 接受一个函数运行时的 this 值和一个参数列表,返回一个新函数:

Function.prototype.bind = function (context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  const _this = this
  const args = [...arguments].slice(1)  // 与call处理方式相同,首先剥离得到bind的调用参数
  return function F() {
    const distArgs = args.concat(...arguments)   // 注意这里的arguments指的是bind返回的新函数的arguments,因为bind可以以 bind(this, ...args)(...args) 的方式调用,也就是说,在bind的时候可以传递参数,返回的新函数也可以传递参数,所以我们需要把两次参数组合起来
    if (this instanceof F) {  // 如果返回的函数以new F()的方式调用,因为new不会被任何方式改变this指向,所以我们需要忽略传入的 this 
      return new _this(...distArgs)
    }
    return _this.apply(context, distArgs)
  }
}

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

面试题解JavaScript(四):了解事件循环吗?JavaScript的执行机制是什么样的?

理解 JavaScript 的执行机制需要先了解下面几个概念。

  • JavaScript是单线程的
  • 执行栈
  • 同步任务与异步任务
  • 事件循环

JavaScript是单线程的

单线程是 JavaScript 这门语言的一大特点,未来也不会改变。在 JavaScript 中,所有的 “多线程” 都是通过单线程模拟出来的,所以尽管 HTML5 实现了 Web Worker,但是 JavaScript 仍然是单线程的。

执行栈

执行栈本质就是一个栈,只是它存放的是任务。

同步任务与异步任务

单线程就意味着所有的任务都需要排队执行,因为主线程只有一个。任务被划分为同步任务和异步任务。你肯定有疑问,为什么任务会被划分为 “同步任务” 和 “异步任务” ?

原因其实很简单,因为如果有一个任务耗时过长,后面的所有任务都需要等待,但是像加载图片音乐,网络请求之类的占用资源大耗时也长的任务,进入主线程后,就会阻塞后面的任务。

而当我们打开一个网站时,可能更希望看到页面更快地渲染出来而不是一直卡着直到图片完全加载完成,所以任务应该有优先级,于是聪明的程序员便将任务分为两类:同步任务和异步任务。

事件循环

计算机通识(一):UDP协议

UDP 的全称叫 用户数据报协议( User Datagram Protocol ),是传输层的一个无连接协议。

我们知道 OSI 的传输层有两个主要的协议,互为补充。其中,面向连接的是 TCP,无连接的是 UDP。

如何理解无连接

通信技术其实大致分为三类:面向连接的电路交换,面向连接的包交换,以及无连接的包交换。

UPD 在这里使用的就是 无连接的包交换,具体来说:

  • 通信前发送端和接收端不需要建立和断开连接,也不需要维护连接状态
  • 通信过程中,直接把每个带有目的地址的包(报文)送到线路上,由系统自主选定路线进行传输

这也就决定了 UDP 只是 报文的搬运工,不会对报文进行任何拆分和拼接操作。

UDP的报文头

因为 UDP 是无连接的,不需要保证数据不丢失且有序到达,所以其头部开销非常小,只有 8 个字节,

UPD.png

其中,头部信息中包含了以下几个数据:

  • Source port & Destination port:两个 16 位的端口号,分别为发送端口和接收端口
  • Length:数据报文的长度
  • Checksum:数据报文校验以及用于发现头部信息和数据中的错误的 IPV4 可选字段

特性

基于无连接的包交换,我们可以很好地理解 UDP 的以下特性,

不可靠性

UDP 的不可靠性体现在对数据报文的处理上,

  • 协议收到什么就传递什么,也不会备份数据,对方能不能收到也不关心
  • 没有拥塞控制,一直会以恒定的速度发送数据,所以在网络条件不好的情况下可能会导致丢包

高效性

由于不提供数据传送的保证机制,所以 UDP 的优点就是高效,适合某些实时性要求较高的场景,比如:

  • 屏幕上报告股票市场,显示航空信息
  • 电话会议,视频会议

总结

UDP 是传输层一个面向报文,无连接的协议,它具有高效但不可靠的特性

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

vscode configuration

vscode.

configuration for react

{
    "editor.fontSize": 18,
    "editor.tabSize": 2,
    "editor.suggestSelection": "first",
    "editor.snippetSuggestions": "top",
    "editor.fontFamily": "Monaco",
    "editor.lineHeight": 30,
    "editor.formatOnSave": false,
    "terminal.integrated.fontSize": 18,
    "terminal.integrated.shell.osx": "/bin/zsh",
    "terminal.integrated.fontWeight": "bold",
    "terminal.integrated.rendererType": "dom",
    "prettier.semi": false,
    "prettier.trailingComma": "es5",
    "git.confirmSync": false,
    "git.autofetch": true,
    "git.enableSmartCommit": true,
    "window.zoomLevel": -2,
    "workbench.iconTheme": "vscode-icons",
    "eslint.validate": ["javascript", "javascriptreact"],
    "gitlens.advanced.messages": { "suppressShowKeyBindingsNotice": true },
    "javascript.implicitProjectConfig.experimentalDecorators": true,
    "javascript.format.insertSpaceBeforeFunctionParenthesis": true,
    "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
    "emmet.triggerExpansionOnTab": true,
    "emmet.includeLanguages": { "javascript": "javascriptreact" },
}

浏览器原理:多进程架构

目前世界上使用率最高的浏览器是 Chrome,它的核心是 Chromium(Chrome 的开发实验版),微软的 Edge 以及国内的大部分主流浏览器,都是基于 Chromium 二次开发而来,它们都有一个共同的特点:多进程架构

当我们用 Chrome 打开一个页面时,会同时启动多个进程,

image

(Chrome 使用了由 Apple 发展来的号称 “地表最快” 的 Webkit 排版引擎,搭载 Google 独家开发的 V8 Javascript 引擎)

高性能的 Web 应用的设计和优化都离不开浏览器的多进程架构,接下来我会以 Chrome 为例,带你了解多进程架构。

进程

介绍多进程架构前,我们先回顾下什么是进程线程

首先,我们知道计算机的核心是 CPU,它承担了所有的计算任务,就像一个时刻运行的工厂,

而进程就好比工厂内的车间,它代表 CPU 可以处理的单个任务,

image

假设工厂电力有限,一次只能给一个车间供电,当这个车间开工时,其他的车间就必须停工,这就好比一个单核 CPU 一次只能处理一个任务。当然,现代的 CPU 通常会使用 时间片轮转调度算法 来实现同时运行多个进程。

进程之间是独立的,在计算机科学领域,一个进程就是一个程序的运行实例

具体来说,当我们启动一个程序的时候,操作系统会为程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境就叫做进程。

那什么又是线程呢?

线程

还是以刚才的工厂车间为例,一个车间里,可以有很多工人,他们可以共享车间的资源,协同来完成一个任务,

而线程就好比车间里的工人,一个进程可以包含多个线程,多个线程共享进程的资源。

如果只有一个工人,那么任务的执行效率是很慢的,所以进程中使用多线程并行处理能提高运算效率

线程不能单独存在,必须由进程来启动和管理,线程和进程有以下一些显著特点,

  1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃
  2. 线程之间可以共享进程中的数据
  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存
  4. 进程之间的内容是相互隔离的

了解了进程和线程后,我们来一起看一下浏览器的进程架构。

单进程架构

2007 年以前,市面上的浏览器基本都是单进程的,浏览器所有的功能模块都是运行在同一个进程,

image

这会导致什么问题呢?

我们知道浏览器的功能模块包括了网络、插件、JavaScript 运行环境、渲染引擎和页面等,如此多的功能模块全部运行在一个进程里,会导致浏览器不稳定、不流畅和不安全

怎么个不稳定?

我们前文提到过,进程中的任意一线程执行出错,都会导致整个进程的崩溃

早期的浏览器许多功能(比如 Web 视频,Web 游戏)都需要借助插件来实现,而插件是最容易出问题的模块,并且还运行在浏览器进程之中,可想而知,一个插件的意外崩溃就会引起整个浏览器的崩溃;此外,渲染引擎模块也是很不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃,进而导致浏览器崩溃,所以单进程架构是不稳定的

为什么不流畅呢?

导致单进程架构不流畅的原因主要有两个,一是所有页面的渲染模块,JavaScript 的执行环境和插件都运行在一个页面线程中,这意味着同一时刻只能有一个模块被执行。想象一下,如果一个耗时的脚本运行在页面线程中,脚本执行时会独占整个线程,导致其他任务都没有机会执行,带来的用户体验就是整个浏览器失去响应,变得卡顿;除此之外,页面的内存泄漏也是单进程变慢的一个原因,我们知道进程关闭时,操作系统会回收进程占用的内存,但是页面是运行在线程中的,当我们运行一个复杂点的页面再关闭页面时,会存在内存不能完全回收的情况。

是什么导致了安全问题呢?

插件和页面脚本是导致安全问题的主要原因。插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源,这意味着恶意插件的开发成本将大幅降低;此外,页面脚本也可以通过浏览器的漏洞来获取系统权限,引发安全问题。

显然,单进程架构的浏览器由于其不稳定,流畅度差和安全问题,已经逐渐被淘汰,那现代浏览器是如何解决这些问题的呢?

多进程架构

答案就是我们接下来要聊的 “多进程架构” 了。还是以 Chrome 为例,早期的多进程架构第一次出现在 Chrome 中是 2008 年,

为了解决单进程浏览器的问题,Chrome 用独立的渲染进程来运行页面,插件也被放到一个单独的插件进程中执行,浏览器的主线程只负责下载资源,管理进程通信和显示渲染进程生成的页面,我们来简单解释一下多进程架构是如何解决上述问题的,

  • 稳定性:进程间相互隔离通过 IPC 机制进行通信,所有页面或插件的崩溃不会影响到主进程和其他页面
  • 流畅性:1个页面就是1个进程,关闭页面整个进程也关闭了不存在内存泄漏;JavaScript 运行在渲染进程中只会阻塞当前页面
  • 安全性:插件进程和渲染进程被锁进沙箱,进程锁导致即使存在恶意程序也无法突破沙箱去获取系统权限

现代浏览器的发展滚滚向前,Chrome 几乎每六周就会更新一次版本,Chrome 多进程架构相较于之前也有一些新的变化,

演变后的多进程架构将 GPU,网络进程等再次独立出来,不同版本可能会有差异吗,总的来说包括,

  • 1 个浏览器主进程(Browser Process):负责界面显示、用户交互、子进程管理等
  • 多个渲染进程(Renderer):渲染进程负责将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,我们之前提到的 Webkit 排版引擎,JavaScript 引擎都位于渲染进程,默认情况下每打开一个 Tab 页签,Chrome 都会为其创建一个渲染进程
  • 1 个GPU 进程(GPU Process):GPU 最初是为了实现 3D CSS 的效果,现在也用来绘制 UI 界面
  • 1 个网络进程(Network Process):负责页面的网络资源加载
  • 多个插件进程(Plugin Process):主要负责插件的运行,独立是为了防止插件崩溃对浏览器和其他页面造成影响

不过你可能发现,上面的架构和我们开篇用 Chrome 打开一个页面时在 Task Manager 看到的还是不尽相同,拆分后的多进程模型虽然提升了浏览器的稳定性、流畅性和安全性,但还是存在一些问题

  • 资源占用过高,每个进程都包含了公共基础结构的副本(如 JavaScript 运行环境),意味着浏览器会消耗更多的内存资源
  • 架构体系复杂,主要体现在浏览器各模块之间耦合性过高、扩展性较差

为了解决这些问题,Chrome 团队在 2016 年提出了 “面向服务的架构”(Services Oriented Architecture,SOA)**,

image

通过解耦模块,重构为独立的服务(Service),使每个服务都可以在独立的进程中运行,同时约定服务的访问必须使用定义好的接口,通过 IPC 来通信。在最新的 Chrome 版本中,除了浏览器的主进程,渲染进程和插件进程外,UI、数据库、文件、设备、网络等模块基本都已重构为基础服务(Chrome Foundation Service),这样高内聚,低耦合的设计使得多进程的架构更易于维护和扩展。

同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以上面我们讲到的面向服务的多进程的方式运行基础服务,但如果在资源受限的设备上,Chrome 会将多个服务整合到一个进程中,以节省内存的占用,

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

2023 再谈 React 组件通信

随着 2019 年 2 月 React 稳定版 hooks 在 16.8 版本发布,涌现了越来越多的 “hooks 时代” 的状态管理库(如 zustand、jotai、recoil 等),“class 时代” 的状态管理库(如 redux)也全面拥抱了 hooks。无一例外,它们都聚焦于解决 组件通信 的问题,

  • 组件通信的方式有哪些?
  • 这么多的状态管理库要怎么选?
  • 可变状态 or 不可变状态?

截至目前,React 中组件间的通信方式一共有 5 种,

  • props 和 callback
  • Context(官方)
  • Event Bus(事件总线)
  • ref 传递
  • 状态管理库(如:redux、mobx、zustand、recoil、valtio、jotai、hox 等)

props & callback

React 组件最基础的通信方式是使用 props 来传递信息,props 是只读的,每个父组件都可以提供 props 给它的子组件,从而将一些信息传递给它,这里的信息可以是,

  • JSX 标签信息,如 classNamesrcaltwidthheight
  • 对象或其他任意类型的值
  • 父组件中的 state
  • children
  • ...

我们通常在 “父传子” 的通信场景下使用 props,下面是一个 props 通信的例子,

import React, { useState } from "react";

function Parent() {
  const [count, setCount] = useState<number>(0);

  return (
    <>
      <button type="button" onClick={() => setCount(count + 1)}>Add</button>
      <Child count={count}>Children</Child>
    </>
  );
}

function Child(props) {
  const { count, children } = props;

  return (
    <>
      <p>Received props from parent: {count}</p>
      <p>Received children from parent: {children}</p>
    </>
  );
}

callback 回调函数也可以是 props,利用回调,我们也可以实现简单的 “子传父” 场景,

import React, { useState } from "react";

function Parent() {
  const [count, setCount] = useState<number>(0);

  return (
    <>
      <div>Received from child: {count}</div>
      <Child updateCount={(value: number) => setCount(value)} />
    </>
  );
}

function Child(props) {
  const { updateCount } = props;
  const [count, setCount] = useState<number>(0);

  return (
    <button
      type="button"
      onClick={() => {
        const newCount: number = count + 1;
        setCount(newCount);
        updateCount(newCount);
      }}
    >
      Add
    </button>
  );
}

此外,如果多个组件需要共享 state,且层级不是太复杂时,我们通常会考虑 状态提升,实现的思路是:将公共 state 向上移动到它们的最近共同父组件中,再使用 props 传递给子组件,你可以点击这个 官方例子 看具体的实现。

image.png

通过以上例子我们不难发现,在多级嵌套组件的场景下,使用 props 进行通信是一件成本极高的事情。

Context

为此,React 官方提供了 Context 避免一级级的属性传递,

image.png

Context 让父组件可以为它下面的整个组件树提供数据,这在一些特定的场景下非常有用,比如,

  • 主题:可以在应用顶层放一个 context provider,并在需要调整其外观的组件中使用该 context
  • 全局的共享信息:如当前登录的用户信息,将它放到 context 中可以方便地在树中任何位置读取
  • 路由:大多数路由解决方案在内部使用 context 保存当前路由,用于判断链接是否处于活动状态

下面是一个使用 Context 完成主题切换的例子,

import React, { useState, useContext } from "react";

enum Theme {
  Light,
  Dark,
}

interface ThemeContextType {
  theme: Theme;
  toggle?: () => void;
}

const ThemeContext = React.createContext<ThemeContextType>({ theme: Theme.Light });

function ThemeProvider(props) {
  const { children } = props;
  const [theme, setTheme] = useState<Theme>(Theme.Light);

  const toggle = () => {
    setTheme(theme === Theme.Light ? Theme.Dark : Theme.Light);
  };

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

function App() {
  const context: ThemeContextType = useContext(ThemeContext);
  const { theme, toggle } = context;

  return (
    <ThemeProvider>
      <div>
        <p>Current theme: {theme === Theme.Light ? "light" : "dark"}</p>
        <button type="button" onClick={toggle}>toggle theme</button>
      </div>
    </ThemeProvider>
  );
}

需要注意的是,使用 Context 我们需要考量具体的场景,因为 Context 本身存在以下问题,

  • context 的值一旦变化,所有依赖该 context 的组件全部都会 force update
  • context 会穿透 React.memo 和 shouldComponentUpdate 的对比

此外,对于异步请求和数据间的联动,Context 也没有提供任何 API 支持,如果使用 Context,需要自己做一些封装。除了上述两个通信方案外,基于发布订阅的全局事件总线也是常见一种组件通信方案。

Event Bus

事件总线的本质就是发布订阅,目前有非常多的开源实现(如 miittiny-emitter 等),

image.png

我们也可以考虑自己实现一个 EventBus

type Callback = (...args: any[]) => void;

class EventBus {
  private events: Map<string, Callback[]>;

  constructor() {
    this.events = new Map();
  }

  on(eventName: string, callback: Callback): void {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    this.events.get(eventName).push(callback);
  }

  off(eventName: string, callback: Callback): void {
    if (this.events.has(eventName)) {
      const callbacks = this.events.get(eventName);
      const index = callbacks.indexOf(callback);
      if (index !== -1) {
        callbacks.splice(index, 1);
      }
    }
  }

  emit(eventName: string, ...args: any[]): void {
    if (this.events.has(eventName)) {
      this.events.get(eventName).forEach((callback) => {
        callback(...args);
      });
    }
  }
}

EventBus 可以实现跨层级的组件通信,但由于事件的订阅和发布都是在运行时动态绑定的,这会增加代码的复杂度和调试难度。此外,我们通常还需要遵循一定的规范和约定,来更好地管理事件,避免事件名重复或滥用等问题。

ref

使用 ref 可以访问到由 React 管理的 DOM 节点,ref 一般适用以下的场景,

  • 管理焦点,获取子组件的值,文本选择或媒体播放
  • 触发强制动画
  • 集成第三方 DOM 库

ref 也是组件通信的一种方案,通过 ref 可以获取子组件的实例,以 input 元素的输入值为例,

import React, { useRef, useState } from "react";

interface ChildProps {
  inputRef: React.RefObject<HTMLInputElement>;
}

const Child: React.FC<ChildProps> = ({ inputRef }) => <input ref={inputRef} />;

const Parent: React.FC = () => {
  const [text, setText] = useState<string>("");
  const inputRef = useRef<HTMLInputElement>(null);

  const handleClick = () => {
    if (inputRef.current) {
      setText(inputRef.current.value);
    }
  };

  return (
    <div>
      <Child inputRef={inputRef} />
      <button type="button" onClick={handleClick}>Get Input Value</button>
      <p>Input Value: {text}</p>
    </div>
  );
};

状态管理库

上述的组件通信方案都有各自的使用场景,如果你的项目庞大,组件状态复杂,你可能需要考虑状态管理库。众所周知,React 的状态管理库一直以来都是 React 生态中非常内卷的一个领域,截至目前比较常见的状态管理库包括,

我们可以在 npmtrends 查看这几个状态管理库 近一年的下载量趋势图

image.png

可以看到,Redux 在下载量上依然遥遥领先其他状态管理库,而往年热度仅次于 Redux 的 Mobx,有逐渐被 zustand 超越的趋势,其他的一些 “hooks 时代” 的状态管理库热度也在逐步上升。我们先从 “class 时代” 走过来的老大哥 Redux 说起。

redux

Redux 是一个基于 Flux 架构的一种实现,遵循“单向数据流”和“不可变状态模型”的设计**,

image.png

通过 Action-Reducer-Store 的工作流程实现状态的管理,具有以下的优点,

  • 可预测和不可变状态,行为稳定可预测、可运行在不同环境
  • 单一 store ,单项数据流集中管理状态,在做 撤销/重做、 状态持久化 等场景有天然优势
  • 成熟的开发调试工具,Redux DevTools 可以追踪到应用的状态的改变

使用 Redux 就得遵循他的设计**,包括其中的 “三大原则”,

  • 使用单一 store 作为数据源
  • state 是只读的,唯一改变 state 的方式就是触发 action
  • 使用纯函数来执行修改,接收之前的 state 和 action,并返回新的 state

下面是一个使用 Redux 简单的示例,

import React from "react";
import { createStore, combineReducers } from "redux";
import { Provider, useSelector, useDispatch } from "react-redux";

// 定义 action 类型
const INCREMENT = "INCREMENT";
const DECREMENT = "DECREMENT";

// 定义 action 创建函数
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });

// 定义 reducer
const counter = (state = 0, action: { type: string }) => {
  switch (action.type) {
    case INCREMENT:
      return state + 1;
    case DECREMENT:
      return state - 1;
    default:
      return state;
  }
};

// 创建 store
const rootReducer = combineReducers({ counter });
const store = createStore(rootReducer);

// 定义 Counter 组件
const Counter: React.FC = () => {
  const count = useSelector((state: { counter: number }) => state.counter);
  const dispatch = useDispatch();

  return (
    <div>
      <h2>Counter: {count}</h2>
      <button type="button" onClick={() => dispatch(increment())}>add</button>
      <button type="button" onClick={() => dispatch(decrement())}>dec</button>
    </div>
  );
};

// 使用 Provider 包裹根组件
const App: React.FC = () =>
  <Provider store={store}>
    <Counter />
  </Provider>

可以看到,由于没有规定如何处理异步加上相对约定式的设计,导致 Redux 存在以下的一些问题,

  • 陡峭的学习曲线,副作用扔给中间件来处理,导致社区一堆中间件,学习成本陡然增加
  • 大量的模版代码,包括 action、action creator 等大量和业务逻辑无关的模板代码
  • 性能问题,状态量大的情况下,state 更新会影响所有组件,每个 action 都会调用所有 reducer

虽然 Redux 一致尝试致力解决上述部分问题,比如后面推出的 redux toolkit,但即便如此,对于开发者(尤其是初学者)而言,仍然有比较高的学习成本和心智负担。

mobx

相比之下,Mobx 的心智模型更加简单,Mobx 将应用划分为 3 个概念,

  • State(状态)
  • Actions(动作)
  • Derivations(派生)

其中 Derivations 又分为,

  • Computed Values(计算值), 总是可以通过纯函数从当前的可观测 State 中派生
  • Reactions(副作用), 当 State 改变时需要自动运行的副作用

Mobx 的整个工作流程非常简单,首先创建可观察的状态,然后通过 Actions 修改状态,Mobx 会自动更新所有的派生(Derivations),包括计算值(Computed value)以及副作用(Reactions),

image.png

如果你选择将 Mobx 结合 React 来用,那同样可以考虑直接使用 Vue,因为 Mobx 的实现基于 mutable + proxy,导致了与 React 结合使用时有一些额外成本,例如,

  • 要给 DOM render 包一层 useObserver/Observer
  • 副作用触发需要在 useEffect 里再跑一个 autorun/reaction

尤大在知乎的 这个回答 里也提到,一定程度上,React + Mobx 也可以被认为是更繁琐的 Vue

zustand

zustand 是一个轻量级的状态管理库,经过 Gzip 压缩后仅 954B 大小,zustand 凭借其函数式的理念,优雅的 API 设计,成为 2021 年 Star 数增长最快的 React 状态管理库,

image.png

与 redux 的理念类似,zustand 也是基于不可变状态模型和单向数据流,区别在于,

  • redux 需要包装一个全局 / 局部的 Context Provider,而 zustand 不用
  • redux 基于 reducers 纯函数更新状态,zustand 通过类原生 useState 的 hooks 语法,更简单灵活
  • zustand 中的状态更新是同步的,不需要异步操作或中间件

zustand 的心智模型非常简单,包含一个发布订阅器和渲染层,工作原理如下,

image.png

其中 Vanilla 层是发布订阅模式的实现,提供了setState、subscribe 和 getState 方法,React 层是 Zustand 的核心,实现了 reselect 缓存和注册事件的 listener 的功能,并且通过 forceUpdate 对组件进行重渲染,发布订阅相信大家都比较了解了,我们重点介绍下渲染层。

首先思考一个问题,React hooks 语法下,我们如何让当前组件刷新?

是不是只需要利用 useStateuseReducer 这类 hook 的原生能力即可,调用第二个返回值的 dispatch 函数,就可以让组件重新渲染,这里 zustand 选择的是 useReducer

const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]

有了 forceUpdate 函数,接下来的问题就是什么时候调用 forceUpdate,我们参考源码来看,

// create 函数实现
// api 本质就是就是 createStore 的返回值,也就是 Vanilla 层的发布订阅器
const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createState

// 这里的 useIsomorphicLayoutEffect 是同构框架常用 API 套路,在前端环境是 useLayoutEffect,在 node 环境是 useEffect
useIsomorphicLayoutEffect(() => {
  const listener = () => {
    try {
      // 拿到最新的 state 与上一次的 compare 函数
      const nextState = api.getState()
      const nextStateSlice = selectorRef.current(nextState)
      // 判断前后 state 值是否发生了变化,如果变化调用 forceUpdate 进行一次强制刷新
      if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) {
        stateRef.current = nextState
        currentSliceRef.current = nextStateSlice
        forceUpdate()
      }
    } catch (error) {
      erroredRef.current = true
      forceUpdate()
    }
  }
  // 订阅 state 更新
  const unsubscribe = api.subscribe(listener)
  if (api.getState() !== stateBeforeSubscriptionRef.current) {
    listener()
  }
  return unsubscribe
}, [])

我们首先从第 24 行 api.subscribe(listener) 开始,这里先创建了 listener 的订阅,这就使得任何的 setState 调用都会触发 listener 的执行,接着回到 listener 函数的内部,利用 api.getState() 拿到了最新 state,以及上一次的 compare 函数 equalityFnRef,然后执行比较函数后判断值前后是否发生了改变,如果改变则调用 forceUpdate 进行一次强制刷新。

这就是 zustand 渲染层的原理,简单而精巧,zustand 实现状态共享的方式本质是将状态保存在一个对象里,与之相对的是一些原子化的状态管理工具,比如接下来我们要介绍的 recoil。

recoil

recoil 是 React Europe 2020 Conference 上 Facebook 官方推出的一个 React 状态管理库,是对 React 内置的状态管理能力的一个补充,recoil 实现的动机如下,考虑到 React 原生状态管理的一些局限性,

  • 组件间状态共享只能通过将 state 提升至它们的公共祖先,可能导致重新渲染一颗巨大的组件树
  • Context 只能存储单一值,无法存储多个各自拥有消费者的值的集合

在实现上,recoil 定义了一个有向图 (directed graph),正交且天然连结于 React 树上,

image.png

看着很高级,其实就是定义原子状态,然后通过原子状态在组件中进行自由地组合和订阅。

recoil 中提供了两个核心方法用于定义状态,

  • atom: 定义原子状态,即组件的某个状态的最小集
  • selector: 定义派生状态,其实就是 Computed Value

消费状态的方式有 useRecoilState、useRecoilValue、useSetRecoilState 等,用法和 react 的 useState 类似,所以几乎没有上手成本,下面是一个简单的示例可以直观感受下,

import React from "react";
import { RecoilRoot, atom, useRecoilState } from "recoil";

// 定义一个原子
const countState = atom({
  key: "countState",
  default: 0,
});

function Counter() {
  // 获取 countState 的值和修改函数
  const [count, setCount] = useRecoilState(countState);

  const handleIncrement = () => setCount(count + 1);
  
  const handleDecrement = () => setCount(count - 1);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={handleIncrement}>+1</button>
      <button onClick={handleDecrement}>-1</button>
    </div>
  );
}

function DisplayCount() {
  // 组件间共享 countState 原子状态
  const [count] = useRecoilState(countState);

  return <h2>Count: {count}</h2>;
}

function App() {
  return (
    // 使用 RecoilRoot 包裹组件
    <RecoilRoot>
      <Counter />
      <DisplayCount />
    </RecoilRoot>
  );
}

与 recoil 类似的原子状态管理库还有 jotai,它们的设计理念基本相同。

jotai

recoil 最为人诟病的是原子状态(atom)定义时需要一个唯一的键值 key,这一反人类的设计导致键命名本身就是一个繁琐的任务,为了降低开发成本和心智负担,而 jotai 中定义 atom 不需要指定键值,同时也支持非 Provider 包裹的语法,

jotai 在实现上使用了 Context 和订阅机制相结合,核心的概念只有四个,

  • atom,定义原子状态,不需要指定唯一键值
  • useAtom,消费原子状态,与 useState hook 语法一致
  • Store,存储原子状态,可以不使用,默认会使用 getDefaultStore 创建默认 Store
  • Provider,为组件子树提供状态,可以不使用

同样是上面的例子,使用 jotai 改写会简单很多,

import React from "react";
import { atom, useAtom } from "jotai";

// 定义原子状态不需要唯一 key
const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);

  const handleIncrement = () => setCount(count + 1);

  const handleDecrement = () => setCount(count - 1);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={handleIncrement}>+1</button>
      <button onClick={handleDecrement}>-1</button>
    </div>
  );
}

function DisplayCount() {
  const [count] = useAtom(countAtom);

  return <h2>Count: {count}</h2>;
}

function App() {
  return (
    // 不需要 Provider 包裹
    <div>
      <Counter />
      <DisplayCount />
    </div>
  );
}

zustand 和 jotai 都来自同一个组织 pmndrs,包括我们接下来要介绍的 valtio。

valtio

valtio 是一个基于可变状态模型和 Proxy 实现的状态管理库,核心实现非常简洁,只有两个方法,

  • proxy,将原始对象包装为一个可观察的状态(observable state)
  • useSnapshot,获取 proxy 的快照,可以访问和修改
import React from "react";
import { proxy, useSnapshot } from "valtio";

// 创建一个状态对象
const state = proxy({
  count: 0,
});

function Counter() {
  // 使用 useSnapshot 获取状态快照
  const snapshot = useSnapshot(state);

  return (
    <div>
      <p>Count: {snapshot.count}</p>
      <button onClick={() => state.count++}>Increment</button>
    </div>
  );
}

valtio 和 mobx 都是基于 Proxy 的状态管理库,理念上 valtio 较于 mobx 更为简单和自由。

以上的库都聚焦于状态管理,接下来我们要介绍的是一个专注于 “组件共享状态” 的库。

hox

hox 聚焦于一个痛点:如何在多个组件间共享状态,是一个简单、轻量的状态共享库。

hox 只有一个 API,

  • createStore,1.x 版本叫 createModel

createStore 会返回一个数组,包含两个参数,

  • useStore,订阅和消费 store 中的数据
  • StoreProvider,状态容器,底层依赖 React Context
import { useState } from "react";
import { createStore } from "hox";

// 使用 createStore 包装一个自定义 hook
export const [useTaskStore, TaskStoreProvider] = createStore(() => {
  const [tasks, setTasks] = useState([]);

  function addTask(task) {
    setTasks((v) => [...v, task]);
  }

  return {
    tasks,
    addTask,
  };
});

function TaskList() {
  // 消费状态
  const { tasks } = useTaskStore();
  return (
    <>
      {tasks.map((task) => (
        <div key={task.id}>{task}</div>
      ))}
    </>
  );
}

function App() {
  return (
    // Provider 包裹的组件间可以进行状态共享
    <TaskStoreProvider>
      <TaskList />
    </TaskStoreProvider>
  );
}

hox 底层实现是一个基于 React Context 的 singleton 单例,感兴趣的可以点击 这里 上阅读它的源码。

小结

时至 2023 年,对于 React 组件的通信,我们有太多可选的方式,对于选型可以参考以下大致的思路,

  • 如果组件间需要共享 state,且层级不是太复杂时,我们通常会考虑状态提升
  • Context 更适合存储一些全局的共享信息,如主题,用户登陆信息等
  • ref 更适用于管理焦点,获取子组件的值,触发强制动画,第三方 DOM 库集成等场景
  • EventBus 可以用于跨层级组件通信,由于本质是发布订阅需要结合一些约定和规范来管理事件
  • 如果你习惯了不可变更新,可以考虑生态丰富的 redux 和轻量的 zustand
  • 如果你习惯了类 Vue 的响应式可变模型,mobx 和 valtio 可能更适合
  • 如果你想尝试原子状态的方案,recoil 和 jotai 是个不错的选择
  • 如果你想基于 custom hook 实现状态持久化和共享,hox 可能更适合

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

React Diff算法解析

React 高效性能的背后得益于 Virtual DOMDiff 算法,而 Diff 算法要解决的根本问题就是:Virtual DOM 变化时,计算差异部分用于局部更新,而虚拟DOM本质上就是一个树形对象,所以问题转化两棵树的对比。

对比两棵树的差异本就是一个复杂的问题,如果使用传统的 Diff 算法,通过循环递归对树节点依次进行对比,不仅效率低下,算法的时间复杂度更是达到 O(n^3),其中 n 是树节点的总数。O(n^3) 意味着如果要展示 1000 个节点,需要进行上十亿次的比较,这种指数型的性能消耗对于前端渲染场景来说代价太高了,现今的 CPU 每秒钟能执行大约 30 亿条指令,即便是最高效的实现,也不可能在一秒内计算出差异情况。

所以,如果想要将 Diff **引入 Virtual DOM,就需要降低 Diff 算法的时间复杂度。

React 开发团队针对前端渲染场景和 React 组件化的**,制定了三个大胆的策略,将时间复杂度从 O(n^3) 降到 O(n)。

Diff策略

在保证页面整体构建性能的前提下,React 团队提出了三个 Diff 策略:

  1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计
  2. 拥有相同类的两个组件会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构
  3. 同一层级的子节点可以通过唯一的 id 进行区分

Diff粒度

基于上诉的三个 Diff 策略,将Diff算法的执行分为三个维度:Tree DiffComponent DiffElement Diff

三个 Diff 的差异在于:粒度不同和执行的顺序不同,执行顺序依次为:Tree Diff, Component Diff, Element Diff。

Tree Diff

由于 DOM 节点跨层级的操作特别少,所以 React 对树的遍历算法进行了优化,通过层级控制进行分层比较,这样只需要一次遍历,就能完成整个 DOM 树的比较。

updateChildren: function(nextNestedChildrenElements, transaction, context) {
  updateDepth++;
  var errorThrown = true;
  try {
    this._updateChildren(nextNestedChildrenElements, transaction, context);
    errorThrown = false;
  } finally {
    updateDepth--;
    if (!updateDepth) {
      if (errorThrown) {
        clearQueue();
      } else {
        processQueue();
      }
    }
  }
}

具体来说,Tree Diff 会对树的每一层进行遍历,如果发现某个节点不存在了,会直接销毁,其对应的子节点也会完全被删除掉:

以上图为例,由于 React 只会简单的考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,React diff 的执行情况:create A -> create B -> create C -> delete A

同时我们也注意到,当出现 节点跨层级移动 时,React 不会进行移动操作,而是以 A 为根节点的整个树被重新创建,所以这是影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作

所以在开发组件时,保持一个稳定的 DOM 结构会有助于性能的提升。例如使用 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点。

Component Diff

React 基于组件构建用户界面,组件在 Virtual DOM 中体现为一个节点。

对于组件的比较,基于策略二,React 团队采取的方法是:

  • 对于同一类型的组件,按照原策略继续比较
  • 对于不同类型的组件,直接进行替换

我们先说对于同类型的组件(即拥有相同类的组件),有可能 Virtual DOM 没有任何变化,这个时候其实不需要进行比较,所以 React 允许用户通过 shouldComponentUpdate 方法来判断该组件是否需要进行 Diff,这也是常见的一种优化手段

对于不同类型的组件,即使它们的结构相似,React 也不会进行比较,而是直接进行替换,以下图为例:

进行 Component Diff 时,React 发现组件 D 和 G 是不同类型的组件,React 会直接删除组件 D 及其子节点,然后重新创建组件 G 及其子节点。此时 Diff 顺序为:delete E -> delete F -> delete D -> create E -> create F -> create G

你是否有疑问,组件 D 和 G 的结构相似,都有相同的子节点 E 和 F,直接将 D 和 G 进行替换不就行了吗,为什么要删除相同的子节点然后又创建呢?是的,当两个组件是不同类型但结构相似时,React diff 确实会影响性能,但是正如 React 官方博客所言:不同类型的组件是很少存在相似 DOM tree 的机会的,因此这种极端因素很难在实际开发过程中造成重大影响。

Element Diff

我们刚才说,对于同一类型的组件,会继续比较下去,所以 Element Diff 其实就是对于同一层级的兄弟节点的策略。

当节点处于同一层级时,React 提供了三种对于节点的操作:

  • INSERT_MARKUP(插入),当节点是全新的节点时,执行插入操作。
  • MOVE_EXISTING(移动),节点存在于旧的 Virtual DOM tree,且可复用时,执行移动操作。
  • REMOVE_NODE(删除),新的 Virtual DOM tree 不存在该节点,或者虽然存在但是不可复用(对应的 element 不同)时,执行删除操作。
function enqueueInsertMarkup(parentInst, markup, toIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.INSERT_MARKUP,
    markupIndex: markupQueue.push(markup) - 1,
    content: null,
    fromIndex: null,
    toIndex: toIndex,
  });
}

function enqueueMove(parentInst, fromIndex, toIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.MOVE_EXISTING,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: toIndex,
  });
}

function enqueueRemove(parentInst, fromIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.REMOVE_NODE,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: null,
  });
}

对于同一层级的节点,如下图所示:

老集合中包含节点:A,B,C,D,更新后新集合中包含节点:B,A,D,C,此时新老集合进行 diff 差异化对比,发现 B != A,则创建并插入 B 至新集合,删除老集合 A,以此类推,创建并插入 A、D 和 C,删除 B、C 和 D。

但是 React 团队发现这类操作繁琐冗余,因为这些都是相同的节点,但由于位置发生变化,导致需要进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动即可。

针对这一现象,React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,虽然只是小小的改动,性能上却发生了翻天覆地的变化:

React 首先会收集新老集合中的 key,然后对新集合的节点进行遍历,通过唯一的 key 就可以判断新老集合中是否存在相同的节点,如果存在相同节点,则进行移动操作;如果新集合中有新加入的节点,会创建新节点;最后对老集合进行循环遍历,判断是否存在新集合中没有但老集合中仍存在的节点,如果发现这样的节点,删除。

_updateChildren: function(nextNestedChildrenElements, transaction, context) {
  var prevChildren = this._renderedChildren;
  var nextChildren = this._reconcilerUpdateChildren(
    prevChildren, nextNestedChildrenElements, transaction, context
  );
  if (!nextChildren && !prevChildren) {
    return;
  }
  var name;
  var lastIndex = 0;
  var nextIndex = 0;
  for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
      continue;
    }
    var prevChild = prevChildren && prevChildren[name];
    var nextChild = nextChildren[name];
    if (prevChild === nextChild) {
      this.moveChild(prevChild, nextIndex, lastIndex);
      lastIndex = Math.max(prevChild._mountIndex, lastIndex);
      prevChild._mountIndex = nextIndex;
    } else {
      if (prevChild) {
        lastIndex = Math.max(prevChild._mountIndex, lastIndex);
        this._unmountChild(prevChild);
      }
      this._mountChildAtIndex(
        nextChild, nextIndex, transaction, context
      );
    }
    nextIndex++;
  }
  for (name in prevChildren) {
    if (prevChildren.hasOwnProperty(name) &&
        !(nextChildren && nextChildren.hasOwnProperty(name))) {
      this._unmountChild(prevChildren[name]);
    }
  }
  this._renderedChildren = nextChildren;
},

moveChild: function(child, toIndex, lastIndex) {
  if (child._mountIndex < lastIndex) {
    this.prepareToManageChildren();
    enqueueMove(this, child._mountIndex, toIndex);
  }
},

createChild: function(child, mountImage) {
  this.prepareToManageChildren();
  enqueueInsertMarkup(this, mountImage, child._mountIndex);
},

removeChild: function(child) {
  this.prepareToManageChildren();
  enqueueRemove(this, child._mountIndex);
},

_unmountChild: function(child) {
  this.removeChild(child);
  child._mountIndex = null;
},

_mountChildAtIndex: function(
  child,
  index,
  transaction,
  context) {
  var mountImage = ReactReconciler.mountComponent(
    child,
    transaction,
    this,
    this._nativeContainerInfo,
    context
  );
  child._mountIndex = index;
  this.createChild(child, mountImage);
},

行文至此,你应该知道为什么 React 建议在列表组件中使用唯一的 key,而且不建议使用数组下标作为 key

tips:使用数组下标作为 key 的弊端可以看这篇文章,Index as a key is an anti-pattern

参考链接

(完)

浏览器原理:渲染流程

“从输入 URL 到页面展示” 是一个极其复杂的过程

要完整地解释这一过程,你需要了解浏览器的多进程架构,网际协议(Internet Protocol,IP),数据包协议(User Datagram Protocol,UDP),传输控制协议(Transmission Control Protocol,TCP),超文本传输​​协议(HyperText Transfer Protocol,HTTP),域名系统(Domain Name System,DNS),HTTP请求流程,导航流程,渲染流程等一系列网络协议、原理和流程。

知识体系的建立是一个循序渐进的过程,没有捷径,更无法一蹴而就,接下来的专栏文章里我将带你完整地探索这一过程。当然,我不打算从数据包和协议开始,因为那太枯燥了,相反,渲染流程是一个很不错的开端。

思考一下,我们编写的 HTML,CSS,JavaScript 文件,是如何转化为下面的页面的?

image

我们知道,HTML 的内容是由标记和文本组成,标记也称为标签,它是 Web 语义化的基础;CSS 叫做层叠样式表,是由选择器和属性组成,通过它我们可以对 HTML 进行布局,设置样式;而 JavaScript 是一门脚本语言,我们可以用它创建动态更新的内容,控制多媒体,动画等等,

image

想要将上述的文件最终呈现为页面,浏览器需要经历一系列复杂的处理流程,如果把整个流程看作一个渲染流水线,按照渲染的时间顺序,流水线包括:构建 DOM 树,样式计算,布局,分层,图层绘制,光栅化,合成

image

接下来让我们逐一来看。

DOM Tree Building

渲染流程的第一阶段是 DOM Tree 的构建,先来回顾一下,什么是 DOM ?

从网络进程传递给渲染引擎的 HTML 文件字节流是无法直接被引擎理解的,我们需要一个描述 HTML 文档的数据结构,这个结构就是 DOM;当然,从脚本的角度,DOM 也是提供给 JavaScript 脚本操作的一套文档接口。我们以下面的一段 HTML 为例,

<html>
  <body>
    <div>Monch</div>
    <div>Lee</div>
  </body>
</html>

DOM Tree 的构建流程为,

image

HTML Parser

执行字节流转换为 DOM 这一流程的是渲染引擎内部的 HTML 解析器( HTMLParser )。

解析器接收数据的流程为:网络进程接收到响应头后,根据响应头中的 Content-Type 判断文件类型,如果是 text/html,浏览器会创建一个渲染进程,然后创建一个网络进程和渲染进程之间共享数据的管道,网络进程接收到数据后就会往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据传递给 HTML 解析器。

所以HTML 解析器并不是等整个文档加载完成后才开始解析的,而是网络进程加载多少数据,解析器便解析多少数据

网络进程传递过来的数据是字节流,字节流首先会转换为 Token,这一步由分词器完成。

HTML 解析器维护了一个 Token 栈,用来计算节点间的父子关系,

image

字节流会被转换为 Tag TokenText Token,其中 Tag Token 又分为 StartTagEndTag,分别代表开始标签和结束标签。

解析的流程为:开始时,HTML 解析器会默认创建一个根为 document 的 DOM 结构并初始化 Token 栈,将第一个 StartTag document 的 Token 压入栈底,然后分词器开始解析;分词器会把解析出来的 Token 依次入栈,每压入一个 Token,渲染引擎都会为其创建一个 DOM 节点,如果是文本 Token,会创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点,

image

如果解析出来的是 EndTag,HTML 解析器会去判断当前栈顶的元素是否是 对应的 StartTag,如果是则从栈顶弹出 StartTag,直到最后所有的 StartTag 全部弹出,此时 Token 栈为空,解析完成,

image

实际的生产环境中,解析器接受到的 HTML 文件可能还包括脚本标签,

接下来我们介绍一下 JavaScript 阻塞是如何发生的。

JavaScript Pending

我们对上面的示例稍做修改,引入一个外部样式文件,然后插入一个 script 标签,

<html>
  <head>
    <style src="https://xxx/foo.css"></style>
  </head>
  <body>
    <div>Monch</div>
    <script>document.getElementsByTagName('div')[0].innerText = 'Steven'</script>
    <div>Lee</div>
  </body>
</html>

暂停思考一下,此时的解析流程是什么样的呢?🤔

解析到 script 标签时,由于无法判断脚本是否会修改当前已经生成的 DOM 结构,此时 HTML 解析器会暂停 DOM 的解析,接着 JavaScript 引擎会介入执行 script 标签内的脚本;如果这里是外链的脚本,在执行前就需要先下载脚本。引擎在执行 JavaScript 脚本前,类似的,由于不清楚 JavaScript 是否操作了 CSSOM,所以会先执行 CSS 文件的下载和解析,再执行 JavaScript 脚本,而 HTML 解析器会一直等到脚本执行完成后再继续工作,所以 JavaScript 会阻塞 DOM Tree 的构建,

image

现代浏览器在这里做了很多优化,比如 Chrome 支持预解析:当渲染引擎收到字节流后,会同时开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件并提前下载这些文件。

我们也可以通过一些策略来规避或优化脚本对 DOM Tree 构建的阻塞,比如,

  • 使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积
  • 将 script 标签后置,比如放到 body 的底部
  • 如果脚本没有操作 DOM,通过 async 或 defer 标记将脚本设置为异步加载

需要注意的是,async 和 defer 虽然都是异步的,但在执行时机上存在差别:使用 async 标记的脚本会在下载完成后立即执行,而 defer 标记的会在 DOMContentLoaded 事件前执行。

接下来我们介绍渲染流程的第二阶段,样式计算(Recalculate Style)

Recalculate Style

样式计算的流程可以分为三个阶段:构建 StyleSheets,属性值标准化,计算节点样式

image

我们知道层叠样式表(Cascading Style Sheets,CSS)描述了 DOM Tree 中节点的布局和样式,和 HTML 文件一样,渲染引擎也是无法直接理解 CSS 文件的,所以渲染引擎在接收到 CSS 文本时,首先会执行一个转换操作:将 CSS 文本转换为 StyleSheets

什么是 StyleSheets 呢 ?为了直观的理解,可以在浏览器控制台输入,

document.styleSheets

StyleSheets 是一颗具有查询和修改功能的样式树,

image

转换完成后,渲染引擎会接着对所有的属性值进行标准化处理

image

标准化完成后会开始计算 DOM Tree 中每个节点的具体样式,这里的计算涉及到 CSS 的层叠规则继承规则,我们来简单回顾一下 CSS 的层叠和继承。

Cascade

CSS 的全称为层叠样式表(Cascading Style Sheets),层叠是 CSS 最基础的概念之一,

那么什么是层叠呢 ?

层叠是一个定义了如何合并来自多个源的属性值的算法。我们知道一个元素可以拥有来自不同源的 CSS 声明,这里的源可能是,

  • 浏览器默认的 UserAgent 样式表
  • 通过 link 引用的外部 CSS 文件
  • <style>标记内的 CSS
  • 元素的 style 属性内嵌的 CSS

当不同的规则都应用于同一个元素时,就会产生冲突(specificity),简单来说,层叠定义了产生冲突时应该应用的规则。如果来源相同,则会根据层叠样式的优先级,层叠顺序决定到底使用哪个值。

你可能发现有些子元素会默认拥有一些样式,这是因为 CSS 属性值继承导致的。

Inheritance

一些 CSS 属性会默认继承其父元素设置的值,这里具体哪些属性会继承很大程度上是由常识决定的。比如像字体大小(font-size),颜色(color)等属性会继承,但是宽度(width), 边距(margins, padding), 边框(border) 等属性就不会被继承。

想象一下,如果 border 可以被继承,我们给父元素设置了边框后,每个子元素和后代元素都会获得一个边框——这太糟糕了!🤔

理解了层叠和继承规则后,回到节点样式的计算流程,这个阶段渲染引擎会遵循 CSS 的层叠和继承规则,计算出 DOM 节点中每个元素的具体样式,最后生成 ComputedStyle,我们可以打开浏览器控制台选择 Element 下的 Computed 标签,查看每个节点的计算样式,

image

完成 DOM Tree 构建和样式计算后,渲染引擎接下来会进入布局阶段

布局

怎么理解布局呢?

想象一下,我们现在手里有已经构建好的 DOM Tree 和节点元素的计算样式,但是并不知道元素的几何信息,而且 DOM Tree 中有很多元素(比如 head 标签)是不需要渲染的,所以我们还需要计算出 DOM 树中可见元素的几何位置,这个计算过程就是布局

布局阶段主要有两个任务:创建布局树和布局计算

image

HTML 解析器构建的 DOM Tree 中包含了一些 “特殊” 的节点,比如 head 标签,display 属性为 none 的元素等,它们是不需要被渲染到屏幕上的,所以渲染引擎会额外构建一颗只包含可见元素的布局树,在构建布局树,遍历 DOM 节点的同时,会进行节点几何坐标位置的计算,也就是布局计算,最后这些信息会被保存在**布局树(LayoutTree)**中。

有了布局树后,是不是就可以开始绘制了呢?还是不行 🤔,因为页面往往还包含很多复杂的效果,比如我们常见的3D 变换、页面滚动,层叠上下文的 z 轴排序等等,为了更方便的实现这些效果,渲染引擎会进行分层

分层

什么是分层呢?

如果大家有用过 PhotoShopSketch 这类的设计软件,相信对图层这个概念并不陌生,这里渲染引擎会为特定的节点生成图层,最后构建一颗布局树(LayoutTree)对应的图层树(LayerTree),这个过程就是分层

image

为了更直观的理解,我们打开浏览器开发者工具(以腾讯首页为例),选择 Layout 标签,

image

可以看到,浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。前面我们提到渲染引擎只会为特定的节点生成图层,那哪些是特定的节点呢?渲染引擎会为哪些节点创建图层呢?这里涉及到 CSS 的层叠上下文

The stacking context

什么是层叠上下文 ?这里可以借 BFC 来辅助理解,

如果你还不了解 BFC,可以阅读我之前的文章可能是最好的 BFC 解析了

我们知道视觉格式化模型中,BFC 定义了块级盒子在文档流中的布局方式,类似的还有 IFC,FFC,GFC,它们都是针对于二维平面的一些上下文,那么对于三维空间而言,元素在 z 轴上应该怎样排列呢?层叠上下文就是对 HTML 元素的一个三维构想,它定义了元素在 z 轴上的排列方式,简单来说,

  • 元素在 z 轴上会按**层叠等级(stacking level)**进行层叠
  • 具有层叠上下文的元素会优先于普通元素进行层叠
  • 同一个层叠上下文中,层叠等级相同的元素,按它们在文档流中出现的顺序进行层叠
  • 同一个层叠上下文中,可以通过 z-index 调整元素的层叠等级

哪些元素会创建层叠上下文呢 ?常见的有,

  • 文档根元素(html)
  • position 为 absolute 或 relative 且 z-index 不为 auto 的元素
  • position 为 fixed 或 sticky 的元素
  • flex 容器内,z-index 不为 auto 的子元素
  • grid 容器内,z-index 不为 auto 的子元素
  • opacity 属性值小于 1 的元素
  • transform、filter、clip-path、perspective 值不为 none 的元素
  • will-change 设定了任一属性

既然这么多元素都会创建层叠上下文,那么他们之间自然也存在层叠顺序,

image

浮动元素也会参与层叠计算,会被放置在非定位块与定位块之间,具体可以看层叠与浮动

了解了层叠上下文后,让我们回到图层创建,通常来说,满足下面的条件之一渲染引擎就会为元素创建图层,

  1. 拥有层叠上下文属性的元素
  2. 需要**裁剪(clip)**的地方
  3. 滚动条

解释一下这里的 clip,以下面的这个 DOM 结构为例,

<div class="box">
  <div class="text">
    腾讯是一家世界领先的互联网科技公司,用创新的产品和服务提升全球各地人们的生活品质。腾讯成立于1998年,总部位于**深圳。公司一直秉承科技向善的宗旨。我们的通信和社交服务连接全球逾10亿人,帮助他们与亲友联系,畅享便捷的出行、支付和娱乐生活。腾讯发行多款风靡全球的电子游戏及其他优质数字内容,为全球用户带来丰富的互动娱乐体验。腾讯还提供云计算、广告、金融科技等一系列企业服务,支持合作伙伴实现数字化转型,促进业务发展。
  </div>
</div>

<style>
  .box {
    width: 200px;
    overflow: auto;
  }

  .text {
    white-space: nowrap;
  }
</style>

当内容超出包含块时就会发生裁剪,这个时候渲染引擎会为原始内容单独创建一个图层

image

渲染引擎通过分层完成了图层树(LayerTree)的构建,接下来就可以进行图层绘制

图层绘制

有了 LayerTree 后,图层的绘制就比较简单了,渲染引擎会遍历 LayerTree,为每一个图层生成一个绘制指令列表

image

我们也可以打开浏览器开发者工具 “Layout” 标签,选择一个图层的 Profiler 标签,查看具体的绘制指令和绘制过程,

image

前面我们介绍的 DOM 树构建,样式计算,布局,分层,绘制指令生成等流程基本都在渲染引擎主线程内完成,而实际上的图层绘制操作是由渲染引擎中的合成线程来完成的,绘制指令列表准备好后,主线程会把列表提交给合成线程,然后执行栅格化流程。

栅格化

介绍栅格化之前,我们来简单看一下上述的两个线程之间的交互关系,

image

思考一下,这里为什么需要提交(Commit)到另一个线程进行绘制呢?🤔

是不是跟我们之前介绍的 HTML 解析器处理数据的流程有点类似,多线程可以加速图层的绘制,合成线程在接受到图层的绘制指令列表后就可以开始栅格化,而不必等待主线程把完整的绘制指令全部生成。

什么是栅格化呢 ?

合成线程接受到指令列表(一个列表就是一个图层)后,首先会将图层划分为图块(tile),一个图块大小一般为 256 x 256 或 512 x 512,然后根据图块来生成位图,生成位图的操作由栅格化线程完成,所以栅格化就是指将图块转换为位图的过程

image

你可能会有疑问,为什么需要分块来合成位图,而不是直接绘制图层 ?

我们来解释一下这里的原因。首先视口(viewport)代表屏幕上页面的可视区域,通常情况下合成线程接收到的图层是大于视口的,为了提高渲染效率,我们没有必要一次性把整个图层全部绘制出来,而是可以选择优先绘制视口附近的位图,所以分块是为了加速图层的绘制,分块后,视口附近的位图会被优先合成。

与前面多线程绘制类似,渲染进程也维护了一个栅格化线程池,用来加速栅格化流程

image

现代浏览器一般会通过 GPU 来加速生成位图,这种方式就是我们说的快速栅格化

合成

栅格化完成后,合成线程会生成一个绘制图块的命令(DrawQuad),然后提交给浏览器进程,浏览器进程接收到 DrawQuad 消息后会将命令对应的页面内容绘制到内存中,然后再将内存显示在屏幕上。最后我们来整体看一下渲染流程,

image

重排 & 重绘

了解了完整的渲染流程后,Web 性能优化里经常涉及到的 重排(Reflow),重绘(Repaint) 等概念就豁然开朗了。

Reflow

当我们 修改了 DOM 元素的几何位置属性 时,浏览器会触发布局,分层,绘制,栅格化等一系列完整的渲染流程,

image

所以重排的开销是非常大的,我们应该要尽量避免触发重排,具体哪些情况会触发 Reflow 呢?

(这里不建议大家去死记硬背,配合上面的渲染流程理解就好)

  1. 改变 DOM 元素的几何属性:比如盒模型相关的 width、height、padding、margin、border,位置信息 left、top 等
  2. 改变 DOM 树结构:比如对 DOM 节点的增减、移动等操作
  3. 获取一些计算属性的值:比如偏移量,滚动量,窗口等计算属性值,offsetTop、scrollTop、 clientWidth 等

这里我们解释一下为什么获取一些计算属性会导致重排,前面我们介绍布局阶段,在遍历 DOM 树构建 Layout Tree 时,需要计算可见元素的几何位置,现代浏览器大多都是通过队列机制来批量进行布局计算的,而上述的计算属性是具有即时性的,当我们获取计算属性值的时候,队列中可能有会影响这些属性或方法返回值的操作,为了保证这些即时值的正确性,浏览器可能会清空队列来确保返回正确的值。

Repaint

由渲染流程可以看到,Reflow 一定会触发 Repaint,此外,当我们修改元素的样式时,也会触发重绘,

image

DOM 元素样式改变导致的重绘虽然会跳过布局和分层阶段,但还是会触发图层绘制,栅格化,合成等流程,所以我们也要尽量避免重绘。修改哪些属性会触发 Repaint 呢?常见的有,

  • outline, visibility, color、background-color 等

读到这里,你是否会有疑问 🤔️,如果我更改了一个既不需要布局也不需要绘制的属性,会发生什么呢?

答案是浏览器会跳过主线程中布局和绘制等阶段,只执行后续的合成操作,由于合成是在非主线程进行的,所以相对于 Reflow 和 Repaint,渲染效率是比较高的,常见的有 transform、opacity 等。

最后我们来谈一谈性能优化。

从渲染流程的角度分析,性能优化我们可以考虑减少 Reflow 和 Repaint,有以下的一些常见方案,

  • 使用 transform 替代 top
  • 使用 visibility 替换 display: none,前者只会引起重绘,后者会引发回流(改变了布局
  • 避免使用不稳定的 table 布局,一个小改动都会造成整个 table 的重新布局
  • 避免设置多层内联样式,CSS 选择符从右往左匹配查找,避免节点层级过多可以提高效率
  • 将动画效果应用到 position 属性为 absolutefixed 的元素上,避免影响其他元素的布局
  • 避免使用 CSS 表达式,表达式可能会引发重排

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

响应式布局,你应该知道的一切

1tjg32bo.jpg

2011年,Google 发布了 Android 4.0,在经历了 Cupcake,Donut,Froyo 等多个甜品名称版本的迭代后,安卓终结了 Symbian(塞班)的霸主地位,迅速占领了手机市场跃居全球第一。同年,腾讯发布了微信开始进军移动互联网,阿里也在 2013 年宣布 ALL IN 无线,随着智能设备的普及和移动互联网时代的到来,响应式布局这个词开始频繁地出现在 Web 设计和开发领域,作为一名优秀的前端攻城狮,要将极致的用户体验和最佳的工程实践作为探索的目标 ): balabala...

所以,响应式布局,要学。不仅要学,我们还要了解它的前世今生,前置知识,实现手段和原理,以便在实际应用时选取合适的技术方案。

阅读完本文,你将 Get 以下知识点,

  • 什么是响应式设计?
  • 什么是像素,什么DPR?设备像素与CSS像素的区别是什么?
  • EM,REM 的计算规则是什么?实际应用中如何选择?
  • 什么是视口 viewport,布局视口,视觉视口,理想视口的区别?
  • 百分比单位和视口单位的计算规则是什么?
  • 弹性盒与网格
  • 设备断点与 CSS 媒体查询
  • 响应式布局的一些最佳实践

响应式设计

著名的网页设计师 Ehan Marcotte 在 2010 年 5 月的一篇名为《Responsive Web Design》的个人文章中,首次提到了响应式网站设计。文中讲到响应式的概念源自响应式建筑设计,即房间或者空间会根据其内部人群数量和流动而变化。

最近一门新兴的学科“响应式建筑(responsive architecture)”开始在探讨物理空间根据流动于其中的人进行响应的方法。建筑师们通过把嵌入式机器人与可拉伸材料结合的方法,尝试艺术装置和可弯曲、伸缩和扩展的墙体结构,达到根据接近人群的情况变化的效果。运动传感器与气候控制系统相结合,调整围绕人们周围的房间的温度以及环境照明。已经有公司制造了“智能玻璃技术”,当室内人数达到一定的阀值时,它可以自动变为不透明状态,为人们提供更多隐私保护

Web 响应式设计的理念与之非常相似,只不过在这里,

我们需要适配的不是建筑,而是 Web 页面

我们期望页面可以根据用户的设备环境,比如系统,分辨率,屏幕尺寸等因素,进行自发式调整,提供更适合当前环境的阅读和操作体验,对已有和未来即将出现的新设备有一定的适应能力。

这就是响应式设计的理念。那么是否有对应的方法论呢?

别急,在谈及实现之前,我们需要了解一些前置知识,比如像素。

像素

什么是像素?

像素是图像中最小的单位,一个不可再分割的点,对应到物理设备上(比如计算机屏幕),就是屏幕上的一个光点。我们常说的分辨率就是长和宽上像素点的个数,比如 IPhone X 的分辨率是 1125×2436,代表屏幕横向和纵向分别有 1125 和 2436 个像素点,这里的像素是设备像素(Device Pixels)。

1px ≠1像素

实际开发中,你可能发现 Iphone X 的设计稿是 375×812,WTF?

这里的 375×812 是 CSS 像素,也叫虚拟像素,逻辑像素。为什么我们不使用设备像素呢?

设备像素对应屏幕上的光点,如今的屏幕分辨率已经达到人眼无法区分单个像素的程度了。试想一下,要在 IPhone X 宽不到 7cm 的屏幕上数出 1125 个像素点,想想就让人头疼。所以我们在实际开发中通常使用 CSS 像素,你眼中的 1px 可能对应多个设备像素,比如上面的 IPhone X,

1 css px = 3 * 3 device px // IPhone X 中,1 个 CSS 像素对应 3*3 的 9 个设备像素点

而上面这个比值 3 就是设备像素比(Device Pixel Ratio,简称 DPR)。

DPR 可以在浏览器中通过 JavaScript 代码获取,

window.devicePixelRatio // IPhone X 中等于 3,IPhone 6/7/8 中等于 2,Web 网页为 1

像素是一个固定单位,一般我们不会使用固定像素来做响应式布局,但是你需要了解他。相反,响应式布局里经常会用到相对单位,比如 EM。

EM

EM 相对于元素自身的 font-size

p {
  font-size: 16px;
  padding: 1em; /* 1em = 16px */
}

如果元素没有显式地设置 font-size,那么 1em 等于多少呢?

这个问题其实跟咱说的 em 没啥关系,这里跟 font-size 的计算规则相关,回顾一下。如果元素没有设置 font-size,会继承父元素的 font-size,如果父元素也没有,会沿着 DOM 树一直向上查找,直到根元素 html,根元素的默认字体大小为 16px。

理解了 EMREM 就很简单了。

REM

REM = Root EM,顾名思义就是相对于根元素的 EM。所以它的计算规则比较简单,

1 rem 就等于根元素 html 的字体大小,

html {
  font-size: 14px;
}

p {
  font-size: 1rem; /* 1rem = 14px */
}

所以,如果我们改变根元素的字体大小,页面上所有使用 rem 的元素都会被重绘。

EM 和 REM 都是相对单位,我们在做响应式布局的时候应该如何选择呢?

根据两者的特性,

  • EM 更适合模块化的页面元素,比如 Web Components
  • REM 则更加方便,只需要设置 html 的字体大小,所以 REM 的使用更加广泛一些

实际开发中,设计图的单位是 CSS 像素,我们可以借助一些工具将 px 自动转换为 rem,

下面是一个用 PostCSS 插件在基于 Webpack 构建的项目中自动转换的例子,

var px2rem = require('postcss-px2rem');

module.exports = {
  module: {
    loaders: [
      {
        test: /\.css$/,
        loader: "style-loader!css-loader!postcss-loader"
      }
    ]
  },
  postcss: function() {
    return [px2rem({remUnit: 75})];
  }
}

我们已经有响应式单位了,接下来要怎么让页面支持响应式布局呢?

第一步需要先设置页面的 viewport

Viewport

著名的 JavaScript 专家 Peter-Paul Koch 曾发表过三篇有关 viewport 的文章,

建议先看完上述文章。viewport 最先由 Apple 引入,用于解决移动端页面的显示问题,通过一个叫 <meta> 的 DOM 标签,允许我们可以定义视口的各种行为,比如宽度,高度,初始缩放比例等,

<!-- 下面的 meta 定义了 viewport 的宽度为屏幕宽度,单位是 CSS 像素,默认不缩放 -->
<meta name="viewport" content="width=device-width, initial-scale=1">

Peter-Paul Koch 在文章中将移动浏览器的视口分为三种。

layout viewport

为了解决早期 Web 页面在手持设备上的显示问题,Apple 在 IOS Safari 中定义了一个 viewport meta 标签,它可以创建一个虚拟的布局视口(layout viewport),这个视口的分辨率接近于 PC 显示器。这样一来,由于两者的宽度趋近,CSS只需要像在PC上那样渲染页面就行,原有的页面结构也基本不会被破坏。

layout viewport 是一个固定的值,由浏览器厂商设定,

  • IOS 和 Android 基本都是 980px
  • 黑莓(BlackBerry)和 IE10 是 1024px

可以通过 document 获取布局视口的宽度和高度,

var layoutViewportWidth = document.documentElement.clientWidth
var layoutViewportHeight = document.documentElement.clientHeight

visual viewport

视觉视口(visual viewport)可以简单理解为手持设备物理屏幕的可视区域。也就是你的手机屏幕,所以不同设备的视觉视口可能不同,有了 visual viewport,我们就可以实现网页的拖拽和缩放了,为什么?

因为有了一个承载布局视口的容器

试想一下,假如我们现在有一台 IPhone 6(375×627),它会在宽为 375px 的 visual viewport 上,创建一个宽为 980px 的 layout viewport,于是用户可以在 visual viewport 中拖动或缩放网页来获得更好的浏览体验。

视觉视口可以通过 window 获取,

var visualViewportWidth = window.innerWidth
var visualViewportHeight = window.innerHeight

idea viewport

我们前面一直在讨论 Web 页面在移动浏览器上的适配问题,但是如果网页本来就是为移动端设计的,这个时候布局视口(layout viewport)反而不太适用了,所以我们还需要另一种布局视口,它的宽度和视觉视口相同,用户不需要缩放和拖动网页就能获得良好的浏览体验,这就是理想视口(idea viewport)。

我们可以通过 meta 设置将布局视口转换为理想视口,

<meta name="viewport" content="width=device-width">

meta

视口可以通过 <meta> 进行设置,viewport 元标签的取值有 6 种,

  • width,正整数 | device-width,视口宽度,单位是 CSS 像素,如果等于 device-width,则为理想视口的宽度
  • height,正整数 | device-width,视口宽度,单位是 CSS 像素,如果等于 device-height,则为理想视口的高度
  • initial-scale,0-10,初始缩放比例,允许小数点
  • minimum-scale,0-10,最小缩放比例,必须小于等于 maximum-scale
  • maximum-scale,0-10,最大缩放比例,必须大于等于 minimum-scale
  • user-scalable,yes/no,是否允许用户缩放页面,默认是 yes

了解了视口之后,让我们回到响应式布局,与视口相关的几个单位有:vw,vh,百分比

vw,vh,百分比

浏览器对于 vwvh 的支持相对较晚,在 Android 4.4 以下的浏览器中可能没办法使用,下面是来自 Can I use 完整的兼容性统计数据,

image.png

新生特性往往逃不过兼容性的大坑,但是这并不妨碍我们了解它。

响应式设计里,vwvh 常被用于布局,因为它们是相对于视口的,

  • vw,viewport width,视口宽度,所以 1vw = 1% 视口宽度
  • vh,viewport height,视口高度,所以 1vh = 1% 视口高度

以 IPhone X 为例,vw 和 CSS 像素的换算如下,

<!-- 假设我们设置视口为完美视口,这时视口宽度就等于设备宽度,CSS 像素为 375px -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<style>
  p {
    width: 10vw; /* 10vw = 1% * 10 * 375px = 37.5px */
  }
</style>

我们说百分比也可以用来设置元素的宽高,它和 vwvh 的区别是什么?

这里只需要记住一点,百分比是相对于父元素的宽度和高度计算的。

到这里,相信你已经掌握了响应式布局里常用的所有单位。接下来,我们介绍弹性盒和栅格,它们都不是单位,而是一种新的布局方案。

弹性盒

W3C 在 2009 年提出了弹性盒,截止目前浏览器对 FlexBox 的支持已经相对完善,下面是 Can I use FlexBox 完整的兼容性情况,

image.png

关于弹性盒模型推荐阅读这篇文章 A Complete Guide to Flexbox

假设你已经阅读完并了解了弹性盒模型,响应式布局中我们需要关注 FlexBox 里的两个角色:容器和子元素

container

指定 display 属性为 flex,就可以将一个元素设置为 FlexBox 容器,我们可以通过定义它的属性,决定子元素的排列方式,属性可选值有 6 种,

  • flex-direction,主轴方向,也就是子元素排列的方向
  • flex-wrap,子元素能否换行展示及换行方式
  • flex-flow,flex-direction 和 flex-wrap 的简写形式
  • justify-content,子元素在主轴上的对齐方式
  • align-items,子元素在垂直于主轴的交叉轴上的排列方式
  • align-content,子元素在多条轴线上的对齐方式

items

子元素也支持 6 个属性可选值,

  • order,子元素在主轴上的排列顺序
  • flex-grow,子元素的放大比例,默认 0
  • flex-shrink,子元素的缩小比例,默认 1
  • flex-basis,分配剩余空间时,子元素的默认大小,默认 auto
  • flex,flex-grow,flex-shrink,flex-basis 的简写
  • align-self,覆盖容器的 align-items 属性

弹性盒模型布局非常灵活,属性值也足够应对大部分复杂的场景,但 FlexBox 基于轴线,只能解决一维场景下的布局,作为补充,W3C 在后续提出了网格布局(CSS Grid Layout),网格将容器再度划分为 “行” 和 “列”,产生单元格,项目(子元素)可以在单元格内组合定位,所以网格可以看作二维布局。

网格

关于网格布局推荐阅读这篇文章 A Complete Guide to Grid

上述文章非常详细地介绍了网格的一些基本概念(比如容器和项目,行和列,单元格和网格线等),使用姿势,注意事项等。作为新兴的布局方案,使用时你需要考虑兼容性是否满足,

image.png

不过在标准之外,我们可能也正通过其他的一些姿势在使用网格。如果你关注时下一些比较热门的 UI 库,比如 Ant DesginMaterial UIElement Plus 等,它们以栅格系统的方式实现了对网格部分特性的支持。

UI 库对 Grid 的实现中,通常会使用到媒体查询,这也是响应式布局的核心技术。

媒体查询

媒体查询(Media Query)是 CSS3 规范中的一部分,媒体查询提供了简单的判断方法,允许开发者根据不同的设备特征应用不同的样式。响应式布局中,常用的设备特征有,

  • min-width,数值,视口宽度大于 min-width 时应用样式
  • max-width,数值,视口宽度小于 max-width 时应用样式
  • orientation,portrait | landscape,当前设备的方向

选择 min-widthmax-width 取值的过程,称为设备断点选择,它可能取决于产品设计本身,下面是 百度 Web 生态团队 总结的一套比较具有代表性的设备断点,

/* 很小的设备(手机等,小于 600px) */
@media only screen and (max-width: 600px) { }

/* 比较小的设备(竖屏的平板,屏幕较大的手机等, 大于 600px) */
@media only screen and (min-width: 600px) { }

/* 中型大小设备(横屏的平板, 大于 768px) */
@media only screen and (min-width: 768px) { }

/* 大型设备(电脑, 大于 992px) */
@media only screen and (min-width: 992px) { }

/* 超大型设备(大尺寸电脑屏幕, 大于 1200px) */
@media only screen and (min-width: 1200px) { }

如果你需要对细分屏幕大小进行适配,ResponsiveDesign 站点上的这篇文章 Media queries for common device breakpoints 可能会有所帮助。

响应式文字和图片

相信你已经掌握了响应式布局的所有知识,接下来我们介绍一些最佳实践。

文字

大多数用户阅读都是从左到右,如果一行文字太长,阅读下一行时容易出错,或者用户只会读一行文字的前半部分,而略读后半部分。在上世纪就有研究表明,一行 45 ~ 90 个英文字符是最好的,对于汉字来说,一行文字合理的数量应该是 22 ~ 45 个字符。

此外,字体大小对阅读体验同样重要,基本字体一般不小于 16px,行高大于 1.2em

p {
  font-size: 16px;
  line-height: 1.2em; /* 1.2em = 19.2px */
}

图片

《高性能网站建设指南》的作者 Steve Souders 曾在 2013 年的一篇 博客 中提到:

我的大部分性能优化工作都集中在 JavaScript 和 CSS 上,从早期的 Move Scripts to the Bottom 和 Put Stylesheets at the Top 规则。为了强调这些规则的重要性,我甚至说过,“JS 和 CSS 是页面上最重要的部分”。几个月后,我意识到这是错误的。图片才是页面上最重要的部分。

图片几乎占了网页流量消耗的 60%,雅虎军规和 Google 都将图片优化作为网页优化不可或缺的环节,除了图片性能优化外,响应式图片无疑带来更好的用户体验。

下面是一些响应式图片的最佳实践,

1.确保图片内容不会超出 viewport

试想一下,如果图片固定大小且超出理想视口的宽度,会发生什么?

内容会溢出视口外,导致出现横向滚动条对不对,这在移动端是非常不好的浏览体验,因为用户往往更习惯上下滚动,而不是左右滚动,所以我们需要确保图片内容不要超出 viewport,可以通过设置元素的最大宽度进行限制,

img {
  max-width: 100%;
}

类似的,相同的规则也应该用于一些其他的嵌入式元素,比如 embed,object,video 等。

2. 图片质量支持响应式

这是一种支持优雅降级的方案,现代浏览器已经支持了 srcsetsizes 属性,对于兼容性不好的浏览器,会继续使用默认 src 属性中的图片,所以我们可以放心大胆的使用。

  • srcset 支持定义几组图片和对应的尺寸
  • sizes 支持一组媒体查询条件
<!-- 响应式图片 -->
<img
  srcset="example-320w.jpg 320w,
          example-480w.jpg 480w,
          example-800w.jpg 800w"
  sizes="(max-width: 320px) 280px,
         (max-width: 480px) 440px,
         800px"
  src="example-800w.jpg" alt="An example image">

如果我们书写了上面代码中的图片,浏览器会根据下面的顺序加载图片,

  1. 获取设备视口宽度
  2. 从上到下找到第一个为真的媒体查询
  3. 获取该条件对应的图片尺寸
  4. 加载 srcset 中最接近这个尺寸的图片并显示

除了上述方式外,我们也可以使用 HTML5 标准中的 picture 标签实现类似的效果,

<picture>
  <source media="(max-width: 799px)" srcset="example-480w-portrait.jpg">
  <source media="(min-width: 800px)" srcset="example-800w.jpg">
  <img src="example-800w.jpg" alt="An example img">
</picture>

小结

我们从响应式布局的设计角度出发,介绍了响应式的设计理念,前置知识(像素,DPR,视口等),相对单位(em,rem,百分比,vw,vh等),布局方案(FlexBox,Gird)以及媒体查询等技术,其中不乏很多前辈们的最佳实践,作为开发者我们应该用这些经验,以更好地优化不同尺寸大小设备的用户体验。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

读书笔记(一):作用域

什么是作用域

几乎所有编程语言最基本的功能之一,就是能够存储变量的值,并且能够在之后对值进行访问和修改,这套对于变量存储和访问控制的规则被称为作用域

简单的说,作用域就是一套规则,用于确定在何处以及如何查找变量(标识符)。

JavaScript是一门编译语言

尽管通常将 JavaScript 归类为 “动态” 或 “解释执行” 语言,但事实上它是一门编译语言。

传统编译语言流程通常会经历三个步骤:

  1. 词法分析:将代码字符串分解成有意义的代码块,这些代码块被称为 词法单元。例如:
var a = 2; // 通常被分解为:var、a、=、2、; 空格是否会被当做词法单元,取决于空格在这门语言中是否有意义。
  1. 语法分析:将词法单元流(数组)转换成 抽象语法树AST)。例如:
var a = 2; // AST是一个由元素逐级嵌套组成的代表程序语法结构的树,转换成AST后,可能有一个叫做 VariableDeclaration 的顶级节点,三个值分别为 a, =, 2 的被叫做 Identifier,AssignmentExpression,NumericLiteral 的子节点
  1. 代码生成:将 AST 转换为可执行代码的过程。

JavaScript 引擎的编译过程相较于传统编译流程要复杂的多。包括但不限于:

  • 语法分析和代码生成阶段通过特定的步骤对运行性能进行优化,冗余元素优化
  • 在代码执行前的几微秒甚至更短的时间内进行编译,而不是在在构建之前

LHS 和 RHS

引擎查找变量的过程会进行两种类型的查询:LHS查询RHS查询,分别代表赋值操作的左侧和右侧查询。
需要注意的是,这里的 “赋值操作的左侧或右侧” 并不一定意味着就是 “=赋值操作符的左侧或右侧”,因为赋值操作有很多形式,从查询的目的上,可以这样理解:

  • LHS查询:查询的目的是对变量进行赋值
  • RHS查询:查询的目的是获取变量的值(retrieve his source value

作用域嵌套与作用域链查找

作用域嵌套:一个块或函数嵌套在另一个块或函数中。
作用域查找:引擎在当前作用域无法找到某个变量时,会去外层嵌套的作用域继续查找,直到找到变量或抵达最外层作用域(全局作用域)为止。

ReferenceError 和 TypeError 异常

ReferenceError 与作用域判别失败有关,例如:

  • RHS查询失败时,即查询所有嵌套作用域都找不到变量时
  • 严格模式下,LHS查询失败时也会抛出 ReferenceError 异常,而在非严格模式下,会导致隐式地创建一个全局变量

TypeError 代表作用域判别成功了,但是对结果操作非法或不合理,例如:

  • 对一个非函数类型的值进行调用
  • 引用 nullundefined 中的属性

(完)

可能是最好的 BFC 解析了...

BFC

全文约 3000 字,阅读完大约需要 6 分钟,配合示例食用更佳

BFC(Block Formatting Contexts),块级格式化上下文,是 CSS 中一个比较晦涩难懂的概念,下面我们尝试以通俗易懂的语言彻底地理解它。

盒模型

CSS 盒模型描述了通过 文档树中的元素 以及相应的 视觉格式化模型 所生成的矩形盒子。简单来说,盒模型定义了一个 矩形盒子,当我们需要对文档进行布局时,浏览器的渲染引擎就会根据盒模型,将所有元素表示为一个个矩形的盒子,盒子的外观由 CSS 决定。

一个标准的盒子由四个部分组成,由内向外分别为:内容内边距边框外边距

boxmodel-3.png

标准的盒模型中,内容区域的大小可以明确地通过 widthmin-widthmax-widthheightmin-heightmax-height 控制,也就是说,通过 CSS 设置的元素宽高只是包含内容区域。你可能听说过 怪异盒模型,这种盒模型最早在 IE 浏览器中出现,也叫 IE盒模型box-sizing 属性值为 border-box 时,元素会呈现怪异盒模型,此时,元素的宽高包含了内容,内边距和边框

视觉格式化模型

CSS 视觉格式化模型描述了盒子是怎样生成的,简单来说,它定义了盒子生成的计算规则,通过规则将文档元素转换为一个个盒子。

每一个盒子的布局由尺寸类型定位盒子的子元素或兄弟元素视口的尺寸和位置等因素决定。

视觉格式化模型的计算,取决于一个矩形的边界,这个矩形边界,就是 包含块( containing block ) ,比如:

<table>
  <tr>
    <td></td>
  </tr>
</table>

上述代码片段中,tabletr 都是包含块,tabletr 的包含块,同时 tr 又是 td 的包含块。

需要注意的是,盒子不受包含块的限制,当盒子的布局跑到包含块的外面时,就是我们说的溢出(overflow)

视觉格式化模型定义了盒(Box)的生成,其中的盒主要包括了块级盒,行内盒匿名盒

块级元素

CSS 属性值 displayblocklist-itemtable 的元素。

块级盒

块级盒具有以下特性:

  • CSS 属性值 displayblocklist-itemtable 时,它就是块级元素
  • 视觉上,块级盒呈现为竖直排列的块
  • 每个块级盒都会参与 BFC 的创建
  • 每个块级元素都会至少生成一个块级盒,称为主块级盒;一些元素可能会生成额外的块级盒,比如 <li>,用来存放项目符号

行内级元素

CSS 属性值 displayinlineinline-blockinline-table 的元素。

行内盒

行内盒具有以下特性:

  • CSS 属性值 displayinlineinline-blockinline-table 时,它就是行内级元素
  • 视觉上,行内盒与其他行内级元素排列为多行
  • 所有的可替换元素(display 值为 inline,如 <img><iframe><video><embed> 等)生成的盒都是行内盒,它们会参与
    IFC(行内格式化上下文) 的创建
  • 所有的非可替换行内元素(display 值为 inline-blockinline-table)生成的盒称为原子行内级盒,不参与 IFC 创建

匿名盒

匿名盒指不能被 CSS 选择器选中的盒子,比如:

<div>
  匿名盒1
  <p>块盒</p>
  匿名盒2
</div>

上述代码片段中,div 元素和 p 元素都会生成一个块级盒,p 元素的前后会生成两个匿名盒。

匿名盒所有可继承的 CSS 属性值都为 inherit,所有不可继承的 CSS 属性值都为 initial

定位方案

CSS 页面布局技术允许我们拾取网页中的元素,并且控制它们相对正常布局流(普通流)、周边元素、父容器或者主视口/窗口的位置。技术布局从宏观上来说是受定位方案影响,定位方案包括普通流(Normal Flow,也叫常规流,正常布局流),浮动(Float),定位技术(Position)。

普通流

浏览器默认的 HTML 布局方式,此时浏览器不对页面做任何布局控制,

positionstaticrelative,并且 floatnone 时会触发普通流,普通流有以下特性:

  • 普通流中,所有的盒一个接一个排列
  • BFC 中,盒子会竖着排列
  • IFC 中,盒子会横着排列
  • 静态定位中(positionstatic),盒的位置就是普通流里布局的位置
  • 相对定位中(positionrelative),盒的偏移位置由 toprightbottomleft 定义,
    即使有偏移,仍然保留原有的位置,其它普通流不能占用这个位置

浮动

  • 浮动定位中,盒称为浮动盒(Floating Box)
  • 盒位于当前行的开头或结尾
  • 普通流会环绕在浮动盒周围,除非设置 clear 属性

定位技术

定位技术允许我们将一个元素从它在页面的原始位置准确地移动到另外一个位置,有四种:静态定位相对定位绝对定位固定定位

静态定位

默认的定位方式(positionstatic),此时元素处于普通流中。

相对定位

相对定位通常用来对布局进行微调,positionrelative 时,元素使用相对定位,此时可以通过 toprightbottomleft 属性对元素的位置进行微调,设置其相对于自身的偏移量

绝对定位

绝对定位方案中,盒会从普通流中移除,不会影响其他普通流的布局。绝对定位具有以下特点:

  • 元素的属性 positionabsolutefixed 时,它是绝对定位元素
  • 它的定位相对于它的包含块,可以通过 toprightbottomleft 属性对元素的位置进行微调,设置其相对于包含块的偏移量
  • positionabsolute 的元素,其定位将相对于最近的一个 relativefixedabsolute 的父元素,如果没有则相对于 body

固定定位

与绝对定位方案类似,唯一的区别在于,它的包含块是浏览器视窗

块级格式化上下文

通过对 CSS 盒模型,定位,布局等知识的了解,我们知道 BFC 这个概念其实来自于视觉格式化模型

它是页面 CSS 视觉渲染的一部分,用于决定块级盒的布局及浮动相互影响范围的一个区域

BFC 的创建

以下元素会创建 BFC

  • 根元素(<html>
  • 浮动元素(float 不为 none
  • 绝对定位元素(positionabsolutefixed
  • 表格的标题和单元格(displaytable-captiontable-cell
  • 匿名表格单元格元素(displaytableinline-table
  • 行内块元素(displayinline-block
  • overflow 的值不为 visible 的元素
  • 弹性元素(displayflexinline-flex 的元素的直接子元素)
  • 网格元素(displaygridinline-grid 的元素的直接子元素)

以上是 CSS2.1 规范定义的 BFC 触发方式,在最新的 CSS3 规范中,弹性元素和网格元素会创建 F(Flex)FCG(Grid)FC

BFC 的范围

A block formatting context contains everything inside of the element creating it, that is not also inside a descendant element that creates a new block formatting context.

直译过来就是,BFC 包含创建它的元素的所有子元素,但是不包括创建了新的 BFC 的子元素的内部元素。

简单来说,子元素如果又创建了一个新的 BFC,那么它里面的内容就不属于上一个 BFC 了,这体现了 BFC 隔离 的**,我们还是以 table 为例:

<table>
  <tr>
    <td></td>
  </tr>
</table>

假设 table 元素创建的 BFC 我们记为 BFC_tabletr 元素创建的 BFC 记为 BFC_tr,根据规则,两个 BFC 的范围分别为:

  • BFC_trtd 元素
  • BFC_table:只有 tr 元素,不包括 tr 里的 td 元素

也就是所说,一个元素不能同时存在于两个 BFC 中

BFC 的特性

BFC 除了会创建一个隔离的空间外,还具有以下特性,附 CodePen 示例链接地址,可结合示例进行理解

  • BFC 内部的块级盒会在垂直方向上一个接一个排列
  • 同一个 BFC 下的相邻块级元素可能发生外边距折叠,创建新的 BFC 可以避免的外边距折叠
  • 每个元素的外边距盒(margin box)的左边与包含块边框盒(border box)的左边相接触(从右向左的格式化,则相反),即使存在浮动也是如此
  • 浮动盒的区域不会和 BFC 重叠
  • 计算 BFC 的高度时,浮动元素也会参与计算

BFC 的应用

自适应多栏布局

利用 特性③特性④,中间栏创建 BFC,左右栏宽度固定后浮动。由于盒子的 margin box 的左边和包含块 border box 的左边相接触,同时浮动盒的区域不会和 BFC 重叠,所以中间栏的宽度会自适应,示例

防止外边距折叠

利用 特性②,创建新的 BFC ,让相邻的块级盒位于不同 BFC 下可以防止外边距折叠,示例

清除浮动

利用 特性⑤BFC 内部的浮动元素也会参与高度计算,可以清除 BFC 内部的浮动,示例

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

React Fiber架构

React设计理念React架构 中我们知道,在 v15 版本 Reconciler 采用递归的方式更新虚拟 DOM,这会导致什么问题呢?

由于递归过程是不能中断的,如果组件树的层级很深,递归更新时间超过了一帧,用户交互就会卡顿,

为了解决这个问题,v16递归的无法中断的更新重构为异步的可中断更新

由于曾经用于递归的虚拟 DOM 数据结构已经无法满足需要,于是全新的 Fiber 架构应运而生。

那到底什么是 Fiber ?Fiber 只是一个架构吗?为什么说 Fiber 同时也作为静态的数据结构和动态的工作单元?

什么是 Fiber

React 团队的核心成员 Andrew Clark 在 2016 年的一次演讲 What's Next for React — ReactNext 2016 中第一次提到 Fiber,

这次演讲后来被整理为一篇介绍 React Fiber Architecture ,Fiber 作为 React 新版本的一种核心架构被正式提出。

A description of React's new core algorithm, React Fiber

在实现上,Fiber 对应了 DOM 树中的一个节点,我们可以从以下三个角度解读 Fiber:架构、数据结构和工作单元

Fiber Architecture

作为架构而言,v15Reconciler 采用递归的方式实现,数据保存在递归调用栈中,叫做 Stack Reconciler

v16Reconciler 基于 Fiber 节点实现,被称为 Fiber Reconciler,支持可中断的异步更新,任务支持切片,

Fiber Data Structure

Fiber 也是一种数据结构,我们可以从源码找到 Fiber节点的属性定义(关键的属性已经添加了注释,可以结合注释来看),

function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode
) {
  // Fiber 对应组件的类型 Function/Class/Host...
  this.tag = tag;
  // Diffing 需要的 key 属性
  this.key = key;
  // 大部分情况同 type,某些情况不同,比如 FunctionComponent 使用 React.memo 包裹
  this.elementType = null;
  // 对于 FunctionComponent,指函数本身,对于 ClassComponent,指 class,对于 HostComponent,指 DOM 节点的 tagName
  this.type = null;
  // Fiber 对应的真实 DOM 节点
  this.stateNode = null;

  // 指向父级 Fiber 节点
  this.return = null;
  // 指向子 Fiber 节点
  this.child = null;
  // 指向兄弟 Fiber 节点
  this.sibling = null;
  this.index = 0;

  this.ref = null;
  this.refCleanup = null;

  // 保存本次更新的状态改变相关信息
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  // 保存本次更新会造成的DOM操作
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;

  // 调度优先级相关
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 指向该 Fiber 在另一次更新时对应的 Fiber
  this.alternate = null;
}

Fiber 作为静态的数据结构,保存了组件的 tagkeytypestateNode 等相关信息。

Fiber Work Unit

Fiber 同时也是一个动态的工作单元,从 FiberNode 的定义中我们发现,

Fiber 保存了本次更新的状态改变相关信息会造成的 DOM 操作(副作用 effect)以及调度优先级相关的信息

Fiber 之所以可以作为 Work Unit,还与其工作原理有关,在正式介绍之前,我们先来了解一个 DOM 更新的技术:双缓存

双缓存

双缓存(Double Buffering)是一个广泛应用于网络传输图形渲染内存读取优化等场景的技术,

我们以大家比较熟悉的渲染场景为例,假设我们需要用 canvas 绘制一个动画,然后显示到屏幕上,通常我们会在显示缓存区进行绘制,绘制每一帧前都会调用 ctx.clearRect 清除上一帧的画面,如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏,

image

为了解决这个问题,我们可以在自定义缓存区中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,这样就可以省去了两帧替换间的计算时间,避免白屏到出现画面的闪烁。

这种在内存中构建并直接替换的技术就是双缓存

React Fiber 就是利用了双缓存技术来完成 “Fiber树” 的创建和替换,从而提高性能,具体是怎么实现的呢?

Alternate

React 中最多会同时存在两颗 Fiber 树,每次状态更新都会产生新的 workInProgress Fiber

  • currentFiber,当前屏幕上显示内容对应的 Fiber 树
  • workInProgressFiber,正在内存中构建的 Fiber 树

它们之间通过 alternate 属性连接,

currentFiber.alternate === workInProgressFiber; // true
workInProgressFiber.alternate === currentFiber; // true

React 应用的根节点通过使 current 指针在不同 Fiber 树的 rootFiber 间切换来完成 currentFiber 树指向的切换,

我们可以以组件 mount/update 的流程为例,了解 Fiber 树的构建和更新过程,考虑如下例子,

function App() {
  const [num, add] = useState(0);
  return (
    <p onClick={() => add(num + 1)}>{num}</p>
  )
}

ReactDOM.render(<App/>, document.getElementById('root'));

mount

首次执行 ReactDOM.render 会创建 fiberRootrootFiber ,其中,

  • fiberRoot 是整个应用的根节点
  • rootFiber<App/> 所在组件树的根节点

此时,fiberRootcurrent 指针指向当前的 Fiber 树,

image

接着会进入 render 阶段,React 会解析组件返回的 JSX 并在内存中依次创建 Fiber 节点连接成 Fiber 树,内存中完成构建的 Fiber 树叫做 workInProgress Fiber 树,这个过程中 React 会尝试复用 current Fiber 树中已有的 Fiber 节点内的属性,

image

然后是 commit 阶段,右侧已经构建完的 Fiber 树会替换掉当前的 Fiber 树,渲染到页面,

image

update

我们来接着讨论更新阶段,假设我们点击 p 节点触发状态改变,num 的值从 0 变为 1

每一次更新 React 都会开启一次新的 render 阶段并构建一棵新的 workInProgress Fiber 树,

mount 时一样,workInProgressFiber 的创建会尝试复用 currentFiber 节点,这个过程就是 Diffing 算法,

image

render 阶段完成后接着会进入 commit 阶段,workInProgressFiber 替换 currentFiber 完成渲染,

image

小结

Fiber 作为 React 新版本的核心架构,在实现可中断的异步更新中至关重要,

通过上述介绍,我们了解到 Fiber 不止作为架构,同时也是一个静态的数据结构和一个动态的工作单元

Fiber 架构的核心是双缓存技术,其创建和更新的过程伴随着 DOM 的更新。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

React进阶系列

React 作为一个优秀的前端框架,在架构上融合了数据驱动视图、组件化、函数式编程、面向对象、Fiber 等经典设计哲学,在底层技术选型上涉及了 JSX、虚拟 DOM 等经典解决方案,在周边生态上至少涵盖了状态管理和前端路由两大领域的最佳实践,此外,它还自建了状态管理机制与事件系统,创造性地在前端框架中引入了 Hooks **...React 十年如一日的稳定输出背后,有太多值得我们去吸收和借鉴的东西。

最近在读修言的《深入浅出搞定 React》,笔者的文笔和文风都非常有趣,又不乏干货,重读几遍后仍收获满满,这个系列是基于上述读物的笔记和自己一些思考,整理出来后分享给大家。本系列大概有 15 篇,如果觉得有帮助可以给个 star,如果发现问题请不吝在对应文章的评论区指正。

文章列表

深入Webpack(一): 模块解析规则

前端模块化中,我们经常使用相对路径和第三方类库名的方式来引入模块:

import foo from './foo.js' // 相对路径
import bar from '@/src/bar.js' // 相对路径,alias也是相对路径
import react from 'react' // 第三方类库名称

当然,还可以通过绝对路径引入模块,但是这种方式不推荐也不常用,我们暂不讨论。

webpack 中有一个专门的模块 enhanced-resolve 来处理依赖模块路径的解析,从这个模块名称我们大致可以猜到是基于 Node.js 的,没错,其实就是 Node.js 模块解析的增强版,支持更多的自定义配置。

webpack的模块解析规则分为三种,相对路径绝对路径模块名

绝对路径

我们先从最简单但是不常用的绝对路径说起,绝对路径通常是从盘符开始,像下面这样:

C:\hello-world\index.js

webpack处理绝对路径的解析规则就是直接查找对应路径下的模块。

相对路径

相对路径的解析规则略微复杂,大致分为下面几个步骤:

  1. 首先查找当前模块的路径下是否有匹配的文件或文件夹
  2. 如果文件名匹配直接加载模块
  3. 如果是文件夹,查找文件夹下面的package.json文件
  4. 如果有package.json文件,查找main字段指定的文件
  5. 如果没有package.json文件或者没有main字段,查找index.js文件

模块名

如果是模块名称,webpack会逐次查找当前目录下,父级目录及以上的目录下的node_modules文件夹,如果找到node_modules文件夹,会去node_modules文件夹中查找指定的模块。

相关推荐

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

知乎页面切换动效的探索——惯性滚动篇

惯性滚动(momentum-based scrolling)一词我们并不陌生,从浏览器到桌面应用,再到移动 App,几乎所有涉及到滚动的一些场景,都能见到它的身影。惯性滚动最早出现在 IOS 系统中,当用户的滑动手势结束,手指离开屏幕后,页面滚动不会马上停止而是会根据滑动时的速度,滑动手势的强烈程度,继续保持一段时间的滚动效果,当页面滚动到顶部/底部时,还有可能触发 “惯性回弹” 的效果,

1.gif22.gif

-webkit-overflow-scrolling

IOS 系统的 Safari 浏览器最早支持了这一特性,我们可以为元素设置 -webkit-overflow-scrolling: touch 属性,让其支持惯性滚动,在 Safira 13+ 的版本 中,所有可滚动的框架或设置 overflow 滚动的元素默认支持了惯性滚动,

.view {
  -webkit-overflow-scrolling: touch;
}

遗憾的是在兼容性方面,除了 safari 外其他浏览器基本全军覆没,

此外,-webkit-overflow-scrolling 在 safari 上本身也存在一些问题

但是你可能发现很多流行的 UI 库,App 的交互都实现了惯性滚动的效果,那么在不支持 -webkit-overflow-scrolling 属性的浏览器上,我们要怎样实现 momentum-based scrolling 的动效呢?

首先我们对惯性滚动做一个简单的建模。

建模

在描述惯性滚动之前,我们先来回顾一下,什么是惯性?牛顿第一定律 表明,一切物体在没有受到外力的作用时,总是保持静止状态或匀速直线运动状态,所以惯性是物体的一种固有属性。那什么是惯性滚动呢?

我们可以把惯性滚动描述为,物体在受到外力的作用下,运动状态改变,但由于物体本身具有惯性,所以会保持原有的运动状态继续运动的过程。试想一下,如果在足够光滑的表面上,物体将一直保持惯性滚动,但实际场景中,物体往往会因为摩擦力,空气阻力等,速度变得越来越慢直到静止。

为了方面理解,这里我们可以用大家熟悉的 滑块模型 的几个阶段来近似描述这一过程,

第一阶段,滑动滑块(F拉 > F摩)使其从静止开始做加速运动
slidermodal.png

由于在实际的场景中,我们一般关注的是用户即将释放手指前的一小阶段,而非滚动的全流程(全流程意义不大),所以这一瞬间阶段也可以简单模拟为滑块均衡受力做 匀加速运动

第二阶段,释放滑块使其在只受摩擦力的作用下继续滑动
slowdown.png

这一阶段,滑块拥有一个初速度,且只受到反向的摩擦力做 匀减速运动。但在实际的场景中,我们一般会将惯性滚动的元素放置在一个容器内,这时滑块可能会滚动到容器边界触发回弹,所以我们还需要考虑回弹过程。

我们可以借助一根弹簧来近似模拟回弹的过程,假设滑块左端与一根弹簧连接,弹簧另一端固定在墙体上。当滑块惯性滚动到达临界点(弹簧即将发生形变)时,滑块会拉动弹簧使其发生形变,此时滑块受到弹簧的反向拉力作减速运动直到停止,

第三阶段,滑块受到弹簧的反向拉力和摩擦力做变减速运动
Untitled Diagram (1).png

此阶段滑块受到弹簧拉力和摩擦力的共同作用,加速度越来越大,最后速度变为 0,此时弹簧的形变量最大。最后弹簧恢复形变,拉动滑块做反向的变加速和变减速运动,

第四阶段,弹簧拉动滑块做变加速和变减速运动

Untitled Diagram (2).png

基于上面的模型,我们可以试着来实现惯性滚动惯性回弹(这里以小程序代码为例)。

惯性滚动

初始化两个页面元素,容器 (container) 和滑块 (slider),

<!-- wxml -->
<wxs module="gesture" src="./index.wxs"></wxs>
<view class="container">
  <view
    class="slider"
    bindtouchstart="{{gesture.touchstart}}"
    bindtouchmove="{{gesture.touchmove}}"
    bindtouchend="{{gesture.touchend}}"
    bindtouchcancle="{{gesture.touchcancel}}"
  ></view>
</view>

对于模型的第一阶段,滑块做匀加速运动,假设滑块的滑动距离为 s1,滑动的时间为 t1,手指离开时滑块的速度为 v1,根据 位移公式

image

可以计算出惯性滚动的初始速度,

image

对于第二阶段,滑块受反向摩擦力做匀减速运动,末速度为 0m/s,假设滑块加速度为 a,滑动时间为 t2,滑动距离为 s2,结合加速度公式和位移公式,

image

可以计算出滑块滑动的距离,

image

由于匀减速的减速度为负(a < 0),这里我们不妨设一个加速度常量 A,使其满足 A = -2a 的关系,那么滑动距离,

image

但在实际应用中,我们发现 v1 算平方会导致最终计算计算出的惯性滚动距离太大(即对滚动手势的强度感应过于灵敏),这里我们不妨把平方去掉,

image

所以,求滑块的滑动距离时,只需要记录用户滚动的距离 s1 和滚动时长 t1,然后设置一个合适的加速度常量 A 即可。经过大量测试,这里加速度常量 A 的取值建议在 0.002 ~ 0.003,

var translateY = 0; // Y 轴的偏移距离
var startTime = 0; // 触发惯性滚动的起始时间
var startY = 0; // 触发惯性滚动的 Y 轴起始坐标
var deceleration = 0.002; // 惯性滚动的加速度常量

function touchstart(e) {
  startTime = e.timeStamp;
  startY = e.detail.changedTouches[0].pageY;
}

需要注意的是,对于实际场景的惯性滚动来说,我们这里讨论的滚动距离和时长是指能够作用于惯性滚动范围内的距离和时长,而非用户滚动页面元素的整个流程,比如下面的这个例子,

3

用户首先以非常缓慢的速度使元素滚动了一段距离,随后在很短的时间内继续滚动页面然后释放手指,这种情况下我们需要的滚动距离和时长应该是后半段用户在短时间内触发惯性滚动的手指移动距离和停留时长,换成代码描述就是,在 touchmove 事件里,如果用户在滑动的过程中,停留时长大于阈值,说明有可能会触发惯性滚动,此时我们需要更新惯性滚动的起始时间和位置

function touchmove(e) {
  // ...
  if (e.timeStamp - startTime > momentumTimeThreshold) {
    startTime = e.timestamp;
    startY = e.detail.changedTouches[0].pageY;
  }
}

为什么说是有可能触发惯性滑动呢?因为我们还需要判断用户滚动的距离,所以在触摸时间结束后,惯性滚动的触发条件是,停留时长 duration 小于某个阈值并且最小位移距离 s1 大于某个阈值。经过测试,这里的停留时长阈值设置为 200ms ~ 300ms,最小位移距离设置为 10px ~ 20px 较为合理,

var momentumTimeThreshold = 300; // 触发惯性滚动的最大时长,ms
var momentumYThreshold = 15; // 触发惯性滚动的最小位移距离,ms

function touchend(e) {
  var endY = e.detail.changedTouches[0].pageY;
  var duration = e.timeStamp - startTime;
  var s1 = endY - startY;
  if (duration < momentumTimeThreshold && Math.abs(s1) > momentumYThreshold) {
    // 触发惯性滑动...
  }
}

接下来我们来分析惯性滚动后可能会触发的第三,四阶段的回弹过程。

惯性回弹

对于模型的第三阶段,滑块受弹簧反向拉力和摩擦力的共同作用做变减速运动,滑块的加速度越来越大,速度降为 0m/s 的时间会很短,我们可以用一个近似的缓动曲线去描述这一过程,假设这里滑块触发了第三阶段后的速度曲线就是 ease-out,滚动时长为 t

1.png

我们需要计算滑块触碰容器边界后的滚动距离,也就是曲线在 0 ~ t 时间范围内与 x 轴围成的面积 s,用积分表示为,

image

我们把 ease-out 函数 $ f(t) = -t^2 + 2t $ 带入,

image

根据 牛顿-莱布尼茨公式

image

要计算上述积分,我们需要找到 ease-out 函数的原函数 F,使函数 F 满足,

image

原则上,只要我们定义了滑块的滚动时长和滚动曲线,就可以计算出滚动的距离,这里的滚动距离也是模型第四阶段的回弹距离,但是在实际应用中,这里的滚动曲线定义和原函数的计算是很复杂的,我们可以考虑把上述模型做适当的简化。

由于滑块在第三阶段的加速度是大于匀减速运动情况下的加速度的,

image.png

所以第三阶段滑块触碰边界后的滚动距离一定小于匀减速运动情况下的滚动距离,假设滑块第三阶段滚动距离为 s3,容器的边界值为 boundY,前面我们分析了滑块在匀速运动情况下的总滑动距离为 s2,那么一定满足,

s3 < s2 - boundY

这里我们不妨设一个回弹的阻力常量 B,使其满足,

s3 = (s2 - boundY) / B

经过测试,这里的阻力常量 B 的取值在 10 ~ 12 较为合适。到此,我们已经完成了对上述模型一些关键指标(滚动距离滚动时长启停条件等)的一个简单的实现,接下来我们来关注一下模型各阶段滑块的滚动效果(缓动效果),也就是速度曲线

缓动

现实生活中,物体并不是突然启动或者停止,或者一直保持匀速移动,在动效设计里,我们经常用缓动来描述物体的变速运动,让整个运动过程更加自然,CSS 提供了过渡和动画配合实现缓动动效。这里缓动我们需要关注两个重要的指标,缓动函数(easing function)和 缓动时长

我们可以用贝塞尔曲线来描述动画进程随时间的变化关系,接下来我们借助一些开源的在线绘制贝塞尔曲线的工具,如 cubic-bezier,对上述的滑块模型做一个简单的分析,

对于模型的第一、二阶段,也就是触发了惯性滚动,滚动结束后还未到达容器边界的情况,滑块先做加速运动,到达最大速度后触发惯性滚动,最后做减速运动直到停止,

4

整个过程的缓动曲线可以描述为 cubic-bezier(0, 0.5, 0.2, 1)

image.png

这里的缓动时长我们可以需要考虑根据惯性滚动的距离动态设置,比如定义强、弱两种惯性时长,然后给定一个强弱惯性的分割值,

var inertialThreshold = 100; // 强弱惯性分割值

.weekInertial {
  transition: transform cubic-bezier(0, 0.5, 0.2, 1) 1.5s;
}

.strongInertial {
  transition: transform cubic-bezier(0, 0.5, 0.2, 1) 3.5s;
}

对于模型的的第一、二、三阶段,滑块首先触发了惯性滚动,但惯性滚动的距离超过了容器的边界,超过边界后迅速减速直到最后停止,

5

整个过程的缓动曲线可以描述为 cubic-bezier(0.25, 0.46, 0.45, 0.94)

image

这里需要注意的是,容器除了要设定边界值外,还需要设置允许超过的最大边界,当我们计算出的滑块惯性滚动的距离过大(即用户的滑动手势过于强烈),大到已经超过允许的最大边界时,我们需要重置滚动的距离为最大边界。

对于模型的第四阶段,滑块从超过边界的某个值返回边界,类似于触发了回弹,

7

这个过程的缓动曲线可以描述为 cubic-bezier(0.16, 0.5, 0.4, 1)

image.png

这里回弹的触发方式有两种,用户滑动超过边界后回弹惯性滚动超过边界后回弹。前者是比较好监听的,可以在用户触摸事件结束时去做处理,但是对于惯性滚动超过边界后的回弹,我们要怎么知道惯性滚动是否结束了呢?

回弹一定是在惯性滚动之后(滚动我们设置了动画),可以通过 监听缓动动画的结束事件

function transitionend() {
  var overBounce = translateY > minY || translateY < maxY;
  if (overBounce) {
    if (translateY > minY) translateY = minY;
    if (translateY < maxY) translateY = maxY;
    ins.addClass('rebound');
    setStyle();
  }
}

此外,当惯性滚动未结束,或者正处于回弹过程,用户再次触碰元素时,我们应该要暂停缓动,如何让缓动停止呢?回想一下这里缓动我们是通过 CSS 样式去设置的,那么一个思路就是在用户再次触碰元素的瞬间,获取元素当前的计算样式,然后拿到我们需要的偏移量值进行重设,

function touchstart(e, instance) {
  // ...
  stop();
  // ...
}

function stop() {
  var computedStyle = ins.getComputedStyle(['transform']);
  var matrix = computedStyle.transform;
  var offsetY = +matrix.split(')')[0].split(', ')[5];
  if (matrix.indexOf('matrix') !== -1 && offsetY) {
    translateY = offsetY;
    setStyle();
  }
}

以上是小程序端惯性滚动的一个简单实现,我们也可以用类似的思路去实现其他平台的惯性滚动,可以点击基于上述思路的一个可运行的 代码片段,在微信开发者工具中打开体验。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

设计模式(一): 单例模式

保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做 单例模式

实现思路

一般情况下,当我们创建了一个类(本质是构造函数)后,

可以通过new关键字调用构造函数进而生成任意多的实例对象:

class SingleDog {
  show() {
    console.log('I am a single dog.');
  }
}

const s1 = new SingleDog();
const s2 = new SingleDog();

s1 === s2; // false

但是很明显,实例对象之间是相互独立的,各占一块内存空间,而单例模式想要做到的是,不管我们尝试去创建多少次,它都只会给你返回第一次所创建的唯一的那个实例。

要做到这一点,就需要构造函数具备判断自己是否已经创建过一个实例的能力

class SingleDog {
  show() {
    console.log('I am a single dog.');
  }

  static getInstance() {
    if (!SingleDog.instance) {
      SingleDog.instance = new SingleDog();
    }
    return SingleDog.instance;
  }
}

const s1 = SingleDog.getInstance();
const s2 = SingleDog.getInstance();

s1 === s2; // true

也可以通过闭包实现:

function SingleDog() {}

SingleDog.prototype.show = function() {
  console.log('I am a signle dog.');
};

SingleDog.getInstance = (function() {
  let instance = null;
  return function() {
    if (!instance) {
      instance = new SingleDog();
    }
    return instance;
  };
})();

const s1 = SingleDog.getInstance();
const s2 = SingleDog.getInstance();

s1 === s2; // true

getInstance 方法的判断和拦截下,

不管调用多少次,SingleDog 都只会给我们返回一个唯一的实例。

Vuex的数据管理哲学

基于 Flux 的架构中,应用最广泛的要数 ReduxVuex,无论是 Redux 还是 Vuex,它们都实现了一个全局的 Store 用于存储应用的所有状态,而 Store 的实现,正是单例模式的典型应用,以 Vuex 为例:

Vuex 使用单一状态树,用一个对象就包含了全部的应用层级状态。至此它便作为一个 “唯一数据源 (SSOT)” 而存在。这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。 -- Vuex官方文档

Vue 中,组件之间是独立的,组件间通信最常用的办法是 props,但当组件非常多、组件间关系复杂、且嵌套层级很深的时候,这种原始的通信方式会使我们的逻辑变得复杂难以维护。这时最好的做法是将共享的数据抽出来、放在全局,供组件们按照一定的的规则去存取数据,保证状态以一种可预测的方式发生变化。

于是便有了 Vuex,而这个用来存放共享数据的唯一数据源,就是 Store。

确保Store的唯一性

项目中引入 Vuex 的方式为:

Vue.use(Vuex)

new Vue({ /* config here */ })

通过调用 Vue.use() 方法,安装 Vuex 插件,它本质上是一个对象,内部实现了 install 方法:

let Vue
...

export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}

install 方法会在插件安装时被调用,从而把 Store 注入到 Vue 实例里面。通过判断传入的 Vue 实例对象是否已经被 installVuex 插件,如果没有则为 Vue 实例对象 install 一个唯一的 Vuex,再将 Vuex 的初始化逻辑写入。通过这种方式,可以保证一个 Vue 实例(即一个 Vue 应用)只会被 install 一次 Vuex 插件,所以每个 Vue 实例只会拥有一个全局的 Store

应用

实现一个 Storage,使得该对象为单例,基于 localStorage 实现方法 getItem, setItem.

构造函数版本:

class Storage {
  constructor() {
    if (Storage.instance) {
      return Storage.instance;
    }
    Storage.instance = this;
    return this;
  }

  getItem(key) {
    return window.localStorage.getItem(key);
  }

  setItem(key, value) {
    return window.localStorage.setItem(key, value);
  }
}

const s1 = new Storage();
const s2 = new Storage();

s1 === s2; // true
s1.setItem('storage', 'I am a single object.');
s2.getItem('storage'); // I am a single object.

静态方法版本:

class Storage {
  static getInstance() {
    if (!Storage.instance) {
      Storage.instance = new Storage();
    }
    return Storage.instance;
  }

  getItem(key) {
    return window.localStorage.getItem(key);
  }

  setItem(key, value) {
    return window.localStorage.setItem(key, value);
  }
}

const s1 = Storage.getInstance();
const s2 = Storage.getInstance();

s1 === s2; // true
s1.setItem('storage', 'I am a single object.');
s2.getItem('storage'); // I am a single object.

闭包版本:

function Storage() {}

Storage.getInstance = (function() {
  let instance;
  return function() {
    if (!instance) {
      instance = new Storage();
    }
    return instance;
  };
})();

Storage.prototype.getItem = function(key) {
  return window.localStorage.getItem(key);
};
Storage.prototype.setItem = function(key, value) {
  return window.localStorage.setItem(key, value);
};

const s1 = Storage.getInstance();
const s2 = Storage.getInstance();

s1 === s2; // true
s1.setItem('storage', 'I am a single object.');
s2.getItem('storage'); // I am a single object.

(完)

纯 CSS 自定义多行省略:从原理到实现

文字溢出怎么展示,你的需求是什么?单行还是多行?截断,省略,自定义样式,自适应高度?在这里你都能找到答案。接下来我会由浅入深,从原理到实现,带你一步步揭开多行省略的面纱。我们先从最简单的单行溢出省略开始,

热身,单行省略

这是一个全宇宙统一的方案,没有太多的魔法,戳我查看示例

/* 原理:设置文字不换行,溢出后隐藏,截断显示省略符 */
.ellipsis {
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
}

1.gif

如何实现多行省略呢?先从最简单的 line-clamp 开始吧。

最简单的多行省略,line-clamp

通过 CSS 属性 -webkit-line-clamp 可以把块容器中的内容限制为指定的行数,示例

.ellipsis {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

2.gif

属性的 -webkit 前缀告诉我们事情似乎并不简单。是的,它只支持基于 webkit 内核的浏览器,这对于移动端是很友好的,在 安卓 2.3+,IOS 5.0+ 的设备上你可以直接将上述代码直接扔进去没啥问题,但如果要在 PC 端使用,需要关注下兼容性问题,

image.png

除了 PC 兼容性问题,line-clamp 的方案也不支持自定义省略样式,如果需要在省略符后面加文字,箭头等自定义样式,我们可能就得考虑其他方案了,比如:浮动

神奇的 float,浮动

什么!浮动也能实现多行省略?是的,下面我们用三个浮动盒子来模拟多行省略。首先准备三个盒子(文字盒,占位盒,自定义样式的省略盒)向右浮动,为了方便理解原理,我们给盒子增加不同的背景色,

<div class="box">
  <!-- 文字盒子 -->
  <div class="box__text">腾讯以技术丰富互联网用户的生活。通过通信及社交软件微信和 QQ 促进用户联系,并助其连接数字内容和生活服务,尽在弹指间。</div>
  <!-- 占位盒子 -->
  <div class="box__placeholder"></div>
  <!-- 自定义省略盒子 -->
  <div class="box__more">...展开</div>
</div>

<style>
  .box__text {
    width: 100%;
    height: 60px;
    line-height: 20px;
    background-color: pink;
    float: right;
  }

  .box__placeholder {
    width: 60px;
    height: 60px;
    background-color: gray;
    opacity: 0.8;
    float: right;
  }

  .box__more {
    width: 60px;
    text-align: right;
    background: yellow;
    float: right;
  }
</style>

image.png

接下来开始调整位置,先给文字盒一个负的左外边距,它的值刚好为占位盒的宽度

.box__text {
  margin-left: -60px;
}

这样一来就给了占位盒子空间,它会浮动到左边,和文字盒排在一排,

image.png

上图中,文字盒的高度小于占位盒高度,此时第一排高度为占位盒子高度,第一排没有多余空间,我们自定义的省略盒子只能排在第二排。试想一下,当文字盒的高度大于占位盒高度时(比如文字显示 4 行),会发生什么?

第一排的高度会被文字盒撑开,这个时候第一排有了多余空间,省略盒子能够挤进去。

9.gif

Awesome 😊,接下来只需要把省略盒子定位到右边和占位盒子同排的位置就可以了,

.box__more {
  position: relative;
  left: 100%;
  transform: translate(-100%, -100%);
}

8.gif

修饰一下,去掉背景色,容器设置溢出隐藏,然后给省略盒子加个文字颜色和渐变,

.box {
  position: relative;
  overflow: hidden;
}

.box__more {
  color: #1890ff;
  background-image: linear-gradient(to left, white 40%, rgba(255, 255, 255, 0.8) 70%, transparent 100%);
}

7.gif

效果还不错,想要完整示例,戳我查看

小结一下,这里其实运用了浮动和 BFC 的原理。(如果你还不了解 BFC 可以去看一下我之前的文章 《可能是最好的BFC解析了...》,里面有非常全面的解析)

外层盒子通过 overflow: hidden 创建一个 BFC,浮动盒子的区域不会和 BFC 重叠,计算 BFC 高度时,浮动元素也会参与计算,浮动盒会浮动到当前行的开头或结尾,再借助一些定位技术,就可以模拟多行省略的效果了。

浮动的方案的优势非常明显,

  • 兼容性强,支持所有主流的 PC,移动端浏览器
  • 支持自定义带渐变的文字省略样式

由于省略样式区域本质上是一个浮动盒子,所以这里我们需要通过渐变来防止穿帮,对于某些背景颜色比较复杂的区域,或者更强的一些自定义省略样式需求时(比如省略样式定义为一张箭头或图片等),这种方案开始显得力不从心了。

有没有其他方式可以实现省略样式完全的自定义呢?

有,将自定义省略盒子的位置预留出来

那要怎么预留呢?我们可以借助 line-clamp。由 line-clamp 截断后的省略号 ... 刚好可以帮助我们进行占位,如果我们能通过某种办法将默认的省略号隐藏掉,再替换为我们自定义的浮动盒子,是不是就可以了!这也就是接下来我们要介绍的方案。

完全自定义,浮动 + line-clamp

我们重新整理一下上述的思路,关键的点有三个,

  1. 借助 line-clamp 默认的省略号,预留自定义省略盒子的位置
  2. 想办法隐藏默认省略号
  3. 通过定位技术替换预留位置为我们自定义的省略盒子

逐一来看,首先是预留位置,line-clamp 默认省略号的大小受字号 font-size 的影响,所以调整字号就可以控制预留位置的大小。这里为了保证省略号的大小只受字体大小的影响,我们可以重置行高和文字间距,

.box__text {
  position: relative;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  font-size: 60px;
  line-height: 0;
  letter-spacing: 0; /* 重置了行高和文字间距,保证省略号占位只受字体大小的影响 */
  color: red; /* 为了方便演示,我们先给省略号一个颜色 */
}

这样就可以通过只调整文字盒子的字号,来控制预留省略盒子位置的大小了。由于 font-size 会继承,所以我们再内嵌一个子盒子来重置字号,

<div class="box__text">
    <div class="box__inner">
      腾讯以技术丰富互联网用户的生活。通过通信及社交软件微信和 QQ 促进用户联系,并助其连接数字内容和生活服务,尽在弹指间。
    </div>
</div>

<style>
  .box__inner {
    font-size: 16px;
    line-height: 20px;
    color: #000;
    vertical-align: top;
    display: inline;
  }
</style>

10.gif

接下来是想办法隐藏省略号,这个比较简单,可以设置透明度或者颜色透明,

.box__text {
  opacity: 0;
  color: transparent;
}

有了省略号的预留位置后,我们要想办法将自定义省略盒子定位到预留位置,怎么办呢?还是 浮动。由于设置了 -webkit-line-clamp,会导致文字盒子无法撑开完整的高度,为了使用浮动来实现定位,我们可以多渲染一份文案用来撑开高度。

准备一个绝对定位的盒子,作为渲染撑开高度文案的容器,

<div class="box__abs">
    <div class="box__fake-text">
      腾讯以技术丰富互联网用户的生活。通过通信及社交软件微信和 QQ 促进用户联系,并助其连接数字内容和生活服务,尽在弹指间。
    </div>
    <div class="box__placeholder"></div>
    <div class="box__more">... 展开</div>
</div>

<style>
  .box__abs {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
  }
</style>

接着用我们前面讲的三个浮动盒子实现多行溢出省略的方式,

.box__fake-text {
  width: 100%;
  margin-left: -60px;
  line-height: 20px;
  float: right;
  color: transparent; /* 文案是为了撑开高度,配合浮动实现多行溢出省略 */
}

.box__placeholder {
  width: 60px;
  height: 60px;
  float: right;
}

.box__more {
  position: relative;
  left: 100%;
  transform: translate(-100%, -100%);
  width: 60px;
  text-align: right;
  color: #1890ff;
  float: right;
}

11.gif

需要注意的是,这里的文字盒子是为了撑开高度,不需要展示,所以我们设置了颜色透明。好了,最后一步,去掉背景色,外层盒子设置溢出隐藏就是我们的最终效果,

13.gif

line-clamp + 浮动的方式可以实现省略符完全自定义,我们重置了行高和文字间距,只需要调整外层盒子的字体大小 font-size 就可以控制自定义省略盒子的宽度,你可以将省略盒子替换为任意的箭头,图片,折角或文字,这下再也不用担心 UI 小姐姐提需求了 ):

Sliding Window

滑动窗口算法框架,

function slidingWindow(s, t) {
  // 利用哈希表(JavaScript 中可以用 Map 或空对象)记录目标字符串和窗口内字符串中字符的出现次数
  var need = {}, window = {};

  // 初始化哈希表,need 默认从 1 开始计数,window 默认从零开始计数
  for (var char of t) {
    need[char] = need[char] ? need[char] + 1 : 1;
    window[char] = window[char] ? window[char] + 1 : 0;
  }

  // 双指针记录滑动窗口的两端,size 记录窗口中满足 need 条件的字符的个数,然后开始滑动窗口
  var left = 0, right = 0, size = 0;
  while (right < s.length) {
    // c 是将移入窗口的字符
    var c = s[right];
    // 增大窗口
    right++;
    // 进行窗口内数据的一系列更新
    // ...

    // 判断左侧窗口是否需要收缩
    while (window needs shrink) {
      // d 是将移出窗口的字符
      var d = s[left];
      // 缩小窗口
      left--;
      // 进行窗口内数据的一系列更新
      // ...
    }
  }
}

比如力扣 76. 最小覆盖子串,我们可以直接套上面的框架,

function minWindow(s, t) {
    var need = {}, window = {}

    for (var char of t) {
        need[char] = need[char] ? need[char] + 1 : 1
        window[char] = window[char] ? window[char] + 1 : 0
    }

    var left = 0, right = 0, size = 0
    // 最小覆盖子串的起始索引和长度
    var start = 0, len = Infinity

    while (right < s.length) {
        var c = s[right]
        right++
        if (need.hasOwnProperty(c)) {
            window[c]++
            if (window[c] === need[c]) size++
        }

        // 判断左侧窗口是否需要收缩
        while (size === Object.keys(need).length) {
            if (right - left < len) {
                start = left
                len = right - left
            }
            var d = s[left]
            left++
            if (need.hasOwnProperty(d)) {
                if (window[d] === need[d]) size--
                window[d]--
            }
        }
    }

    return len === Infinity ? "" : s.substr(start, len)
};

再比如力扣 567. 字符串的排列,我们也可以直接套用框架,

function checkInclusion(s1, s2) {
    var need = {}, window = {}

    // 初始化哈希表,need = { a: 1, b: 1 }, window = { e: 0, i: 0, d: 0, b: 0, a: 0, o: 2 }
    for (var char of s1) {
        need[char] = need[char] ? need[char] + 1 : 1
        window[char] = window[char] ? window[char] + 1 : 0
    }

    // size 表示窗口中满足 need 条件的字符的个数
    // 比如对于窗口内的字符 a,need 中的出现次数为 1,当窗口中 a 出现次数也刚好为 1 时,我们把 size 加 1
    var left = 0, right = 0, size = 0

    while (right < s2.length) {
        var c = s2[right]
        right++
        // 增大窗口,更新当前元素出现的次数和 size
        if (need.hasOwnProperty(c)) {
            window[c]++
            if (window[c] === need[c]) size++
        }

        // 判断是否需要收缩左侧窗口
        // 什么时候需要收缩呢?题设要我们找的是排列,也就是当窗口长度刚好等于子串 s1 长度时,开始收缩
        while (right - left === s1.length) {
            // 收缩前先判断是否已经找到了子串
            if (size === Object.keys(need).length) {
                return true
            }
            var d = s2[left]
            left++
            if (need.hasOwnProperty(d)) {
                if (window[d] === need[d]) size--
                window[d]--
            }
        }
    }
    return false
};

Tree Shaking:从原理到实现

image.png

时下火热的 Vue.js 3.0 从源码、性能和语法 API 三个大的方面对框架进行了优化。其中,在性能的优化中,对于源码体积的优化,主要体现在移除了一些冷门 Feature(比如 filter、inline-template) 并引入了 Tree-Shaking 减少打包体积。自从 rollup 提出这个术语以来,每每谈及打包性能优化,几乎都有 Tree-Shaking 的一席之地,所以了解 Tree-Shaking 的原理是很有必要的。

阅读完本文,你可以 Get 以下问题的答案,

  • 什么是 Tree-Shaking?Tree-Shaking 的发展历史?
  • Tree-Shaking 的原理是什么?
  • 什么是 Dead code?
  • ECMAScript 6 的模块机制?
  • Webpack 中 Tree-Shaking 的原理?

故事的开始:Rich Harris 和他的 Rollup

业界知名的模块打包器 rollup.js 的作者 Rich Harris 在 2015 年 12 月的一篇博客
《Tree-shaking versus dead code elimination》中首次提到了 Tree-Shaking 的概念,随后 Webpack 2 的正式版本内置支持了 ECMAScript 2015 模块,增加了对 Tree-Shaking 的支持,而在更早前,Google 推出的开发者工具 Closure Compiler 也在做类似的事情。

I’ve been working (albeit sporadically of late, admittedly) on a tool called Rollup, which bundles together JavaScript modules. One of its features is tree-shaking, by which I mean that it only includes the bits of code your bundle actually needs to run.

Rich Harris 在文中提到 Tree-Shaking 是为了 Dead code elimination,这是编译器原理中常见的一种编译优化技术,简单来说就是消除无用代码(Dead code)。那么到底什么是 Dead code 呢?

Dead code

Dead code,也叫死码,无用代码,它的范畴主要包含了以下两点,

  1. 不会被运行到的代码(unreachable code)
  2. 只会影响道无关程序运行结果的变量(Dead Variables)

我们尝试通过一些 JavaScript 代码片段来理解它。

首先,不会被运行到的代码很好理解,比如在函数 return 语句后的代码,

function foo() {
  return 'foo';
  var bar = 'bar'; // 函数已经返回了,这里的赋值语句永远不会执行
}

或者不会执行的假值条件语句块,

if(0) {
  // 这个条件判断语句块内部的代码永远不会执行
}

Dead Variables 常见的像一些未使用的变量,

function add(a, b) {
  let c = 1; // unused variable 在这里可以被看作死码
  return a + b;
}

需要注意的是,模块如果未使用也可以看作 Dead code,比如下面的 bar 模块,

// foo.js
function foo() {
  console.log('foo');
}
export default foo;

// bar.js
function bar() {
  console.log('bar');
}
export default bar;

// index.js
import foo from './foo.js';
import bar from './bar.js';
foo();

// 这里入口文件虽然引用了模块 bar,但是没有使用,模块 bar 也可以被看作死码

Dead code 我们知道了,那么什么是 Tree-Shaking 呢?

在传统的静态编程语言编译器中,编译器可以判断出某些代码根本不影响输出,我们可以借助编译器将 Dead CodeAST(抽象语法树)中删除,但 JavaScript 是动态语言,编译器不能帮助我们完成死码消除,我们需要自己实现 Dead code elimination

我们说的 Tree-Shaking,就是 Dead code elimination 的一种实现,它借助于 ECMAScript 6 的模块机制原理,更多关注的是对无用模块的消除,消除那些引用了但并没有被使用的模块。

这里为了更好地理解 Tree-Shaking 的原理,我们需要先了解 ES6 的模块机制。

ECMAScript 6 module

JavaScript 的模块化经历一个漫长的发展历程,我们知道刚开始 JavaScript 是没有模块的概念的,最初我们只能借助 IIFE 尽量减少对全局环境的污染,后来社区出现了用于浏览器端的以 RequireJS 为代表的 AMD 规范和以 Sea.js 为代表的 CMD 规范,服务器端也出现了 CommonJS 规范,再后来 JavaScript 原生引入了 ES Module,取代社区方案成为浏览器端一致的模块解决方案。

ES Module 现在也可以用于服务器,Node.js 在 v12.0.0 版本实现了对 ES Module 的支持。

对于 ES Module 基础语法不了解的可以参考下面的文章,我们接下来主要理解它的机制,

对比是理解知识非常有效的一种手段。我们通过对比 ES Module 与 CommonJS 的区别来理解 ES Module 的模块机制,它们的区别主要体现在模块的输出和执行上,

  • ES Module 输出的是值的引用,而 CommonJS 输出的是值的拷贝
  • ES Module 是编译时执行,而 CommonJS 模块是在运行时加载

所以 ES Module 最大的特点就是静态化,在编译时就能确定模块的依赖关系,以及输入和输出的值,这意味着什么?意味着模块的依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,正是基于这个基础,才使得 Tree-Shaking 成为可能,这也是为什么 rollup 和 Webpack 2 都要用 ES6 Module 语法才能支持 Tree-Shaking。

了解原理后,接下来我们来看下如何实现 Tree-Shaking。

Tree Shaking

借助静态模块分析,Tree-Shaking 实现的大体思路:借助 ES6 模块语法的静态结构,通过编译阶段的静态分析,找到没有引入的模块并打上标记,然后在压缩阶段利用像 uglify-js 这样的压缩工具删除这些没有用到的代码。

是这样吗?接下来我们以 webpack 为例,验证下上述思路。

初始化项目安装最新的 webpackwebpack-cli ,笔者写这篇文章时最新的版本是 v5.35.1

$ mkdir tree-shaking && cd tree-shaking
$ npm init -y
$ npm i webpack webpack-cli -D

添加一个简单的配置文件和一个 math 模块,这里我们只引用 math 模块的 cube 函数,

// webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  optimization: {
    // 开启 usedExports  收集 Dead code 相关的信息
    usedExports: true,
  },
};

// src/math.js
export function square(x) {
  return x * x;
}

export function cube(x) {
  var a, b, c; // 这里引入了三个未使用的变量作为 Dead code 的一种
  return x * x * x;
}

// src/index.js
import { cube } from "./math.js";

function component() {
  var element = document.createElement("pre");
  element.innerHTML = "5 cubed is equal to " + cube(5);
  return element;
}

document.body.appendChild(component());

运行打包命令,定位到 bundle.jsmath 模块打包后代码,

/***/ "./src/math.js":
/*!*********************!*\
  !*** ./src/math.js ***!
  \*********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"cube\": () => (/* binding */ cube)\n/* harmony export */ });\n/* unused harmony export square */\nfunction square(x) {\r\n  return x * x;\r\n}\r\n\r\nfunction cube(x) {\r\n  var a, b, c;\r\n  return x * x * x;\r\n}\r\n\n\n//# sourceURL=webpack://tree-shaking/./src/math.js?");

/***/ })

为了方便阅读,我们将 eval 函数内的换行符去掉,简单整理下格式,

/* harmony export */
__webpack_require__.d(__webpack_exports__, {
  /* harmony export */
  cube: () => /* binding */ cube /* harmony export */,
});
/* unused harmony export square */
function square(x) {
  return x * x;
}
function cube(x) {
  var a, b, c;
  return x * x * x;
}

可以看到,__webpack_exports__ 只导出了 cube 函数,而没有使用的 square 函数没有被导出,并打上了 /* unused harmony export square */ 的注释标识,但是 square 函数声明以及 cube 函数中未使用的变量声明 a, b, c 还是被打包了。这印证了我们之前推测的 webpack 可以通过 Tree-Shaking 找到没有引入的模块,并不会删除 Dead code。

接着我们将 mode 切换到 production 以启用 uglify-js 进行压缩,然后再次运行打包命令,

(() => {
  "use strict";
  var e, t;
  document.body.appendChild(
    (((t = document.createElement("pre")).innerHTML =
      "5 cubed is equal to " + (e = 5) * e * e),
    t)
  );
})();

结果和我们预期一致,uglify-js 在压缩的同时去除了 Dead code,包括,

  • 没有使用的 square 函数
  • 没有使用的变量 a, b, c

我们也可以单独引入 uglify-js 来验证这一点,

// math.js
function cube(x) {
  var a, b, c;
  return x * x * x;
}

// minify.js
const fs = require("fs");
const UglifyJS = require("uglify-js");
const code = fs.readFileSync("math.js", "utf-8");
const result = UglifyJS.minify(code, {
  compress: {
    dead_code: true, // dead_code 默认为 true
  },
});

console.log(result.code); // function cube(n){return n*n*n}

小结

我们从 Tree-Shaking 的起源切入,了解了它是 Dead code elimination 的一种实现,然后拓展学习了什么是 Dead Code,接着回顾了 JavaScript 模块化的发展史,正是因为 ES Module 的静态结构,使得模块级别的 Tree-Shaking 实现成为可能。最后以打包工具 webpack 为例,结合 uglify-js 压缩工具,解释了 Tree-Shaking 的实现原理。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

React架构

上一篇 我们介绍了 React 的设计理念,可以简单概括为快速响应

快速响应的关键是解决 CPU 和 IO 的瓶颈,实现上需要将同步的更新变为可中断的异步更新

这对应了 React 架构的演变过程(v15 → v16)。

React 15

React15架构可以分为两层:

  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

image

Stack Reconciler

Reconciler 负责找出变化的组件,这个“找变化”的过程在 React 中叫做 协调

我们知道在 React 15 中,可以通过以下的方式触发组件更新,

  • this.setState
  • this.forceUpdate
  • ReactDOM.render

每当有更新发生时,协调器 Reconciler 都会做如下的工作,

  • 调用函数组件或 Class 组件的 render 方法,将返回的 JSX 转化为 Virtual DOM
  • 对比 Virtual DOM 和上次更新时的 Virtual DOM ,找出本次更新中变化的 Virtual DOM
  • 通知 Renderer 将变化的 Virtual DOM 渲染到页面上

虚拟 DOM 的比较本质上是对两个树进行对比,即使使用 最优的算法,其时间复杂度仍为 O(n3),其中 n 是树中元素的数量。

如果在 React 中使用该算法,那么展示 1000 个元素则需要 10 亿次的比较,这个开销实在是太过高昂,于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:

  1. 两个不同类型的元素会产生出不同的树
  2. 开发者可以使用 key 属性标识哪些子元素在不同的渲染中可能是不变的

基于这两个假设实现的启发式算法叫做 Diffing 算法,为了更好地理解协调器,我们简单了解下 Diffing 的过程。

Diffing 算法

Diffing 算法从粒度上可以分为三个层次:类型不同的元素,类型相同的元素以及子元素。

1. 对比类型不同的元素

Diffing 是一个递归的过程,假设 1 是复杂度从 O(n3) 降到 O(n) 的关键策略。当对比两棵树时,React 首先会比较两棵树的根节点(逐层对比),这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较,

当节点为不同类型的元素时,该节点及其子节点都会被完全删除并重建,以下面的 DOM 结构为例,

<!-- 旧的DOM -->
<div>
  <Counter />
</div>

<!-- 新的DOM -->
<span>
  <Counter />
</span>

React 会销毁根节点 div 及其子节点 Counter ,并重建一个新的根节点 span 以及新的子节点 Counter 组件。

2. 对比类型相同的元素

当节点类型相同时,分两种情况,

  • 节点是两个类型相同的 React 元素,React 会保留 DOM 节点,仅比对及更新有改变的属性
  • 节点是两个类型相同的组件,React 会保留组件实例,更新该实例的 props,并调用实例的生命周期方法

比如下面的两个节点,

<div className="before" title="stuff" style={{color: 'red', fontWeight: 'bold'}} />

<div className="after" title="stuff" style={{color: 'green', fontWeight: 'bold'}} />

React 会修改 DOM 元素上的 classNamestyle 属性,其中 style 属性 React 仅更新有改变的 color 属性值。

3. 对子节点进行递归

当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表,产生差异时,生成一个 mutation

针对可能的差异,React 定义了新增、删除和替换等 mutation 标记,

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;

不难发现子元素列表末尾新增元素时,更新开销比较小,但如果只是简单的将新增元素插入到表头,那么更新开销会比较大,

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

因为 React 无法判断应该保留 <li>Duke</li><li>Villanova</li>,而是会重建每一个子元素,这种情况会带来性能问题。

为了解决上述问题,React 引入了 key 属性。当子元素拥有 key 时,React 使用 key 来匹配以提高树的转换效率。

key 不需要全局唯一,但在子元素列表中需要保持唯一

Diffing 的结果会交给渲染器 Renderer,渲染器会将差异变化更新到页面。

Renderer

每次更新发生时,Renderer 会接到 Reconciler 通知,将变化的组件渲染在当前宿主环境,

我们知道 React 支持跨平台,所以不同平台有不同的 Renderer,常见的有,

同步更新的问题

React 15 架构是同步更新,在 Reconciler 中,mount 的组件会调用mountComponent,update 的组件会调用updateComponent,这两个方法都会递归更新子组件

递归更新带来的问题就是,更新一旦开始,就无法中断,当层级很深时,递归更新时间超过了一帧,用户交互就会卡顿,

为了解决这个问题,React 16 进行了架构重构,用可中断的异步更新代替同步的更新

React 16

React16架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优先级的任务会优先进入 Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

image

相比于 v15,引入了调度器 Scheduler,同时将 Stack Reconciler 重构为 Fiber Reconciler。

Scheduler

前面我们介绍过,可中断的异步更新的实现,需要以浏览器是否有剩余时间作为任务中断的标准

所以我们需要一种机制,在浏览器有剩余时间时通知我们,怎么判断浏览器是否有剩余时间呢

可以通过原生的 requestIdleCallback,但由于以下因素,React 放弃使用 requestIdleCallback,

  • 浏览器兼容性
  • 触发频率不稳定,受很多因素影响。比如切换 Tab 后,之前注册的 requestIdleCallback 触发频率会变低

为此,React 实现了功能更完备的 requestIdleCallback polyfill,这就是 Scheduler。

除了在空闲时触发回调的功能外,Scheduler 还提供了多种调度优先级供任务设置。

Fiber Reconciler

为了实现可中断的异步更新,Reconciler 的更新工作从递归变成了可以中断的循环过程

每次循环都会调用 shouldYield 判断当前是否有剩余时间,

/** @noinline */
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

同时,Reconciler 与 Renderer 不再是交替工作,整个 Scheduler 与 Reconciler 的工作都在内存中进行。只有当所有组件都完成 Reconciler 的工作,才会统一交给 Renderer,这样就解决了中断更新时 DOM 渲染不完全的问题,

这个新的协调器叫做 Fiber Reconciler

重构后的 Fiber Reconciler 具有以下特性,

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

当然 Fiber 不止作为架构,同时也是静态的数据结构及动态的工作单元,接下来我们将花较大的篇幅深入理解 Fiber

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

React进阶系列之JSX

React 作为一个优秀的前端框架,在架构上融合了数据驱动视图、组件化、函数式编程、面向对象、Fiber 等经典设计哲学,在底层技术选型上涉及了 JSX、虚拟 DOM 等经典解决方案,在周边生态上至少涵盖了状态管理和前端路由两大领域的最佳实践,此外,它还自建了状态管理机制与事件系统,创造性地在前端框架中引入了 Hooks **...React 十年如一日的稳定输出背后,有太多值得我们去吸收和借鉴的东西。

题外:关于学习,我认为,学习的本质是重复。

JSX 三问

如果你不能很好地回答下面的问题,那你可能把 JSX 想的过于简单了。

1. JSX 的本质是什么?它和 JS 之间到底是什么关系?

JSX 是 Javascript 的语法扩展,它和模板语言很接近,但是充分具备 Javascript 的能力,通过 Babel 等编译工具编译后 JSX 就变成了 React.createElement,所以 JSX 的本质就是 React.createElement 这个 Javascript 调用的语法糖。

2. 为什么要用 JSX?不用会有什么后果?

React.createElement 调用代码过于繁琐,而作为语法糖的 JSX 则层次分明,嵌套关系清晰,这就是为什么官方推荐使用 JSX 的原因,这种类 HTML 标签的语法糖能够帮助我们快速创建虚拟 DOM。

3. JSX 背后的功能模块是什么?这些功能模块都做了哪些事情?

JSX 背后主要对应 React.createElement 和 ReactElement 两个函数,这两个函数帮助我们将 JSX 映射为虚拟 DOM,最后通过 ReactDOM.render 我们可以将虚拟 DOM 渲染为真实 DOM。React.createElement 主要做了三件事,处理 config,构造 props,然后返回一个 ReactElement 的调用,ReactElement 接受处理后的参数,简单组装后返回一个 element,也就是我们说的虚拟 DOM 的一个节点。

接下来我们重点看 JSX 相关的两个函数源码。

React.createElement

React.createElement 源码如下,

/**
 * Create and return a new ReactElement of the given type.
 * See https://reactjs.org/docs/react-api.html#createelement
 */
export function createElement(type, config, children) {
  let propName;

  // Reserved names are extracted
  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  if (config != null) {
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    // Remaining properties are added to a new props object
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &amp;&amp;
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // Children can be more than one argument, and those are transferred onto
  // the newly allocated props object.
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // Resolve default props
  if (type &amp;&amp; type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

从源码可以看到,createElement 函数主要做了三件事:

  1. 处理 config
  2. 构造 props(3步:提取config,处理子元素、处理默认值)
  3. 调用 ReactElement

具体来说,

  1. 首先声明 propName、props、key、ref、self、source 等变量
  2. 依次对 ref、key、self 和 source 属性赋值,其中 key 会强制转换为字符串
  3. 开始构造 props,首先遍历 config 对象,筛选出可以提取到 props 中的属性
  4. 处理子元素,生成 props.children,这里分为一个和多个子元素两种情况
  5. 处理 defaultProps,如果有默认属性值则覆盖
  6. 传入处理好的参数,返回一个 ReactElement 函数的调用

有哪些需要留意的点?

  1. 方法的入参,type(节点类型) | config(元素的属性)| children(子元素可能有多个)
  2. 属性 key 如果不是字符串会被强制转换为字符串
  3. 默认属性是通过 type 传入的而不是 config
  4. 本质上 createElement 就是一个 “参数中介” 没有太多魔法

ReactElement

ReactElement 的源码如下,

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };

  return element;
};

ReactElement 只做了一件事,就是创建并返回一个 element 对象,这个实例对象就是我们说的虚拟 DOM 中的一个节点。既然是虚拟 DOM,那就意味着和渲染到页面上的真实 DOM 之间还有一些距离,而这个距离就是由大家喜闻乐见的 ReactDOM.render 方法来填补的,

const virtualElement = React.createElement('div', null);
const rootElement = document.getElementById("root");
ReactDOM.render(virtualElement, rootElement);

新增的 $$typeof 属性用于判断一个元素是否为 ReactElement,

function isValidElement(object) {
  // 对象检测和 $$typeof 属性检测
  return (
    typeof object === 'object' &amp;&amp;
    object !== null &amp;&amp;
    object.$$typeof === REACT_ELEMENT_TYPE
  );
}

至此,相信你已经可以很好地回答上面的关于 JSX 的三个问题,接下来我们从老生常谈的 React 生命周期开始,一起体会下 React 背后的设计**:组件化和虚拟 DOM,以及 React 几个版本生命周期的不同,其废旧立新的背后的考量。

相关文章

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

React进阶系列之生命周期

React 生命周期已经是一个老生常谈的话题了,几乎没有哪一门 React 入门教材会省略对组件生命周期的介绍。然而,入门教材在设计上往往追求的是“简单省事、迅速上手”,这就导致许多同学对于生命周期知识的刻板印象为“背就完了、别想太多”。

“背就完了”这样简单粗暴的学习方式,或许可以帮助你理解“What to do”,到达“How to do”,但却不能帮助你去思考和认知“Why to do”。作为一个专业的 React 开发者,我们必须要求自己在知其然的基础上,知其所以然。

以史为镜,可以知兴衰,React 生命周期经历了 V15, V16.3, V16.4 等多个版本的迭代,我们有必要对这版本的生命周期进行探讨、比对和总结,通过搞清楚一个又一个的“Why”,来建立系统而完善的生命周期知识体系

生命周期背后的设计**有哪些?

组件化和虚拟 DOM

虚拟 DOM 在整个 React 工作流中有哪些作用?

虚拟 DOM 在 React 工作流的两个阶段(挂载和更新)中都是核心算法的基石。组件在初始化时,会通过调用生命周期中的 render 方法,生成虚拟 DOM,然后再通过调用 ReactDOM.render 实现虚拟 DOM 到真实 DOM 的转换;当组件更新时,会再次通过调用 render 方法生成新的虚拟 DOM,然后借助 diff 定位出两次虚拟 DOM 的差异,从而针对发生变化的真实 DOM 做定向更新。

组件化作为一种优秀的设计**,在 React 中有哪些体现?

React 组件遵循“封闭开放”原则。所谓封闭,主要是针对渲染工作流,也就是从组件数据改变到更新发生的过程,组件拥有自身的渲染工作流,每个组件只处理其内部的渲染逻辑,在没有数据流交互的情况下,组件与组件之间可以做到各自为政;所谓开放,则是针对组件间通信而言,React 允许开发者基于单向数据流完成组件通信,通信结果将改变双方或某一方内部的数据,进而对渲染结果构成影响,所以在数据层面,组件之间又是彼此开放的。

生命周期方法本质的一些理解

render 方法堪比组件的 “灵魂”,render 之外的生命周期方法可以理解为组件的 “躯干”,我们可以选择性地省略对 render 之外的任何生命周期方法内容的编写,但是 render 函数却不能省略,虚拟 DOM 的生成,更新都要仰仗 render。

React 15 生命周期

需要记忆的一些点,

  • V15 生命周期分为挂载,更新,卸载三个阶段,三个阶段对应的生命周期及执行顺序
  • 组件的更新分为两种,一是由父组件触发的更新,二是组件调用自身 setState 触发的更新。父组件触发的更新会额外调用 componentReceiveProps 生命周期,而组件自身触发的更新则直接从 shouldComponentUpdate 开始
  • componentReceiveProps 并不是由 props 的变化触发的,而是由父组件的更新触发的
  • shouldComponentUpdate 可以阻止组件 re-render 重渲染
  • 触发 componentWillUnmount 的方式有两种,一是将组件从父组件移除,二是组件中设置了 key,父组件在 render 过程中,发现 key 值和上一次不一致

React V16.3 生命周期

image.png

主要做了以下调整,

  • 挂载阶段,废弃了 componentWillMount,新增 getDrivedStateFromProps 用于从 props 派生 state 默认值
  • 更新阶段,废弃了 componentWillReceiveProps,componentWillUpdate,新增 getSnapshotBeforeUpdate 用于获取更新前的真实 DOM 和更新前后的 state & props 信息

需要注意的点有,

  • getDrivedStateFromProps 是一个静态方法,无法访问实例,应该只用于从 props 派生 state,需要返回一个对象用于更新组件 state
  • getSnapshotBeforeUpdate 的执行时机是在 render 方法之后,真实 DOM 更新之前,可以同时获取到更新前的真实 DOM 和更新前后的 state & props 信息,这在一些特殊场景十分有用,比如某些既要感知数据变化,又需要获取真实 DOM 信息的需求
  • getSnapshotBeforeUpdate 的返回值会作为第三个参数传给 componentDidUpdate

React V16.4 生命周期

image.png

16.4 在 16.3 的基础上进行了一次微调,16.3 版本只有父组件的更新会触发 getDerivedStateFromProps,16.4 中,任何因素触发的组件更新流程(包括由 this.setState 和 forceUpdate 触发的更新流程)都会触发 getDerivedStateFromProps

V16.3 后的生命周期还可以从 Fiber 层面划分为三个阶段,

  • render 阶段:纯净且没有副作用,可能会被 React 暂停、终止或重新启动
  • pre-commit 阶段:可以读取 DOM
  • commit 阶段:可以使用 DOM,运行副作用,安排更新

生命周期“废旧立新”背后的思考

废弃生命周期的共性,都是处于 render 阶段有可能被重复执行的,我们知道 Fiber 异步渲染的机制下,任务执行到一半,可能会被高优先级的任务打断,render 阶段的可能被暂停,终止和重启。

相关文章

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

JavaScript类型判断的1010种方式(史上最全)

在最新的 ECMAScript规范 中,一共定义了 7 种数据类型,

  • 基本类型:String、Number、Boolean、Symbol、Undefined、Null
  • 引用类型:Object

基本类型是存储在 栈(Stack) 中的简单数据段,引用类型的值是存储在 堆(Heap) 中的对象。

数据类型的值存储在堆中还是栈中,取决于值的特性。基本类型占据的空间固定,存储在栈中按值访问,可以提升变量的查询速度。引用类型的值大小不固定,不适合存储在栈中,JavaScript中采取的做法是,将引用类型的值存储在堆中,同时在栈中存储值的访问地址,所以引用类型是 按地址访问 的。

var arr = [1, 2, 3] // 引用类型的值其实就是存储在堆内存中的对象,访问方式为按地址访问,首先找到栈区中值的地址,然后沿着地址找到堆内存中对应的对象。

对于数据类型的判断,JavaScript 也提供了很多种方法,遗憾的是,不同的方法得到的结果参差不齐,下面列出了我所知道的所有的类型判断方法,如果有遗漏,欢迎在评论区补充。

typeof

typeof 返回未经计算的操作数的类型的字符串,可能会有一些意料之外的结果:

typeof null  === 'object' // true, 从一开始出现 JavaScript 就是这样的
typeof NaN === 'number' // true, 尽管 NaN 是 Not-A-Number 的缩写,typeof 还是会返回 number
typeof [] === 'object' // true, 引用类型除了 function 外,都返回 object
typeof Infinity  === 'number' // true
typeof function() {} === 'function' // true

typeof 能够判断大多数类型,总结 typeof 的运算规则就是:

  • 对于基本类型,除了 null 以外都可以返回正确结果
  • 对于引用类型,除了 function 以外都返回 object
  • 对于 null,返回 object
  • 对于函数类型,返回 function

instanceof

instanceof 用来判断 A 是否是 B 的实例,所以 instanceof 可以正确地判断对象的类型,因为其内部的实现机制是通过判断对象的原型链中是否能找到类型的 prototype

[] instanceof Array // true
[].__proto__ === Array.prototype // true

也正是因为 instanceof 的实现本质上是基于原型链的查找,所以也会出现意料之外的情况:

[] instanceof Object // true
[].__proto__.__proto__ === Object.prototype // true

此外,instanceof 操作符的语法是 object instanceof constructor,要求操作符的左右两侧都必须是对象,所以无法判断 null, undefined 等类型。

constructor

我们知道当创建一个函数 F 时,JavaScript 引擎会为 F 添加 prototype 属性,然后在 prototype 属性上添加一个 constructor 属性,让它指向 F 的引用:

function F() {}
F.prototype.constructor === F // true

这是引擎默认的行为,目的是表明对象是由那个函数构造的,在 JavaScript 中,function 其实就是一个语法糖,所有的函数本质上都是一个 Function 对象。利用这一特性,我们可以通过 constructor 来判断对象的数据类型,因为 JavaScript 为我们提供了很多的内置对象:

[].constructor === Array // true
''.constructor === String // true
false.constructor === Boolean // true
new Number().constructor === Number // true

但是 constructor判断类型是 “不可靠的”,因为 constructor 属性可以被修改,比如:

var a = 1
a.constructor === Number // true
a.__proto__.constructor = String
a.constructor === Number // false
a.constructor === String // true

constructor 也不能用来判断 nullundefined,因为他们都不是对象。

Object.prototype.toString.call

Object 原型上的方法 toString 返回一个表示该对象的字符串 [object Xxx]

ECMAScript 5 和随后的 Errata 中定义,从 JavaScript 1.8.5 开始,toString() 调用 null 返回 [object Null]undefined 返回 [object Undefined]。对于 Object 对象,直接调用 toString 就可以获得类型字符串,其他类型,需要借助 Object.prototype.toString.call/apply 来返回正确的类型值。至此,toString 成为判断数据类型最完备的一种方法:

Object.prototype.toString.call('') // [object String]
Object.prototype.toString.call(/([A-Z])\w+/g) // [object RegExp]
Object.prototype.toString.call(null) // [object Null]
Object.prototype.toString.call(undefined) // [object Undefined]
Object.prototype.toString.call(NaN) // [object Number]

我们只需要对 toString 稍加封装就可以实现 JavaScript 中所有类型的判断,举个例子:

function getType(obj) {
  const typeClass = Object.prototype.toString.call(obj)
  const classMatch = {
    '[object String]': 'string',
    '[object Number]': 'number',
    '[object Boolean]': 'boolean',
    '[object Symbol]': 'symbol',
    '[object Object]': 'object',
    '[object Array]': 'array',
    '[object Function]': 'function',
    '[object RegExp]': 'regexp',
    '[object Date]': 'date',
    '[object Error]': 'error',
    '[object Window]': 'window',
    '[object HTMLDocument]': 'document'
  }
  if (obj == null) { // 如果是 null 或 undefined ,调用 toString 后返回
    return obj + ''
  } else { // 如果是 object 和 function ,调用 Object.prototype.toString 匹配具体的类型,其余情况直接调用 typeof
    return (typeof obj === 'object' || typeof obj === 'function') ? classMatch[typeClass] : typeof obj
  }
}

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

你是懂标题的

  1. 某xx小哥,竟靠一个xxx直接封神!
  2. 看xxx写的代码,那叫一个优雅!
  3. XXXX面试官竟然这样问?再不学就out了
  4. 工作这么久了,还不懂xxxx吗?
  5. 面试官:知道xxxx是什么吗?在xxx中怎么用的?和xxx有啥区别?
  6. xxx大厂是这样写xxx的,你造吗?
  7. 还在用xxx?快试试XXX,性能提升100倍!
  8. xxx技术已死,XXX称王!
  9. 20xx年了,你竟然还不会xx?
  10. 吊打xx大厂面试官,我这样解释xxx
  11. 学会xxx,大厂offer拿到手软
  12. 自从掌握了xxx,工资翻了番
  13. 吊炸天,xxx还有这种用法!
  14. 有追求的coder,具备了哪些习惯?
  15. 纳尼?又有bug,xxx竟然有这些问题
  16. xxx技术的十个好方法
  17. 写xxx的20个技巧
  18. xxx不要只会xxx,试试这几条写法
  19. 还不会xxx吗?一招解决
  20. 初看xxx一脸懵逼,看懂直接跪下
  21. 我受精了,xxx竟然还有这种用法!
  22. 为什么xxx大厂不让用xxx,原来有这种原因
  23. xxx只看这一篇文章就够了
  24. 再有人问你xxx,把这篇文章丢给他
  25. 吐血整理xxx技术,免费送
  26. 可能是xxx技术最好的一篇解释
  27. 必看!xxx,傲视诛仙
  28. 看完这个,还不会xxx,请你吃瓜
  29. 看完这篇xxx,跟面试官聊*没问题
  30. 真的,xxx入门看这个就够了
  31. 面试最爱问的xxx
  32. xxx真的很*,可惜你不会
  33. xxx太复杂,大神直接干掉它
  34. 弱智都能看懂的xxx,错过血亏
  35. 10秒钟搞定xxx
  36. 从零到一的xxx,保姆级教学
  37. 我以为我很懂xxx,直到我遇见了xxx
  38. 使用xxx后,摸鱼时间又长了
  39. 学会xxx后,我被录取当总监了
  40. 这篇xxx,我吹不动了
  41. 谁要是再敢用xxx,我过去就是一JIO
  42. xxx就是这么简单
  43. 面试官问我xxx,我哭了
  44. 面试xxx,99%的人都爱问这些问题
  45. 还在xxx,试试xxx

读书笔记(二): 闭包

JavaScript 语言中一个非常重要但又难以掌握,近乎神话的概念: 闭包

垃圾回收机制

解释什么是闭包之前,有必要简单了解下垃圾回收的机制。

JavaScript中,如果一个对象不再被引用,那么这个对象就会被垃圾回收机制回收,如果两个对象相互引用,而没有被第三者引用,那么这两个相互引用的对象也会被垃圾回收机制回收。

垃圾回收的实现方式有 引用计数标记清除,引用计数无法解决循环引用的问题,现代浏览器的垃圾回收机制实现要复杂的多,本文的主角不是垃圾回收机制,贴一个 V8传送门,有兴趣的读者可以深入了解。

什么是闭包

闭包是函数和声明该函数的词法环境的组合。简单来说,当函数可以记住并访问其所在的词法作用域时(即使函数是在当前词法作用域外执行),就产生了闭包。其实通过枯燥的定义和术语来理解闭包是不推荐的,最好的方式还是结合代码:

function foo() {
  var name = 'foo' // 外部函数定义的变量

  return function bar() { // 外部函数返回了一个内部函数
    console.log(name) // 内部函数内保留了对外部函数定义的变量的引用,这里是一个 RHS 引用查询
  }
}

var baz = foo() // 一个引用函数的变量可以看做有两个指针,一个指向函数的指针,一个指向闭包的隐藏指针。正常来说,foo 函数执行完后就会被垃圾回收机制回收,其作用域链也会被销毁,但是因为闭包的原因,其内部函数保留了其所在的词法作用域内一个变量 name 的引用,所以执行 baz 时仍然可以访问这个变量
baz() // foo

闭包是函数吗

确切来说,不是。虽然在《JavaScript高级程序设计》中作者描述,闭包是指有权访问另一个函数作用域中的变量的函数《JavaScript权威指南》中也有关于闭包的解释,从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。不可否认,在 JavaScript 中,如果你在另一个函数中使用了 function 关键字(eval也会创建闭包),就创建了一个闭包,但是闭包不只是函数,还应该包括函数可访问的词法作用域。

闭包存储在哪里

在早期的 MDN 版本中,认为闭包是引用了自由变量的函数,让很多人误认为自由变量是存储在堆内存中,现在看来这种说法并不准确。

JavaScript 中数据到底是存储在 还是 中,跟数据的类型有关。

中一般存放一些简单数据段,对应到 JavaScript 的数据类型,就是基本数据类型:String, Boolean, Number, Symbol, Undefined, Null 中存储的是基本数据类型的值,访问的方式是按值访问。

引用类型的值存放在 中,也就是 堆内存 中的对象,如果一个变量的类型是引用类型,那么变量中保存的实质上是一个指针,也就是一个内存地址,这个内存地址存放在 中,访问的方式是,首先从 中读取内存地址,然后沿着指针找到 中对应的对象。

我们知道,闭包包含了函数及其可访问的词法环境,这里的词法环境实质上是一条作用域链,作用域链上可能有基本类型的变量,也有引用类型的变量,所以确切的说,闭包存储在堆栈中而非堆中。

闭包的缺陷

闭包的缺陷主要有两种:占用内存可能造成内存泄漏

占用内存很好理解,因为闭包创建时携带了可访问的词法环境,为什么说闭包可能造成内存呢?

这个主要是针对老版本的 IE 而言。我们知道,内存泄漏最常见的原因就是因为循环引用,在 IE 早期的垃圾回收处理机制中,使用的是引用计数的方式,在 IE8 及其以下的浏览器中的 DOM 和 BOM 对象中创建闭包(比如绑定事件回调),如果不清除循环引用手动释放,就会造成内存泄漏。

闭包的应用

  • 封装私有变量和方法
  • 实现独立作用域
  • 实现单例模式
  • 回调和计时器
  • 绑定上下文(如 bind 的实现)
  • 偏函数
  • 模块的实现

(完)

React设计理念

image

全文约 1500 字,阅读完大约需要 6 分钟,读完本文,你可以 get 到,

  • React 的设计理念是什么?
  • 有哪些因素可以制约网页的快速响应?
  • 什么是 CPU 的瓶颈?
  • 什么是 IO 瓶颈?
  • 什么是掉帧?
  • React 如何将人机交互研究的结果整合到真实的 UI 中?
  • React 引入新 Fiber 架构的原因?

正式开始之前,建议先阅读官网的 React 哲学 一文 ,

假设你已经阅读完 React 哲学,文中提到,

React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式,它在 Facebook 和 Instagram 上表现优秀。

可见 快速响应 是 React 设计中至关重要的一个理念,那么如何实现 快速响应 呢?

这里我们可以从相反的角度考虑,制约快速响应的因素有哪些?

一般来说,网页的响应速度受以下两类场景的约束,

  • 遇到大计算量的操作或者设备性能不足导致的页面掉帧,卡顿
  • 等待网络请求数据返回才能进一步操作导致的 IO 耗时

这两类场景可以概括为,

  • CPU的瓶颈
  • IO 的瓶颈

React 是如何解决这两个瓶颈的呢?

CPU的瓶颈

要解决 CPU 瓶颈,首先要明白什么是 “掉帧” 。

我们知道主流浏览器的刷新频率为 60Hz(1000ms / 60Hz),即每 16.6ms 刷新一次,而 Javascript 是可以操作 DOM 的,为了避免无法预期的渲染结果,在浏览器的架构设计上,渲染进程和 JS 引擎是互斥的,所以当一帧内 Javascript 脚本执行的时间过长(超过了浏览器的刷新时间 16.6ms)时,本次刷新就没有时间去执行样式布局样式绘制了,也就导致了所谓的 “掉帧”。

比如下面的堆栈图打印,我们可以看到 Javascript 的执行时间为 73.65ms,远多于一帧的时间,

明白了原理,解决这个问题就很简单了,我们需要在浏览器每一帧的时间中,预留一部分时间给 JS 线程,然后把控制权交还给渲染进程,让浏览器有剩余时间去执行样式布局和绘制。

源码里,预留的时间是 5ms,

// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;

当预留时间不够时,React 将控制权交还给渲染进程使其有时间来渲染 UI,React 则等待下一帧时间到来继续被中断的工作。

这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,叫做时间切片(Time Slice),这与进程和网络调度程序常用的 时间片轮转调度 (Round-Robin Scheduling) 算法类似,这种方式的缺点是任务运行的总时间变长了,但可以明显减少掉帧的可能性,对于浏览器渲染而言,这种取舍是有必要的,

所以解决 CPU 瓶颈的关键是 时间切片,而时间切片的关键是:将同步更新变为可中断的异步更新

IO的瓶颈

前面我们介绍到,IO 的瓶颈主要来源于 网络延迟,但实际上,网络延迟 是前端开发者无法解决的。

如何在网络延迟客观存在的前提下,减少用户对延迟的感知呢?

我们可以在目前人机交互最顶尖的苹果(IOS)系统中找到一些灵感,以设置面板中的“通用”和“Siri与搜索”为例,

这里的点击“通用”的交互是同步的,而点击“Siri与搜索”是异步的,但从用户感知上看,两者的区别微乎其微,怎么做到的?

仔细观察我们发现,点击“Siri与搜索”后,系统在当前页面停留了一小段时间,这一小段时间被用来请求数据,当“这一小段时间”足够短时,用户是无感知的,如果请求时间超过一个范围,再显示 loading 的效果。这其实是人机交互研究的一个结果,通过类似的方案,可以明显改善用户对网络延迟的感知。

React 借鉴并应用了这一研究成果,实现了 Suspense 及配套的 useDeferredValue,将人机交互研究的结果整合到真实的 UI 中。

而在源码内部,为了支持这些特性,同样需要将同步的更新变为可中断的异步更新

设计理念

综上,我们不难发现 React 为了践行 “构建快速响应的大型 Web 应用程序” 做出的努力,其设计理念可以简单概括为快速响应

快速响应的关键在于解决 CPU 和 IO 瓶颈,

具体实现上,需要将同步的更新变为可中断的异步更新,这也是为什么 React 需要引入新的 Fiber 架构的原因。

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

JavaScript执行机制:执行上下文

你是否对 JavaScript 中的执行上下文词法环境变量环境变量对象活动对象作用域链闭包提升this执行栈等概念有很多困惑,或者了解其中的一些但总觉得一知半解?那是因为脱离了环境,零散的概念本身就晦涩难懂,一位不知名的前端攻城狮说过,理解零散概念的最好方式就是想办法建立知识体系,把它们全部串联在一起,而上述的概念都是围绕下面这个问题,

一段 JavaScript 代码到底是如何执行的 ? 🤔

var name = 'Monch Lee~'

var sayName = function () {
  console.log(name)
}

function sayName() {
  console.log(name)
  var name = 'Pony Ma~'
}

我们知道 JavaScript 代码需要在某种环境中托管和运行,大多数情况下,这个 “环境” 可能是浏览器,以浏览器为例,

《浏览器原理:渲染流程》中我们介绍过,JavaScript 代码最终会由 引擎(JavaScript Engine) 解释并执行,问题变成了,

引擎是如何执行 JavaScript 代码的? 需要先编译吗?

编译(Compile)

了解过其他编译语言(如 C、C++、Golang、Pascal、汇编等)编译原理的同学应该知道,

一段代码在执行前通常要经历三个步骤:词法分析语法分析代码生成

image

  • 词法分析:将字代码分解为词法单元(Token),生成词法单元流数组
  • 语法分析:将词法单元流数组转换为 抽象语法树(Abstract Syntax Tree,AST)
  • 代码生成:将 AST 转换为可执行代码

AST 转换的工具有很多(如 esprima、traceur、acorn、shift),你可以点击这个 esprima 的例子直观的感受下。

JavaScript 也是一门编译语言,任何 JavaScript 代码在执行前都要进行编译,

image

那么编译阶段 JavaScript 引擎具体会做哪些事情呢?

执行上下文(Execution Context)

首先,为了编译和执行 JavaScript 代码,引擎会创建一个特殊的运行环境,这个环境就是执行上下文

简单来说,执行上下文就是一段代码(包括函数)执行所需的所有信息

从类型上,执行上下文可以分为,

  • 全局执行上下文(GEC):引擎编译和执行全局代码时创建,在浏览器中,全局执行上下文的 VO 就是 window 对象
  • 函数执行上下文(FEC):每一个函数调用都会创建函数执行上下文,调用结束后从执行栈弹出并销毁
  • eval的执行上下文:不推荐也不常见的一种,但 eval 函数调用时引擎会创建 eval 的执行上下文

一段代码执行到底需要哪些信息呢,换言之,引擎创建的执行上下文到底包含了哪些东西呢? 🤔

我们知道 ECMAScript 标准几乎每年都在更新,一些术语经历了比较多的版本和社区的演绎,而且比较遗憾的是,网上的文章对这些术语的定义往往都比较混乱,这里我觉得有必要从标准的角度帮你重新梳理一遍,

首先,在 ES3 中,执行上下文包含三个部分,

image

  • Variable Object:变量对象,存储执行上下文中定义的所有变量和函数
  • Scope Chain:作用域链,用于代码执行阶段的标识符查找
  • This Value:this 值

你可能还听说过活动对象(Activation Object),它是针对函数而言,在函数执行上下文中,活动对象就是变量对象,只不过相较于全局执行上下文中的变量对象,它还额外拥有一个 arguments 属性,

function hello(name) {
  console.log(arguments)
}

hello('Monch Lee~') // Arguments ['Monch Lee~', callee: ƒ, Symbol(Symbol.iterator): ƒ]

arguments 是一个对应于传递给函数的参数的类数组对象,它是除了箭头函数外,所有函数中都可用的一个局部变量

ES5 中,上面一些术语发生了变化,执行上下文改为了由下面三部分组成,

image

  • Lexical Environment:词法环境,用于获取变量和函数
  • Variable Environment:变量环境,存储声明的变量和函数
  • This Value:this 值

在较新的 ES2018 中,this 值被归入了词法环境,同时增加了一些新的内容,

image

  • Lexical Environment:词法环境
  • Variable Environment:变量环境
  • Code Evaluation State:用于恢复代码执行位置
  • Function:执行的任务是函数时使用,表示正在被执行的函数
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码
  • Realm:使用的基础库和内置对象实例
  • Generator:生成器上下文特有的这个属性,表示当前生成器

尽管经历了多个版本的迭代,执行上下文的一些核心东西还是不变的,这里我们需要重点关注是:词法环境变量环境

变量环境(Variable Environment)

在编译阶段,引擎首先会确定变量环境,这个阶段会创建变量对象(Variable Object)用来存储所有声明的变量和函数

提升(Hosting)就是发生在这一阶段。

提升(Hosting)

var 声明的变量或函数会被提升到当前作用域顶端(作用域我们在后面词法环境中会详细介绍),并且函数提升优先于变量

console.log(a) // ƒ a() {}

var a = 2

function a() {}

由于存在提升,var 的声明和赋值其实是两个阶段,可以把上面的代码看成,

function a() {}

var a

console.log(a)

a = 2

此外,var 还会穿透 for、if 等语句,你可能发现在只有 var,没有 let 的旧 JavaScript 时代,我们经常通过创建一个函数,并立即执行,来构造一个新的域,从而控制 var 的范围,但由于语法规定 function 关键字开头的是函数声明,我们一般会加上括号让函数变成一个函数表达式,也就是我们常说的立即函数表达式(IIFE),

(function() { 
  var a;
  //code
})();

最常见的做法是直接加括号让函数变成一个表达式,但如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,所以你可能发现一些推荐不加分号的代码风格规范,会要求在括号前面加上分号,

;(function() {
  var a;
  //code
}())

我们也可以用 void 关键字,语义上 void 运算表示忽略后面表达式的值,变成 undefined,我们确实不关心 IIFE 的返回值,

void function() {
  var a;
  //code
}();

变量对象创建完成后,引擎会创建作用域链(Scope Chain),它是我们接下来要介绍的词法环境的一部分。

词法环境(Lexical Environment)

词法环境被用于 JavaScript 引擎获取变量或者 this 值

在编译和执行 JavaScript 代码前,引擎会收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限,这个过程会生成作用域链(Scope Chain),确定 this 指向,

作用域(Scope)和作用域链(Scope Chain)可以看成一个东西,什么是作用域呢?

作用域(Scope)

前面我们介绍到,编译器在语法分析阶段后会将 AST 转换为可执行代码,然后引擎会执行代码,我们以一个简单的代码片段为例,

var a = 2

这里看似一段简单的变量赋值操作实际上会经历两个阶段:编译时处理运行时处理

首先为了确定在何处以及如何查找变量(标识符),引擎会创建当前代码的作用域(Scope),

你可以简单地把作用域理解为一个集合,它存储了我们所有声明的标识符,并提供了一系列查询来访问这些标识符

  • 编译时处理:编译器在遇到上述代码片段后,会在当前作用域中声明一个变量 a ,如果当前作用域已经存在同名的变量 a,则会忽略该声明;真实的场景中,执行上下文可能有多个且可以嵌套,所以这里最终会创建多个嵌套的作用域,也就是我们常说的作用域链(Scope Chain)

  • 运行时处理:代码执行阶段稍有不同,引擎在遇到变量 a 时,会先在当前作用域中查找变量 a,如果找到就会使用这个变量,否则引擎会继续沿着作用域链查找,查找的过程遵循 “属性遮蔽”(Property Shadowing) 原则,也就是查找到的最近的属性会被优先应用;作用域查找会一直向上,直到找到变量或抵达全局作用域后停止。

根据查找的类型,作用域查找又可以分为 LHS 查询RHS 查询,分别代表赋值操作的左侧和右侧

LHS & RHS

这里的赋值操作并不简单意味着 "=" 操作符,JavaScript 中存在譬如隐式赋值等多种赋值操作,以下面的代码片段为例,

function foo(a) {
  var b = a
  return a + b
}
var c = foo( 2 )

这里函数的调用 foo() 是一次 RHS 查询,因为引擎需要去查找 foo 到底是什么,我们可以把 RHS 查询理解为 retrieve his source value(取到它的源值),所以上述代码会执行 4 次 RHS 查询,

  • foo(2...,foo 函数调用,对 foo 的查询
  • = a,a 赋值给 b 时,对变量 a 的查询
  • a ...,对 + 运算左侧变量 a 的查询
  • ... b,对 + 运算右侧变量 b 的查询

LHS 查询的目的是为了对变量进行赋值,需要注意隐式变量分配也是一次 LHS 查询,上述代码会执行 3 次 LHS 查询,

  • c = ...,对变量 c 的赋值查询
  • a = 2,隐式变量分配
  • b = ...,对变量 b 的赋值查询

这里理解 LHS 查询和 RHS 查询其实是有必要的,因为它对应着 JavaScript 中两种常见的异常类型。

异常

对于未声明的变量,LHS 查询和 RHS 查询导致的行为是不一样的,具体来说,

RHS 查询在所有嵌套作用域(整个作用域链)中找不到变量时,引擎会抛出 ReferenceError 异常,

console.log(a) // Uncaught ReferenceError: a is not defined

LHS 查询如果找不到变量,在严格和非严格模式下会有不同表现,

  • 非严格模式下,引擎会在全局作用域中创建一个同名变量
  • 严格模式下,引擎会抛出 ReferenceError 异常
a = 2
console.log(a) // 2,a 未声明,对变量 a 的 LHS 查询,引擎会在全局作用域创建一个同名变量 a
"use strict"
a = 2 // Uncaught ReferenceError: a is not defined,严格模式下,禁止自动或隐式地创建全局变量,这时会抛出 ReferenceError 异常

你可能还遇到过 TypeError 异常,这是因为 RHS 查询找到了变量,但我们尝试对这个变量的值进行不合理的操作时导致的

什么是不合理的操作呢?比如常见的有,

  • 试图对一个非函数类型的值进行函数调用
  • 引用 null 或 undefined 类型的值中的属性
var a = 2
a() // Uncaught TypeError: a is not a function
a.foo.bar // Uncaught TypeError: Cannot read properties of undefined (reading 'bar')

总的来说,ReferenceError 和作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是不合理的

了解了词法环境和变量环境后,接下来我们来看 JavaScript 中比较晦涩的一个概念: 闭包

闭包(Closure)

闭包的翻译自英文单词 Closure,在计算机科学的不同领域它可能代表不同的东西,

  • 编译原理中,它是处理语法产生式的一个步骤;
  • 计算几何中,它表示包裹平面点集的凸多边形(翻译作凸包)
  • 编程语言领域,它表示一种函数

闭包的概念最早可以追溯到 1964 年的《The Computer Journal》(牛津大学出版社出版的最古老的计算机科学研究期刊之一),

P. J. Landin《The mechanical evaluation of expressions》 一文中第一次提及了 closure 的概念,

image

在这个最古典的闭包定义中,closure 表示 “带有一系列信息的一个 λ 表达式”,我们知道对于函数式语言而言,λ 表达式其实就是函数,所以上述的定义可以简单理解为:一个绑定了执行环境的函数,具体来说,一个闭包包含了以下两个部分,

  • 环境部分:环境和标识符列表
  • 表达式部分

然而纵观 JavaScript 的所有标准,似乎都未提及闭包(Closure)这个术语,但我们却不难根据古典定义,结合前面介绍的词法环境,在 JavaScript 中找到对应的闭包组成部分,

  • 环境部分:函数执行上下文中的词法环境,以及函数中用到的变量组成的标识符列表
  • 表达式部分:函数体

所以,闭包在 JavaScript 就是函数,只不过这个函数携带了标识符列表和词法环境

我希望通过上述的古典定义,帮你理清楚 “JavaScript 中闭包就是函数” 这个概念,它和普通的函数并没有本质的区别,

// 你完全可以把函数 foo 看成闭包,闭包没有什么特殊的魔法,就是一个携带环境的函数,普通函数也会携带环境
function foo() {}

只不过与普通函数相比,闭包可能会携带更多的环境信息,

function foo() {
  var a = 2
  return function bar() {
    console.log(a)
  }
}

var baz = foo()
baz() // 2,foo 函数从执行栈中弹出后,我们仍然可以访问其内部变量 a

比如上面的例子中,我们通过调用一个外部函数返回了一个内部函数,根据词法作用域的规则,内部函数可以访问包含它自身及外部函数的词法环境,所以即使我们的外部函数 foo 已经执行结束(从执行栈中弹出)了,但内部函数引用外部函数的变量 a 依然保存在内存中,导致这一内部函数 bar 携带了额外的环境信息(外部函数 foo 的词法环境)。

利用闭包携带的环境信息,我们可以搭配 IIFE 模拟实现块级作用域,你可能在一些早期的 JavaScript 库中见到过类似用法。

好了,让我们把视角拉回到词法环境,前面我们介绍到,引擎确定作用域链后,接着会进行 this 值的绑定。

this

this 是 JavaScript 中的一个关键字,它的使用方法类似于一个变量,全局执行上下文中,this 的值跟当前是否处于严格模式有关,

  • 非严格模式下,this 的值为全局对象,对应到浏览器中就是 window
  • 严格模式下,this 的值为 undefined

这没有什么魔法,真正让大多数人困惑的是 this 在函数执行上下文中的行为,所以我们接下来重点介绍函数中的 “this”。

在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性 [[Environment]],当一个函数执行时,会创建一条新的执行环境记录,记录的外层词法环境(outer lexical environment)会被设置成函数的 [[Environment]],这个动作就是我们说的 执行上下文切换

我们知道 JavaScript 用一个栈(执行栈,Execution Stack)来管理执行上下文,这个栈中的每一项又包含一个链表,

image

当函数调用时,会入栈一个新的执行上下文,函数调用结束时,执行上下文被弹出。

JavaScript 标准定义了 [[thisMode]] 私有属性来处理函数执行上下文中的 this 值,[[thisMode]] 有三个取值,

  • lexical:表示从上下文中寻找 this,这对应了箭头函数
  • global:表示当 this 为 undefined 时,取全局对象,对应了普通函数
  • strict:当严格模式时使用,this 严格按照调用时传入的值,可能为 null 或者 undefined

global 和 strict 呼应了我们前面介绍的全局执行上下文中的 this,这里比较难理解的是 lexical,如何从上下文中寻找 this

对于普通函数而言,this 值由函数的调用方式决定,具体来说是由 “调用它所使用的引用” 决定,

function showThis() {
  console.log(this);
}

var o = {
  showThis: showThis,
};

showThis(); // global
o.showThis(); // o

我们获取函数的表达式,它实际上返回的并非函数本身,而是一个 Reference 类型,Reference 由三部分组成,

  • base,一个对象
  • reference name,一个属性值
  • strict mode flag

在函数调用,delete 等算术运算时,Reference 类型会被解引用,以获取真正的值(被引用的内容)来参与运算,上述例子中,Reference 类型中的对象 o 被当作 this 值,传入了执行函数时的上下文中,所以对于普通函数的 this,我们已经非常清晰了,

调用函数时使用的引用,决定了函数执行时刻的 this 值

上面的方式被网上一些文章称为 “隐式绑定”,从运行时角度来看,this 跟面向对象毫无关联,它只与函数调用时使用的表达式相关,这里也有例外,对于箭头函数和通过 new 实例化一些场景来说,this 的指向有所不同。

我们先来说比较特殊的箭头函数(Arrow Function),

函数创建新的执行上下文中的词法环境记录时,会根据 [[thisMode]] 来标记新纪录的 [[ThisBindingStatus]] 私有属性,然后在代码执行遇到 this 时,会逐层检查当前词法环境记录中的 [[ThisBindingStatus]],这有点类似我们前面介绍的作用域查找,由于箭头函数在创建词法环境记录时并没有定义 this,所以导致了箭头函数无论嵌套多少层,其内部的 this 都指向了最外层普通函数的 this

var o = {};
o.foo = function foo() {
  console.log(this);
  return () => {
    console.log(this);
    return () => console.log(this);
  };
};

o.foo()()(); // o, o, o

箭头函数特殊的 this 绑定方式是优先于前面提到的引用绑定的,比如我们把前面的例子改为箭头函数,

const showThis = () => {
  console.log(this);
};

var o = {
  showThis: showThis,
};

showThis(); // global
o.showThis(); // global

可以发现,不论用什么引用来调用它,都不影响它的 this 值了

接着我们看绑定到 “类” 的 this,

class C {
  a = 1

  showThis() {
    console.log(this)
  }
}
var o = new C()
var showThis = o.showThis

showThis() // undefined
o.showThis() // o
o.a // 1

我们创建了一个类 C,并实例化出对象 o,再把 o 的方法赋值给了变量 showThis,当我们用 showThis 这个引用去调用方法时,得到了 undefined,导致这一行为的原因是 class 被设计成了默认按 strict 模式执行,所以 this 严格按照调用时传入的值进行绑定,这也解释了为什么我们下一行通过 o 去调用时 this 被绑定到了 o 这个引用上。

当然,new 实例化的方式本身也会绑定 this,而且这种优先级是最高的,这主要与 new 关键字的实现原理有关,

我们简单回顾下 new 的实现原理,大致可以分为以下几个步骤,

  1. 获得构造函数
  2. 链接到原型
  3. 绑定 this,执行构造函数
  4. 返回 new 出来的对象
// 模拟 new 实现
function _new() {
  var constructor = [].shift.call(arguments)
  var obj = Object.create(constructor.prototype)
  var result = constructor.apply(obj, arguments)
  return typeof result === 'object' ? result : obj
}

// 调用
function foo() {
  this.bar = 'bar'
}

var obj = _new(foo)

console.log(obj) // foo {bar: 'bar'}
console.log(obj.bar) // 'bar'

new 关键字的实现机制里,会将我们传入的构造函数的原型作为 this 绑定到 new 出来的目标对象上。

我们在上述模拟实现 new 的方法里用到了 apply,实际上,JavaScript 中提供了一系列函数的内置方法(call, apply, bind)来操纵 this 值,这种直接绑定 this 值的方式也被称为 “显式绑定”,当然,如果你只是想确认 this 的指向而不关注其背后的机制,可以参考我之前的文章 可能是最好的 this 解析了... 里提供的一个框架思路,

根据绑定规则和优先级,我们可以总结出 this 判断的通用模式,

  1. 函数是否通过 new 调用?
  2. 是否通过 call,apply,bind 调用?
  3. 函数的调用位置是否在某个上下文对象中?
  4. 是否是箭头函数?
  5. 函数调用是在严格模式还是非严格模式下?

至此,我们已经介绍完执行上下文中最重要的变量环境和词法环境,包括比较晦涩的 This value,闭包等概念,相信你已经对这个 JavaScript 代码执行的基础设施:执行上下文 有了一定的了解,接下来的文章我们会基于执行上下文,执行栈等概念,了解代码执行阶段另一个重要的机制:事件循环(Event Loop)。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

React进阶系列之Why Hooks?

whyhooks.png

本篇我们来深入一个问题,Why React Hooks?

上一篇我们几乎花了通篇的文字阐述了 Hooks 的设计理念,最后的结论是 函数组件从设计**上来看,更加契合 React 的理念,这可以作为上述问题的一个答案,除此之外,还有哪些理由可以作为我们去使用 React Hooks 的原因吗?

Why Hooks?

以 “Why xxx” 开头的问题,往往都没有标准答案,但会有一些关键的“点”,除了设计理念层面,还有以下原因,

  • 告别难以理解的 Class
  • 解决业务逻辑难以拆分的问题
  • 使状态逻辑复用变得简单可行

接下来我们就上述三个点展开来看一下,Why Hooks?

告别难以理解的 Class:把握 Class 的两大痛点

类组件的两大痛点是什么?this和生命周期

对于生命周期,大家应该都了解,经过三次版本的更迭,其中有不少学习和理解成本。我们重点来说说 this,以下面代码为例,我们不用关心组件具体的逻辑,就看 changeText 这个方法:它是 button 按钮的事件监听函数。当我点击 button 按钮时,希望它能够帮我修改状态,但事实是,点击发生后,程序会报错。原因很简单,changeText 里并不能拿到组件实例的 this。

class Example extends Component {
  state = {
    text: ""
  };
  changeText() {
    // 这里会报错
    this.setState({ text: 'Hello'});
  }
  render() {
    return <button onClick={this.changeText}>{this.state.text}</button>
  }
}

为了解决 this 不符合预期的问题,前端几乎也是各显神通,之前用 bind、现在推崇箭头函数。但不管什么招数, 本质上都是在用实践层面的约束来解决设计层面的问题。而 Hooks 基于函数组件,我们不需要关心 this,因为根本就没有 this,这在心智层面和理解上无疑是优于类组件的。

解决业务逻辑难以拆分的问题:Hooks可以实现更好的逻辑拆分

回忆一下在过去,我们是如何组织业务逻辑的?多数情况下应该都是先想清楚业务的需求是什么样的,然后将对应的业务逻辑拆分到不同的生命周期函数里面去,这意味着什么呢?意味着逻辑与生命周期耦合在一起了

在这样的前提下,我们发现生命周期函数往往背离了单一职责原则,一个生命周期函数里面往往需要做不只一件事,在稍复杂的 React 应用里,它们的体积往往很庞大,内部的逻辑也异常复杂,给阅读和维护带来很多麻烦,比如下面这个例子,

class Example extends Component {
  componentDidMount() {
    // 1. 这里发起异步调用
    // 2. 这里从 props 里获取某个数据,根据这个数据更新 DOM
    // 3. 这里设置一个订阅
    // ...
  }
  componentWillUnMount() {
    // 在这里卸载订阅
  }
  componentDidUpdate() {
    // 1. 在这里根据 DidMount 获取到的异步数据更新 DOM
    // 2. 这里从 props 里获取某个数据,根据这个数据更新 DOM
  }
}

这些事情之间看上去毫无关联,逻辑就像是被“打散”进生命周期里了一样 。比如,设置订阅和卸载订阅的逻辑,虽然它们在逻辑上是有强关联的,但是却只能被分散到不同的生命周期函数里去处理,这无论如何也不能算作是一个非常合理的设计

而在 Hooks 的帮助下,我们完全可以把这些繁杂的操作 按照逻辑上的关联拆分进不同的函数组件里: 我们可以有专门管理订阅的函数组件、专门处理 DOM 的函数组件、专门获取数据的函数组件等。Hooks 能够帮助我们 实现业务逻辑的聚合,避免复杂的组件和冗余的代码

Hooks使状态逻辑复用变得简单可行

React 在原生层面并没有为我们提供状态逻辑复用的姿势,所以在过去,我们靠的是 HOC(高阶组件)和 Render Props 这些组件设计模式,但这些设计模式并非万能,它们在实现逻辑复用的同时,也破坏着组件的结构,其中一个最常见的问题就是“嵌套地狱”现象。

而 Hooks 可以看作 React 为解决状态逻辑复用这个问题所提供的一个原生途径,现在我们可以通过自定义 Hooks 来解决状态复用的问题。

保持清醒:Hooks 并非万能

尽管 Hooks 有千般好,我们仍要保持清醒,认识到它的一些局限性,主要有以下几点,

  • Hooks 暂时还不能完全地为函数组件补齐类组件的能力 :比如 getSnapshotBeforeUpdate、componentDidCatch
  • “轻量”几乎是函数组件的基因,这可能会使它不能够很好地消化“复杂” :如果用函数组件来解决复杂的业务问题,逻辑的拆分和组织会是一个很大的挑战,耦合和内聚的边界,有时候真的很难把握, 函数组件给了我们一定程度的自由,却也对开发者的水平提出了更高的要求
  • Hooks 在使用层面有着严格的规则约束:如果不能牢记并践行 Hooks 的使用原则,对 Hooks 的关键原理没有扎实的把握,很容易把自己的 React 项目搞成大型车祸现场

相关文章

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

深入Webpack(二): resolve

本篇是深入webpack系列的第二篇,上一篇 我们学习了webpack的路径解析规则。

webpack 配置中,模块路径解析的相关配置都在 resolve 字段中,resolve 到底有什么魔法?

alias

你可能经常在一些代码中看到如下的片段:

import { color } from '@/constants'

这里模块路径中的 @ 就是别名,webpack 支持通过 alias 属性配置我们需要的别名,假设我们有个 src 模块经常用,可以通过配置模块别名来使模块的引入变得简单:

module.exports = {
  //...
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    }
  }
}

上面的配置表示模糊匹配,只要路径中有 @ 都会被替换掉,比如:

import App from '@/index.js' // 相当于 import App from '[项目绝对路径]/src/index.js'
import { reduce } from '@/utils/array.js' // 相当于 import { reduce } from '[项目绝对路径]/src/utils/array.js'

如果想要精确匹配,可以在别名末尾加上 $

alias: {
  '@$': path.resolve(__dirname, 'src') // 只会匹配 import '@',不会匹配 import '@/xxx'
}

extensions

webpack 支持通过 extensions 配置路径解析模块的扩展名,配置后引入模块可以不带扩展,默认为:

extensions: ['.wasm', '.mjs', '.js', '.json']

上述配置表示,路径解析时,如果路径不包含扩展名 webpack 会尝试给路径添加 extensions 中的扩展名,然后再进行依赖模块的查找。比如,引入 src 目录下的 index.js 文件就可以写成:

import App from 'src/index'

需要注意的是,如果配置了 extensions 会覆盖 webpack 原有的默认配置。假设 extension 配置是:

extentsions: ['.css']

这时再通过不带扩展名的方式引入 js 文件,就会报错:

import App from 'src/index' // Module not found,因为 extension 中找不到扩展名对应的模块

想要解决这个问题,可以将扩展名重新添加到 extensions 数组,

extentsions: ['.js', '.css']

modules

modules 指定了 webpack 解析模块时需要搜索的目录,默认为 node_modules

modules: [`node_modules`]

上述的配置表示,webpack 在解析模块名时,以引入 react 为例:

import React from 'react'

webpack 会逐级向上搜索 node_modules 目录,直到找到名称为 react 的模块。

mainFiles

mainFiles 指定了解析目录时使用的文件名,默认为 index

mainFiles: ['index']

上面的配置表示,如果 webpack 路径解析遇到目录时,默认查找目录下的 index.js 文件。

mainFields

mainFields 指定了当路径解析,引用的是一个目录或模块的时候,应该使用 package.json 中的哪个字段指定的文件,默认的配置是:

mainFields: ['browser', 'module', 'main'], // target 为 web 或 webworker 时

mainFields: ['module', 'main'], // target 为其他时,比如(node)

D3package.json 为例:

{
  "main": "dist/d3.node.js",
  "module": "index.js",
}

上述的配置表示,当我们从 npm 包导入 D3 模块时,默认查找的是 main 字段指定的文件:

import * as D3 from 'd3'

按照默认的配置,如果有 browser 字段,会优先解析 browser 字段指定的文件,其次是 module, 再其次是 main。而由 webpack 打包的 Node.js 应用程序默认会从 module 字段中解析文件。

相关推荐

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

可能是最好的跨域解决方案了...

今天我们来聊一个老生常谈的话题,跨域!

又是跨域,烦不烦 ?网上跨域的文章那么多,跨的我眼睛都疲劳了,不看了不看了 🤣 别走...我尽量用最简单的方式将常见的几种跨域解决方案给大家阐释清楚,相信认真看完本文,以后不管是作为受试者还是面试官,对于这块的知识都能够游刃有余。

什么是“跨源”

不是讲跨域吗 ?怎么又来个“跨源” ?字都能打错的 ?

😄...稍安勿躁,其实我们平常说的跨域是一种狭义的请求场景,简单来说,就是“跨“过浏览器的同源策略去请求资“源”,所以我们叫它“跨源”也没啥问题。那么,

跨源,源是什么?浏览器的同源策略

什么是同源?协议,域名,端口都相同就是同源

干巴巴的,能不能举个栗子?栗子:),有的有的

const url = 'https://www.google.com:3000'

比如上面的这个 URL,协议是:https,域名是 www.google.com,端口是 3000

不同源了会怎么样?会有很多限制,比如

  • Cookie,LocalStorage,IndexDB 等存储性内容无法读取
  • DOM 节点无法访问
  • Ajax 请求发出去了,但是响应被浏览器拦截了

我就想请求个东西,至于吗,为什么要搞个这么个东西限制我?基于安全考虑,没有它,你可能会遇到

  • Cookie劫持,被恶意网站窃取数据
  • 更容易受到 XSS,CSRF 攻击
  • 无法隔离潜在恶意文件
  • ... ...

所以,得有。正是因为浏览器同源策略的存在,你的 Ajax 请求有可能在发出去后就被拦截了,它还会给你报个错:

✘ Access to XMLHttpRequest at 'xxx' from origin 'xxx' has been block by CORS,
  policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

这种发出去拿不到响应的感受,就像你在网上冲浪时,被一股神秘的东方力量限制了一样:

16e5950a96ad63ab.jpg

非常难受,所以,我们接下来就来看看怎么用科学的方法上网(啊呸,科学的方法解决跨域的问题)。

JSONP

这玩意儿就是利用了 <script> 标签的 src 属性没有跨域限制的漏洞,让我们可以得到从其他来源动态产生的 JSON 数据。

为什么叫 JSONP ?JSONP 是 JSON with Padding 的缩写,额,至于为什么叫这个名字,我网上找了下也没个标准的解释,还望评论区的各位老哥知道的赶紧告诉我: )

怎么实现 ?具体实现思路大致分为以下步骤

  • 本站的脚本创建一个 元素,src 地址指向跨域请求数据的服务器
  • 提供一个回调函数来接受数据,函数名可以通过地址参数传递进行约定
  • 服务器收到请求后,返回一个包装了 JSON 数据的响应字符串,类似这样:callback({...})

浏览器接受响应后就会去执行回调函数 callback,传递解析后的 JSON 对象作为参数,这样我们就可以在 callback 里处理数据了。实际开发中,会遇到回调函数名相同的情况,可以简单封装一个 JSONP 函数:

function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    // 创建一个临时的 script 标签用于发起请求
    const script = document.createElement('script');
    // 将回调函数临时绑定到 window 对象,回调函数执行完成后,移除 script 标签
    window[callback] = data => {
      resolve(data);
      document.body.removeChild(script);
    };
    // 构造 GET 请求参数,key=value&callback=callback
    const formatParams = { ...params, callback };
    const requestParams = Object.keys(formatParams)
      .reduce((acc, cur) => {
        return acc.concat([`${cur}=${formatParams[cur]}`]);
      }, [])
    .join('&');
    // 构造 GET 请求的 url 地址
    const src = `${url}?${requestParams}`;
    script.setAttribute('src', src);
    document.body.appendChild(script);
  });
}

// 调用时
jsonp({
  url: 'https://xxx.xxx',
  params: {...},
  callback: 'func',
})

我们用 Promise 封装了请求,使异步回调更加优雅,但是别看楼上的洋洋洒洒写了一大段,其实本质上就是:

<script src='https://xxx.xxx.xx?key=value&callback=xxx'><script>

想要看例子 ?戳这里

JSONP 的优点是简单而且兼容性很好,但是缺点也很明显,需要服务器支持而且只支持 GET 请求,下面我们来看第二种方案,也是目前主流的跨域解决方案,划重点!😁

CORS

CORS(Cross-Origin Resource Sharing)的全称叫 跨域资源共享,名称好高大上,别怕,这玩意儿其实就是一种机制。浏览器不是有同源策略呐,这东西好是好,但是对于开发人员来说就不怎么友好了,因为我们可能经常需要发起一个 跨域 HTTP 请求。我们之前说过,跨域的请求其实是发出去了的,只不过被浏览器给拦截了,因为不安全,说直白点儿就是,你想要从服务器哪儿拿个东西,但是没有经过人家允许啊。所以怎么样才安全 ?服务器允许了不就安全了,这就是 CORS 实现的原理:使用额外的 HTTP 头来告诉浏览器,让运行在某一个 origin 上的 Web 应用允许访问来自不同源服务器上的指定的资源

兼容性

目前,所有的主流浏览器都支持 CORS,其中,IE 浏览器的版本不能低于 10,IE 8 和 9 需要通过 XDomainRequest 来实现

完整的兼容性情况 ? 戳这里

实现原理

CORS 需要浏览器和服务器同时支持,整个 CORS 的通信过程,都是浏览器自动完成。

怎么个自动法

简单来说,浏览器一旦发现请求是一个跨域请求,首先会判断请求的类型

如果是简单请求,会在请求头中增加一个 Origin 字段,表示这次请求是来自哪一个。而服务器接受到请求后,会返回一个响应,响应头中会包含一个叫 Access-Control-Allow-Origin 的字段,它的值要么包含由 Origin 首部字段所指明的域名,要么是一个 "*",表示接受任意域名的请求。如果响应头中没有这个字段,就说明当前源不在服务器的许可范围内,浏览器就会报错:

GET /cors HTTP/1.1
Origin: https://xxx.xx
Accept-Language: en-US
Connection: keep-alive
... ...

如果是非简单请求,会在正式通信之前,发送一个预检请求(preflight),目的在于询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段,只有得到肯定答复,浏览器才会发出正式的请求,否则就报错。你可能发现我们在日常的开发中,会看到很多使用 OPTION 方法发起的请求,它其实就是一个预检请求:

OPTIONS /cors HTTP/1.1
Origin: http://xxx.xx
Access-Control-Request-Method: PUT
Accept-Language: en-US
... ...

那么到底哪些是简单请求,哪些是非简单请求 ?

请求类型

不会触发 CORS 预检的,就是简单请求。哪些请求不会触发预检 ?

使用以下方法之一:GET, HEAD, POST,

并且 Content-Type 的值仅限于下列三者之一:

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

相反,不符合上述条件的就是非简单请求啦。

所以,实现 CORS 的关键是服务器,只要服务器实现了 CORS 的相关接口,就可以实现跨域。CORS 与 JSONP相比,优势是支持所有的请求方法,缺点是兼容性上较 JSONP 差。除了 JSONP 和 CORS 外,还有一种常用的跨域解决方案:PostMessage,它更多地用于窗口间的消息传递。

PostMessage

PostMessage 是 Html5 XMLHttpRequest Level 2 中的 API,它可以实现跨文档通信(Cross-document messaging)。兼容性上,IE8+,Chrome,Firfox 等主流浏览器都支持,可以放心用😊,如何理解跨文档通信?

你可以类比设计模式中的发布-订阅模式,在这里,一个窗口发送消息,另一个窗口接受消息,之所以说类似发布-订阅模式,而不是观察者模式,是因为这里两个窗口间没有直接通信,而是通过浏览器这个第三方平台。

window.postMessage(message, origin, [transfer])

postMessage 方法接收三个参数,要发送的消息,接收消息的源和一个可选的 Transferable 对象,如何接收消息 ?

window.addEventListener("message", function receiveMessage(event) {}, false); // 推荐,兼容性更好

window.onmessage = function receiveMessage(event) {} // 不推荐,这是一个实验性的功能,兼容性不如上面的方法

接收到消息后,消息对象 event 中包含了三个属性:source,origin,data,其中 data 就是我们发送的 message。

此外,除了实现窗口通信,postMessage 还可以同 Web Worker 和 Service Work 进行通信,有兴趣的可以 戳这里

Websocket

Websocket 是 HTML5 的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。什么是全双工通信 ?简单来说,就是在建立连接之后,server 与 client 都能主动向对方发送或接收数据

原生的 WebSocket API 使用起来不太方便,我们一般会选择自己封装一个 Websocket(嗯,我们团队也自己封了一个 : ))或者使用已有的第三方库,我们这里以第三方库 ws 为例:

const WebSocket = require('ws');

const ws = new WebSocket('ws://www.host.com/path');

ws.on('open', function open() {
  ws.send('something');
});

ws.on('message', function incoming(data) {
  console.log(data);
});
... ...

需要注意的是,Websocket 属于长连接,在一个页面建立多个 Websocket 连接可能会导致性能问题。

Nginx 反向代理

我们知道同源策略限制的是:浏览器向服务器发送跨域请求需要遵循的标准,那如果是服务器向服务器发送跨域请求呢?

答案当然是,不受浏览器的同源策略限制

利用这个思路,我们就可以搭建一个代理服务器,接受客户端请求,然后将请求转发给服务器,拿到响应后,再将响应转发给客户端:

反向代理.png

这就是 Nginx 反向代理的原理,只需要简单配置就可以实现跨域:

# nginx.config
# ...
server {
  listen       80;
  server_name  www.domain1.com;
  location / {
    proxy_pass   http://www.domain2.com:8080;  #反向代理
    proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
    index  index.html index.htm;

    # 当用 webpack-dev-server 等中间件代理接口访问 nignx 时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Credentials true;
    # ...
  }
}

Node 中间件代理

实现的原理和我们前文提到的代理服务器原理如出一辙,只不过这里使用了 Node 中间件做为代理。需要注意的是,浏览器向代理服务器请求时仍然遵循同源策略,别忘了在 Node 层通过 CORS 做跨域处理:

const https = require('https')
// 接受客户端请求
const sever = https.createServer((req, res) => {
  ...
  const { method, headers } = req
  // 设置 CORS 允许跨域
  res.writeHead(200, {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': '*',
    'Access-Control-Allow-Headers': 'Content-Type',
    ...
  })
  // 请求服务器
  const proxy = https.request({ host: 'xxx', method, headers, ...}, response => {
    let body = ''
    response.on('data', chunk => { body = body + chunk })
    response.on('end', () => {
      // 响应结果转发给客户端
      res.end(body)
    })
  })
})

document.domain

二级域名相同的情况下,设置 document.domain 就可以实现跨域。什么是二级域名 ?

a.test.com 和 b.test.com 就属于二级域名,它们都是 test.com 的子域。如何实现跨域 ?

document.domain = 'test.com' // 设置 domain 相同

// 通过 iframe 嵌入跨域的页面
const iframe = document.createElement('iframe')
iframe.setAttribute('src', 'b.test.com/xxx.html')
iframe.onload = function() {
  // 拿到 iframe 实例后就可以直接访问 iframe 中的数据
  console.log(iframe.contentWindow.xxx)
}
document.appendChild(iframe)

总结

当然,除了上述的方案外,比较 Hack 的还有:window.name, location.hash,但是这些跨域的方式现在我们已经不推荐了,为什么 ?因为相比之下有更加安全和强大的 PostMessage 作为替代。跨域的方案其实有很多,总结下来:

  • CORS 支持所有的 HTTP 请求,是跨域最主流的方案
  • JSONP 只支持 GET 请求,但是可以兼容老式浏览器
  • Node 中间件和 Nginx 反向代理都是利用了服务器对服务器没有同源策略限制
  • Websocket 也是一种跨域的解决方案
  • PostMessage 可以实现跨文档通信,更多地用于窗口通信
  • document.domain, window.name, location.hash 逐渐淡出历史舞台,作为替代 PostMessage 是一种不错的方案

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在对应的 issues 进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

深入JavaScript: 原型与原型链

proto.jpeg

抛开封面图,我们先以 MDN 的一句话作为开端,

对于使用过基于类的语言 (如 Java 或 C++) 的开发人员来说,JavaScript 有点令人困惑,因为它是动态的,并且本身不提供一个 class 实现。虽然在 ES2015/ES6 中引入了 class 关键字,但那只是语法糖,JavaScript 仍然是基于原型的

那么到底什么是原型?

原型

我们先用构造函数创建一个实例对象,

其实理解原型,就是理解构造函数,实例对象和原型对象之间的关系

function Engineer(name) {
  this.name = name
}

Engineer.prototype.coding = function() {
  console.log('write less, do more.')
}

const engineer = new Engineer('campcc')

JavaScript 中,每一个构造函数都有一个 prototype 属性,它指向构造函数的原型对象:

Engineer.prototype // {coding: ƒ, constructor: ƒ}

原型对象中有一个 constructor 属性指回构造函数:

Engineer.prototype.constructor === Engineer // true

而每一个实例对象都有一个 __proto__ 属性,当我们使用构造函数创建实例时,实例的 __proto__ 属性就会指向构造函数的原型对象:

engineer.__proto__ === Engineer.prototype // true,__proto__ 其实是一个 JavaScript 的非标准但许多浏览器都实现的属性,从 ECMAScript 6 开始,支持通过符号 [[Prototype]] 或者方法 Object.getPrototypeOf() 访问

构造函数,实例对象与原型对象的关系为:

构造函数实例对象和原型对象关系图.png

原型链

为了更好的理解什么是原型链,我们尝试调用实例对象的几个方法,

engineer.coding() // write less, do more.

engineer.toString() // "[object Object]"

engineer.map() // Uncaught TypeError: engineer.map is not a function

结果看似很出乎意料,因为我们其实并没有在实例里定义 codingtoString 方法啊,但是它们却能够被成功调用,为什么?因为在 JavaScript 中,当我们试图访问一个对象的属性或方法时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索:

原型链.png

我们尝试访问 map 方法时报错了,因为原型链上找不到此方法。原型链查找会一直持续,直到找到一个名字匹配的属性或方法,或者达到原型链的末尾,而根据定义,null 就是原型链的末尾

Object.prototype.__proto__ === null // true,null 在这里可以理解为 “没有对象”

如果查找过程中遇到同名属性或方法,位于原型链底端的属性或方法会被优先应用,这叫做 “属性遮蔽(property shadowing)”。比如我们在 Engineer 的原型对象上声明一个同名的 name 属性:

Engineer.prototype.name = 'engineer'

engineer.name // campcc,这里不会打印 'engineer',因为在原型链查找的过程中,实例对象中就已经存在 name 属性了

总结一下,原型链其实就是对象或原型对象的 __proto__ 组成的一条原型查找链

几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object 的实例,但有两个例外:

  1. Object.prototype:我们刚才说了,它的原型是 null
  2. Object.create(null):创建一个原型为 null 的空对象

补充

关于封面图,其实隐喻了 JavaScript 中一直存在争议的一个问题,

Function.__proto__ === Function.prototype // true

先来看看在 Chorme V8 中的打印结果:

Function.__proto__ // ƒ () { [native code] }

Function.prototype // ƒ () { [native code] }

Object.__proto__ // ƒ () { [native code] }

从打印结果来看,根本不存在 "Function 也是 Function 本身的一个实例" 的说法,因为不管是 Function.__proto__Function.prototype 还是 Object.__proto__ 都是引擎创建的,Function 也是由引擎创建的,至于为什么会相等,只能说每个语言都存在缺陷,这个问题就像为什么 typeof null === "object" 一样,不用过于纠结。

总结

JavaScript 中,

  • 每个函数都有 prototype 属性,代表构造函数的原型对象
  • 每个实例对象都有 __proto__ 属性,指向构造函数的原型对象
  • 每个原型对象中都有 constructor 属性,指向构造函数,标识原型对象的是由哪个函数构造的
  • 对象或原型对象的 __proto__ 组成了一条原型链,原型链其实也是一条查找链
  • 原型链查找会一直持续,直到找到同名的属性或方法,或者到达原型链的末尾 null
  • 原型链查找会遵循 属性遮蔽 原则,位于底层的属性或方法会被优先找到

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

面试题解JavaScript(一): 写出以下代码打印结果(京东)

请写出以下代码的打印结果:

var name = 'Tom';
(function () {
  if (typeof name == 'undefined') {
    var name = 'Jack';
    console.log('Goodbye ' + name);
  } else {
    console.log('Hello ' + name);
  }
})();

答案:Goodbye Jack

立即调用函数表达式

立即调用函数表达式,也叫 IIFE ,顾名思义是一个在定义时就会立即执行的 JavaScript 函数,这样的设计模式也被成为 自执行匿名函数,常见于各种类库的封装,通过一对圆括号包裹一个匿名函数,在声明后立即调用, 为什么会有这样的一种设计模式?

(function () {})(); // 一对圆括号包裹的一个匿名函数

IIFE 的出现更像是在弥补 JavaScript 语言在 访问控制 设计上的不足,相较于其他大部分面向对象的语言,直到 ES6 之前,JavaScript 只有 全局作用域函数作用域,没有 块级作用域 。也就是说,在 JavaScript 中,你只能通过函数实现 作用域隔离。IIFE 以一种较优雅的方式,通过函数作用域模拟实现了 块级作用域,由于匿名函数拥有自己独立的词法作用域,不仅避免了外界访问 IIFE 中的变量,而且也不会污染全局作用域。

此外,IIFE 还有以下优点:

  • 隔离作用域,避免全局污染,命名冲突
  • 减少闭包占用的内存问题,IIFE 中定义的变量和函数,都会在执行完后被销毁,因为没有指向匿名函数的引用,只要函数执行完毕,就会立即销毁其作用域链了

变量提升

变量提升(Hoisting)被认为是,JavaScript 中执行上下文工作方式的一种认识,单从概览来理解比较晦涩,简单来说,就是指声明的变量和函数在编译阶段被放入内存中。

什么是执行上下文?

JavaScript 引擎遇到可执行代码(executable code)时,会创建执行环境,执行上下文(execution context)可以理解为当前执行代码的环境 / 作用域。

可执行代码的类型只有三种:

  • 全局代码
  • 函数代码
  • Eval代码

对应的执行上下文也有三种:

  • 全局执行上下文
  • 函数执行上下文
  • Eval函数执行上下文

一段可执行代码中可能有多个执行上下文,每个函数执行时都会创建一个执行上下文,为了管理执行上下文,JavaScript 引擎还会创建 执行上下文栈(execution context stack),执行栈本质上就是一个普通的栈,只是它里面存放的是执行上下文。

回到变量提升,怎么去理解 声明的变量和函数在编译阶段被放入内存中

JavaScript 引擎在正式执行代码前,会进行一次"预编译",在内存中开辟一些空间,用来存放变量和函数,具体的步骤如下:

  • 创建 GO 全局对象(Global Object)Window
  • 加载第一个脚本文件
  • 脚本加载完成后,进行语法分析
  • 开始预编译,查找函数声明,变量声明,函数和变量会被赋值为全局对象的一个属性,其中函数声明的值为函数体,变量的值为 undefined
  • 词法分析
  • 加载第二个脚本文件
  • ... ...

可以看出,JavaScript 引擎仅提升声明,而不会提升初始化,所以如果先使用变量,再声明初始化它,变量的值将会是 undefined:

console.log(foo); // undefined
var foo = 'foo';

上面的代码相当于:

var foo;
console.log(foo);
foo = 'foo';

此外,函数和变量相比,会被优先提升,与声明的先后顺序无关:

console.log(foo) // ƒ foo () {}
var foo = 'foo'
function foo () {}

作用域链查找

对于每一个执行上下文,有三个重要的属性:

  • 变量对象(variable object)
  • 作用域链(scope chain)
  • this

什么是作用域链

JavaScript 引擎在查找变量时,会先从当前执行上下文的变量对象中去找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中去找,一直找到全局上下文的变量对象,也就是全局对象,由多个执行上下文的变量对象构成的一个链表就叫作用域链。

也就是说,作用域链查找是从作用域链最底层开始查找,当遇到同名变量时,查找到的是距离当前执行上下文最近的变量:

var name = 'Tom'
function () {
  var name = 'Jack'
  console.log(name) // Jack
}

解析

回到我们刚开始的题目,由于变量提升,上述的代码相当于:

var name = 'Tom';
(function () {
  var name
  if (typeof name == 'undefined') {
    name = 'Jack';
    console.log('Goodbye ' + name);
  } else {
    console.log('Hello ' + name);
  }
})();

匿名函数内的变量 name 被提升至当前执行上下文(匿名函数)的最顶端,由于仅提升声明,不提示初始化,所以 name 的初始值为 undefined

执行 typeof name 会查找 “name” 这个变量,通过作用域链查找,发现当前匿名函数的执行上下文就有 name,所以这里的 typeof 相当于:

typeof undefined

需要注意的是 typeof 返回的值是字符串,所以最终打印结果为 Goodbye Jack

下一篇文章

面试题解JavaScript(二): new 的实现原理是什么

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

深入数据结构:“堆”

堆(Heap) 是一种特殊的树,广泛应用于排序,搜索,优先级队列,求中位数等场景,接下来我将带你一起系统地学习,从原理,实现到应用全面地深入 “堆” 这个数据结构。

正式开始之前,你可以先思考一个问题,

如何在包含 10 亿个搜索关键词的日志文件中,快速获取到热门榜 Top 10 的搜索关键词呢?

这个场景在如今搜索引擎的热门搜索排行榜上非常常见,而我们接下来要介绍的 “堆” 这个数据结构就可以解决这个问题。

堆的定义

在介绍堆的定义之前,我觉得有必要先带你回顾下相关的一些知识,

  • 二叉树:一种非线性表数据结构,每个节点最多有左、右两个子节点;
  • 完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列;
  • 时间复杂度:衡量算法执行效率的一种方式,我们一般用 大 O 复杂度表示法 来表示,定义为 $T(n) = O(fn)$,其中 $n$ 表示数据规模的大小,$T(n)$ 表示代码执行的时间,与每行代码执行次数总和 $f(n)$ 成正比;在计算上,我们一般只关注循环执行次数最多的一段代码,忽略掉常量、低阶和系数,遵循加法法则乘法法则
  • 原地排序:空间复杂度为 $O(1)$ 的排序

标准定义上,我们认为一颗树满足以下的两点,就是一个堆,

  1. 一颗完全二叉树
  2. 每个节点的值大于等于(或小于等于)其子树中每个节点的值

根据子节点与父节点值的大小关系,可以把堆分为,

  1. 最大堆(也叫大根堆,大顶堆),任意父节点都比其子节点的值要大
  2. 最小堆(也叫小根堆,小顶堆),任意父节点都比其子节点的值要小

image

比如上图中, 1、2 为最大堆,3 是最小堆,4 不是堆。

堆的实现

要实现一个堆,我们可以先思考,如何存储一个堆

树的存储方式一般有链式存储线性存储,分别对应我们常见的链表数组两种方式,对于完全二叉树而言,用数组来存储是非常节省存储空间的,因为不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点,

所以堆我们一般也用数组来存储,

假设我们从数组下标 1 开始存储,不难发现,对于下标为 $i$ 的任意节点,存在以下关系,

// 其左子节点的索引满足
var leftIndex = 2 * i;
// 其右子节点的索引满足
var rightIndex = 2 * i + 1;
// 其父节点的索引满足
var parentIndex = i / 2;

为什么我们要从数组下标 1 开始存储,从 0 开始不行吗?

当然可以,如果从 0 开始存储,在代码的实现上,只是计算子节点和父节点的下标的公式改变了,对于下标为 $i$ 的任意节点,

// 其左子节点的索引满足
var leftIndex = 2 * i + 1;
// 其右子节点的索引满足
var rightIndex = 2 * i + 2;
// 其父节点的索引满足
var parentIndex = i - 1 / 2;

那为什么我们不从下标 0 开始存储呢?我想你应该已经猜到答案了,这里与 数组下标从 0 开始计数,而不是 1 开始 有着异曲同工之妙,我们从下标 1 开始存储,每次计算子节点和父节点时,都可以减少一次加法操作,本质是为了提高代码的性能。

知道了如何存储一个堆后,我们接着来看堆都支持哪些操作?

一般来说,堆有几个核心的操作,比如往堆中插入一个元素,删除堆顶元素。

堆的插入

往堆中插入一个元素后,为了保持堆的特性,我们需要对插入元素的位置进行调整,这个过程叫做堆化(heapify),以最大堆为例,

堆化的过程其实非常简单,比如上图中我们往最大堆中插入一个元素 “22”,为了保持堆的特性,只需要顺着节点所在的路径,向上与其父节点进行对比交换,直到满足大小关系即可,

image

class Heap {
  constructor(n) {
    this.a = new Array(n); // 存储堆的数组,从下标 1 开始存储数据
    this.n = n; // 堆可以存储的最大数据个数
    this.count = 0; // 堆中已经存储的数据个数
  }

  insert(data) {
    // 堆满直接返回
    if (this.count >= this.n) return;
    // 为了最大性能的存储堆,并保证堆中节点与数组下标的关系,我们从下标 1 开始存储数据,所以这里是 ++count
    ++this.count;
    // 从数组末尾插入,然后开始堆化(heapify),这里是最大堆,我们从下往上堆化
    // 堆化的过程就是:用当前节点与父节点做比较,大于父节点就交换,直到小于父节点为止
    this.a[count] = data;
    var i = count;
    while (i / 2 > 0 && this.a[i] > this.a[Math.floor(i / 2)]) {
      swap(this.a, i, Math.floor(i / 2));
      i = Math.floor(i / 2);
    }
  }
}

// 交换数组 a 中下标为 i 和 j 的元素
function swap(a, i, j) {
  var temp = a[i];
  a[i] = a[j];
  a[j] = temp;
}

往堆中插入数据我们解决了,如何从堆中删除数据呢?

堆的删除

堆中数据的删除我们可以先考虑删除堆顶元素

从堆定义的第二条我们发现,堆顶元素存储的就是堆中数据的最大值或者最小值,以最大堆为例,最容易想到的是:删除堆顶元素后,把子节点中较大的元素放到堆顶,然后迭代地删除第二大节点,以此类推直到叶子节点被删除,

image

这种方式有没有什么问题呢?

从上图我们发现,删除后可能会出现数组空洞,而且最后堆化出来的堆并不满足完全二叉树的特性,有没有更好的办法呢?

这里我们可以尝试换一种思路,先删除叶子节点(最后一个元素),把它放到堆顶(与堆顶元素交换),然后从上往下进行堆化

image

因为我们移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性,

// 从上往下堆化
function heapify(a, n, i) {
  while (true) {
    var maxPos = i;
    // 找到左右子节点中的最大值
    if (i * 2 <= n && a[i] < a[i * 2]) maxPos = i * 2;
    if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1]) maxPos = i * 2 + 1;
    if (maxPos === i) break;
    // 交换堆顶元素和最大值,保证堆顶元素最大
    swap(a, i, maxPos);
    i = maxPos;
  }
}

这样一来,删除堆顶元素就很简单了,

function removeMax() {
  if (count === 0) return -1;
  // 交换叶子节点和堆顶元素,然后从上到下进行堆化
  a[1] = a[count];
  --count;
  heapify(a, n, 1);
}

如果我们要删除堆中的任意一个节点的元素,应该怎么做呢?

类似的,我们只需要将数组的最后一个元素与要删除的节点元素互换,然后从要删除的节点开始从上往下堆化即可,这里你可以试着实现一下。

我们来讨论一下上面两个核心操作的时间复杂度。

首先,对于一个包含 $n$ 个节点的完全二叉树,树的高度不会超过 $\log_2{n}$,堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,用大 O 复杂度表示法就是 O($\log{n}$);我们发现插入和删除主要逻辑就是堆化,其他操作都是常数级别的,

所以往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O($\log{n}$)。

存储,插入和删除我们都解决了,接下来我们来看如何构建一个堆,以及利用堆进行排序。

堆排序

我们知道 堆排序 是时间复杂度为 O($n\log{n}$) 的原地排序算法,具体是怎么实现的呢?

堆排序的过程大致可以分为两步,建堆排序

堆的构建

建堆意味着我们需要将数组原地构建成一个堆,一般有两种思路,

  1. 往堆中依次插入元素,我们假设起始堆中只包含一个数据,就是下标为 1 的数据,然后将下标从 2 到 $n$ 的数据依次插入到堆中
  2. 从最后一个非叶子节点开始,从上往下进行堆化

第一种思路是最容易想到的,我们需要遍历整个数组然后对每个数据进行插入操作,但时间复杂度较高,为 O($n\log{n}$);第二种思路不太好理解,这里我们展开一下,

前面我们提到,对于完全二叉树,根据数组存储规律,不难发现下标 $\frac{n}{2} + 1$$n$ 的是叶子节点,下标 1 到 $\frac{n}{2}$ 是非叶子节点,叶子节点不需要堆化,这里我们只需要对非叶子节点进行堆化就可以构建堆, 还是以最大堆为例,

我们从最后一个非叶子节点开始,从上往下进行堆化,

image

image

我把上述的过程翻译成了代码,你可以参考一下,

// 构建最大堆
function buildHeap(a, n) {
  // 根据完全二叉树数组存储的规律,下标为 1 到 n/2 的为非叶子节点
  // 为了保证堆的特性,我们从最后一个非叶子节点开始堆化
  for (var i = Math.floor(n / 2); i >= 1; --i) {
    heapify(a, n, i);
  }
}

// 从上往下堆化
function heapify(a, n, i) {
  while (true) {
    var maxPos = i;
    // 找到左右子节点中最大的元素,与当前节点交换
    if (i * 2 <= n && a[i] < a[i * 2]) maxPos = i * 2;
    if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1]) maxPos = i * 2 + 1;
    // 循环退出条件,当前节点已经是最大,不能继续往下堆化
    if (maxPos === i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

// 交换 a[i] 和 a[j]
function swap(a, i, j) {
  var temp = a[i];
  a[i] = a[j];
  a[j] = temp;
}

你能分析出上面这种建堆方式的时间复杂度是多少吗?

我们知道每个节点堆化的时间复杂度是 O($\log{n}$),那 $\frac{n}{2} + 1$ 个节点堆化的总时间复杂度是不是就是 O($n\log{n}$) 呢?

实际上,建堆的时间复杂度是 O($n$),为什么?我们来分析一下。

建堆的过程中叶子节点是不需要堆化的,所以需要堆化的节点是从倒数第二层开始,每一层我们需要比较和交换的次数,和树的高度成正比,

image

所以建堆的时间复杂度,就是除最后一层外,每层节点比较和交换的次数之和,

image

这个公式的求解非常简单,我们把公式左右都乘以 2 得到另一个公式 $S_2$,然后将 $S_1$$S_2$ 错位对齐求差值得到,

image

$S$ 的后面部分是等比数列,带入等比数列的求和公式,

image

前面提到树的高度 $h = \log_2{n}$,代入公式 $S$,就能得到 $S=O(n)$

所以,建堆的时间复杂度是 $O(n)$

堆排序

构建好堆后,排序就很简单了,我们发现数组中的第一个元素就是堆顶,也就是最大的元素,假设我们要从小到大对数据进行排序,应该怎么做呢?

是不是只需要把堆顶元素与最后一个元素(下标为 $n$ 的元素)进行交换,这样最大的元素就排到了数组末尾,接着通过堆化的方法,将剩下的 $n$−1 个元素重新构建成堆;然后再取堆顶的元素,与倒数第二个元素(下标是 $n$−1 的元素)交换,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了,

image

// 堆排序
function heapSort(a, n) {
  buildHeap(a, n);
  var k = n;
  while (k > 1) {
    // 交换堆顶和最后一个元素
    swap(a, 1, k);
    --k;
    // 交换后只有堆顶元素不符合堆的特性,这里我们只需要对堆顶节点堆化即可,而不用重新构建整个堆
    heapify(a, k, 1);
  }
}

我们来分析一下堆排序的空间,时间复杂度和稳定性,

  • 堆排序是原地排序吗?是,堆排序只需要存储极个别的变量,空间复杂度是 $O(1)$
  • 堆排序的时间复杂度是多少?前面我们提到,堆排序分为建堆和排序两个过程,建堆是 $O(n)$,排序是 $O(n\log{n})$,根据加法法则,堆排序整体的时间复杂度为 $O(n\log{n})$
  • 堆排序是稳定的排序算法吗?不是,排序的过程我们会将堆顶元素与最后一个元素交换,这里是有可能改变值相同数据的原始相对顺序的

好了,现在你应该对 “堆” 这种数据结构有一定的了解了,接下来我们来深入堆的一些应用。

堆的应用

堆除了可以实现复杂度稳定的 $O(n\log{n})$ 的堆排序外,还有几个非常经典的应用:求 Top K 、优先级队列和求中位数

Top K 问题

求 Top K 问题可以抽象为两类,静态数据集合动态数据集合

针对静态数据,最经典的是,如何在包含 $n$ 个数据的数组中,查找前 $k$ 大的数据呢?

这里如果用堆来解决,思路是什么样的呢?我们可以维护一个大小为 $k$ 的最小堆,然后顺序遍历数组,从下标为 $k$ + 1 的元素开始,与堆顶元素进行比较,

  • 如果比堆顶元素大,我们把堆顶元素删除,将这个元素插入到堆中
  • 如果比堆顶元素小,则不做处理

这样数组遍历完后,堆中就是前 $k$ 大的数据,

// 交换元素
function swap(a, i, j) {
  var temp = a[i];
  a[i] = a[j];
  a[j] = temp;
}

// 堆化
function heapify(a, n, i) {
  while (true) {
    var minPos = i;
    if (2 * i <= n && a[i] > a[2 * i]) minPos = 2 * i;
    if (2 * i + 1 <= n && a[minPos] > a[2 * i + 1]) minPos = 2 * i + 1;
    if (minPos === i) break;
    swap(a, i, minPos);
    i = minPos;
  }
}

// 构建堆
function buildHeap(a, n) {
  for (var i = Math.floor(n / 2); i >= 1; --i) {
    heapify(a, n, i);
  }
  return a;
}

// 利用堆求 Top K 问题
function findTopK(a, k) {
  // 从数组下标 1 开始构建堆,减少一次获取节点时的加法操作
  a.unshift(null);
  var len = a.length;
  // 构建 k 个元素的最小堆
  var minHeap = buildHeap(a, k);
  // 从第 k + 1 个元素开始比较,如果大于堆顶元素,替换堆顶元素,再次构建堆
  // 这里和之前思路一样,替换堆顶元素后只有堆顶元素不符合堆的特性,所以我们不需要重新构建整个堆,只需要对堆顶元素再次堆化即可
  for (var i = k + 1; i < len; i++) {
    if (a[i] > minHeap[1]) {
      swap(minHeap, i, 1);
      heapify(minHeap, k, 1);
    }
  }
  // 当前堆的 (1, k + 1) 元素就是 topk
  var topk = minHeap.slice(1, k + 1);
  return topk;
}

// var a = [7, 5, 19, 8, 4, 1, 20, 13, 16, 33, 44, 5, 3, 1, 2, 23];
// var topk = findTopK(a, 5);
// console.log(topk);

上面利用堆求 Top K 的时间复杂度是多少呢?我们来简单分析一下,

首先这里我们只对 $k$ 个元素构建了最小堆,每次堆化的时间复杂度为 $O(\log{k})$,建堆 $(0, k)$ 和遍历数组 $(k + 1, n)$ 整体的时间复杂度为 $O(n)$,所以通过上述方式求 Top K 的时间复杂度为 $O(n\log{k})$,其中 $n$ 是静态数据的大小。

当然,实际的应用场景中我们可能会遇到动态数据集合,如何针对动态数据求 Top K 呢?

动态数据求 Top K 其实就是求实时 Top K,想象一下,如果我们每次都基于当前动态数据重新去计算的话,那时间复杂度就是 $O(n\log{k})$;有没有什么办法能更高效地查询 Top K 呢?

有。实际上,我们可以一直维护一个大小为 $k$ 的最小堆,当有数据被添加到集合中时,我们就拿它与堆顶元素进行对比,跟前面类似的,

  • 如果比堆顶元素大,把堆顶元素删除,将这个元素插入到堆中
  • 如果比堆顶元素小,则不做处理

这样,每次数据添加的时候,其实只做了一次常数级别的堆化处理,带来的好处是,无论任何时候查询 Top K,我们都可以在 $O(1)$ 的时间复杂度内立即返回。

优先级队列

与普通队列的先进先出(FIFO)不同,优先级队列队列中,数据的出队顺序是按照优先级来的,优先级最高的,最先出队。

我们知道优先级队列的应用场景非常多,而且很多的数据结构和算法(比如赫夫曼编码、图的最短路径、最小生成树算法等)都依赖它,那如何实现一个优先级队列呢?

毋庸置疑,最直接、最高效的方式是通过堆来实现,因为堆本身就可以看作一个优先级队列

  • 往优先级队列中插入数据,就相当于往堆中插入一个元素
  • 从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素

我们通过一个例子来直观感受下。假设我们有一个定时器,其中维护了很多的定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间,如果到达了,就拿出来执行。

但这样每过 1 秒就扫描一遍任务列表的做法其实是比较低效的,为什么?

  1. 任务的约定执行时间离当前时间可能还有很久,前面很多次扫描都是徒劳的
  2. 每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时

如何优化这个定时器,让它具有更高的性能呢?

我们可以将任务存储在优先级队列(最小堆)中,堆顶的元素就是最先执行的任务,我们取堆顶任务拿到首个任务的执行时间点,然后与当前时间点相减,得到一个时间间隔 $T$,这样定时器就可以设定在 $T$ 秒之后,等 $T$ 秒时间过去之后,定时器取优先级队列中队首的任务执行,然后再计算新的队首任务的执行时间点与当前时间点的差值 $T_1$,创建新的定时器,这样定时器就不需要每隔 1 秒轮询列表了,

// 任务列表
var taskList = [
  { name: 'taskA', timestamp: '2022-08-22 10:00:00', run: () => console.log('taskA run') },
  { name: 'taskB', timestamp: '2022-08-22 10:03:00', run: () => console.log('taskB run') },
  { name: 'taskC', timestamp: '2022-08-22 10:05:00', run: () => console.log('taskC run') },
];

// 构建最小堆
var minHeap = buildHeap(taskList, taskList.length);

// 利用堆模拟高性能定时器
function timer() {
  // 计算下一个出队任务的时间间隔
  var T = calcInterval(minHeap[1].timestamp, Date.now());
  setTimeout(() => {
    // 取堆顶任务出队执行
    var task = minHeap.shift();
    task.run();
    // 交换最后一个任务到堆顶,重新构建堆
    swap(minHeap, 1, minHeap.length - 1);
    heapify(minHeap, minHeap.length, 1);
    // 开启下一轮定时
    timer();
  }, T);
}

求中位数

对于静态数据,中位数是固定的,我们可以先排序,第 $\frac{n}{2}$ 个数据就是中位数,尽管排序的代价较大,但边际成本是很小的,但对于动态数据集合而言,中位数在不停地变动,我们再使用上面的方式效率就很低了,如何高效地求动态数据集合中的中位数呢?

这里我们可以维护两个堆,一个最大堆,一个最小堆,假设有 $n$ 个数据,我们可以从小到大进行排序,将前 $\frac{n}{2}$(n为偶数)或 $\frac{n}{2} + 1$(n为奇数)个数据存储在最大堆中,剩余数据存储在最小堆中,这样最大堆的堆顶元素就是我们要求的中位数

image

现在的问题是,数据是动态变化的,当新添加一个数据的时候,我们该如何调整两个堆,让最大堆中的堆顶元素继续是中位数呢?

这里我们可以考虑新添加数据的大小关系来判断到底应该插入到哪一个堆,

  1. 如果新加入的数据小于等于最大堆的堆顶元素,将这个新数据插入到大顶堆
  2. 否则,将这个新数据插入到最小堆

需要注意的是,为了满足两个堆中数据个数的存储约定,插入数据后,我们需要从一个堆中将堆顶元素搬移到另一个堆,保证最大堆中的元素个数满足,

  • $n$ 为偶数时,元素个数为 $\frac{n}{2}$
  • $n$ 为奇数时,元素个数为 $\frac{n}{2} + 1$

image

// 求动态数据集合中的中位数
class MedianFinder {
  constructor(a, n) {
    // 从小到大排序,然后初始化两个堆,最大堆存储前 n/2 个元素,最小堆存储剩余元素
    a.sort((i, j) => i - j < 0);
    this.maxHeap = buildHeap(a, n / 2);
    this.minHeap = buildHeap(a, n / 2 + 1);
  }

  // 往堆中添加元素
  push(a, data) {
    a.push(data);
    // 交换至堆顶,然后进行堆化
    swap(a, 1, a.length - 1);
    heapify(a, a.length, 1);
  }

  // 添加新元素
  add(data) {
    if (data <= this.maxHeap[1]) {
      this.push(this.maxHeap, data);
    } else {
      this.push(this.minHeap, data);
    }
    // 调整两个堆,让存储的元素个数满足约定
    if (this.maxHeap.length - this.minHeap.length > 1) {
      var last = this.maxHeap.pop();
      this.push(this.minHeap, last);
    } else {
      var last = this.minHeap.pop();
      this.push(this.maxHeap, last);
    }
  }

  // 获取中位数
  findMedian() {
    return this.maxHeap[1];
  }
}

以上是常见的堆的一些典型应用,当然除了最大堆和最小堆,还有 二项堆斐波那契堆 等等,它们适用于一些特殊的场景,有兴趣的同学可以去了解下,最后让我们来解答开篇的问题,如何在包含 10 亿个搜索关键词的日志文件中,快速获取到热门榜 Top 10 的搜索关键词?

当然,这里有很多其他高级的解决方案,比如 MapReduce,我们重点来分析下如何通过堆来解决这个问题。

首先用户的搜索关键字可能是重复的,这里我们可以先通过散列表,顺序扫描这 10 亿个搜索关键词,当扫描到某个关键词时,

  • 如果存在,我们将散列表中对应的次数加一
  • 如果不存在,我们将它插入到散列表,并记录次数为 1

然后,根据前面讲的用堆求 Top K 的方法,建立一个大小为 10 的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,接着与堆顶的搜索关键词进行对比。如果出现次数比堆顶搜索关键词的次数多,删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中,

// 从 10 亿搜索关键词的日志文件中,获取热门榜 Top K 的搜索关键词
function findTopk(a, k) {
  // 扫描搜索关键词,构建散列表
  var map = new Map();
  for (var keyword of a) {
    if (map.has(keyword)) {
      map.set(keyword, map.get(keyword) + 1);
    } else {
      map.set(keyword, 1);
    }
  }
  // 构建大小为 k 的最小堆
  var minHeap = buildHeap([...map].slice(0, k), k);
  // 遍历散列表,如果遍历到的搜索关键词的出现次数比当前堆顶元素的出现次数大,替换堆顶元素,重新构建堆
  map.entries((keyword, count) => {
    var top = minHeap[1];
    if (count > top[1]) {
      minHeap.push([keyword, count]);
      swap(minHeap, 1, minHeap.length - 1);
      heapify(minHeap, minHeap.length, 1);
    }
  });
  // 散列表遍历完成后,当前的堆就是我们要求的热门榜 Top K
  return minHeap.map((keywordMap) => keywordMap[0]);
}

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

React进阶系列之数据流

数据流

React 的核心特征是 数据驱动视图,这个特征在业内有一个非常有名的函数式来表达:

UI = render(data)
UI = f(data)

React 的视图会随着数据的变化而变化,我们说的组件通信其实就是组件之间建立的数据上的连接,这背后是一套环环相扣的 React 数据流解决方案。

基于 props 的单向数据流

基于 props 传参,可以实现简单的父子,子父和兄弟组件通信,所谓单向数据流,指的就是当前组件的 state 以 props 的形式流动时,只能流向组件树中比自己层级更低的组件。React 中的单向数据流场景包括,

  • 基于 props 的父子通信:父组件的 state 作为子组件的 props
  • 基于 props 的子父通信:父组件传递一个绑定自身上下文的函数
  • 基于 props 的兄弟组件通信:以父组件未桥梁,转换为子父 + 父子通信

以上是 props 传参比较适合处理的三种场景,如果通信需求较为复杂,基于 props 的单向数据流可能并不适合,我们需要考虑其他更灵活的方案,比如通信类问题的 “万金油”:发布-订阅模式。

利用 “发布-订阅” 模式驱动数据流

发布-订阅模式的优点在于,只要组件在同一个上下文里,监听事件的位置和触发事件的位置是不受限的,所以原理上我们可以基于发布订阅模式实现任意组件的通信,下面是一个简单的 EventEmitter,

class EventEmitter {
  constructor() {
    this.eventMap = {};
  }

  on(type, handler) {
    if (!(handler instanceof Function)) {
      throw new Error('event handler expect to be a function');
    }
    if (!this.eventMap[type]) {
      this.eventMap[type] = [];
    }
    this.eventMap[type].push(handler);
  }

  emit(type, params) {
    if (this.eventMap[type]) {
      this.eventMap[type].forEach((handler, index) => {
        handler(params);
      });
    }
  }

  off(type, handler) {
    if (this.eventMap[type]) {
      this.eventMap[type].splice(this.eventMap[type].indexOf(handler) >>> 0, 1);
    }
  }
}

除了上述介绍的两种方式,我们还可以使用 React 原生提供的全局通信方式 Context API。

使用 Context API 维护全局状态

Context API 是 React 官方提供的一种组件树全局通信的方式。

Context 基于生产者-消费者模式,对应到 React 中有三个关键的要素:React.createContext、Provider、Consumer。通过调用 React.createContext,可以创建出一组 Provider。Provider 作为数据的提供方,可以将数据下发给自身组件树中任意层级的 Consumer,而 Cosumer 不仅能够读取到 Provider 下发的数据,还能读取到这些数据后续的更新,

const AppContext = React.createContext();
const [Provider, Consumer] = AppContext;

<Provider value={ content: this.state.content }>
  <Content />
</Provider>

<Consumer>
  {value => <div>{value.content}</div>}
</Consumer>

下面是 Context 工作流的简单图解,

context.png

但是在 V16.3 之前,由于存在种种局限性,Context 并不被 React 官方提倡使用,旧的 Context 存在哪些局限呢?

  • 代码不够优雅:生产者需要定义 childContextTypes 和 getChildContext,消费者需要定义 contextTypes 才能通过 this.context 访问生产者提供的数据,属性设置和 API 编写过于繁琐,很难辨别谁是 Provider,谁是 Consumer

  • 数据可能无法及时同步:这个问题在 React 官方中有过介绍,如果组件提供的一个 Context 发生了变化,而中间父组件的 shouldComponentUpdate 返回了 false,那么使用到该值的后代组件不会进行更新,这违背了模式中的 “Cosumer 不仅能够读取到 Provider 下发的数据,还能读取到这些数据后续的更新” 的定义,导致数据在生产者和消费者之间可能不能及时同步。

V16.3 后新的 Context API 改进了这一点,即使组件的 shouldComponentUpdate 返回 false,它仍然可以“穿透”组件继续向后代组件进行传播,再加上更优雅的语义化声明式写法,Context 成为一种确实可行的 React 组件通信解决方案。

理解了 Context API 的前世今生,接下来我们继续串联 React 组件间通信的解决方案。

三方数据流框架的“课代表”:Redux

简单的跨层级组件通信,可以使用发布订阅模式或者 Context API 搞定,随着应用的复杂度不断提升,需要维护的状态会越来越多,组件间关系也越来越复杂,这时我们可以考虑引入三方的数据流框架,比如 Redux。

Redux 是 JavaScript 状态容器,它提供可预测的状态管理。

简单解读一下这句话,首先 Redux 是为了 Javascript 应用而生的,也就是说它不是 React 的专利,任何框架或原生 Javascript 都可以用。我们知道状态其实就是数据,所谓状态容器,就是一个存放公共数据的仓库

要理解可预测的状态管理,我们得先知道 Redux 是什么以及它的工作流是什么样的。

Redux 主要由三个部分组成:store、reducer 和 action。

  • store 是一个只读的单一数据源
  • action 是一个描述状态变化的对象
  • reducer 是一个对变化进行分发和处理的纯函数

在 Redux 的整个工作过程中,数据流是严格单向的

image.png

下面我们从编码的角度来理解 Redux 工作流,

使用 createStore 创建 store 对象

import { createStore } from 'redux';
const store = createStore(reducer, initialState, applyMiddleware());

createStore 接受三个入参:reducer、初始状态和中间件。

reducer 的作用是将新的 state 返回给 store

reducer 就是一个接受旧的状态和变化,返回一个新的状态的纯函数,没有任何副作用,

const reducer = (state, action) => newState

当我们基于 reducer 去创建 store 的时候,其实就是给这个 store 指定了一套更新规则。

action 的作用是通知 reducer “让改变发生”

action 是一个包含自身唯一标识的对象,在浩如烟海的 store 状态库中,想要命中某个希望发生改变的 state,必须使用正确的 action 来驱动,

const action = { type: 'ACTION_TYPE', payload: {} }

dispatch 用来派发 action

action 本身只是一个对象,想要让 reducer 感知到 action,还需要派发 action 这个动作,这个动作是由 store.dispatch 完成的,

store.dispatch(action)

派发 action 后,对应的 reducer 会做出响应从而触发 store 中状态的更新

相关文章

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

面试题解JavaScript(二): new的实现原理是什么

new 的实现原理是什么?

什么是 new

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。MDN

有点晦涩难懂,简单来说就是:

  • new 是一个运算符
  • new 的作用,就是执行构造函数,返回一个实例对象

实现原理

使用 new 命令时,它后面的构造函数会执行以下操作:

  1. 创建一个空的简单的 JavaScript 对象
  2. 将空对象的原型,指向构造函数的 prototype 属性
  3. 将当前函数内部的 this 绑定到空对象
  4. 执行构造函数,如果没有返回对象,则返回 this,即新创建的对象

步骤 3 中,将新创建的对象作为了当前函数 this 的上下文,这也是为什么通过 new 创建实例时,构造函数内部的 this 指向创建的实例对象的原因。

Tips: 构造函数之所以叫 “构造函数”,可能也是因为这个函数的目的,就是操作一个空对象(this),然后将它 “构造” 成我们想要的样子吧。 -- 阮一峰

模拟实现一个 new

由于 new 是保留字,我们没有办法直接覆盖,所以我们创建一个函数 _new,来模拟 new 的实现,调用时,new Foo(args) 等同于 _new(Foo, args)

function _new() {
  let constructor = [].shift.call(arguments);
  let context = Object.create(constructor.prototype);
  let result = constructor.apply(context, arguments);
  return (typeof result === 'object' && typeof result !== null) ? result : context;
}

上面的实现中,

  1. 首先取出构造函数
  2. 创建一个空对象,继承构造函数的 prototype 属性,相当于将空对象的原型指向构造函数的原型:
context.__proto__ = constructor.prototype
  1. 执行构造函数,获取返回值
  2. 判断返回值类型,如果没有返回对象,返回 context ,即当前的 this

判断返回值的类型是有必要的,因为如果构造函数返回一个对象,new 命令也会返回这个对象

function foo(name) {
  this.name = name
  return { name: 'foo' }
}

(new foo('bar')).name // foo

Reflect.construct

新的语法中,还有一个和 new 操作符行为相似的方法:Reflect.construct

区别在于,Reflect.construct 允许使用可变参数来调用构造函数:

let obj = Reflect.construct(Foo, args)

上面的代码和 new 搭配展开运算符是等价的:

let obj = new Foo(...args)

此外,Reflect.construct 还可以替代 Object.create 来创建一个对象 Reflect.construct

下一篇文章

面试题解JavaScript(三):call,apply 及 bind 函数的内部实现是什么样的

勘误与提问

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

React Fiber树的构建

8462732e7041db333e82f54229faf558

前面我们介绍了 React架构,为了实现可中断的异步更新v16 使用 Fiber 作为核心架构进行了重构,

接下来我们将深入 Fiber 树的构建,正式开始之前,我们先回顾下 Fiber 相关的一些术语,

  • Fiber 架构下,React 架构分为三层 SchedulerReconcilerRenderer
  • Reconciler 工作的阶段被称为 render 阶段,在该阶段会调用组件的 render 方法
  • Renderer 工作的阶段被称为 commit 阶段,在阶段会把 render 阶段提交的信息渲染到页面
  • rendercommit 阶段统称为 work ,如果任务正在 Scheduler 内调度,则不属于work

Fiber 树的构建发生在 render 阶段,也就是 Reconciler 工作的阶段,我们知道 Fiber Reconciler 是由 Stack Reconciler 重构而来,通过遍历的方式实现可中断的递归,所以 Fiber Reconciler 的工作可以分为两个部分:“递”“归”

可中断的递和归

render 阶段开始于 performSyncWorkOnRoot performConcurrentWorkOnRoot 方法,

分别会调用 workLoopSyncworkLoopConcurrent,这取决于本次更新是同步更新还是异步更新

// 如果是同步更新,会调用 performSyncWorkOnRoot,performSyncWorkOnRoot 会调用 workLoopSync
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// 如果是异步更新,会调用 performConcurrentWorkOnRoot,performConcurrentWorkOnRoot 会调用 workLoopConcurrent
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

可以看到,它们都会调用 performUnitOfWork ,唯一的区别是是否判断 shouldYield

如果浏览器没有空闲时间,shouldYield 会终止循环,直到浏览器有空闲时间后再继续遍历。

performUnitOfWork 就是递和归的起点,参数 workInProgress 就是我们之前介绍的正在构建中的 Fiber 树,

递归遍历的流程为,

  1. 首先进入“递”阶段,从 rootFiber 开始向下进行深度优先遍历,为遍历到的每个节点调用 beginWork 方法
  2. beginWork 会根据传入的 Fiber 节点创建子 Fiber 节点,并将这两个 Fiber 节点连接起来
  3. 遍历到叶子节点时进入“归”阶段
  4. 归”阶段会为每个节点调用 completeWork
  5. 如果节点存在兄弟(sibling)节点,进入兄弟节点的“递”阶段,如果不存在,进入父节点的“归”阶段
  6. 递”和“归”阶段会交错执行直到“归”到 rootFiber

为了方便理解,我们以下面的代码为例,

function App() {
  return (
    <div>
      Monch
      <span>Lee</span>
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById("root"));

对应的 Fiber 树结构如下图,我们把节点的 beginWorkcompleteWork 打印出来,

image

render 阶段会依次执行,

image

1. rootFiber beginWork
2. App Fiber beginWork
3. div Fiber beginWork
4. "Monch" Fiber beginWork
5. "Monch" Fiber completeWork
6. span Fiber beginWork
7. span Fiber completeWork
8. div Fiber completeWork
9. App Fiber completeWork
10. rootFiber completeWork

你可能会疑惑,为什么没有 “Lee” Fiber?

原因是作为一种性能优化手段,React 会特殊处理只有单一文本子节点Fiber

render 阶段的递归本质上就是 beginWorkcompleteWork,我们先从 beginWork 开始。

beginWork

函数 beginWork 的定义如下,你可以从源码的这里找到完整的定义,

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...
}

前面我们介绍,beginWork 的工作是:传入当前 Fiber 节点,创建子 Fiber 节点

整体流程如下,

image

beginWork 接受三个参数,其中,

  • current,当前组件对应的 Fiber 节点在上一次更新时的 Fiber 节点,即 workInProgress.alternate
  • workInProgress,当前组件对应的 Fiber 节点
  • renderLanes,当前 Fiber 节点的优先级

深入函数体之前,先思考一个问题🤔 ,对于一个 Fiber 节点而言,可能存在首次渲染更新两种情况,我们要怎么区分呢?

React Fiber架构 中我们介绍到,Fiber 架构是基于双缓存技术,React 中最多同时存在两颗 Fiber 树,除了 rootFiber 外,首次渲染 mount 时,是不存在当前 Fiber 节点在上一次更新时的 Fiber 节点的,

所以我们可以通过判断当前的 current Fiber 树是否为 null 来决定是 mount 还是 update

如果 current !== null,说明是 update,React 会尝试复用节点,否则会创建节点,

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  // 如果 current 存在,说明是 update,此时可能存在优化路径,React 会尝试复用 current(即上一次更新的 Fiber 节点)
  if (current !== null) {
    // 尝试复用 current
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  } else {
    // 如果 current 不存在,说明是 mount,此时会创建子 Fiber 节点
    didReceiveUpdate = false;
  }

  // mount时,根据 tag 不同,创建不同的子 Fiber 节点
  switch (workInProgress.tag) {
    case IndeterminateComponent:
    // ...
    case FunctionComponent:
    // ...
    case ClassComponent:
    // ...
    case HostComponent:
    // ...
  }
}

什么情况下可以复用节点呢?

节点在满足 didReceiveUpdate === false 时 React 会尝试复用,

if (current !== null) {
  const oldProps = current.memoizedProps;
  const newProps = workInProgress.pendingProps;

  if (
    oldProps !== newProps ||
    hasLegacyContextChanged() ||
    (__DEV__ ? workInProgress.type !== current.type : false)
  ) {
    didReceiveUpdate = true;
  } else if (!includesSomeLane(renderLanes, updateLanes)) {
    didReceiveUpdate = false;
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  } else {
    didReceiveUpdate = false;
  }
} else {
  didReceiveUpdate = false;
}

也就是需要满足以下情况,

// props 相同,节点类型相同,节点的优先级不够
oldProps === newProps && workInProgress.type === current.type && !includesSomeLane(renderLanes, updateLanes)

这与我们前面介绍的 O(n) 的启发式 Diffing 算法的假设是相呼应的,只有类型相同的元素会继续 Diffing

不满足复用条件时,React 会根据节点类型(tag)调用对应的方法创建子节点,最终都会调用 reconcileChildren

reconcileChildren

reconcileChildren 会创建新的 Fiber 节点,与 beginWork 类似,通过 current === null 区分是 mount 还是 update

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes
    );
  }
}
  1. 如果是 mount,创建新的子 Fiber 节点
  2. 如果是 update,调用 Diffing 算法,将比较的结果生成新 Fiber 节点

不论走哪个逻辑,最终都会生成新的子 Fiber 节点并赋值给 workInProgress.child

这个值会作为本次 beginWork返回值,及下次 performUnitOfWork 执行时 workInProgress传参

mountChildFibersreconcileChildFibers 逻辑基本一致,唯一的区别是后者会为生成的 Fiber 带上 effectTag 属性,

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

function ChildReconciler(shouldTrackSideEffects) {
  // ...
}

什么是 effectTag 呢?

effectTag

我们知道,render 阶段是在内存中进行的,结束后会通知 Renderer 需要执行的 DOM 操作,

这里的 effectTag 就是要执行 DOM 操作的具体类型

export const Placement = /*                */ 0b00000000000010; // 插入
export const Update = /*                   */ 0b00000000000100; // 更新
export const PlacementAndUpdate = /*       */ 0b00000000000110; // 插入并更新
export const Deletion = /*                 */ 0b00000000001000; // 删除
// ...

这里通过二进制表示 effectTag,是为了方便的使用位操作为 fiber.effectTag 赋值多个 effect

前面我们提到,mountChildFibers 不会为 Fiber 节点赋值 effectTag那么首屏渲染是如何完成呢?

实际上,mount 时只会对 rootFiber 赋值 Placement effectTag

这样可以保证在 commit 阶段只需要一次 DOM 插入就完成整个 DOM 树的首屏渲染,

function ChildReconciler(shouldTrackSideEffects) {
  // ...

  // reconcileChildFibers 会调用 placeChild 等方法为递归到的 Fiber 节点打上 effectTag
  function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number
  ): number {
    if (current !== null) {
      // ...
    } else {
      newFiber.effectTag = Placement;
      return lastPlacedIndex;
    }
  }

  // mountChildFibers 只会为 rootFiber 打上一个 Placement effectTag,作为一种优化手段,在 commit 阶段一次性完成首屏渲染
  function placeSingleChild(newFiber: Fiber): Fiber {
    if (shouldTrackSideEffects && newFiber.alternate === null) {
      newFiber.effectTag = Placement;
    }
    return newFiber;
  }
}

要插入 effectTag,需要 Fiber 节点中保存对应的 DOM 节点,也就是 stateNode 属性,它会在 completeWork 中创建。

completeWork

类似于 beginWorkcompleteWork 也是针对不同的 fiber.tag 调用不同的处理逻辑,

image

源码 completeWork 的定义如下,

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case IndeterminateComponent:
    case FunctionComponent:
    case Fragment:
    case MemoComponent:
      return null;
    case ClassComponent: {
      // ...
      return null;
    }
    case HostRoot: {
      // ...
      updateHostContainer(workInProgress);
      return null;
    }
    case HostComponent: {
      // ...
      return null;
    }
  }
}

我们以页面渲染必须的 HostComponent 为例,看下 completeWork 都做了哪些工作。

HostComponent

beginWork 类似,可以通过 current === null? 判断当前是处于 mount 还是 update 流程,

由于 update 时依赖真实的 DOM 节点,所以还要考虑 workInProgress.stateNode != null ?

case HostComponent: {
  popHostContext(workInProgress);
  const rootContainerInstance = getRootHostContainer();
  const type = workInProgress.type;

  // 如果 current 存在并且 Fiber 节点存在对应的 DOM 节点
  if (current !== null && workInProgress.stateNode != null) {
    // update
  } else {
    // mount
  }
  return null;
}

mount 中存在和 update 类似的流程,这里我们先看 completeWorkupdate 流程。

update

根据前面的判断条件,update 时,Fiber 节点已经存在 DOM 节点,所以不需要创建 DOM,

update 会调用 updateHostComponent 来处理 props,包括,

updateHostComponent = function (
  current: Fiber,
  workInProgress: Fiber,
  type: Type,
  newProps: Props,
  rootContainerInstance: Container
) {
  const oldProps = current.memoizedProps;
  if (oldProps === newProps) {
    return;
  }

  const instance: Instance = workInProgress.stateNode;
  const currentHostContext = getHostContext();
  const updatePayload = prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
    currentHostContext
  );

  // 处理完的 props 会被赋值给 workInProgress.updateQueue,最终会在 commit 阶段被渲染在页面上
  // updatePayload 为数组形式,偶数索引的值为变化的 prop key,奇数索引的值为变化的 prop value
  workInProgress.updateQueue = (updatePayload: any);
  if (updatePayload) {
    markUpdate(workInProgress);
  }
};
  1. onClickonChange 等回调函数的注册
  2. 处理 style prop
  3. 处理 DANGEROUSLY_SET_INNER_HTML prop
  4. 处理 children prop

如果是 mount ,流程会有所不同。

mount

mount 流程的主要逻辑包含三个,

  1. Fiber 节点生成对应的 DOM 节点
  2. 将子孙 DOM 节点插入刚生成的 DOM 节点中
  3. update 阶段的 updateHostComponent 类似,处理 props
const currentHostContext = getHostContext();
// 为 Fiber 创建对应 DOM 节点
const instance = createInstance(
    type,
    newProps,
    rootContainerInstance,
    currentHostContext,
    workInProgress,
  );
// 将子孙 DOM 节点插入刚生成的 DOM 节点中
appendAllChildren(instance, workInProgress, false, false);
// DOM 节点赋值给 fiber.stateNode
workInProgress.stateNode = instance;

// 处理 props
if (
  finalizeInitialChildren(
    instance,
    type,
    newProps,
    rootContainerInstance,
    currentHostContext,
  )
) {
  markUpdate(workInProgress);
}

这里的 appendAllChildren 就是我们前面提到的离屏 DOM 树创建的关键,每次调用 appendAllChildren 都会将已生成的子孙 DOM 节点插入当前生成的 DOM 节点下,当递归到 rootFiber 时,我们就可以得到一个构建好的离屏 DOM 树,这样 commit 阶段就可以只通过一次插入 DOM 的操作将整棵 DOM 树插入到页面。

最后,所有存在 effectTagFiber 会生成 effectList

effectList

effectList 是一个单向链表,在 completeWork 的上层函数 completeUnitOfWork 中构造,每个执行完 completeWork 且存在 effectTagFiber 节点会被保存链表中,为什么需要缓存 effectTag 呢?

这里的原因也是为了提效。

作为 DOM 操作的依据,commit 阶段需要找到所有有 effectTagFiber 节点并依次执行 effectTag 对应操作,如果不缓存,则需要再遍历一次 Fiber 树寻找 effectTag !== null 的节点,所以为了避免这种低效的操作,所有有 effectTag 的Fiber节点都会被追加在 effectList 中,最终形成一条以 rootFiber.firstEffect 为起点的单向链表,

image

这样,在 commit 阶段只需要遍历 effectList 就能执行所有 effect

小结

completeWork 的工作是对上一个节点 Diffing 完成后进行一些收尾工作,会根据不同的 tag 执行 mountupdate 操作,

如果节点需要 mount,会为其创建对应的 DOM 节点并赋值给 fiber.stateNode,这个过程会将子孙 DOM 节点依次插入生成离屏的 DOM 树,同时还会初始化 DOM 对象的事件监听器及内部属性。

如果节点需要 update,会 diff props,返回一个需要更新的属性名组成的数组然后赋值给 workInProgress.updateQueue

最后有 effectTagfiber 会生成一个单向链表 effectList 挂载到父级 fiber,并返回下一个 workInProgress

render 阶段工作完成后,fiberRootNode 会被传递给 commitRoot方法,开启 commit 阶段的工作流程。

commitRoot(root);

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

面试官:手写斐波那契数列

斐波那契数列(Fibonacci sequence),也叫黄金分割数列,兔子数列,定义为,

F(0) = 1,F(1) = 1, F(n) = F(n - 1) + F(n - 2)(n > 2,n ∈ N*

数列从第三项开始,每一项都等于前两项之和。

普通递归

function Fibonacci(n) {
  if (n <= 2) return n;
  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

每次递归都会创建新函数,导致调用栈过长,n 较大时就会导致堆栈溢出

尾调用优化

尾调用:函数的最后一个动作是一个函数调用,为了实现尾调用,我们可以使用柯里化改写函数

"use strict"; // ES6中,尾调用优化只会在严格模式下开启
function Fibonacci(n, acc1 = 1, acc2 = 1) {
  if (n <= 2) return acc2;
  return Fibonacci(n - 1, acc2, acc1 + acc2);
}

循环

所有的递归都可以用循环实现,

function Fibonacci(n) {
  if (n <= 2) return acc2;
  let acc1 = 1, acc2 = 1, sum;
  for (let i = 2; i < n; i++) {
    sum = acc1 + acc2;
    acc1 = acc2;
    acc2 = sum;
  }
  return sum;
}

解构优化循环

function Fibonacci(n) {
  if (n <= 2) return acc2;
  let acc1 = 1, acc2 = 1;
  for (let i = 2; i < n; i++) {
    [acc1, acc2] = [acc2, acc1 + acc2];
  }
  return acc2;
}

可能是最好的 Event Loop 解析了... ...

先写点零碎的思路,待整合完善

JavaScript是单线程的

CPU,进程,线程,浏览器,渲染进程(Tab,浏览器内核),GUI渲染线程,JS引擎线程

为什么?

  • 创建 JavaScript 时,多进程多线程架构不流行,硬件支持不好
  • 多线程复杂性高,操作需要加锁,导致编码复杂性高
  • JavaScript 定位为脚本语言,可以操作 DOM,如果多线程又不加锁的情况下,可能会导致 DOM 渲染不可预期

所以 GUI渲染线程和JS引擎线程是互斥的,JS引擎执行时,GUI渲染线程会被挂起

单线程带来了什么?

所有任务都需要排队,一个接一个执行

精读《Promises/A+》规范

1_owmzjJxRWRlE3S-T9EQGPg.jpeg

一位不愿意透露姓名的顶级摸鱼工程师曾经说过,学习 Promise 最好的方式就是先阅读它的规范定义。那么哪里可以找到 Promise 的标准定义呢?

答案是 Promises/A+ 规范

假设你已经打开了上述的规范定义的页面并尝试开始阅读(不要因为是英文的就偷偷关掉,相信自己,你可以的),规范在开篇描述了 Promise 的定义,与之交互的方法,然后强调了规范的稳定性。关于稳定性,换言之就是:我们可能会修订这份规范,但是保证改动微小且向下兼容,所以放心地学吧,这就是权威标准,五十年之后你再去谷歌 Promise,出来的规范还是这篇 😂。

好的,让我们回到规范。从开篇的介绍看,到底什么是 Promise ?

A promise represents the eventual result of an asynchronous operation.

Promise 表示一个异步操作的最终结果

划重点!!这里其实引出了 JavaScript 引入 Promise 的动机:异步

学习一门新技术,最好的方式是先了解它是如何诞生的,以及它所解决的问题是什么。Promise 跟我们说的异步编程有什么联系呢?Promise 到底解决了什么问题?

要回答这些问题,我们需要先回顾下没有 Promise 之前,异步编程存在什么问题?

异步编程

JavaScript 的异步编程跟浏览器的事件循环息息相关,网上有很多的文章或专栏介绍了浏览器的事件循环机制,如果你还不了解,可以先阅读下面的文章,

假设你已经了解了事件循环,接下来我们来看异步编程存在什么问题?

由于 Web 页面的单线程架构,决定了 JavaScript 的异步编程模型是基于消息队列(Message Queue)和事件循环(Event Loop)的,就像下面这样,

image.png

我们的异步任务的回调函数会被放入消息队列,然后等待主线程上的同步任务执行完成,执行栈为空时,由事件循环机制调度进执行栈继续执行。

这导致了 JavaScript 异步编程的一大特点:异步回调,比如网络请求,

// 成功的异步回调函数
function resolve(response) {
  console.log(response);
}
// 失败的异步回调函数
function reject(error) {
  console.log(error);
}

var xhr = new XMLHttpRequest();

xhr.onreadystatechange = () => resolve(xhr.response);
xhr.ontimeout = (e) => reject(e);
xhr.onerror = (e) => reject(e);

xhr.open("Get", "http://xxx");
xhr.send();

虽然可以通过简单的封装使得异步回调的方式变得优雅,比如,

$.ajax({
  url: "https://xxx",
  method: "GET",
  fail: () => {},
  success: () => {},
});

但是仍然没有办法解决业务复杂后的“回调地狱”的问题,比如多个依赖请求,

$.ajax({
  success: function (res1) {
    $.ajax({
      success: function (res2) {
        $.ajax({
          success: function (res3) {
            // do something...
          },
        });
      },
    });
  },
});

这种线性的嵌套回调使得异步代码变得难以理解和维护,也给人很大的心智负担。
所以我们需要一种技术,来解决异步编程风格的问题,这就是 Promise 的动机。

了解 Promise 背景和动机有利于我们理解规范,现在让我们重新回到规范的定义。

规范

Promise A+ 规范首先定义了 Promise 的一些相关术语和状态。

Terminology,术语

  1. “promise” ,一个拥有 then 方法的对象或函数,其行为符合本规范
  2. “thenable”,一个定义了 then 方法的对象或函数
  3. “value”,任何 JavaScript 合法值(包括 undefinedthenablepromise
  4. “exception”,使用 throw 语句抛出的一个值
  5. “reason”,表示一个 promise 的拒绝原因

State,状态

promise 的当前状态必须为以下三种状态之一:PendingFulfilledRejected

  • 处于 Pending 时,promise 可以迁移至 Fullfilled 或 Rejected
  • 处于 Fulfilled 时,promise 必须拥有一个不可变的终值且不能迁移至其他状态
  • 处于 Rejected 时,promise 必须拥有一个不可变的拒绝原因且不能迁移至其他状态

所以 Promise 内部其实维护了一个类似下图所示的状态机,

image.png

Promise 在创建时处于 Pending(等待态),之后可以变为 Fulfilled(执行态)或者 Rejected(拒绝态),一个承诺要么被兑现,要么被拒绝,这一过程是不可逆的。

定义了相关的术语和状态后,是对 then 方法执行过程的详细描述。

Then

一个 promise 必须提供一个 then 方法以访问其当前值、终值和拒绝原因。

then 方法接受两个参数,

promise.then(onFulfilled, onRejected);
  • onFulfilled,在 promise 执行结束后调用,第一个参数为 promise 的终值
  • onRejected,在 promise 被拒绝执行后调用,第一个参数为 promise 的拒绝原因

对于这两个回调参数和 then 的调用及返回值,有如下的一些规则,

  1. onFulfilled 和 onRejected 都是可选参数。

  2. onFulfilled 和 onRejected 必须作为函数被调用,调用的 this 应用默认绑定规则,也就是在严格环境下,this 等于 undefined,非严格模式下是全局对象(浏览器中就是 window)。关于 this 的绑定规则如果不了解的可以参考我之前的一篇文章 《可能是最好的 this 解析了...》,里面有非常详细地介绍。

  3. onFulfilled 和 onRejected 只有在执行环境堆栈仅包含平台代码时才可被调用。由于 promise 的实施代码本身就是平台代码(JavaScript),这个规则可以这么理解:就是要确保这两个回调在 then 方法被调用的那一轮事件循环之后异步执行。这不就是微任务的执行顺序吗?所以 promise 的实现原理是基于微任务队列的。

  4. then 方法可以被同一个 promise 调用多次,而且所有的成功或拒绝的回调需按照其注册顺序依次回调。所以 promise 的实现需要支持链式调用,可以先想一下怎么支持链式调用,稍后我们会有对应的实现。

  5. then 方法必须返回一个 promise 对象。

针对第 5 点,还有如下几条扩展定义,我们将返回值与 promise 的解决过程结合起来,

promise2 = promise1.then(onFulfilled, onRejected);

then 的两个回调参数可能会抛出异常或返回一个值,

5.1 如果 onFulfilled 或者 onRejected 抛出一个异常 e,那么返回的 promise2 必须拒绝执行,并返回拒绝的原因 e

5.2 如果 onFulfilled 或者 onRejected 返回了一个值 x,会执行 promise 的解决过程

  • 如果 x 和返回的 promise2 相等,也就是 promise2 和 x 指向同一对象时,以 TypeError 作为拒绝的原因拒绝执行 promise2
  • 如果 x 是 promise,会判断 x 的状态。如果是等待态,保持;如果是执行态,用相同的值执行 promise2;如果是拒绝态,用相同的拒绝原因拒绝 promise2
  • 如果 x 是对象或者函数,将 x.then 赋值给 then;如果取 x.then 的值时抛出错误 e ,则以 e 为拒绝原因拒绝 promise2。如果 then 是函数,将 x 作为函数的 this,并传递两个回调函数 resolvePromise, rejectPromise 作为参数调用函数

读到这里,相信你跟我一样已经迫不及待想要实现一个 Promise 了,既然了解了原理和定义,我们就来手写一个 Promise 吧。

手写 Promise

const PENDING = "PENDING";
const FULFILLED = "FULFILLED";
const REJECTED = "REJECTED";

function resolve(value) {
  return value;
}

function reject(err) {
  throw err;
}

function resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(
      new TypeError("Chaining cycle detected for promise #<Promise>")
    );
  }
  let called;
  if ((typeof x === "object" && x != null) || typeof x === "function") {
    try {
      let then = x.then;
      if (typeof then === "function") {
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(x);
      }
    } catch (e) {
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    resolve(x);
  }
}

class Promise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    this.resolveCallbacks = [];
    this.rejectCallbacks = [];

    let resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        this.resolveCallbacks.forEach((fn) => fn());
      }
    };

    let reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        this.rejectCallbacks.forEach((fn) => fn());
      }
    };

    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : resolve;
    onRejected = typeof onRejected === "function" ? onRejected : reject;
    let promise2 = new Promise((resolve, reject) => {
      if (this.status === FULFILLED) {
        setTimeout(() => {
          try {
            let x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      }

      if (this.status === REJECTED) {
        setTimeout(() => {
          try {
            let x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      }

      if (this.status === PENDING) {
        this.resolveCallbacks.push(() => {
          setTimeout(() => {
            try {
              let x = onFulfilled(this.value);
              resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });

        this.rejectCallbacks.push(() => {
          setTimeout(() => {
            try {
              let x = onRejected(this.reason);
              resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
      }
    });

    return promise2;
  }
}

小结

我们从 Promise A+ 规范作为切入点,先探索了 Promise 诞生的背景和动机,了解了异步编程的发展历史,然后回到规范精读了其中对于相关术语,状态及执行过程的定义,最后尝试了简版的 Promise 实现。最新的 《JavaScript高级程序设计(第4版)》 中,将 Promise 翻译为 “承诺”,作为现代 JavaScript 异步编程的方案,Promise 通过回调函数延迟绑定、回调函数返回值穿透和错误“冒泡”等技术解决了多层嵌套的问题,规范了对异步任务的处理结果(成功或失败)的统一处理。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

JavaScript语法

虽然标准中写的 == 十分复杂,但是归根结底,类型不同的变量比较时 == 运算只有 3 条规则:

  • undefinednull 相等;
  • 字符串和 bool 都转为数字再比较;
  • 对象转换成 primitive 类型再比较,如果转换后与右边类型相同,不再转为数字

2023 年了,你为什么还不用 SWR ?

故事的开始,2023 年的一个早晨,你刚到公司座位坐下,PM 扔给了你一个需求,需要编写一个 React 应用,从接口获取一个列表的数据并渲染到页面。身经百战的你打开 Visual Studio Code 完成了项目的初始化,考虑到网络请求属于一个渲染副作用,于是你毫不犹豫的选择了 useEffect 进行数据的获取,仅用了一分钟,你就完成了代码编写,

type ListItem = {
  id?: string | number;
  name?: string;
};

function App() {
  const [list, setList] = useState<ListItem[]>([]);

  useEffect(() => {
    fetch("/api/list")
      .then((res: Response) => res.json())
      .then((data: ListItem[]) => setList(data));
  }, []);

  return (
    <ul>
      {list.map((item: ListItem) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

成就满满的你熟练地打开 terminal,敲下 npm run dev,列表成功渲染,

image

Loading State

你刷新了一次页面,发现首次加载到数据获取完成期间,页面出现了短暂的白屏,用户体验很不好,身为一个 "将极致的用户体验和最佳的工程实践作为探索的目标" 的前端工程师,你决定实现一个加载中的进度提示,于是引入了一个新的状态 isLoading,考虑到列表结构稳定,更好的视觉效果和用户体验,你选择了骨架屏作为加载提示,

function App() {
  const [list, setList] = useState<ListItem[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
    fetch("/api/list")
      .then((res: Response) => res.json())
      .then((data: ListItem[]) => {
        setIsLoading(false);
        setList(data);
      });
  }, []);

  // 加载状态,数据获取期间展示骨架屏
  if (isLoading) {
    return <Skeleton />;
  }

  return (
    <ul>
      {list.map((item: ListItem) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}
image

Error State

加载状态虽然有了,但是你又意识到,接口还有可能报错,你还需要在数据请求出错时显示错误提示,必要时可能还需要上报错误日志,于是你又引入了一个新的状态 error,处理数据请求失败的情况,

function App() {
  const [list, setList] = useState<ListItem[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
    fetch("/api/list")
      .then((res: Response) => res.json())
      .then((data: ListItem[]) => {
        setIsLoading(false);
        setList(data);
      })
      .catch((error) => {
        setError(true);
        setIsLoading(false);
      });
  }, []);

  // 加载状态,数据获取期间展示骨架屏
  if (isLoading) {
    return <Skeleton />;
  }

  // 数据请求出错
  if (error) {
    // 上报错误...
    // 支持重试...
    return <div>请求出错啦~</div>;
  }

  return (
    <ul>
      {list.map((item: ListItem) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Custom Hook

后来,你发现每个需要从接口获取数据的场景都要写上面类似的代码,不仅重复而且繁琐,机智的你想到了自定义 hook,于是你决定将数据请求的逻辑封装为一个 useFetchhook

type FetchOptions = {
  method?: string;
};

function useFetch(url: string, options: FetchOptions) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
    fetch(url, options)
      .then((res) => res.json())
      .then((data) => {
        setIsLoading(false);
        setData(data);
      })
      .catch((err) => {
        setError(true);
        setIsLoading(false);
      });
  }, [url, options]);

  return { data, isLoading, error };
}

这种方式非常有用,你在项目中大量地使用了 useFetch,数据请求的模板代码减少了很多,逻辑也更加简洁,

function ComponentFoo() {
  const { data, isLoading, error } = useFetch("/api/foo");

  if (isLoading) {
    // ...
  }

  if (error) {
    // ...
  }
}

function ComponentBar() {
  const { data, isLoading, error } = useFetch("/api/bar");

  if (isLoading) {
    // ...
  }

  if (error) {
    // ...
  }
}

Request Race

你非常有成就感,useFetch 真是太好用了,直到有一天 PM 又扔给了你一个需求,这次你需要实现点击某个列表项的时候显示对应的详情,结果测试的时候你发现,快速地在多个列表项间切换点击时,有时候你点击的是下一个列表项,页面确渲染了上一个列表项的详情。机智的你很快就找到了原因,因为你没有在 useEffect 中声明如何清除你的副作用,发送网络请求是一个异步的行为,收到服务器数据的顺序并不一定是网络请求发送时的顺序,导致出现了 Race Condition

| =============== Request Detail 1 ===============> | setState()
| ===== Request Detail 2 ====> | setState() |

比如上面的第二个列表项详情数据返回比第一个快的情况,你的 data 就会被前一个数据覆盖,

于是你在 useFetch 里面写了一个清除副作用的逻辑,

function useFetch(url: string, options: FetchOptions) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<boolean>(false);

  useEffect(() => {
    let isCancelled = false;

    setIsLoading(true);
    fetch(url, options)
      .then((res) => res.json())
      .then((data) => {
        if (!isCancelled) {
          setIsLoading(false);
          setData(data);
        }
      })
      .catch((err) => {
        if (!isCancelled) {
          setError(true);
          setIsLoading(false);
        }
      });

    return () => {
      isCancelled = true;
      setIsLoading(false);
    };
  }, [url, options]);

  return { data, isLoading, error };
}

AbortController

感谢 JavaScript 闭包的力量,现在即使出现了请求的 Race Condition,你的数据也不会被覆盖掉了,不仅如此,机智的你还想到了在清除副作用时检测下浏览器是否支持 AbortController,如果支持的话尝试取消请求,

const isAbortControllerSupported: boolean = typeof AbortController !== "undefined";

function useFetch(url: string, options: FetchOptions) {
  // ...

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    setIsLoading(true);
    fetch(url, options).then({
      // ...
    });

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    };
  }, [url, options]);

  return { data, isLoading, error };
}

Cache

你迫不及待地将新的 useFetch 用在了列表中,然后来回地切换列表项,这次详情数据终于没有被覆盖了,但每次切换都会由于 url 改变,导致 useEffect 重新执行,触发一次新的网络请求,实际上频繁快速地切换触发的网络请求是不必要的,你考虑为
useFetch 加一个缓存,

const isAbortControllerSupported = typeof AbortController !== "undefined";
// 使用 Map 更快的访问缓存
const cache = new Map();

function useFetch(url: string, options: FetchOptions) {
  // ...

  useEffect(() => {
    // ...

    // 如果有缓存数据,不再发起网络请求
    if (cache.has(url)) {
      setData(cache.get(url));
      setIsLoading(false);
    } else {
      setIsLoading(true);
      fetch(url, options)
        .then((res) => res.json())
        .then((data) => {
          if (!isCancelled) {
            // 缓存 url 对应的接口数据
            cache.set(url, data);
            setData(data);
            setIsLoading(false);
          }
        });
      // ...
    }

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    };
  }, [url, options]);

  return { data, isLoading, error };
}

Cache Refresh

知名前 Netscape 工程师 Phil Karlton 曾说过,

There are only two hard things in Computer Science: cache invalidation and naming things.

一旦引入缓存,就需要考虑缓存失效的问题,什么时候刷新缓存,否则我们的 UI 显示的数据就可能会过时,机智的你想到了可以在下面的这些时机去刷新缓存,

  • 标签页失去焦点
  • 定时重复更新
  • 网络状态改变
  • ...

以上的缓存刷新方式对应了不同的应用场景,正常来说你应该让 useFetch 全部支持,为了让自己还能有精力多搬几年砖,你决定先实现一个标签页失去焦点的缓存刷新,

const isAbortControllerSupported = typeof AbortController !== "undefined";
const cache = new Map();
const isSupportFocus = typeof document !== "undefined" && typeof document.hasFocus === "function";

function useFetch(url: string, options: FetchOptions) {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const removeCache = useCallback(() => {
    cache.delete(url);
  }, [url]);

  const revalidate = useCallback(() => {
    // 重新 fetch 数据更新缓存
  }, []);

  useEffect(() => {
    const onBlur = () => removeCache();
    const onFocus = () => revalidate();

    window.addEventListener("focus", onFocus);
    window.addEventListener("blur", onBlur);

    return () => {
      window.removeEventListener("focus", onFocus);
      window.removeEventListener("blur", onBlur);
    };
  });

  // fetch 相关逻辑
  // useEffect(() => ...

  return { data, isLoading, error };
}

Concurrent Rendering

实现了缓存的 useFetch 如虎添翼,你本以为从此数据请求可以高枕无忧了,但是你发现你使用了新版本的 React 18 Concurrent Rendering,这个模式下,低优先级的任务在 render 阶段可能会被打断、暂停甚至终止,而我们在实现 useFetch 缓存的时候,cache 是一个全局变量,一个 useFetch 调用 cache.set 后无法通知其他 useFetch 更新,可能会导致多个组件缓存数据的不一致,

试想下面的场景,我们开启了 Concurrent Mode,渲染了两个组件 <Foo /><Bar /> 都使用了 useFetch 从同一个 url 获取数据,它们共享一份缓存数据,但 React 为了响应用户在 <Bar /> 组件更高优先级的交互,暂停了 <Foo /> 的更新,导致了两个组件更新是不同步的,而恰巧在这两次更新期间,<Bar /> 调用了 useFetch 导致缓存刷新,发上了改变,但 <Foo /> 仍然使用的是上次缓存的数据,导致了最终的缓存不一致。

为了解决这个问题,你需要重写 cache 实现,在缓存更新时通知同一个 urluseFetch 自动执行来保持缓存一致性,机智的你还发现 React 18 提供了一个 useSyncExternalStorehook 来订阅外部的更新,

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

于是你打算再折腾一下,基于 useSyncExternalStore 重新实现了 cache

const cache = {
  __internalStore: new Map(),
  __listeners: new Set(),
  set(key) {
    this.__internalStore.set(key);
    this.__listeners.forEach((listener) => listener());
  },
  delete(key) {
    this.__internalStore.delete(key);
    this.__listeners.forEach((listener) => listener());
  },
  subscribe(listener) {
    this.__listeners.add(listener);
    return () => this.__listeners.delete(listener);
  },
  getSnapshot() {
    return this.__internalStore;
  },
};

function useFetch(url: string, options: FetchOptions) {
  // 获取最新同步的 cache
  const currentCache = useSyncExternalStore(
    cache.subscribe,
    useCallback(() => cache.getSnapshot().get(url), [url]),
  );

  // 缓存刷新逻辑
  // useEffect(() => {})...

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    if (currentCache) {
      setData(currentCache);
      setIsLoading(false);
    } else {
      setIsLoading(true);
      fetch(url, { signal: abortController?.signal, ...requestInit })
        .then((res) => res.json())
        .then((data) => {
          if (!isCancelled) {
            cache.set(url, data);
            setData(data);
            setIsLoading(false);
          }
        })
        .catch((err) => {
          // if (!isCancelled) ...
        });
    }

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    };
  }, [url, options]);
}

现在每当有一个 useFetch 写入 cache 时,所有使用了相同缓存的 useFetch 的组件都会同步到最新的缓存。

Request Deduplication & Merge

你信心满满地将 useFetch 用在了项目中,然后你发现同一个页面内,使用相同 urluseFetch 同步渲染的多个组件,在首次加载没有缓存时,仍然会向同一个 url 发送不止一次的请求,为了合并相同的请求,你可能还需要实现一个互斥锁(mutex lock)或者单例,然后你还要实现一个发布订阅,将接口响应数据广播到所有使用这个 urluseFetch

等等,还没完,作为一个基础通用的用于发送网络请求的工具 hook,你可能还需要实现,

  • Error Retry:在数据加载出现问题的时候,要进行有条件的重试(如仅 5xx 时重试,403、404 时放弃重试)
  • Preload:预加载数据,避免瀑布流请求
  • SSR、SSG:服务端获取的数据用来提前填充缓存、渲染页面、然后再在客户端刷新缓存
  • Pagination:针对大量数据、分页请求
  • Mutation:响应用户输入、将数据自动发送给服务端
  • Optimistic Mutation:用户提交输入时先更新本地 UI、形成「已经修改成功」的假象,同时异步将输入发送给服务端;如果出错,还需要回滚本地 UI,比如点赞
  • Middleware:各类的日志、错误上报、Authentication 中间件

所以你为什么不用类似 SWR 一样现成的数据请求库,它能够覆盖上述所有的需求。

SWR

最终,你放弃了自己封装的 useFetch,尽管他已经支持了许多功能,转而拥抱了 SWR

仅需一行代码,你就可以简化项目中数据请求的逻辑,

import useSWR from 'swr'

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher)

  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

作为一个由 vercel 团队出品的 React Hooks 数据请求库,特性自然不会太少,

  • 内置缓存和重复请求去除:内置缓存机制,自动缓存请求结果,请求相同的数据直接返回缓存结果,避免重复请求
  • 实时更新:支持组件挂载、用户聚焦页面、网络恢复等时机的实时更新
  • 智能错误重试:可以根据错误类型和重试次数来自动重试请求
  • 间隔轮询:可以通过设置 refreshInterval 选项来实现数据的定时更新
  • 支持 SSR/ISR/SSG:可以在服务端获取数据并将数据预取到客户端,提高页面的加载速度和用户体验
  • 支持 TypeScript:提供更好的类型检查和代码提示
  • 支持 React Native,可以在移动端应用中直接使用
  • ...

近一年的下载量趋势上,与 React-Query 不相上下,

image

重要的是,你不再需要为了数据请求的能力花费时间和精力去维护 useFetch 了,你需要的,SWR 都能给到。

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

重学CSS(一):选择器

全文约 2000 字,阅读完大约需要 5 分钟

CSS 中的模式匹配规则决定了哪些样式会被应用到 CSSOM 树中的元素,而这些模式就叫选择器

选择器中,文档元素的大小写敏感性取决于文档语言,

比如 HTML 中,大小写是不敏感的,而在 XML 中,大小写是敏感的。

模式语法

下面是 CSS2.1 规范中定义的选择器的模式,

通配选择器

*,匹配文档树中任意一个元素,

* { font-family: sans-self; } // 这个字体的样式规则会对所有的文档元素生效

类型选择器

element,匹配文档元素的类型名

h1 { margin: 0; } // 匹配文档树中的所有 h1 元素

后代选择器

selector1 selector2,匹配一个元素的后代元素

h1 em { color: red; } // 匹配 h1 里面的所有 em 元素

div p *[herf] { padding: 0; } // 后代不一定必须是文档元素的类型名,比如这里,表示具有 href 属性集且位于本身位于 div 内部的 p 的内部的任意元素

子选择器

selector1 > selector2,匹配作为某些元素子级的元素

body > p { line-height: 1.3; } // 匹配所有作为 body 子级的 p 元素
div ol>li p { line-height: 1.3; } // 匹配一个作为 li 元素后代的 p 元素,这个 li 元素必须是 ol 元素的子元素,且 ol 元素必须是 div 元素的后代

相邻兄弟选择器

selector1 + selector2,匹配拥有相同父级的元素

math + p { text-indent: 0; }  // 当一个 p 元素紧跟在一个 math 元素后面时,它不应该缩进

属性选择器

[attribute],属性选择器可以通过 4 种方式匹配。

  • [attr],匹配有指定属性名的元素,
h1[title] { font-size: 18px; } // 匹配有 title 属性的 h1 元素
  • [attr=val],匹配属性值刚好是 val 的元素,
span[class=example] { color: 'red'; } // 匹配 class 属性值为 example 的 span 元素
  • [attr~=val],匹配属性值刚好包含 val 的元素,
a[rel~="copyright"] { color: 'gray'; } // 匹配 rel 属性值包含 copyright 的 a 元素
  • [attr|=val],匹配属性值以 val 开头的元素,
*[lang|="en"] { color : red; } // 匹配 lang 属性值以 en 开头的元素

类选择器

类选择器本质上就是属性选择器的一种

它相当于 [attr~=val],为了简化,我们使用句号 . 来表示类选择器,

button.ant-btn { box-sizing: border-box; } // 匹配 class 为 ant-btn 的 button 元素

ID选择器

无论在什么文档语言中,ID 属性都被用作元素的唯一标识,

div#root { font-size: 1rem; } // 匹配 id 属性值为 root 的 div 元素

伪元素

伪元素的行为表现的像 CSS 中真实的元素一样,CSS2.1规范**定义了 4 种伪元素

:first-line伪元素

UA 实现存在差异,一般用于对段落内容的第一个格式化行应用特殊样式,例如

p:first-line { text-transform: uppercase; } // 每个段落的第一行的字母变成大写

:first-letter伪元素

最常见的用法是实现 首字母下沉,首字母必须出现在第一个格式化行,

p:first-letter { text-transform: uppercase; }

:before伪元素

在一个元素的内容之后插入生成的内容

h1:before {content: counter(chapno, upper-roman) ". "}

:after伪元素

在一个元素的内容之前插入生成的内容

p.special:after {content: "Special! "}

伪类

伪类根据元素的特征分类,允许根据文档树之外的信息来格式化,常见的伪类有,

:first-child伪类

匹配一个作为某个其它元素的第一个子级的元素,比如,

div > p:first-child { text-indent: 0; } // 匹配所有作为 div 元素的第一个子级的 p 元素,禁止 div 中的第一个段落缩进

需要注意的是,因为匿名盒不是文档树的一部分,计算第一个子级时不算它们,例如,

<p>abc <em>default</em></p> // 这里 p 元素的第一个子级元素是 em

link伪类

为了匹配已访问和未访问的链接,CSS 提供了伪类 :link:visited 作为区分,

a.external:visited { color: blue; } // 匹配 class 为 external 的已访问链接

动态伪类

交互式 UA 有时会改变渲染(效果)以响应用户动作,CSS 提供了 3 个伪类以实现这种交互效果,

:hover:active:focus,分别匹配悬停,激活和获取焦点的交互。

需要注意的是,:hover 必须放在 link 伪类(:link, :visited)后面,否则层叠规则将会隐藏 :hover 规则中的 color 属性,

a:link    { color: red; }    /* unvisited links */
a:visited { color: blue; }   /* visited links   */
a:hover   { color: yellow; } /* user hovers     */
a:active  { color: lime; }   /* active links    */

语言伪类

HTML 中,文档定义的语言是 lang 属性,伪类 :lang(L) 匹配语言为 L 的元素,大小写不敏感

下面的规则会给一个加拿大法语或德语 HTML 文档设置引号,

html:lang(fr-ca) { quotes: '« ' ' »' }
html:lang(de) { quotes: '»' '«' '\2039' '\203A' }
:lang(fr) > Q { quotes: '« ' ' »' }
:lang(de) > Q { quotes: '»' '«' '\2039' '\203A' }

CSS3新增的选择器

CSS3新增选择器

其中,关于伪类 :nth-child(n):nth-of-type(n) 的差异可以参考张鑫旭老师的文章

优先级

优先级判定的算法为,

A specificity is determined by plugging numbers into (a, b, c, d):

  1. If the styles are applied via the style attribute, a=1; otherwise, a=0.
  2. b is equal to the number of ID selectors present.
  3. c is equal to the number of class selectors, attribute selectors, and pseudoclasses present.
  4. d is equal to the number of type selectors and pseudoelements present.

所以,内联样式 > ID选择器 > 类选择器 > 类型选择器

当然,规则都有例外,在一个样式声明中使用一个 !important 规则时,此声明将覆盖任何其他声明

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

源码拾遗系列:Axios

image.png

这是 源码拾遗系列 的第一篇文章,阅读完本文,下面的问题会迎刃而解,

  • Axios 的适配器原理是什么?
  • Axios 是如何实现请求和响应拦截的?
  • Axios 取消请求的实现原理?
  • CSRF 的原理是什么?Axios 是如何防范客户端 CSRF 攻击?
  • 请求和响应数据转换是怎么实现的?

全文约两千字,阅读完大约需要 6 分钟,文中 Axios 版本为 0.21.1

我们以特性作为入口,解答上述问题的同时一起感受下 Axios 源码极简封装的艺术。

Features

  • 从浏览器创建 XMLHttpRequest
  • 从 Node.js 创建 HTTP 请求
  • 支持 Promise API
  • 拦截请求与响应
  • 取消请求
  • 自动装换 JSON 数据
  • 支持客户端 XSRF 攻击

前两个特性解释了为什么 Axios 可以同时用于浏览器和 Node.js 的原因,简单来说就是通过判断是服务器还是浏览器环境,来决定使用 XMLHttpRequest 还是 Node.js 的 HTTP 来创建请求,这个兼容的逻辑被叫做适配器,对应的源码在 lib/defaults.js 中,

// defaults.js
function getDefaultAdapter() {
  var adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}

以上是适配器的判断逻辑,通过侦测当前环境的一些全局变量,决定使用哪个 adapter。
其中对于 Node 环境的判断逻辑在我们做 ssr 服务端渲染的时候,也可以复用。接下来我们来看一下 Axios 对于适配器的封装。

Adapter xhr

定位到源码文件 lib/adapters/xhr.js,先来看下整体结构,

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    // ...
  })
}

导出了一个函数,接受一个配置参数,返回一个 Promise。我们把关键的部分提取出来,

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestData = config.data;

    var request = new XMLHttpRequest();

    request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);
    request.onreadystatechange = function handleLoad() {}
    request.onabort = function handleAbort() {}
    request.onerror = function handleError() {}
    request.ontimeout = function handleTimeout() {}

    request.send(requestData);
  });
};

是不是感觉很熟悉?没错,这就是 XMLHttpRequest 的使用姿势呀,先创建了一个 xhr 然后 open 启动请求,监听 xhr 状态,然后 send 发送请求。我们来展开看一下 Axios 对于 onreadystatechange 的处理,

request.onreadystatechange = function handleLoad() {
  if (!request || request.readyState !== 4) {
    return;
  }

  // The request errored out and we didn't get a response, this will be
  // handled by onerror instead
  // With one exception: request that using file: protocol, most browsers
  // will return status as 0 even though it's a successful request
  if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) {
    return;
  }

  // Prepare the response
  var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;
  var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response;
  var response = {
    data: responseData,
    status: request.status,
    statusText: request.statusText,
    headers: responseHeaders,
    config: config,
    request: request
  };

  settle(resolve, reject, response);

  // Clean up request
  request = null;
};

首先对状态进行过滤,只有当请求完成时(readyState === 4)才往下处理。
需要注意的是,如果 XMLHttpRequest 请求出错,大部分的情况下我们可以通过监听 onerror 进行处理,但是也有一个例外:当请求使用文件协议(file://)时,尽管请求成功了但是大部分浏览器也会返回 0 的状态码。

Axios 针对这个例外情况也做了处理。

请求完成后,就要处理响应了。这里将响应包装成一个标准格式的对象,作为第三个参数传递给了 settle 方法,settle 在 lib/core/settle.js 中定义,

function settle(resolve, reject, response) {
  var validateStatus = response.config.validateStatus;
  if (!response.status || !validateStatus || validateStatus(response.status)) {
    resolve(response);
  } else {
    reject(createError(
      'Request failed with status code ' + response.status,
      response.config,
      null,
      response.request,
      response
    ));
  }
};

settle 对 Promise 的回调进行了简单的封装,确保调用按一定的格式返回。

以上就是 xhrAdapter 的主要逻辑,剩下的是对请求头,支持的一些配置项以及超时,出错,取消请求等回调的简单处理,其中对于 XSRF 攻击的防范是通过请求头实现的。

我们先来简单回顾下什么是 XSRF (也叫 CSRF跨站请求伪造)。

CSRF

背景:用户登录后,需要存储登录凭证保持登录态,而不用每次请求都发送账号密码。

怎么样保持登录态呢?

目前比较常见的方式是,服务器在收到 HTTP请求后,在响应头里添加 Set-Cookie 选项,将凭证存储在 Cookie 中,浏览器接受到响应后会存储 Cookie,根据浏览器的同源策略,下次向服务器发起请求时,会自动携带 Cookie 配合服务端验证从而保持用户的登录态。

所以如果我们没有判断请求来源的合法性,在登录后通过其他网站向服务器发送了伪造的请求,这时携带登录凭证的 Cookie 就会随着伪造请求发送给服务器,导致安全漏洞,这就是我们说的 CSRF,跨站请求伪造。

所以防范伪造请求的关键就是检查请求来源,refferer 字段虽然可以标识当前站点,但是不够可靠,现在业界比较通用的解决方案还是在每个请求上附带一个 anti-CSRF token,这个的原理是攻击者无法拿到 Cookie,所以我们可以通过对 Cookie 进行加密(比如对 sid 进行加密),然后配合服务端做一些简单的验证,就可以判断当前请求是不是伪造的。

Axios 简单地实现了对特殊 csrf token 的支持,

// Add xsrf header
// This is only done if running in a standard browser environment.
// Specifically not if we're in a web worker, or react-native.
if (utils.isStandardBrowserEnv()) {
  // Add xsrf header
  var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
    cookies.read(config.xsrfCookieName) :
    undefined;

  if (xsrfValue) {
    requestHeaders[config.xsrfHeaderName] = xsrfValue;
  }
}

Interceptor

拦截器是 Axios 的一个特色 Feature,我们先简单回顾下使用方式,

// 拦截器可以拦截请求或响应
// 拦截器的回调将在请求或响应的 then 或 catch 回调前被调用
var instance = axios.create(options);

var requestInterceptor = axios.interceptors.request.use(
  (config) => {
    // do something before request is sent
    return config;
  },
  (err) => {
    // do somthing with request error
    return Promise.reject(err);
  }
);

// 移除已设置的拦截器
axios.interceptors.request.eject(requestInterceptor)

那么拦截器是怎么实现的呢?

定位到源码 lib/core/Axios.js 第 14 行,

function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

通过 Axios 的构造函数可以看到,拦截器 interceptors 中的 request 和 response 两者都是一个叫做 InterceptorManager 的实例,这个 InterceptorManager 是什么?

定位到源码 lib/core/InterceptorManager.js

function InterceptorManager() {
  this.handlers = [];
}

InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    synchronous: options ? options.synchronous : false,
    runWhen: options ? options.runWhen : null
  });
  return this.handlers.length - 1;
};

InterceptorManager.prototype.eject = function eject(id) {
  if (this.handlers[id]) {
    this.handlers[id] = null;
  }
};

InterceptorManager.prototype.forEach = function forEach(fn) {
  utils.forEach(this.handlers, function forEachHandler(h) {
    if (h !== null) {
      fn(h);
    }
  });
};

InterceptorManager 是一个简单的事件管理器,实现了对拦截器的管理,

通过 handlers 存储拦截器,然后提供了添加,移除,遍历执行拦截器的实例方法,存储的每一个拦截器对象都包含了作为 Promise 中 resolve 和 reject 的回调以及两个配置项。

值得一提的是,移除方法是通过直接将拦截器对象设置为 null 实现的,而不是 splice 剪切数组,遍历方法中也增加了相应的 null 值处理。这样做一方面使得每一项ID保持为项的数组索引不变,另一方面也避免了重新剪切拼接数组的性能损失。

拦截器的回调会在请求或响应的 then 或 catch 回调前被调用,这是怎么实现的呢?

回到源码 lib/core/Axios.js 中第 27 行,Axios 实例对象的 request 方法,

我们提取其中的关键逻辑如下,

Axios.prototype.request = function request(config) {
  // Get merged config
  // Set config.method
  // ...
  var requestInterceptorChain = [];
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

	var responseInterceptorChain = [];
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
  });

  var promise;

  var chain = [dispatchRequest, undefined];

  Array.prototype.unshift.apply(chain, requestInterceptorChain);

  chain.concat(responseInterceptorChain);

  promise = Promise.resolve(config);

  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
};

可以看到,当执行 request 时,实际的请求(dispatchRequest)和拦截器是通过一个叫 chain 的队列来管理的。整个请求的逻辑如下,

  1. 首先初始化请求和响应的拦截器队列,将 resolve,reject 回调依次放入队头
  2. 然后初始化一个 Promise 用来执行回调,chain 用来存储和管理实际请求和拦截器
  3. 将请求拦截器放入 chain 队头,响应拦截器放入 chain 队尾
  4. 队列不为空时,通过 Promise.then 的链式调用,依次将请求拦截器,实际请求,响应拦截器出队
  5. 最后返回链式调用后的 Promise

这里的实际请求是对适配器的封装,请求和响应数据的转换都在这里完成。

那么数据转换是如何实现的呢?

Transform data

定位到源码 lib/core/dispatchRequest.js

function dispatchRequest(config) {
  throwIfCancellationRequested(config);

  // Transform request data
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );
  
  var adapter = config.adapter || defaults.adapter;

  return adapter(config).then(function onAdapterResolution(response) {
    throwIfCancellationRequested(config);

    // Transform response data
    response.data = transformData(
      response.data,
      response.headers,
      config.transformResponse
    );

    return response;
  }, function onAdapterRejection(reason) {
    if (!isCancel(reason)) {
      throwIfCancellationRequested(config);

      // Transform response data
      if (reason && reason.response) {
        reason.response.data = transformData(
          reason.response.data,
          reason.response.headers,
          config.transformResponse
        );
      }
    }

    return Promise.reject(reason);
  });
};

这里的 throwIfCancellationRequested 方法用于取消请求,关于取消请求稍后我们再讨论,可以看到发送请求是通过调用适配器实现的,在调用前和调用后会对请求和响应数据进行转换。

转换通过 transformData 函数实现,它会遍历调用设置的转换函数,转换函数将 headers 作为第二个参数,所以我们可以根据 headers 中的信息来执行一些不同的转换操作,

// 源码 core/transformData.js
function transformData(data, headers, fns) {
  utils.forEach(fns, function transform(fn) {
    data = fn(data, headers);
  });

  return data;
};

Axios 也提供了两个默认的转换函数,用于对请求和响应数据进行转换。默认情况下,

Axios 会对请求传入的 data 做一些处理,比如请求数据如果是对象,会序列化为 JSON 字符串,响应数据如果是 JSON 字符串,会尝试转换为 JavaScript 对象,这些都是非常实用的功能,

对应的转换器源码可以在 lib/default.js 的第 31 行找到,

var defaults = {
	// Line 31
  transformRequest: [function transformRequest(data, headers) {
    normalizeHeaderName(headers, 'Accept');
    normalizeHeaderName(headers, 'Content-Type');
    if (utils.isFormData(data) ||
      utils.isArrayBuffer(data) ||
      utils.isBuffer(data) ||
      utils.isStream(data) ||
      utils.isFile(data) ||
      utils.isBlob(data)
    ) {
      return data;
    }
    if (utils.isArrayBufferView(data)) {
      return data.buffer;
    }
    if (utils.isURLSearchParams(data)) {
      setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8');
      return data.toString();
    }
    if (utils.isObject(data)) {
      setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
      return JSON.stringify(data);
    }
    return data;
  }],
  
  transformResponse: [function transformResponse(data) {
    var result = data;
    if (utils.isString(result) && result.length) {
      try {
        result = JSON.parse(result);
      } catch (e) { /* Ignore */ }
    }
    return result;
  }],
}

我们说 Axios 是支持取消请求的,怎么个取消法呢?

CancelToken

其实不管是浏览器端的 xhr 或 Node.js 里 http 模块的 request 对象,都提供了 abort 方法用于取消请求,所以我们只需要在合适的时机调用 abort 就可以实现取消请求了。

那么,什么是合适的时机呢?控制权交给用户就合适了。所以这个合适的时机应该由用户决定,也就是说我们需要将取消请求的方法暴露出去,Axios 通过 CancelToken 实现取消请求,我们来一起看下它的姿势。

首先 Axios 提供了两种方式创建 cancel token,

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

// 方式一,使用 CancelToken 实例提供的静态属性 source
axios.post("/user/12345", { name: "monch" }, { cancelToken: source.token });
source.cancel();

// 方式二,使用 CancelToken 构造函数自己实例化
let cancel;

axios.post(
  "/user/12345",
  { name: "monch" },
  {
    cancelToken: new CancelToken(function executor(c) {
      cancel = c;
    }),
  }
);

cancel();

到底什么是 CancelToken?定位到源码 lib/cancel/CancelToken.js 第 11 行,

function CancelToken(executor) {
  if (typeof executor !== "function") {
    throw new TypeError("executor must be a function.");
  }

  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

CancelToken 就是一个由 promise 控制的极简的状态机,实例化时会在实例上挂载一个 promise,这个 promise 的 resolve 回调暴露给了外部方法 executor,这样一来,我们从外部调用这个 executor方法后就会得到一个状态变为 fulfilled 的 promise,那有了这个 promise 后我们如何取消请求呢?

是不是只要在请求时拿到这个 promise 实例,然后在 then 回调里取消请求就可以了?

定位到适配器的源码 lib/adapters/xhr.js 第 158 行,

if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (!request) {
      return;
    }

    request.abort();
    reject(cancel);
    // Clean up request
    request = null;
  });
}

以及源码 lib/adaptors/http.js 第 291 行,

if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (req.aborted) return;

    req.abort();
    reject(cancel);
  });
}

果然如此,在适配器里 CancelToken 实例的 promise 的 then 回调里调用了 xhr 或 http.request 的 abort 方法。试想一下,如果我们没有从外部调用取消 CancelToken 的方法,是不是意味着 resolve 回调不会执行,适配器里的 promise 的 then 回调也不会执行,就不会调用 abort 取消请求了。

小结

Axios 通过适配器的封装,使得它可以在保持同一套接口规范的前提下,同时用在浏览器和 node.js 中。源码中大量使用 Promise 和闭包等特性,实现了一系列的状态控制,其中对于拦截器,取消请求的实现体现了其极简的封装艺术,值得学习和借鉴。

参考链接

勘误与提问

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)

可能是最好的 this 解析了...

this.jpeg

全文约 2000 字,读完大约需要 5 分钟

可能是最好的 this 解析了...

今天我们就来啃下这个你可能害怕但又不得不去吃的瓜,this !

找对象

首先, this 在大多数情况下是一个对象,也有可能是 undefined 或其他值

什么情况下,thisundefined ?函数运行在严格模式下,应用默认绑定规则的时候:

var a = 1;

function foo() {
  "use strict";
  console.log(this.a);
};

foo(); // Uncaught TypeError: Cannot read property 'a' of undefined

原理其实很简单,因为规范定义了严格模式下,不能将全局对象 Window 用于默认绑定。而大多数情况下,我们说的 this,其实就是一个对象,所以确定 this 的指向,本质上就是要找到这个对象

所以接下来我就来教大家如何 “找对象” 🤣 。

绑定规则

找对象最重要的是什么?是不是得先通过各种途径(社交,搭讪,相亲...)去认识对象,途径越多,我们找到对象的几率就越大,对吧,这里也是一样,所以我们需要尽可能的了解 this 的绑定规则。

ECMAScript 5规范 定义的 this 的绑定规则,有 4 种。

默认绑定

教科书会告诉我们,几乎所有的规则都会有一个默认的情况,this 绑定也不例外,默认绑定的规则为:

非严格模式下,this 指向全局对象,严格模式下,this 会绑定到 undefined

var a = 1;

function foo() {
  console.log(this.a);
};

function bar() {
  "use strict";
  console.log(this.a);
};

foo(); // 1,非严格模式下,this 指向全局对象 Window,这里相当于 Window.a

bar(); // Uncaught TypeError: Cannot read property 'a' of undefined,严格模式下,this 会绑定到 undefined,尝试从 undefined 读取属性会报错

隐式绑定

如果函数在调用位置有上下文对象,this 就会隐式地绑定到这个对象上

说起来有点晦涩,直接看例子:

var a  = 1;

function foo() {
  console.log(this.a);
};

var obj = {
  a: 2,
  foo: foo, // <-- foo 的调用位置
};

obj.foo(); // 2,foo 在调用位置有上下文对象 obj,this 会隐式地绑定到 obj,this.a 相当于 obj.a

这个规则可能会让你想起关于 this 经常听到的一句话,this 依赖于调用函数前的对象

需要注意的是,隐式绑定在某些情况下可能会导致绑定丢失,具体来说有两种情况,

第一种是使用函数别名调用时:

var a = 1;

function foo() {
  console.log(this.a);
};

var obj = {
  a: 2,
  foo: foo,
};

var bar = obj.foo;

bar(); // 1,赋值并不会改变引用本身,使用函数别名调用时,bar 虽然是 obj.foo 的一个引用,但是实际上引用的还是 foo 函数本身,所以这里隐式绑定并没有生效, this 应用的是默认绑定

第二种是函数作为参数传递时:

function foo() {
  console.log(this.a);
};

function bar(fn) {
  fn(); // <-- 调用位置
};

var a = 1;

var obj = {
  a: 2,
  foo: foo,
};

bar(obj.foo); // 1, 参数传递也是一种隐式赋值,即使传入的是函数,这里相当于 fn = obj.foo,所以 fn 实际上引用的还是 foo 函数本身,this 应用默认绑定

显式绑定

我们知道 callapplybind 等方法可以改变 this 的指向,通过传入参数就可以指定 this 的绑定值,够不够显式 ?这种明目张胆的绑定 this 的规则就叫显式绑定。

callapply 的区别只是接受的参数格式不同,call 接受一个参数列表,apply 接受一个参数数组,但两者的第一个参数都是相同的,都是 绑定的 this 值

function foo() {
  console.log(this.a);
};

var a = 1;

var obj = { a: 2 };

foo.call(obj); // 2,调用时显式地将 foo 的 this 绑定为 obj 对象,所以这里的 this.a 相当于 obj.a

foo.apply(obj); // 2,同理

前文我们提到隐式绑定可能会导致绑定丢失,显式绑定也不例外,

思考一下,如何才能解决绑定丢失的问题?

答案其实很简单,只需要在调用函数的内部使用显式绑定,强制地将 this 绑定到对象:

function foo() {
  console.log(this.a);
};

var obj = {
  a: 2,
  foo: foo,
};

function bar(fn) {
  fn.call(obj);
};

var a = 1;

bar(obj.foo); // 2,

这其实就是 bind 的实现原理,与 callapply 不同,bind 调用后不会执行,而是会返回一个硬绑定的函数,所以通过 bind 可以解决绑定丢失的问题。bind 也是显式绑定,我们来回顾下 bind 的用法:

function foo() {
  console.log(this.a);
};

var obj = { a: 2 };

var a = 1;

var bar = foo.bind(obj);

bar(); // 2,bar 是通过 bind 返回后的一个硬绑定函数,其内部应用了显式绑定

此外,需要注意的是,将 nullundefined 作为第一个参数传入 callapplybind ,调用时会被忽略,实际应用的是默认绑定规则,即严格模式下,thisundefined,非严格模式下为全局对象。

new绑定

先来回顾下 new 的实现原理,

function _new() {
  let obj = new Object(); // 1. 创建一个空对象
  let Con = [].shift.call(arguments); // 2. 获得构造函数
  obj.__proto__ = Con.prototype; // 3. 链接到原型
  let result = Con.apply(obj, arguments); // 4. 绑定 this,执行构造函数
  return typeof result === 'object' ? result : obj; // 5. 返回 new 出来的对象
}

了解了原理,我们不难发现,在使用 new 来调用函数时,会创建一个链接到函数原型的对象,并把它绑定到函数调用的 this,所以应用了 new 绑定规则后,不会被任何方式修改 this 指向:

function foo(a) {
  this.a = a;
};

var bar = new foo(2);

bar.a; // 2,new 会返回一个对象,这个对象绑定到构造函数的 this

【特殊】箭头函数中的this

ES6 中新增了一种函数类型,箭头函数,箭头函数中 this 不会应用上述规则,而是根据最外层的词法作用域来确定 this,简单来说,箭头函数的 this 就是它外面第一个不是箭头函数的函数的 this

function foo() {
  return () => {
    return () => {
      console.log(this.a);
    };
  };
};

foo()(); // undefined,箭头函数调用时,this 取决于最外层的第一个不是箭头函数的函数,这里就是 foo 函数,非严格模式下,默认绑定全局对象 Window,this.a 相当于 Window.a,输出 undefined

优先级

new绑定 > 显式绑定 > 隐式绑定 > 默认绑定

判断模式

根据绑定规则和优先级,我们可以总结出 this 判断的通用模式,

  1. 函数是否通过 new 调用?
  2. 是否通过 call,apply,bind 调用?
  3. 函数的调用位置是否在某个上下文对象中?
  4. 是否是箭头函数?
  5. 函数调用是在严格模式还是非严格模式下?

总结

  • this 的绑定规则有四种:默认绑定,隐式绑定,显式绑定,new绑定
  • 无法应用其他 3 种规则时就是默认绑定,严格模式下 this 为 undefined,非严格模式下为全局对象
  • 函数在调用位置有上下文对象时,this 会隐式绑定到这个对象
  • 可以通过 call,apply,bind 显式地改变 this 的指向
  • 通过 new 调用时,this 会绑定到调用函数,new 绑定是优先级最高的绑定
  • 箭头函数中的 this 继承至它外层第一个不是箭头函数的函数

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

React进阶系列之Hooks设计动机

当我们由浅入深地认知一样新事物地时候,往往需要遵循 “Why-What-How” 的一个认知过程,这三者往往是相辅相成、缺一不可的。某种层面上,对于一个工程师而言,他/她对 “Why” 的执着程度,很大程度上能反映其职业天花板的高度。

Motivation

我们先从 Why 开始,聊聊 React Hooks 设计的动机。

Hooks 在 React 16.8 后被推而广之,在这之前,组件多以类组件无状态的函数组件形式为主,而 Hooks 正是 React 团队在真刀真枪的 React 组件开发实践中,逐渐认识到的一个改进点,这背后其实涉及对类组件函数组件两种组件形式的思考和侧重。因此,你首先得知道,什么是类组件、什么是函数组件,并完成这两种组件形式的辨析。

类组件

基于 ES6 Class 的写法,通过继承 React.Component、React.PureComponent 得来的 React 组件,

class Component extends React.Component {
  constructor() {
    this.state = {};
  }

  render() {
    return (
      <div>React Class Component</div>
    );
  };
}

函数组件

以函数形态存在的 React 组件,早期没有 Hooks 加持,内部无法定义和维护 state,因此它还有一个别名叫“无状态组件”,

function Component(props) {
  const { text } = props;
  return (
    <div>{text}</div>
  );
}

函数组件与类组件对比:无关“优劣”,只谈“不同”

  • 类组件需要继承 class,函数组件不需要
  • 类组件可以访问生命周期方法,函数组件不能
  • 类组件中可以获取实例化后的 this,并基于 this 做各种各样的事情,函数组件不能
  • 类组件中可以定义并维护 state 状态,函数组件不能
  • ... ...

单就我们上述列出的这几点里面,频繁出现了“类组件可以xxx,函数组件不能xxx”,是否意味着类组件比函数组件更好呢?

答案当然是否定的。只是在 Hooks 出现之前,类组件的能力边界明显强于函数组件,但我们讨论这两种组件形式,不应怀揣“孰优孰劣”这样的成见,而应该更多地去关注两者的不同,进而把不同的特性与不同的场景做连接,这样才能求得一个全面的、辩证的认知。

重新理解类组件:包裹在面向对象**下的“重装战舰”

类组件体现了面向对象的封装和继承,内置了 state、生命周期等相当多的“现成的东西”等着你去调度、定制,而想要得到这些东西,你只需要简单地继承一个 React.Component 即可,这种感觉就好像是你不费吹灰之力,就拥有了一辆“重装战舰**。但是多就是好吗?

未必。想象一下现在你只是需要打死一只蚊子,给你一辆重装战舰,似乎也 ”可以,但没有必要“,类组件 ”大而全“的背后,是不可忽视的学习成本和心智负担,这主要体现在,

  • 编写需要理解各种复杂的姿势,比如 this 原理,生命周期
  • 组件内部的逻辑难以实现拆分和复用,想要打破僵局,则需要进一步学习更加复杂的设计模式(比如高阶组件,Render Props 等),用更高的成本来换取一点点的编码灵活度

函数组件:呼应 React 设计**的”轻巧快艇“

毋庸置疑,函数组件更加契合 React 框架的设计理念,

image.png

React 组件本身的定位就是函数,一个吃进数据,吐出 UI 的函数,这意味着 React 的数据应该总是紧紧地和渲染绑在一起,而类组件做不到这一点,这也是两类组件最大的不同:函数组件会捕获 render 内部的状态。React 作者 Dan 早期特意为此写过一篇非常棒的对比文章, 如果你疑惑为什么类组件做不到,可以先阅读上面的文章。

假设你已经阅读完 Dan 的文章,我们不难发现其中的缘由,因为虽然 props 本身是不可变的,但 this 却是可变的,this 上的数据是可以被修改的,所以当我们通过 setTimeout 将预期的渲染推迟 3s 后,脱离了 React 生命周期对执行顺序的调控,这打破了 this.props 和渲染动作之间的这种时机上的关联,进而导致渲染时捕获了一个错误的、修改后的 this.props

但是函数组件可以做到真正的把数据和渲染绑在一起,因为父组件传入新的 props 时,本质上是基于新的 props 发起一次全新的函数调用,不会影响上一次调用对于上一个 props 的捕获。

经过岁月的洗礼,React 团队也认识到了,函数组件是一个更加匹配其设计理念、也更有利于逻辑拆分与重用的组件表达形式,所以后面用实际行动支持开发者编写函数组件,而这里的实际行动就是 Hooks。

Hooks 的本质:一套能够使函数组件更强大、更灵活的钩子

React Hooks 的出现,补齐函数组件相较于类组件缺失的能力,如果说函数组件是一台轻巧的快艇,那么 React-Hooks 就是一个内容丰富的零部件箱,而且类组件这个重装战舰里预设那些装备,这里箱子里基本全都有,关键是它还不强制你全都要,而是允许你自由地选配和使用,所以不由得感叹一句:真香!

行文至此,想必你已经深刻理解了 React Hooks 的设计动机,接下来我们将深入地理解 Hooks 的工作机制和原理。

相关文章

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

(完)

Binary Search

二分查找的一些技巧:

function binarySearch(nums, target) {
    var left = 0;
    var right = nums.length - 1;

    while (left <= right) {
        var mid = left + Math.floor((right - left) / 2);
        if (nums[mid] === target) return mid;
        if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1;
};
// 1. 计算 mid 时防止溢出
// 我们可以简单比较下 (right - left) / 2 的情况,两者虽然结果相同,但如果 left 和 right 太大,直接相加可能导致溢出
var mid = left + (right - left) / 2;
// 2. 终止条件的判断可以想象为区间的开闭
// 右边界 right 一般是数组的 length - 1,因为索引大小 nums.length 是越界的
// 如果是 left <= right,终止条件是 left = right + 1,对应的就是 [right, right + 1] 这个闭区间
while(left <= right) {
  // code ...
}
// 如果是 left < right,终止条件是 left === right,对应区间为 [right, right],这种情况会少遍历一个元素
while(left < right) {
  // code ...
}
// 3. 二分搜索的区间判断需要考虑已搜索的元素 mid
// 我们的初始区间为 [left, right],并且 mid 已经判断过了,那么下次搜索的区间就是 [left, mid - 1],[mid + 1, right]
// 如果目标元素比 mid 大,我们搜索右区间,left = mid + 1, right = right
// 如果目标元素比 mid 小,我们搜索左区间,left = left, right = mid - 1
if (nums[mid] < target) {
  left = mid + 1;
} else {
  right = mid - 1;
}

基于上述的区间思路,我们可以很容易写出寻找左侧边界或右侧边界的二分查找,关键点就是,

  1. 搜索到 target 时,先不急着返回,而是收缩左右边界
  2. 缩小边界可能会导致越界问题,我们在返回时加上越界判断即可

寻找左侧边界的二分查找,

function binarySearchLeft(nums, target) {
  var left = 0;
  var right = nums.length - 1;
  while (left <= right) {
    var mid = left + Math.floor((right - left) / 2);
    if (nums[mid] === target) {
      right = mid - 1; // 收缩右侧边界
    } else if (nums[mid] > target) {
      right = mid - 1;
    } else if (nums[mid] < target) {
      left = mid + 1;
    }
  }
  // 越界判断,检查 left 是否越界
  if (left >= nums.length || nums[left] !== target) return -1;

  return left;
}

寻找右侧边界的二分查找,

function binarySearchRight(nums, target) {
  var left = 0;
  var right = nums.length - 1;
  while (left <= right) {
    var mid = left + Math.floor((right - left) / 2);
    if (nums[mid] === target) {
      left = mid + 1; // 收缩左侧边界
    } else if (nums[mid] > target) {
      right = mid - 1;
    } else if (nums[mid] < target) {
      left = mid + 1;
    }
  }
  // 越界判断,检查 right 是否越界
  if (right < 0 || nums[mid] !== target) return -1;

  return right;
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.