Live and learn


  • 首页

  • 标签

  • 归档

前端模块化之路

发表于 2020-08-21

前言


  • 随着CPU,内存,以及浏览器内核性能不断提升,前端在软件开发中扮演越来越重要的角色。
  • 前端框架层出不穷,逻辑日益复杂,代码量日益庞大。
  • 前端也需要一种有效组织和管理大型应用系统代码的方式。
  • 前端开始探索自己的模块化开发之路。

什么是模块化


  • 模块化是指解决一个复杂问题时自顶向下逐层把系统分解为更好的可管理模块的方式。
  • 每个模块完成一个特定的子功能,封装细节,提供使用接口,彼此之间可相互依赖,但互不影响。
  • 所有的模块可以按某种方法组装起来,成为一个整体,完成整个系统所要求的功能。
  • 模块化使代码耦合度降低,最大化的设计重用,以最少的模块,更快速的满足更多的个性化需要。

科学的模块化肯定要具备以下几点:

  • 分解成模块,每个模块实现特定子功能。
  • 模块封装具体细节,提供使用接口。
  • 模块间互不影响。
  • 模块间依赖明确,可连接。

模块化的好处

  • 避免变量名冲突(全局变量污染)
  • 更好的分离代码,可按需加载,提升性能
  • 可维护性更高
  • 可复用性更高
  • …

前端模块化探索


  • 在ES6出现之前,JS先天是不具备模块化能力的。
  • 我们需要基于JS的原生土壤(Object,Function,Cursor…)去抽象和封装。
  • JS是面向函数编程的。若无任何封装,基本遍地的全局函数。最突出的问题便是全局命名空间污染,命名冲突。

那么,加一个命名空间好了。

命名空间模式-对象封装

我们把模块封装成一个对象,这个对象(保证命名唯一性)就相当于一个命名空间。

比如,我们用 Object 来封装一个我们常用的日期处理函数库:

/**************定义模块**********/
var MyDateUtils = {
  defaultFmt:'yyyy-MM-dd',
  // 格式化日期格式
  dateFormat: function(date, _fmt) {
    var fmt = _fmt || this.defaultFmt;
    var o = {
      'M+': date.getMonth() + 1, // 月份
      'd+': date.getDate(), // 日
      'h+': date.getHours(), // 小时
      'm+': date.getMinutes(), // 分
      's+': date.getSeconds(), // 秒
      'q+': Math.floor((date.getMonth() + 3) / 3), // 季度
      S: date.getMilliseconds(), // 毫秒
    };
    if (/(y+)/.test(fmt)) { fmt = fmt.replace(RegExp.$1, (`${date.getFullYear()}`).substr(4 - RegExp.$1.length)); }
    for (var k in o) {
      if (new RegExp(`(${k})`).test(fmt)) { fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : ((`00${o[k]}`).substr((`${o[k]}`).length))); }
    }
    return fmt;
  },
  // 获取当前日期
  getDateNow(fmt) {
    return this.dateFormat(new Date(), fmt);
  },
  //其他API略...
}
  • 优点:模块增加了命名空间,大大降低了命名冲突的情况。
  • 缺点:外部可以随意修改对象内部成员。这种不可控的状态非常要命,属于很大的安全漏洞。
// A同学正常使用
MyDateUtils.getDateNow(); 
// B同学修改内部成员为自己场景常用的格式,就会影响使用默认格式的A同学
MyDateUtils.defaultFmt = 'MM-dd'; 

模块化满足情况:

  • 分解成模块,每个模块实现特定子功能。 ✅ 对象即模块
  • 模块封装具体细节,提供使用接口。 ❌ 全暴露,可读可写
  • 模块间互不影响。 ❌ 可随意篡改其他模块
  • 模块间依赖明确,可连接。 ❌ how?

函数封装

对象封装明显不靠谱,还是得靠JS的一等公民:Function。我们知道,JS函数有自己独立的作用域。

还是上面的日期函数库,我们尝试使用Function来封装,最大程度符合模块化特性可能是这样:

/**************定义模块**********/
var MyDateUtils = function (defaultFmt){
  this.defaultFmt = defaultFmt || 'yyyy-MM-dd';
};
MyDateUtils.prototype.dateFormat = function(date, fmt) {
  // 略...(同上)
};
MyDateUtils.prototype.getDateNow = function(fmt) {
  return this.dateFormat(new Date(), fmt);
}

/**************使用模块**********/
var newDateUtils = new MyDateUtils();
var today = newDateUtils.getDateNow();

也可以使用ES6-class语法糖,这么定义:

/**************定义模块**********/
class MyDateUtils = {
  constructor(defaultFmt){
    this.defaultFmt = defaultFmt || 'yyyy-MM-dd';
  };

  dateFormat(date, fmt) {
    // 略...(同上)
  };

  getDateNow (fmt) {
    return this.dateFormat(new Date(), fmt);
  }
}

/**************使用模块**********/
var newDateUtils = new MyDateUtils();
var today = newDateUtils.getDateNow();

再来看看模块化满足情况:

  • 分解成模块,每个模块实现特定子功能。 ✅ 函数对象即模块
  • 模块封装具体细节,提供使用接口。 ❌ 所有方法可访问,其实模块中往往存在很多子方法是不需要外部访问的,也毫无意义
  • 模块间互不影响。 ✅ 所有方法定义在 prototype 属性上才能防止其他模块复写。
  • 模块间依赖明确,可连接。 ❌ no way!

如何才能只暴露部分接口,还能和外界取得联系?

闭包

  • 闭包 就是能够读取其他函数内部变量的函数。
  • 由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成”定义在一个函数内部的函数”。
  • 所以在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

使用闭包-IIFE(立即执行函数)

我们使用IIFE来封装前面的模块:

/**************定义模块**********/
var MyDateUtils = (function (){
  let defaultFmt = 'yyyy-MM-dd';

  function dateFormat(date, _fmt) {
    // 略...(同上)
  }
  // 获取当前日期
  function getDateNow(fmt) {
    return this.dateFormat(new Date(), fmt);
  }

  return { 
    getDateNow: getDateNow;
  }
})();

或者(流行方式):

/**************定义模块**********/
;(function (){
  let defaultFmt = 'yyyy-MM-dd';

  function dateFormat(date, _fmt) {
    // 略...(同上)
  }
  // 获取当前日期
  function getDateNow(fmt) {
    return this.dateFormat(new Date(), fmt);
  }

  window.MyDateUtils = { 
    getDateNow: getDateNow;
  }
})();
/**************使用模块**********/
MyDateUtils.getDateNow(); // 可以访问
MyDateUtils.dateFormat(); // 不可以访问

再来看看模块化满足情况:

  • 分解成模块,每个模块实现特定子功能。 ✅
  • 模块封装具体细节,提供使用接口。 ✅
  • 模块间互不影响。 ✅
  • 模块间依赖明确,可连接。 ❌

问题:那么一个模块依赖另一个模块怎么办?

办法:把依赖的模块作为立即执行函数的参数即可。

/**************定义模块**********/
;(function (_A,_B){

  // 略...(同上) 这里便可以使用 ModuleA 和 ModuleB 暴露的方法

  window.MyDateUtils = { 
    getDateNow: getDateNow;
  }
})(ModuleA,ModuleB);

上面模块化方案也是后来一系列模块化框架和规范的基石。

模块化规范


前面都是原生的模块化探索。在此基础上,衍生了一系列模块化的库和规范:

  • commonJS:服务端nodeJS引入,同步加载。
  • requireJS:遵循 AMD 规范
  • seaJS: 遵循 CMD 规范
  • ES6 模块化

CommonJs

1)介绍

  • 由服务端 nodeJS 引入并发扬光大。
  • 同步加载,适用于服务端。
  • 客户端(浏览器端)JS一般是异步加载,不适用。
  • 模块加载的顺序,按照其在代码中出现的顺序。
  • 服务器端Node,模块的加载是运行时同步加载的;浏览器端Node,模块需要提前编译打包处理。

2)语法

  • 定义模块:一个文件就是一个模块,拥有自己独立的作用域。
  • 暴露模块:module.exports = value或exports.xxx = value。
  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径。require 返回该模块的 exports对象。
const webpack = require('webpack')
const path = require('path')

module.exports = env => {
  let config = { };
  //...
  return config;
}

RequireJS(AMD)

1) 介绍

  • AMD 即Asynchronous Module Definition,异步模块定义的意思。它是一个在浏览器端模块化开发的规范。
  • 浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。
  • JS原生不支持AMD规范,需要对应的工具库来做这件事。requireJS诞生,用于客户端的模块管理。

requireJS 主要解决两个问题:

  • 多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
  • js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长

2)语法

  • 定义模块:define(id?, [dependencies]?, factory);
    • id: 可选,用来定义模块的标识,一般使用默认的脚本文件名(去掉拓展名);
    • dependencies: 可选,如果有依赖,列出依赖的模块数组;
    • factory:工厂方法 如果是对象 则表示模块的返回。
  • 暴露模块:通过在上面的 factory 工厂方法中 return value。
  • 引入模块:require([dependencies], function(){});
//定义没有依赖的模块
define(function(){

   return { ...模块对象/方法 }
})

define({ ...模块对象/方法 }) //模块是对象,直接暴露整个模块对象写法。
//定义有依赖的模块
define(['module1', 'module2'], function(m1, m2){
   return { ...模块对象/方法 }
})
//引入模块
require(['module1', 'module2'], function(m1, m2){
   // 使用m1/m2
})

require()函数在加载依赖的函数的时候是异步加载的,这样浏览器不会失去响应,它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。

SeaJs(CMD)

1) 介绍

  • CMD 即Common Module Definition,通用模块定义的意思。它是一个在国内发展起来的模块化开发规范。
  • seaJS是 CMD 规范在浏览器端的实现。
  • seaJS 要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同。

2)语法

  • 定义模块:define(function(require, exports, module) {...})。只有一个工厂方法。
  • 暴露模块:使用上面的工厂方法的exports 对象 exports.doSomething = ...。
  • 引入模块:使用上面的工厂方法的require 对象。还提供了异步引入的方法require.async.

定义模块时其实也可以指定id和依赖,不常用,不推荐:define(id?, dependencies?, function(require, exports, module) {...})

// 定义模块moduleA
define(function(require, exports, module) {
  function fn1 () {
    //...
  }
  function fn2 () {
    //...
  }
  // 暴露模块内容
  export.fn1 = fn1;
});
// 引入模块(同步)
define(function(require, exports, module) {
   //引入依赖模块(同步)
  var moduleA = require('./moduleA');
  moduleA.fn1();
});

// 引入模块(异步)
define(function(require, exports, module) {
  require.async('./moduleA', function (mA) {
    mA.fn1()
  })
});

AMD与CMD区别

注意,AMD 和CMD加载模块都是异步的。他们最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机。

  • AMD依赖前置,js可以方便知道依赖模块是谁,立即加载,加载模块完成后就会执行该模块,所有模块都加载执行完后会进入require的回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。

  • CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块(这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略)。CMD加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。

两者各有优劣,很多人说 AMD用户体验好,因为没有延迟,依赖模块提前执行了,CMD性能好,因为只有用户需要的时候才执行的原因。

UMD

UMD 叫做通用模块定义规范(Universal Module Definition)。随着大前端的趋势所诞生,它可以通过运行时或者编译时让同一个代码模块在使用 CommonJs、CMD 甚至是 AMD 的项目中运行。未来同一个 JavaScript 包运行在浏览器端、服务区端甚至是 APP 端都只需要遵守同一个写法就行了。

它没有自己专有的规范,是集结了 CommonJs、CMD、AMD 的规范于一身,大体实现如下:

((root, factory) => {
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        //CommonJS
        var $ = requie('jquery');
        module.exports = factory($);
    } else {
        root.testModule = factory(root.jQuery);
    }
})(this, ($) => {
    //todo
});

ESM (ECMA Script Modules)

1) 介绍

前面讲到,JS 原生是不支持模块化的,必须借助其他抽象的工具库。ES6 的出现彻底改变了这种局面,对 JS 模块化方面进行了补充:

  • export: 用来暴露模块的 API,可以有多个输出。export default命令,为模块指定默认输出,其他模块加载该模块时,可以为该匿名函数指定任意名字。
  • import: 用于引入其他模块提供的功能。

导出示例:

//导出多个
const obj1 = { ... }
const obj2 = { ... }
export { obj1, obj2 }
//默认导出
export default { str: "abc" }

导入示例:

// 默认导出可以随意命名
import str1 , { obj1 , obj2 } from "A.js";
// 导入多个api,命名需和导出一致
import { obj1 , obj2 } from "A.js";
// 可设置别名
import { obj1 as obj3 , obj2 } from "A.js";

ESM 运行机制与 CommonJS 不一样。CommonJS 模块输出的是一个值的拷贝;ESM 模块输出的是静态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。ESM在编译时就能确定模块的依赖关系,以及输入和输出的变量,Tree Shaking 就是基于 ESM 来实现的。

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

2)使用

现在,基本上所有的主流浏览器版本都已经支持 ESM。但是浏览器对 ES6 语法的兼容还不够全面,我们需要使用 Babel 编译成浏览器都识别的 ES5 代码来使用。在实际项目中,我们通过打包构建工具( 如 webpack,rollup) 来统一处理。

参考文献


前端模块化详解
前端模块化
SeaJs快速API
简单实现一个RequireJS
Node.js require()源码解读
SystemJs探秘
差点被SystemJs惊掉了下巴,解密模块加载黑魔法

React核心原理

发表于 2020-07-14

React哲学-简单之美


  • React 通过 UI = renderWithJSX(state) 完美的解决了如何将 动态数据/频繁交互 高效地反映到复杂的用户界面上。

  • React 通过 虚拟DOM,用 整体刷新 的方式替代了传统的局部刷新。开发人员不需要频繁进行复杂的 DOM 操作,只需要关注 数据状态变化 和最终的 UI 呈现,其他的 React 自动解决,大大降低了开发的复杂度。

  • React 通过 Diff 算法,高效的解决了整体刷新带来的性能问题。

  • React 把 组件 作为构建用户界面的基本单位。

  • React 通过单向数据流动模型,来管理组件之间,组件和数据模型直接的通信。

  • React 还提倡使用只读数据建立数据模型。并开发了一整套框架 immutable.js ,将只读数据的概念引入 JavaScript。只读的数据可以让代码更加的安全和易于维护,你不再需要担心数据在某个角落被某段神奇的代码所修改;也就不必再为了找到修改的地方而苦苦调试。

  • React 项目经理在演讲中说过,React 最有价值的其实是声明式的,直观的编程方式。以简单直观,复合习惯的方式编程,让代码更容易被理解,从而易于维护和不断演进。这就是 React 的设计哲学。

软件工程向来不提倡用高深莫测的技巧去编程,相反,如何写出可理解可维护的代码才是质量和效率的关键。(深有同感)

虚拟DOM & JSX


虚拟DOM 是 React 的核心机制之一。

虚拟 DOM 其实就是用 JavaScript对象表示的一个 DOM 节点, 内部包含了节点的 tag , props 和 children 。

React 利用 虚拟DOM 将一部分昂贵的浏览器重绘工作转移到相对廉价的存储和计算资源上,以此减少对实际 DOM 的操作从而提升性能。

虚拟DOM 可以通过 JavaScript 来创建,例如:

var child1 = React.createElement('li', null, 'First Text Content');
var child2 = React.createElement('li', null, 'Second Text Content');
var root = React.createElement('ul', { className: 'my-list' }, child1, child2);

但这样的代码可读性并不好,于是 React 发明了 JSX,利用我们熟悉的 Html 语法来创建 虚拟DOM:

var root =(
  <ul className="my-list">
    <li>First Text Content</li>
    <li>Second Text Content</li>
  </ul>
);

JSX 并不等同于传统 MVC 框架中的 模版引擎,而是一种类似 XML 的高级语法糖,它完美的将 Javascript 和 Dom 融合在一起,你中有我,我中有你。

JSX 其实并没有增加 React 的学习门槛,只要你熟悉 Html 结构,会 Javascript 就很容易掌握。其并不比学习一些模版引擎的学习成本高。

JSX 通过 babel 编译后,其实也是通过 React.createElement 创建的虚拟Dom对象。

DIFF算法


React 中最神奇的部分莫过于 虚拟DOM,以及其高效的 Diff 算法。这让我们可以无需担心性能问题而 “毫无顾忌” 的随时 “刷新”整个页面。

在 React 中,构建 UI 界面的思路是由当前状态决定界面。前后两个状态就对应两套界面,然后由 React 来比较两个界面的区别,这就需要对 DOM 树进行 Diff 算法分析。

但是树的标准 Diff 算法复杂度需要 O(n^3),这显然无法满足性能要求。Facebook 工程师结合 Web 界面的特点做出了两个简单的假设,使得 Diff 算法复杂度直接降低到 O(n)。

  • 两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构。
  • 对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。

首先,React 的 DOM Diff 算法实际上只会对树进行逐层比较。

如果节点类型/组件不同:
React 直接删除前面的节点(包括所有子节点),然后创建并插入新的节点。同样的,当 React 在同一个位置遇到不同的组件时,会简单的销毁第一个组件,而把新创建的组件加上去。

如果节点类型相同:
比较简单,React 会对属性进行重设来实现节点的转换。对于同一类型的组件,当组件的props更新时, 组件实例保持不变, React调用组件的 componentWillReceiveProps(), componentWillUpdate() 和 componentDidUpdate() 生命周期方法, 并执行 render()方法.

列表节点比较特殊:
同层元素较多,经常会有删除,插入,排序操作。比如插入一条数据,按前面的逻辑,会频繁的进行销毁和重建,Dom操作过于频繁。React 没法高效的进行更新。所以,对于列表节点提供唯一的 key 属性可以帮助 React 定位到正确的节点进行比较,从而大幅减少 DOM 操作次数,提高性能。

当你了解了 Diff算法,在使用 React 开发组件的过程中,也要有意识的书写高性能的代码:

  • 保持稳定的 DOM 结构,比如通过CSS显示隐藏节点,而不是真的移除和添加。
  • 对于列表,尽量设置唯一 Key 属性,让 React 更高效的更新。

切记,这里的 key 一定是能唯一标示这一条数据的,遍历方法中((item,index) => {}) 的 index 属性是不行的,还会引发不确定的bug。因为插入,排序,删除操作以后重新遍历,相同的 index 已经指向了不同的数据。

但是,我们面临着一个重大的性能问题:

刷新率:大部分显示器屏幕都有固定的刷新率(比如最新的一般在 60Hz),所以浏览器更新最好是在 60fps。如果在两次硬件刷新之间浏览器进行两次重绘是没有意义的只会消耗性能。 浏览器会利用这个间隔 16ms(一帧)适当地对绘制进行节流,如果在 16ms 内做了太多事情,会阻塞渲染,造成页面卡顿, 因此 16ms 就成为页面渲染优化的一个关键时间。

浏览器的 渲染线程 和JS的 执行线程 是互斥的。当如果 组件树 的层级很深,递归处理 虚拟Dom 会长时间占用主线程。这使得一些需要高优先级处理的操作如用户输入、动画等被阻塞,造成卡顿, 严重影响使用体验。

上述问题是普遍存在的,浏览器中其实已经提供了 window.requestIdleCallback() 方法试图解决这个问题(目前还处于实验性阶段)。这个方法将在浏览器的空闲时段内调用方法设置的函数队列。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

Fiber


为了解决高优先级处理任务被阻塞的问题,React16 版本对其核心进行了一系列重构,React16 架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

可以看到,相较于React15,React16中 新增了 Scheduler(调度器)。Reconciler 内部采用了Fiber的架构。

*时间切片策略 要求我们将 虚拟DOM 的更新操作分解为小的工作单元, 同时具备以下特性:

  • 可暂停、可恢复的更新;
  • 可跳过的重复性、覆盖性更新;
  • 具备优先级的更新.

对于递归形式的程序来说, 这些是难以实现的。 于是就需要一个处于递归形式的 虚拟DOM 树更上层的一种数据结构, 来辅助完成这些特性。

这就是 React16 在重构中引入的算法核心 —— Fiber 链表数据结构。

从概念上来说, Fiber 就是重构后的 虚拟DOM 节点, 一个Fiber就是一个JS对象。

Fiber节点之间构成 单向链表 结构。React 使用 “双缓存” 来完成 Fiber 树的构建与替换——对应着 DOM 树的创建与更新。

在内存中构建并直接替换的技术叫做双缓存.

Scheduler 是 React 引入 时间切片(Time Slice)策略的产物。考虑到浏览器的兼容性以及 requestIdleCallback 方法的不稳定性(没秒执行20次,正常浏览器刷新是60), React 自己实现了 React 专用的的类似 requestIdleCallback 且功能更完备的 Scheduler 来实现空闲时触发回调, 并提供了多种优先级供任务设置。

主要用到的技术点如下:

  • requestAnimationFrame

  • macrotasks(宏任务)。

  • MessageChannel/postMessage 生成高优先级宏任务(比 setTimeout 执行时机更靠前)

浏览器一帧内工作:一个task(宏任务) – 队列中全部job(微任务) – requestAnimationFrame – 浏览器重排/重绘 – requestIdleCallback

调度器主要工作流程如下:

  • 更新队列产生以后,调度器启动。

  • 调度时通过 requestAnimationFrame 在浏览器每次重绘前做想做的事。给requestAnimationFrame 设置的回调方法animationTick 会在浏览器动画执行前执行。

  • 在 animationTick 中可以确定下一帧结束的时间点,因为不知道 react 更新需要多少时间,所以不在 animationTick 中判断当前帧剩余时间来执行 react 更新,而是通过 postMessage 把用于更新 的 flushWork 推入宏任务队列 macrotasks。

  • 在 window.addEventListener('message', idleTick, false); 的 idleTick回调中,会一直拖到当前帧完全过期时才把 didTimeout = true, 才去执行这次 react 更新。

  • 这样, react 更新 的 flushWork 作为 宏任务 会先于 requestAnimationFrame 执行。这时 flushWork 就算更新时间超过当前帧剩余时间借用了下一帧的时间,也是最大限度的保证了浏览器动画的流畅性和优先级。

合成事件 SyntheticEvent


React 提供了一种 “顶层注册,事件收集,统一触发” 的事件机制。我们称其为合成事件(SyntheticEvent)。它自己遵循W3C的规范又实现了一遍浏览器的事件对象接口,这样可以抹平差异,而原生的事件对象只不过是它的一个属性(nativeEvent)

SyntheticEvent 是浏览器的原生事件的跨浏览器包装器,除了兼容所有浏览器外,还拥有浏览器原生事件接口,包括e.stopPropagation(),e.preventDefault()。当你需要使用浏览器底层事件时,只需要使用 nativeEvent属性来获取即可。比如 e.nativeEvent.stopImmediatePropagation()。

我们在 React 组件中通过 JSX 语法中的绑定的所有事件都挂载在 document 上( 不是 window,也不是 document.body )。当真实 Dom 触发后冒泡到 document 后才会对 React 事件进行处理。

当同时存在 原生API注册事件 和 合成事件 的情况下,事件触发顺序如下:

  • 声明为 捕获阶段 执行的原生事件 执行(父元素上事件先执行)。
  • 其他 绑定在 document 子元素上的 原生事件(默认为冒泡阶段执行) 执行。
  • React事件按实际触发元素的 冒泡顺 序执行 (子 > 父)。
  • 绑定在 docoment 元素上的 原生事件 执行。
  • 绑定在 window 上的 原生事件 执行。

依次举例如下:

  • document.addEventListener('click', () => {}), true);
  • document.body.addEventListener('click', () => {}), false);
  • <div onClick={ () => {} }></div>
  • document.addEventListener('click', () => {}), false);
  • window.addEventListener('click', () => {}), false);

阻止冒泡:

  • e.stopPropagation():能阻止下层 React合成事件 到 上层 React 合成事件的冒泡。因为 React 合成事件 都是注册在document 上,所以对于原生事件,只能阻止向 window 事件冒泡。
  • e.nativeEvent.stopImmediatePropagation():阻止 原生事件执行。条件是这个原生事件一定是绑定在document元素上,并且是冒泡阶段执行。

下面我们来探究以下合成事件的实现原理,我们可以分为两个阶段:

  • 事件注册 和 存储。
  • 事件触发 并 执行。

事件注册/存储

每当组件进入 render 阶段的 complete阶段时,名称为 onClick... 的 prop 会被识别为 事件 进行事件注册处理。通过lastProps、nextProps 判断事件是新增还是删除,删除会调用事件卸载方法。

React会根据 事件名称匹配它所依赖的原生事件,例如 onMouseEnter 事件依赖了 mouseout 和 mouseover 两个原生事件,onClick只依赖了click一个原生事件。

并将 事件回调 存储在 EventPluginHub.listenerBank中,并通过元素的唯一 key 来标识:listenerBank[registrationName][key],其中 registrationName 是事件名称,如onClick。

最终 document 元素上会注册所有涉及类型的原生事件。事件处理函数则是 根据事件类型创建的各类事件监听器 listener 。一般有以下三种事件监听器:

  • dispatchDiscreteEvent: 处理离散事件
  • dispatchUserBlockingUpdate:处理用户阻塞事件
  • dispatchEvent:处理连续事件

事件触发/执行

当 React 在 document 元素上注册的原生事件被触发,对应的事件监听器 listener 开始工作。它按照事件的优先级去安排接下来的工作:构造合成事件、将事件处理函数收集到执行路径、 事件执行。

根据事件类型,通过 SyntheticEvent 构造函数生成对应的合成事件对象。

从触发事件的的最深层元素开始,遍历这个元素的所有父元素,根据事件名称,收集到之前存储的所有 事件处理函数 到 执行路径。

最后会生产如下的 dispatchQueue 结构的 eventQueue:

[
  {
    event: SyntheticEvent,
    listeners: [ listener1, listener2, ... ]
  }
]

可以看到,我们将 同一类事件 构造的合成事件存储在 listeners 事件队列中,用于冒泡/捕获的模拟处理。我们遍历事件队列,执行带有合成事件对象(event)参数的回调函数。

  • 冒泡阶段事件,从前往后遍历。通过 isPropagationStopped 判断当前事件是否执行了阻止冒泡方法。如果阻止了冒泡,则停止遍历。
  • 捕获阶段事件,从后往前遍历 即可。

参考文献


深入浅出 React
React事件机制
深入React合成事件机制原理
React核心原理浅析
React技术揭秘
Build Your Own React
React 源码解析 - 调度模块原理 - 实现 requestIdleCallback 

React Hooks 原理与应用

发表于 2020-05-24

React Hooks 起源

  • React 一直都提倡使用函数式组件。更轻便,更优雅,性能更佳。函数式组件又称无状态组件(FSC)。
  • 以前,需要使用 state ,生命周期等React 特性,必须重构为 class 组件。
  • Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
  • 现在,你可以直接在现有的函数式组件中使用 Hooks,而无须重构为 class 组件。
  • 全新的思维方式。no magic, just javascript and some rules。

类组件被诟病

  • 类(累):自js开天辟地,就是面向函数式编程(FP), 面向对象编程(OOP)为何物。烦人的构造函数。super是什么?……
  • this绑定:类方法不会自动绑定 this 到实例上。现有四种bind方式。不优雅,易出错,bind还影响性能(使用箭头函数后有所改善)。
  • setState(): 异步更新机制,state 浅合并机制。不理解这些概念,很容易踩坑。
  • 生命周期耦合:每个生命周期方法通常包含一堆不相关的逻辑;不同生命周期中的逻辑又有关联。

下面组件来自实际项目,经过简化和微调(方便演示和直观感受),基本上暴露出了上面所有问题。业务逻辑严谨性不用推敲:

class NumberInput extends React.Component {
  constructor(props) {
    // 为什么必须super,不传props会怎样
    super(props)
    this.state = {
      focus: false
    }
    this.tradingpwd = ''
    // 第一种bind,官方推荐
    ;['onBlur'].forEach(method => {
      this[method] = this[method].bind(this)
    })
  }
  // 下面两个生命周期得相互配合,实现某些功能
  componentDidMount() {
    this.tradingPwdHideInput.focus()
    // 处理某类兼容问题
    let bodyTop = document.body.getBoundingClientRect().top
    const styleText = 'position: fixed; width: 100%; top: ' + bodyTop + 'px'
    document.body.style.cssText = styleText
  }

  componentWillUnmount() {
    this.tradingPwdHideInput.blur()
    document.body.style.position = 'static'
  }

  tradingPwdChange(e) {
    // ...
    this.tradingpwd = e.target.value
    this.props.inputChangeCallback(e.target.value)
    // ...
  }
  // 第二种bind
  onFocus = () => {
    this.setState({
      focus: true
    })
  }
  onBlur() {
    this.setState({
      focus: false
    })
  }

  render() {
    return (
      <div className={classNames('NumberInput')}>
        <input
          type='tel'
          ref={ref => {
            this.tradingPwdHideInput = ref
          }}
          id='tradingPwdHideInput'
          /* 第三种bind */
          onClick={() => {
            this.tradingPwdHideInput.focus()
          }}
          onBlur={ this.onBlur }
          onFocus={ this.onFocus }
          /* 第四种bind,不推荐,在每次 render() 方法执行时绑定类方法,消耗性能*/
          onChange={ this.tradingPwdChange.bind(this) }
        />
      </div>
    )
  }
}

proposal-class-fields 新提案会改善上述情况,目前处于第三阶段。

随着类组件趋于复杂,还有其他诟病:

  • 难拆分,本地state逻辑到处都是,当组件越来越复杂,想拆分比较难。
  • 状态逻辑难复用:需要引入高阶特性进行代码重构,需要调整组件结构,成本高。
  • 抽象地狱:大型React往往使用render props ,HOC,Context 等高阶特性,形成大量包装组件(wrapping components)。层级冗余,逻辑难追踪。

Hooks 优越性

Hooks 引入的一个重要的原因,就是类组件存在着种种诟病。那他必然存在一些优越性。

在说明这些优越性之前,先了解一个概念:

副作用:React 中主要指那些没有发生在数据向视图(M-V)转换过程中的逻辑,如 Ajax 请求、访问原生 DOM 元素、本地持久化缓存、绑定/解绑事件、添加/取消订阅、设置定时器、记录日志等。

Hooks 的优越性:

  • 函数式编程:No class, No super, No this。对于不了解 OOP 的 React 初学者更友好。
  • 有状态逻辑易复用:可以通过 Custom Hook(后面讲解)重构,而不用修改组件结构。
  • 易拆分:状态管理和副作用管理松耦合,原子性强。很容易将一些相关联的逻辑拆分成更小的函数。
  • 可逐步引入:Hooks 向后兼容,与现有代码可并行工作,因此我们可以逐步采用它们。
  • 副作用分组:很多副作用逻辑分散在类组件生命周期函数中。而 Hooks 可以将每个副作用的设置和清理封装在一个函数中。
  • 副作用分离:副作用操作都在页面渲染之后。

抛弃类组件?

既然 Hooks 存在这么多优越性。那是不是就到了抛弃 class 组件的时候了。

对此,官方说:

  • 新版本依然支持 class 相关API,在相当一段时期内,class 组件 和 Hooks 组件并存。
  • 向后兼容,是加法。注意,是函数组件的加法,即 Hooks 只能用在函数组件中。
  • 推荐使用 函数组件 + Hooks。

个人看法:

不抛弃,不放弃。class 组件将我们带到了 OOP 的世界,OOP在编程界举足轻重,其思想是值得学习的,还会长期存在。即便 class 组件已然成为一种历史产物,但他的存量巨大,依然需要去维护,去慢慢消化。

所以:

  • 对于 React 老司机:拥抱Hooks,是拥抱变化。这个变化,是加法,是学习新的API,新的技能,新的思想。
  • 对于 React 新手:拥抱Hooks,降低了学习门槛,可以更快入门。但是类组件也非常有必要去了解,理解。知己知彼,重构不殆。

说了这么多,我们来和这些 React Hooks 的 API 见个面:

基础 Hook

  • useState 简单状态管理
  • useEffect 副作用管理

常用 Hook

  • useContext 全局状态管理
  • useReducer 复杂状态管理
  • useRef 访问 Dom 元素

其他 Hook

  • useMemo
  • useCallback
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue
  • … 还会增加

useState hook

  • 功能:在函数组件中用来进行简单状态管理,创建一些本地 state。
  • API:const [currentState, setFunction] = useState(initialState);。传一个参数,返回一个数组(包含两个值)- 三要素。
import React, { useState } from 'react';
function Form() {
  // ES6 解构
  const [name, setName] = useState('Mary');              // State 变量 1
  const [surname, setSurname] = useState('Poppins');     // State 变量 2
  const [width, setWidth] = useState(window.innerWidth); // State 变量 3

  function handleNameChange(e) {
    setName(e.target.value);
  }

  function handleSurnameChange(e) {
    setSurname(e.target.value);
  }

  return (
    <>
      <input value={name} onChange={handleNameChange} />
      <input value={surname} onChange={handleSurnameChange} />
      <p>Hello, {name} {surname}</p>
      <p>Window width: {width}</p>
    </>
  );
}
export default Form;

useState「粒度」问题

看到这里,对于写过class组件的我们,很容易产生一个疑问。 实际工作中,一个类组件的 this.state 中往往有十几项,用 Hooks 改写的话难道要写十几个 useState 么?

根据官方文档,总结下来,有几点:

  • 建议将 state 分割为多个 useState。粒度更细,更易于管理,更好复用。
  • 可能一起改变的 state 可合并成一个useState( 比如Dom元素的 top left)。
  • 当 state 逻辑趋于复杂,建议使用 useReducer 或 Custom Hook 管理(后面介绍)。

当组件的 state 很多的时候,为了提高代码的可读性,也可以把逻辑相关的一些 state 合并为一个 useState( 比如分页参数 )。但这些 state 并不是一起改变的,所以当其中一个 state 改变,调用对应的 setFunction 的时候。你需要做对象合并(不合并就丢了):

const [ pageData, setPageDate ] = useState({ pageSize: 20, current: 1, total:0, })

const onPageChange = current => {
  // 常规操作
  setPageDate( Object.assign( {}, pageData, { current } ) )
  // 官方建议
  setPageDate(currentPageData => ({ ...currentPageData, current}));
}

知识点:调用 useState 的更新函数时,可以传一个箭头函数,这个函数的参数是当前最新的 state, 返回值是要设置的 state。

useEffect hook

API 可抽象为: useEffect(arrowFunction, [depsArr])

  • arrowFunction: 必须。执行函数,执行副作用操作。它决定了做什么。
  • depsArr: 非必须。一个依赖项数组。它决定了什么时候做(下面示例中介绍)。

根据实际情况,可细分为三种:

// 第一种
// 最基础的,只有箭头函数。没有依赖项,所以组件每次渲染都会执行。
// 相当于  componentDidMount + componentDidUpdate
useEffect(() => { 
  //side-effect 
})
// 第二种
// 有依赖项,是一个空数组,因为它永远不会变,所以只会首次执行。
// 相当于 componentDidMount
useEffect(() => { 
  //side-effect 
}, [])
// 第三种
// 有第二个参数,且非空数组。首次渲染会执行。重新渲染时,只有当依赖项的值改变了才会执行。
useEffect(() => { 
  //side-effect 
}, [...state])

总结下来:

  • 功能:管理 React 函数组件的副作用,赋予生命周期能力。
  • 怎么管:组件每次渲染到屏幕之后,根据依赖项的情况判断是否调用执行函数。
  • 二要素:执行函数,依赖项。
  • 清理机制:你可以在执行函数中返回另一个函数-清理函数,清理函数会在组件卸载的时候,会在组件重新渲染,且useEffect的依赖项值改变的时候调用。起到了 class 组件中componentWillUnmount的作用, 后续会在场景实例中介绍。
  • 使用上:和 useState 一样,可使用多个。建议一个副作用对应一个 useEffect。

以定时器为例,让我们来实现一个秒表组件。这是一个学习和理解 useEffect 非常有意思的例子:

import React, { useState, useEffect } from 'react';

function App() {
  // 秒表开关
  const [isOn, setIsOn] = useState(false);
  // 计数
  const [timer, setTimer] = useState(0);

  useEffect(() => {
    let interval;
    //开关打开的时候才执行
    if (isOn) {
      // 通过定时器增加计数
      interval = setInterval(
        () => setTimer(val => val + 1),
        1000,
      );
    }
    // 需要清除定时器
    return () => clearInterval(interval);
  },[isOn]);

  return (
    <>
      <p>{timer}</p>

      {!isOn && (
        <button type="button" onClick={() => setIsOn(true)}>
          Start
        </button>
      )}

      {isOn && (
        <button type="button" onClick={() => setIsOn(false)}>
          Stop
        </button>
      )}
    </>
  );
}

export default App;

规则:useEffect 不能接收 async 作为执行函数。useEffect 接收的函数,要么返回一个能清除副作用的函数,要么就不返回任何内容。而 async 返回的是 promise。
useEffect 调用的函数如果依赖 state 或者 props。最好在执行函数中定义。这样依赖容易追踪。

useEffect 的使用,看起来很简单。但是要做到不滥用,正确使用也不是那么容易。主要在使用之前要多一些思考。

Custom Hooks

它并不是 React hooks 的 API,而是自定义 hook。顾名思义,React允许你构建自己的 hooks。在学习完前面两个最受欢迎的 hooks 以后,你完全具备了实现自定义 hooks 的能力。

官网定义: 自定义 Hook 是一个 JavaScript 函数,其名称以 ”use” 开头,可以调用其他 Hook。

为什么需要Custom Hooks?

  • useState 解决了函数组件无状态的问题。
  • useEffect 实现了副作用管理,生命周期的功能。
  • Custom Hooks 将解决有状态(stateful)逻辑共享的问题(相当于类组件中Hoc的功能)。👇

我们来到一个实际场景。如今 HTML5 移动应用或 Web app 中越来越普遍的使用了离线浏览技术,所以用 JS 检测浏览器在线/离线状态非常常见。首先,我们用 React Hooks 来实现这个功能:

import React, { useState, useEffect } from 'react';
function App() {
  const [isOffline, setIsOffline] = useState(window.navigator.onLine);
  // 离线事件处理方法
  function onOffline() {
    setIsOffline(true);
  }
  // 在线事件处理方法
  function onOnline() {
    setIsOffline(false);
  }
  useEffect(() => {
    // 事件监听
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);
    // 清理函数
    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []); // 只需要首次执行
  return (
    <>
       { 
         isOffline
         ? <div>网断已断开 ...</div>
         : <div>网络已连接 ...</div>
       }
    </>
  )
}
export default App;

无论浏览器是否在线,navigator.onLine 属性都会提供一个布尔值。 如果浏览器在线,则设置为 true ,否则设置为 false 。

OK,我们实现了一个很不错的功能。很明显,这个功能是可复用的,应该共享的。
我们把功能逻辑提取出来,把它封装成一个 Custom hook 就可以了:

import React, { useState, useEffect } from 'react';
// 自定义 hook
function useOffline() {
  const [isOffline, setIsOffline] = useState(window.navigator.onLine);
  function onOffline() {
    setIsOffline(true);
  }
  function onOnline() {
    setIsOffline(false);
  }
  useEffect(() => {
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);
    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []);
  return isOffline; // 只暴露一个 state
}

// 函数组件
function App() {
  const isOffline = useOffline();
  return (
    <>
       { 
         isOffline
         ? <div>网断已断开 ...</div>
         : <div>网络已连接 ...</div>
       }
    </>
  )
}
export default App;

从重构层面来说,就是把组件中的一些 hooks 抽离到一个函数中,再使用这个函数。这个函数就是 React custom hooks。

书写 custom hooks 需要注意些什么呢:

  • 自定义 Hooks 自然地遵循 Hooks 设计的约定。即遵循所有你用到的 Hooks 的规则。
  • 请使用 use 开头。这个习惯非常重要。如果没有它,我们就不能自动检查该 Hook 是否违反了 Hooks 的规则,因为我们无法判断某个函数是否包含对其内部 Hooks 的调用。

useRef Hook

在典型的 React 数据流中,DOM 元素的修改都是通过 M-V 的形式重新渲染。M 主要是组件的本地 state ,以及父组件传递给子组件的 props。
但是,在某些情况下,你需要在典型数据流之外,手动使用/操作 DOM:

  • 管理焦点,媒体播放,文本选择,触发强制动画等。
  • 集成第三方 DOM 库。
import React, { useState, useEffect, useRef } from "react";
function App() {
  const [value, setValue] = useState("hellow hooks");
  //创建 refs 只有这里有修改
  const inputRef = useRef();
  // 日志A
  console.log(inputRef.current) 
  useEffect(() => {
    // 日志B
    console.log(inputRef.current);
    inputRef.current.focus();
  });
  function inputHandler(val) {
    setValue(val.target.value);
  }
  return (
    <>
      <input type="text" value={value} ref={inputRef} onChange={inputHandler} />
    </>
  );
}
export default App;
  • 类组件中:React16.3 及以上版本建议使用 React.createRef 这个API,低版本使用 回调refs。
  • 函数组件中:为性能考虑,建议都使用 useRef 这个 hook。

useContext Hook

React 16.3.0 中引入了 Context 系列 API。用于共享那些对于一个组件树而言是 “全局” 的 state。可用于解决 prop drilling 问题。

useContext Hook 只是新增了一种方式,让你在函数组件中更方便,更优雅的消费 context:

//
// 目标子组件 - D
// 使用 context 对象的 Consumer 组件获取共享数据,通过更新函数更新数据
const D = () => (
  const { value, setValue } = React.useContext(MyContext)
  return (
    <input
      type="text"
      value={value}
      onChange={e => {
        setValue(e.target.value);
      }}
    />
  )
);

可读性更高了,还可很方便的消费多个 context。

UseReducer Hook

useReducer hook 用于 React 函数组件中管理复杂的 state 。它把一个reducer方法,和初始state作为输入,包含当前state,和一个dispatch方法的解构数组作为输出。

API( 对照 useState ):

// useReducer hook API
const [current, dispatch] = useReducer(reducer, initState);

// useState hook API
const [current, setFunc] = useReducer(initState);

来看一个使用 useReducer 的简单例子:

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

上述示例中的 state 很简单,其实使用 useState 就可以。那么,我们什么时候该使用 useReducer 呢?

适用于useReducer 的复杂 state 的场景主要有:

  • state 逻辑较复杂且包含多个子值(大的对象,数组)。
  • state 更新依赖于之前的 state。
  • state 组件树深层更新。使用useReducer可以向子组件传递 dispatch 而不是回调函数,这样可以优化性能。

原理

  • 集中管理:每个组件都有一个 “内存单元” 的内部列表,作为 Hooks管理中心。
  • just js:只是 JavaScript 对象 + 闭包。你可以想象它是一个数组(实际上是一个单向链表),我们可以在其中放置一些数据。
  • 顺序调用:当组件调用 useState() 等 Hook 时,它读取当前单元格(或在第一次呈现时初始化它),然后将指针(cursor) 移动到下一个单元格。这就是多个 useState() 调用各自获取独立本地state的方式。

缺点

需要开发者遵从许多规则。理解并合理运用这些规则,能写出优雅的,可读性高的,性能好的代码。反之,很容易出现死循环,数据重复请求等问题。最让人担心的是性能,很多时候业务功能实现了,但是其实存在很多不必要的开销。


参考资料
官网-Hooks
What Are React Hooks
React Hooks 详解 + 项目实战

(Nodejs 开发 cli 工具) - Commander

发表于 2020-05-13 | 分类于 Nodejs

Nodejs 的出现让前端具备了服务端的能力,还有一个特别重要的用途是用来开发很多提升效率的 CLI 工具,比如初始化项目的 脚手架,项目打包运行的工具库等。CLI 是 Command-Line Interface 的缩写,命令行界面的意思。

基于Nodejs开发CLI工具,首先就离不开 Commander 库。它是 Nodejs 进行 CLI 开发的完整解决方案。

Commander安装


npm install commander@5.0.0

注意: 本文基于 commander@5.0.0 版本。

引入

常规方式:

const program  = require('commander');

复杂程序中,Commander 可能通过多样化的方式使用,可以通过创建本地 Commander的方式使用:

// 方式A
const Command  = require('commander');
const program = new Command();

// 方式B 
// 5.0.0 版本新增
const { createCommand } = require('commander');
const program = createCommand();

版本


const program = require('commander');
// 写死版本
program.version('0.0.1');

const program = require('commander');
// 动态读取 package 的版本
program.version(require('../package').version);

可以通过 $ comd-cli -V 显示版本号。

options

option() 方法用于 定义 Commander 的参数选项 options。

基本API:

program.option('-n, -name', 'description')
  • n: 参数名称的短标示(flag),用单个字符表示。
  • name: 参数名称。
  • n 和 name之间除了用 ,,还可以用 空格 或者 | 分割。
  • description: 参数描述。

示例:

const program = require('commander');

program
  .option('-d, --debug', 'debugging')
  .option('-s, --small', 'small size')
  .parse(process.argv);
console.log(program.opts());

//执行日志如下👇
$ comd-cli
 { debug: undefined, small: undefined}
$ comd-cli -d
 { debug: true, small: undefined}
$ comd-cli -ds
 { debug: true, small: true}
  • program.opts() 获取命令所有参数信息。
  • 不带对应标示,参数的默认值是 undefined。
  • 带对应标示,参数值为boolean类型 - true。
  • -d -s 可以简写为 -ds。
  • $comd-cli --debug 等同于 $comd-cli -d, 一般都使用更短的标示。
  • 类似 --template-engine 这种命名的名称,会被自动转为驼峰 templateEngine。

后续示例都假定通过 $comd-cli 这个 CLI 执行。

非 boolean 类型参数

如果希望参数不是一个 boolean 类型,而是具体的 vaue。可以通过增加 <type> 或者 [type] 实现:

const program = require('commander');

program
  .option('-d, --debug ', 'if debugging')
  .option('-s, --size <type>', 'size type')
  .option('-l, --limit [type]', 'file size limit')
  .parse(process.argv);
console.log(program.opts());

//执行日志如下👇
$ comd-cli -s
 error option '-s, --size <type>' argument missing
$ comd-cli -s small
 { debug: undefined, size: 'small', limit: undefined }
$ comd-cli -l
 { debug: undefined, size: 'small', limit: true }
$ comd-cli -l 1m
 { debug: undefined, size: 'small', limit: '1m' }
  • 被 <> 包裹的拓展参数 type 为必输值。
  • 被 [] 包裹的拓展参数 type 为非必输,默认值为 true。
  • type 是由用户自主命名。

自定义默认值

如前所述,参数的默认值都为 true。你可以在 options() 方法中添加第三个参数作为默认值

const program  = require('commander');

program
  .option('-d, --debug ', 'if debugging', '')
  .option('-s, --size <type>', 'size type', 'smal')
  .option('-l, --limit [type]', 'file size limit', '1M')
  .parse(process.argv);
console.log(program.opts());

//执行日志如下👇
$ comd-cli 
 { debug: undefined, size: 'small', limit: '1M' }
$ comd-cli -s large
 { debug: undefined, size: 'large', limit: '1M' }
  • 对于带拓展 type 的参数,如上例的 size 和 limit,可设置默认值。
  • 无拓展 type 的参数,如上例的 debug,设置默认值无效。
  • 默认值可以是对象,数组等。

变更默认值

可以通过 --no-name 达到修改 name 参数的默认值的效果。

const program  = require('commander');

program
  .option('-d, --debug ', 'if debugging')
  .option('-s, --size <type>', 'size type', 'small')
  .option('--no-debug', 'change debug')
  .option('--no-size', 'Remove size')
  .parse(process.argv);
console.log(program.opts());
//执行日志如下👇
$ comd-cli 
 { debug: true, size: true}
$ comd-cli -d
 { debug: false, size: true}
$ comd-cli -s big
 { debug: false, size: 'big'}
$ comd-cli --no-debug
 { debug: false, size: true}
$ comd-cli --no-size
 { debug: false, size: undefined}
  • --no-name 参数会把 name 命名的参数的默认值变为 true,不管它是 boolean 还是 value。
  • 命令带 --no-name 参数执行,boolean 类型的 name 会变成 false,value 值会变成undefined。

自定义option处理

当 option() 方法的第三个参数是一个函数(回调函数)的时候,该函数会自动对参数值做处理。

const program  = require('commander');

const func = (dummyValue, previous) => {
  console.log(`dummyValue: ${dummyValue} ; previous: ${previous}`);
  return dummyValue.split(',')
}

program
  .option('-d, --debug ', 'if debugging')
  .option('-s, --size [type]', 'size type',func,['1'])
  .parse(process.argv);
console.log(program.opts());

//执行日志如下👇
$ comd-cli -s 2,3,4
  dummyValue: 2,3,4 ; previous: 1
  { debug: undefined, size: [ '2', '3', '4' ] }
  • 这个时候,默认值可做为 option() 方法的第四个参数。
  • 回调函数参数:dummyValue 用户指定的值; previous: 当前值,上述示例是默认值。
  • 回调函数返回值作为 option 最新值。

必需参数

如果一个命令行必须有某个参数才可以执行,可以通过 requiredOption() 方法处理。

const { program } = require('commander');

program
  .option('-d, --debug ', 'if debugging')
  .requiredOption('-s, --size <type>', 'size type');
  .parse(process.argv);

//执行日志如下👇
$ comd-cli
  error: required option '-s, --size <type>' not specified 

参数传递

你可以使用 -- 来表示选项的结束,剩余的参数将被使用而不会被解释。这对于将选项传递给另一条命令特别有用。

const program  = require('commander');
console.log(process.argv);
program
  .option('-d, --debug ', 'if debugging')
  .option('-s, --size [type]', 'size type','small')
  .parse(process.argv);
console.log(program.opts());
console.log(program.args);

//执行日志如下👇
$ comd-cli -d -- -s big git react 
 { debug: true, size: 'small' }
 [ '-s', 'big', 'git', 'react' ]
  • 注意,-- 后面的 -s big 便不会被 option 处理。
  • 一般,我们通过 parse(process.argv) 方法把其他没有被 options 处理的参数变为 program.args 对象使用。

Commands

Commander 提供了两种用于添加命令的方式,分别对应一下两个 API:

  • command(): 通过用户自己定义命令名称,和一些命令参数来添加一个命令。
  • addCommand(): 用于添加一个已经初始化好的子命令。复杂场景下,往往会定义一个方法来生成命令,就需要使用该方式。

其实,用 addCommand() 添加的子命令,也是通过 command() 方式初始化的。

addCommand() 方式是在 5.0.0 版本才引入的。

命令的执行也有两种方式:

  • 给命令添加一个 action 事件处理程序。需要5.0.0及以上版本
  • 通过一个单独的可执行文件。

下面,我们通过以下三种情况来了解:

  • command()+ action handler
  • command()+ 可执行文件
  • addCommand() 使用示例

command()+ action handler

一定注意,5.0.0及以上版本才支持的方式:

program
  .command('clone <required> [optional]')
  .alise('c')
  .useage('<command> <fileName>')
  .desciption('clone command descption')
  .action((required, optional) => {
    console.log('command called')
  })

//执行日志如下👇
$ comd-cli clone fileName
  command called
  • clone: 命令名称。
  • required: 必须参数。
  • optional: 其他可选参数。
  • alise('c'): 给命令一个简短的别名 c。
  • usage(): 命令的使用格式。使用 --help 会显示。
  • desciption(): 设置命令描述信息。
  • action(handler): 命令执行程序,handler 是一个回调函数,可以获取到命令参数。

command()+ 可执行文件

这种方式是早期版本便一直支持的方式:

// 假设该入口脚本文件目录为:example/comdCli.js
program
  .command('clone <required> [optional]','clone command descption')
  .alise('c')

//执行日志如下👇
$ comdCli clone fileA
  Error: 'comdCli-clone' does not exist
  • 命令描述作为 command() 方法的第二个参数。这个时候就会通过可执行文件执行命令。
  • Commander 会自动使用 program-subCommand(上例中是 comdCli-clone ) 的名字搜索入口脚本文件目录下的可执行文件来执行子命令。
  • 不需要 description 和 action。usage 和 alise同上。

addCommand() 使用示例

addCommand() 只是 5.0.0 版本增加的一个用于增加一个已经初始化好的子命令到 program 的方法:

const Command  = require('commander');
const program = new Command();
program
  .command('tea')
  .action(() => {
    console.log('brew tea');
  });
function makeHeatCommand() {
  const heat = new Command();
  heat
    .command('jug')
    .action(() => {
      console.log('heat jug');
    });
  return heat;
}
// 看这里
program.addCommand(makeHeatCommand());
program.parse(process.argv);

参考资料

gitHub-commander

拥抱React-Hooks (五) - useReducer

发表于 2020-05-09 | 分类于 React

Redux 基础


我们都知道,像 React 框架,本地 state 是交给用户自己打理的,它们分散在组件树中,并随着数据的流动而相互影响。随着应用的复杂性提升,state 也会越来越复杂。很容易就陷入 state难管理,逻辑难追踪,应用难维护的境地。

Redux 的出现就是为了解决以上的问题。目标是让 state 可维护,可预测,可持续。
Redux 原理很简单,它就是 Javascript + 设计模式(思想)。其设计模式也非常简单,可总结为:

三原则

  • 单一数据源 store 用来存储 state
  • state 只读,只能通过 action修改
  • 使用纯函数执行 state 修改,需要编写 reducers

对应三要素:

  • Store 数据集中存储的容器。
  • Action 含有type属性的一个对象,修改数据的唯一途径,它会运送数据到 Store。
  • Reducer 一个纯函数,接受当前 State 和 Action 作为参数,返回一个新的 State。

Store 三方法:

  • store.getState(): 获取当前 state
  • store.dispatch(action): View 发出 Action 的唯一方法。
  • store.subscribe(func): 订阅方法,当 state 改变,会触发 func 重新执行。

在React中应用Redux的大致流程图:

在 React 中使用 Redux 的代码示例:

import React from "react";
import ReactDOM from "react-dom";
import { createStore } from "redux";

// 创建reducer纯函数计算state
const reducer = (state = 0, action) => {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;
    case "DECREMENT":
      return state - 1;
    default:
      return state;
  }
};
// 使用 createStore 方法创建 store,参数为 reducer
// 多个reducer的情况下 应该有一个reducer工厂方法
const store = createStore(reducer);

const rootElement = document.getElementById("root");
const render = () => {
  // 使用 getState 方法 获取state
  const count = store.getState();
  // 使用 dispatch 方法 发送 Action 触发state修改
  const INCREMENT = () => {
    store.dispatch({ type: "INCREMENT" });
  };
  const DECREMENT = () => {
    store.dispatch({ type: "INCREMENT" });
  };
  ReactDOM.render(
    <React.StrictMode>
      <div>
        <h1>{count}</h1>
        <button onClick={INCREMENT}>+</button>
        <button onClick={DECREMENT}>-</button>
    </div>
    </React.StrictMode>,
    rootElement
  );
};
render();
// 通过 subscribe 方法 关联 store 和 App 组件
// 当 state 改变 触发组件重新渲染
store.subscribe(render);

UseReducer Hook


useReducer hook 用于 React 函数组件中管理复杂的 state 。它把一个reducer方法,和初始state作为输入,包含当前 state,和一个 dispatch 方法的 解构数组 作为输出。

API( 对照 useState ):

// useReducer hook API
const [current, dispatch] = useReducer(reducer, initState);

// useState hook API
const [current, setFunc] = useReducer(initState);
  • reducer 用于改变 state 的 reducer 函数,同 Redux。
  • initState 初始state。
  • current 当前state。
  • dispatch 负责传递一个 action 给 reducer 函数,以此改变当前 state。

和 useState 相比,useReducer 多一个reducer 函数。reducer 函数通过 dispatch 传递的 action执行不同的state修改操作。

和redux相比,用户无需关心 store 对象。另外 state 改变,组件会自动触发重新渲染。

来看一个使用 useReducer 的简单例子:

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

上述示例中的 state 很简单,其实使用 useState 就可以。那么,我们什么时候该使用 useReducer 呢?


useReducer 适用场景


我们先来看看和 state 管理相关的三大 Hooks 定位:

  • useState: 简单 State
  • useReducer: 复杂 State
  • useContext: 全局 State

适用于useReducer 的复杂 state 的场景主要有:

  • state 逻辑较复杂且包含多个子值(大的对象,数组)。
  • state 更新依赖于之前的 state。
  • state 组件树深层更新。使用useReducer可以向子组件传递 dispatch 而不是回调函数,这样可以优化性能。

相对于 useState, useReducer只是略复杂。所以当 state 有一定复杂度,便可以大胆使用 useReducer。我们更多的不是纠结要不要使用 useReducer,而是怎么用好 useReducer。

比如上面的代码示例中,reducer 函数可以优化一下,使其可持续发展:

...
// 用好`useReducer`的关键 - reducer 函数
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {...state, { count: state.count + 1} }; // 看这里
    case 'decrement':
      return {...state, { count: state.count - 1} }; // 看这里
    default:
      throw new Error();
  }
}
...

这样改写的原因是,随着组件复杂度提升,state 对象会扩展其他属性,而不仅有 count。


参考资料

Redux 入门教程(一)
Redux 中文网
What is a Reducer in JavaScript/React/Redux?
How to useReducer in React

React 类组件简略语法

发表于 2020-04-16

常规标准语法


class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0,
    };
    this.onIncrement = this.onIncrement.bind(this);
    this.onDecrement = this.onDecrement.bind(this);
  }
  onIncrement() {
    this.setState(state => ({ counter: state.counter + 1 }));
  }
  onDecrement() {
    this.setState(state => ({ counter: state.counter - 1 }));
  }
  render() {
    return (
      <div>
        <p>{this.state.counter}</p>
        <button
          onClick={this.onIncrement}
          type="button"
        >
          Increment
        </button>
        <button
          onClick={this.onDecrement}
          type="button"
        >
          Decrement
        </button>
      </div>
    );
  }
}

简略语法


  • Arrow Function 实现 auto-bind 。
  • 类属性( class properties ) 简化构造函数。
class Counter extends Component {
  state = {
    counter: 0,
  };
  onIncrement = () => {
    this.setState(state => ({ counter: state.counter + 1 }));
  }
  onDecrement = () => {
    this.setState(state => ({ counter: state.counter - 1 }));
  }
  render() {
    return (
      <div>
        <p>{this.state.counter}</p>
        <button
          onClick={this.onIncrement}
          type="button"
        >
          Increment
        </button>
        <button
          onClick={this.onDecrement}
          type="button"
        >
          Decrement
        </button>
      </div>
    );
  }
}

类属性尚在提案阶段,需要引入专门的babel插件处理。

拥抱React-Hooks(四)- 性能优化

发表于 2020-04-13

ReRender 问题


在 React 组件树中,组件重新渲染的情况主要有以下几种:

  • 当组件的 state 或者 props 发生变化,会触发组件的重新渲染。
  • 类组件中,当 setState 调用后,组件的 render 方法也会自动调用,并且会嵌套渲染所有子组件。
  • 函数组件中,使用 useState,state 改变导致组件重新渲染时,也会嵌套渲染所有子组件。

上述特性暴露出 React 一个典型的性能问题 - reRender 问题。确切的说,是子组件不必要的 reRender 问题。

Context 中 Provider 的 value 值改变以后,所有对应的 Consumer 组件也会重新渲染。不过没有reRender 问题。

Diff


reRender 问题是问题么,React 不是有 diff 算法么?我之前也有这个误解。
我们可以把重新渲染过程分为两个阶段:

  • 阶段一:重新渲染,生成新的虚拟 Dom 树。reRender 问题发生在这个阶段。
  • 阶段二:通过对比新旧虚拟 Dom 树。如有差异,更新真实 Dom。diff 算法作用于这个阶段。

虽然不必要的 reRender 在 diff 的时候没有差异,所以不会更新真实 Dom。但是生成新的虚拟Dom,进行diff计算本身就消耗很多性能。

shouldComponentUpdate


在类组件中,我们一般通过 shouldComponentUpdate 生命周期来阻止 reRender 。
shouldComponentUpdate 函数在重渲染时,会在 render() 函数调用前被调用,它接受两个参数:nextProps和nextState,分别表示下一个props和下一个state的值。并且,当函数返回false时候,阻止接下来的render()函数的调用,阻止组件重新渲染,而返回true时,组件照常重新渲染。

class Square extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    if (this.props.number === nextProps.number) {
      return false;
    } else {
      return true;
    }
  }
  render() {
    return <Item>{this.props.number * this.props.number}</Item>;
  }
}

实际项目中,要考虑对象引用,复杂数据结构在 === 时候的坑。浅比较可能对比不出深层差异,深比较本身性能消耗也较大。要仔细权衡。

如果你不关心具体数据的变更,想对 props 和 state 整体对比的常规写法如下:

shouldComponentUpdate(nextProps, nextState) {
  if (this.props === nextProps && this.state === nextState) {
    return false;
  } else {
    return true;
  }
}

这个时候,你可以考虑使用 PurComponent。

React.PurComponent

React.PurComponent “纯”组件,是自带 shouldComponentUpdate 生命周期函数实现的组件。它会在组件重新渲染的时候对 新旧 state 和 props 整体做浅层比较。如果都相等,就阻止重新渲染。

// PurComponent 实现源码
if (this._compositeType === CompositeTypes.PureClass) {
  shouldUpdate = !shallowEqual(prevProps, nextProps) || ! shallowEqual(inst.state, nextState);
}
// shallowEqual 源码
const hasOwn = Object.prototype.hasOwnProperty
function is(x, y) {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export default function shallowEqual(objA, objB) {
  //在 === 基础上 修复了 NaN 和 +-0 的情况
  if (is(objA, objB)) return true

  if (typeof objA !== 'object' || objA === null ||
      typeof objB !== 'object' || objB === null) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (!hasOwn.call(objB, keysA[i]) ||
        !is(objA[keysA[i]], objB[keysA[i]])) {
      return false
    }
  }

  return true
}

因为是浅层比较,一些不合理的写法,会导致 PurComponent中 shallowEqual 无法找出差异。

看下面代码:

const newObj = this.state.obj;
newObj.id = 1;
this.setState({
  obj: newObj
})

newObj 和 obj 指向同一个引用地址,shallowEqual 比较结果是 true 阻止了重新渲染。正确的书写方式是通过 clone来定义newObj。

另外,因为是浅层比较,对于复杂数据结构,不建议使用 PurComponent。
PurComponent 并不等于高性能,对于 props 经常改变的组件,PurComponent 频繁比较,反而性能会降低。

React.memo


React.memo 为高阶组件。它与 React.PureComponent 非常相似,但只适用于函数组件,而不适用类组件。
React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹。其内部使用了 useState 或 useContext。当 state , context 发生变化时,它仍会重新渲染。

默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。

function MyComponent(props) {
  /* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
  /*
  如果把 nextProps 传入 render 方法的返回结果与
  将 prevProps 传入 render 方法的返回结果一致则返回 true,
  否则返回 false
  */
}
export default React.memo(MyComponent, areEqual);

useCallback Hook


在函数组件中,我们经常会创建很多函数。还会把函数作为子组件的参数向下传递。那么当组件 reRender 的时候,这些函数每次都会重新定义。

所以我们会有这样的误解:这会不会很消耗性能,是不是应该缓存下来?而 useCallback 的功能就是在函数组件中做函数的缓存来优化性能。

所以很多人陷入一个误区:对于会频繁 reRender 的函数组件,我们定义的函数都应该使用 useCallback 来缓存下来,避免反复定义。

  • 一方面,官方文档也指出,在现代浏览器中,创建函数和闭包的性能消耗,只有在个别极端情况下才会有明显差异。* 另一方面,在 javascript 中,当组件刷新时,未被 useCallback 包裹的函数将被垃圾回收并重新定义,但被 useCallback 所制造的闭包将保持对回调函数和依赖项的引用,不利于垃圾回收。用的越多,反而负担越重。

useCallback 正确的用法是配合 React.memo 来避免渲染成本较高的子组件非必要 reRender 问题。比如这样一个典型的场景:

import React, { useState } from 'react';

const  FatherComp = () => {
  const [dataA, setDataA] = useState(0);
  const [dataB, setDataB] = useState(0);

  const onClickA = () => {
      setDataA(o => o + 1);
  };

  const onClickB = () => {
      setDataB(o => o + 1);
  }

  return (
    <div>
      <A data= {dataA} onClick={(onClickA)}/>
      <B data= {dataB} onClick={(onClickB)}/>
    </div>
  )
}
export default FatherComp;

父组件 FatherComp 中有2个受控子组件,其中组件 A 交互频繁,而导致父组件频繁 reRender。子组件 B 也因此频繁进行不必要的 reRender。如果 B 是一个渲染成本非常高的组件,那就得优化其不必要reRender 的问题。

这个时候,你只是用 React.memo 包裹 B 组件是没有办法阻止其重新渲染的,因为每次 onClickB 都是重新定义的,B 组件的 props 是改变的。这个时候就需要 useCallback 来包裹 onClickB 来达到阻止的效果。

import React, { useState } from 'react';

const  FatherComp = () => {
  const [dataA, setDataA] = useState(0);
  const [dataB, setDataB] = useState(0);

  const onClickA = () => {
    setDataA(o => o + 1);
  };

  const onClickB = useCallback(() => {
    setDataB(o => o + 1);
  },[])

  return (
    <div>
      <A data= {dataA} onClick={(onClickA)}/>
      <MemoB data= {dataB} onClick={(onClickB)}/>
    </div>
  )
}

const MemoB =  React.memo(B);

export default FatherComp;

我们抽象 useCallback 的API为:const memoFun = useCallback(arrFun, depsArr);

  • arrFun: 必须,是一个函数,首次渲染时,会赋给 memoFun 并缓存。
  • depsArr:可为空,依赖项,更新渲染时:
    • 依赖项没有变化,会直接将上次缓存的 arrFun 赋给 memoFun(memoFun 没有变)。
    • 依赖项发生变化,会重新声明一个 arrFun 赋给 memoFun(memoFun 改变了)。

useMemo Hook


useMemo 这个 Hook,用于函数组件重新渲染时,阻止无用方法的调用(无需重新调用),尤其一些高开销的计算逻辑。它用于局部优化,而不是阻止整个组件 Rerender。

import React, { useMemo, useState } from 'react'

export default function UseMemoPage() {

    const [count, setCount] = useState(0)
    const [value, setValue] = useState("")

    //-----------当前的计算只和count有关------
    const expensive = useMemo(() => {
        console.log("compute");
        let sum = 0;
        for (let i = 0; i < count; i++) {
            sum += i
        }
        return sum;
        // 只有 count 发生改变的时候,才执行这个方法
    }, [count])
    //-------------重点!!局部优化---------

    return (
        <div>
            <h3>UseMemo</h3>
            <p>count:{count}</p>
            <p>expensive:{expensive}</p>
            <button onClick={() => setCount(count + 1)}>add</button>
            <input value={value} onChange={event => setValue(event.target.value)} />
        </div>
    )
}

把 局部高开销计算封装成一个函数 , 和 依赖项数组 一起作为参数传入 useMemo,返回一个 memoized 值 。重新渲染时,它仅会在某个依赖项改变时才重新调用函数计算 memoized 值。

  • 传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo。

  • 所有计算函数中引用的值都应该出现在依赖项数组中。如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值,便没有任何意义。

  • 依赖项数组不会作为参数传给计算函数。

  • 保险起见,先编写在没有 useMemo 的情况下也可以执行的代码。之后再在你的代码中添加 useMemo,以达到优化性能的目的。

总结

  • shouldComponentUpdate生命周期 和 React.PurComponent 用于类组件优化 reRender。
  • React.memo 和 useCallback 用于函数组件优化 reRender性能问题。适用于组件树中父组件交互频繁,而自身 props 较少修改而渲染消耗较大的子组件。
  • useMemo用于局部包含复杂计算逻辑方法优化,相对其他API,更灵活,更放心。
  • 以上 API 本身存在额外性能消耗。所以很多时候,reRender 问题不一定要优化。
  • 别不小心阻止了必要reRender的情况。

参考文献

官网-purecomponent
React 渲染优化
React rerender component

拥抱React-Hooks(三)- useContext

发表于 2020-04-09 | 分类于 React

Prop Drilling 问题


  • 当 React 应用趋于复杂,往往会形成深层组件树。
  • 父子组件通过 props 传递参数来通信。
  • React 是单向数据流。

基于上述三点,就容易产生这样的问题:父组件要逐层把一些 props 传递给目标子组件,而中间子组件并不关心这些props,却要负责透传这些 props ,还要确保中间不出问题。

这就是 React 中 Prop Drilling 问题。

          +----------------+
          |                |
          |        A       |
          |        |Props  |
          |        v       |
          |                |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |        +       |
                      |        |Props  |
                      |        v       |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |        +       |
                      |        |Props  |
                      |        C       |
                      |                |
                      +----------------+

Context


React 16.3.0 中引入了 Context 系列 API。用于共享那些对于一个组件树而言是 “全局” 的 state。可用于解决 prop drilling 问题。但会导致组件复用性降低,忌滥用。

Context 的设计基于 Provider-Consumer 模式,包含一系列 API。

基础 API

  • React.createContext(defaultValue) : 创建一个 Context 对象。
  • Context.Provider : 你创建的 Context 对象的组件,用于 初始化 共享数据的组件。
  • Context.Consumer: 你创建的 Context 对象的组件,用于 获取 共享数据的组件。

我们来通过示例来看一下这些 API :

// ./MyContext.jsx
import React from "react";
// 创建 context 对象
const MyContext = React.createContext("hello");
// Devtools 中显示用
MyContext.displayName = 'MyDisplayName';
export default MyContext
import React from "react";
// 引入 context 对象
import MyContext from "./MyContext";
// 父组件(Provider)
// 使用 context 对象的 Provider 组件共享数据
const A = () => (
  <>
    <MyContext.Provider value="hello context">
      <B />
    </MyContext.Provider>
    <DD/>
  </>
);
// B C 为 中间组件 
const B = () => <C />;
const C = () => <D />;
// 目标子组件 - D
// 使用 context 对象的 Consumer 组件获取共享数据 
const D = () => (
  <MyContext.Consumer>
    {value => <input type="text" value={value} />}
  </MyContext.Consumer>
);
// 没有被 Provider 组件包裹的子组件 - DD
// value 的值为 `hello` 即context创建时的默认值
const DD= () => (
  <MyContext.Consumer>
    {value => <input type="text" value={value} />}
  </MyContext.Consumer>
);
export default A;
  • 使用 React.createContext("hello") 创建了一个 context 对象 - MyContext 。
  • 使用 MyContext 对象的 Provider 组件来共享数据。数据赋值给 value 属性,可以是复杂数据。被Provider 组件包裹的子组件链才能获取 value 中数据。
  • 使用 MyContext 对象的 Consumer 组件来获取共享数据。获取数据是通过内部的一个回调函数,回调函数的参数就是共享的数据。
  • DD 子组件没有在 Provider 包裹的组件链中,使用Consumer 组件只能获取到定义 context 时候的默认值 - hello。
          +----------------+
          |                |
          |       A        |
          |                |
          |     Provide    |
          |     Context    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       DD       |    |        B       |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |        D       |
                      |                |
                      |     Consume    |
                      |     Context    |
                      +----------------+

其他须知:

  • 多个 Provider 也可以嵌套使用,里层的会覆盖外层的数据。
  • 当 Provider 的 value 值发生变化时,它内部的所有 Consume 组件都会重新渲染。且都不受制于shouldComponentUpdate 函数。

Class.contextType

这是一种仅支持 class组件的,更灵活的一种消费单个 context 的 API:

class MyClass extends React.Component {
  componentDidMount() {
    let value = this.context;
    /* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
  }
  componentDidUpdate() {
    let value = this.context;
    /* ... */
  }
  componentWillUnmount() {
    let value = this.context;
    /* ... */
  }
  render() {
    let value = this.context;
    /* 基于 MyContext 组件的值进行渲染 */
  }
}
MyClass.contextType = MyContext;

挂载在 class 上的 contextType 属性会被重赋值为一个由 React.createContext() 创建的 Context 对象。这能让你使用 this.context 来消费最近 Context 上的那个值。你可以在任何生命周期中访问到它,包括 render 函数中。

复杂场景

  • 动态 & 可更新 Context
  • 多个 Context

动态 & 可更新 Context

对于复杂交互的深层嵌套组件树,其 Prop Drilling 问题可能更严重 - 数据双向传递。即父组件把初始数据通过 props 层层传递到目标子组件。目标子组件会改变数据,再通过 callback 层层传递给父组件来统一提交。

对于这种个别情况,你也不想因此引入 redux。那么,你可以通过 Context 来解决。Context 可以是动态的,可更新的。

来看下面示例:

// 创建context 
// 确保传递给 createContext 的默认值数据结构是调用的组件(consumers)所能匹配的!
export default MyContext = React.createContext({
  value: 'hello',
  setValue: () => {},
});
// 父组件
import React from "react";
import MyContext from "./MyContext";
class A extends React.Component {
  constructor(props) {
    super(props);

    this.setValue = newValue => {
      this.setState({ value: newValue });
    };
    // State 也包含了更新函数,因此它会被传递进 context provider。
    this.state = {
      value: "Hello context",
      setValue: this.setValue
    };
  }
  render() {
    return (
      <>
        <MyContext.Provider value={this.state}>
          <B />
        </MyContext.Provider>
        <p>{this.state.value}</p>
      </>
    );
  }
}
// B C 为 中间组件
const B = () => <C />;
const C = () => <D />;
// 目标子组件 - D
// 使用 context 对象的 Consumer 组件获取共享数据,通过更新函数更新数据
const D = () => (
  <MyContext.Consumer>
    {({ value, setValue }) => (
      <input
        type="text"
        value={value}
        onChange={e => {
          setValue(e.target.value);
        }}
      />
    )}
  </MyContext.Consumer>
);

export default A;

我们把父组件本地 state,和用于更新 state 的函数作为一个对象,赋于 provider 的 value。目标子组件可以读取父组件共享的动态数据,并在数据改变的时候更新它。这相当于把修改后数据回传给了父组件。

多个 Context

当共享数据较多,不同的子组件需要的数据也不一样。我们可以考虑创建多个 context。而其中某个组件需要不止一个 context 共享数据的时候,它可以消费多个 context:

// 一个组件通过Consumer组件嵌套,消费多个 context 👇
function Content() {
  return (
    <ThemeContext.Consumer>
      {theme => (
        <UserContext.Consumer>
          {user => (
            <ProfilePage user={user} theme={theme} />
          )}
        </UserContext.Consumer>
      )}
    </ThemeContext.Consumer>
  );
}

useContext hook


既然 React Context 的 API 也可以在函数组件中使用,为什么还需要 useContext Hook 呢?
前面我们了解到,子组件要消费 context 需要通过 Consumer 组件包装。要消费多个 context,还需要嵌套 Consumer 组件。useContext Hook 只是新增了一种方式,让你在函数组件中更方便,更优雅的消费 context。创建 和 Provider 方式不变。

前面 动态&更新context 的示例,目标子组件使用 useContext Hook 消费 context 代码如下:

//
// 目标子组件 - D
// 直接使用 useContext 消费共享数据, 并通过更新函数更新数据。
const D = () => (
  const { value, setValue } = React.useContext(MyContext)
  return (
    <input
      type="text"
      value={value}
      onChange={e => {
        setValue(e.target.value);
      }}
    />
  )
);

可读性更高了,还可很方便的消费多个 context,这是 class组件 的 contextType 所不能的。


参考资料

官网-Context
React Context

拥抱React-Hooks(二)- useRef

发表于 2020-03-30 | 分类于 React

Why & When Refs


在典型的 React 数据流中,DOM 元素的修改都是通过 M-V 的形式重新渲染。M 主要是组件的本地 state ,以及父组件传递给子组件的 props。
但是,在某些情况下,你需要在典型数据流之外,手动使用/操作 DOM:

  • 管理焦点,媒体播放,文本选择,触发强制动画等。
  • 集成第三方 DOM 库。

这个时候,就不要用类似 document.getElementById() 这种原生方式了。React 提供了 Refs 这样一种方式,来访问 DOM 节点或在 render 方法中创建的 React 元素。

另外,refs 还可用于创建一个可变对象(mutable object),并且修改它不会触发组件更新。

但是,一定不能滥用 Refs。避免使用 refs 来做任何可以通过声明式渲染(Declarative,DOM随状态(数据)更新而更新)来完成的事情。

Class 组件中的 Refs


在类组件中,目前有三种方式来创建和使用Refs:

  • String类Refs: 官方表示会弃用的,过时的 API,还不支持函数组件。所以别再用了。
  • 回调Refs:将 回调函数 作为 ref 的一种方式。
  • React.createRef: 这是 React@16.3 版本引入的 顶层 API。

String类refs

命名一个 string 作为元素的ref属性,然后通过 this.refs[string] 的形式访问。我们来看一个媒体播放的示例:

import React from 'react';
class VideoPlay extends React.Component {
  constructor(props) {
    super(props);
  }
  componentDidMount () {
    // 使用refs
    this.refs.myVideo.play()
  }
  render() {
    // 创建refs
    return (
      <video 
        ref='myVideo' 
        src="https://media.w3.org/2010/05/sintel/trailer.mp4" 
        playsinline="" 
      />
    );
  }
}

过时 API,了解一下即可。再次强调:不要再这样使用。

备注:本篇文章的示例验证,都是使用 React 16.12.0 版本。

回调Refs

你可以传递一个函数作为 ref。这个函数中接受 React 组件实例或 DOM 元素作为参数,以便它们能在其他地方被存储和访问。
React 在组件挂载时,会调用 ref 回调函数并传入 DOM 元素,当卸载时调用它并传入 null。在 componentDidMount 或 componentDidUpdate 触发前,React 会保证 ref 一定是最新的。

上面的例子用 回调Refs 实现如下:

import React from 'react';
class VideoPlay extends React.Component {
  constructor(props) {
    super(props);
  }
  componentDidMount () {
    // 使用refs
    this.myVideo.play()
  }
  render() {
    // 创建refs
    return (
      <video 
        ref={ (elem) => { this.myVideo = elem } }
        src="https://media.w3.org/2010/05/sintel/trailer.mp4" 
        playsinline="" 
      />
    );
  }
}

React.createRef

React 16.3 版本新增的这个顶层 API,用于创建 ref。组件挂载时,会把 DOM 元素或组件实例传给 ref 的 current 属性,并在组件卸载时置为 null 。ref 会在 componentDidMount 或 componentDidUpdate 生命周期钩子触发前更新。

ref 的值根据它所赋予的节点的类型有所不同:

  • 当 ref 属性用于 DOM 元素时,ref 接收底层 DOM 元素作为其 current 属性 :
import React from 'react';
class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 textInput 的 DOM 元素
    this.textInput = React.createRef();
  }

  focusTextInput = () => {
    // 直接使用原生 API 使 text 输入框获得焦点
    // 注意:我们通过 "current" 来访问 DOM 节点
    this.textInput.current.focus();
  }

  render() {
    // 告诉 React 我们想把 <input> ref 关联到
    // 构造器里创建的 `textInput` 上
    return (
      <div>
        <input
          type="text"
          ref={this.textInput} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusTextInput}
        />
      </div>
    );
  }
}
export default APP;
  • 当 ref 属性用于自定义 class 子组件时,ref 对象接收 组件的挂载实例 作为其 current 属性。
import React from "react";
class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 子组件的实例
    this.textInput = React.createRef();
  }

  focusTextInput = () => {
    // 注意:这里 “ref” 的 "current" 是子组件的实例 
    // 所以可以调用实例的 “setValue” 方法
    this.textInput.current.setValue("hello");
  };

  render() {
    // 告诉 React 我们想把 <input> ref 关联到
    // 构造器里创建的 `textInput` 上
    return (
      <div>
        <InputText ref={this.textInput} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusTextInput}
        />
      </div>
    );
  }
}
// 注意 仅在 InputText 组件声明为 class 的时候有用
class InputText extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: ""
    };
  }
  // 演示用的实例方法,没现实意义
  setValue = value => {
    this.setState({ value });
  };
  render() {
    return <input type="text" value={this.state.value} />;
  }
}
export default APP;

如果子组件通过 import InputText from "./InputText" 的方式引入,InputText 就是子组件的实例。那这种 refs 作用于子组件的使用方式意义何在?可能用 refs 显得更语义化,规范化。比如使用了多个 InputText 的情况。

注意: 你不能在函数组件上像这样使用 ref 属性,因为函数组件没有实例。不过可以通过forwardRef转发refs使用,这个后面会讲到。

函数组件中的 Refs


首先,我们思考这样一个问题:类组件中三种 refs 的使用方式能否在函数组件中应用。我们来逐个分析一下。

首先,String类refs。不能!官方明确表示不支持函数组件,也是过时 API, Just forget about it。

其次,我们在函数组件中应用一下 回调refs,比如写一个自动聚焦的 input组件:

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

function App() {
  const [value, setValue] = useState("hellow hooks");
  let inputRef = null;

  console.log(inputRef);// 日志A
  useEffect(() => {
    console.log(inputRef); //日志B
    inputRef.focus();
  });

  function inputHandler(val) {
    setValue(val.target.value);
  }

  return (
    <>
      <input
        type="text"
        value={value}
        ref={elem => {
          inputRef = elem;
        }}
        onChange={inputHandler}
      />
    </>
  );
}
export default App;
  • 上面是一个简单的 input 输入组件。

  • 通过 回调refs 创建一个 ref( 对应 input元素) 赋值给 inputRef 变量。

  • 使用 useEffect 实现自动 focus功能。这里没有加依赖项 [] 是想看看组件重新渲染以后,refs的变化。

  • 发现功能一切正常。每次渲染,日志A 为 null,日志B 为 <input type="text" value="hellow hooks"></input>(其中 value 是当前最新值)。

以上,我们完全可以得出一个结论:至少在函数组件内部,回调refs 完全可用。但是从日志A和日志B看出,每次渲染都会通过回调refs 的方式重新创建一个 ref 赋值给 inputRef 变量。明显存在性能问题。

让我们再试试createRef,还是上面的例子:

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

function App() {
  const [value, setValue] = useState("hellow hooks");
  //创建 refs
  const inputRef = React.createRef();
  // 日志A
  console.log(inputRef.current || null) 
  useEffect(() => {
    // 日志B
    console.log(inputRef.current);
    inputRef.current.focus();
  });

  function inputHandler(val) {
    setValue(val.target.value);
  }

  return (
    <>
      <input type="text" value={value} ref={inputRef} onChange={inputHandler} />
    </>
  );
}
export default App;
  • 使用 createRef 创建一个 inputRef,直接赋予 input 的 ref 属性。比 回调refs 优美一些。
  • 功能正常。日志跟回调refs 一样。

结论:和 回调refs 一样 react.createRef 同样可以在函数组件内部使用。当然也有同样的性能问题。

网上有看到文章说在函数组件内部,使用 react.createRef 永远获取不到 refs 。这和我的验证不符,可能是由于 React 版本差异。

这里有一点需要说明,上面的两个示例之所以有性能问题,是因为我们在函数组件中使用了 hooks。而我们使用的两种 refs 并没有 hooks 特性。所以,我们需要更好的选择。

useRef hooks


更好的选择那就是 useRef hooks,我们将要学习的新的 React Hooks,也是今天的主角。
和createRef相比,就是改用 useRef 这个 hooks API 来创建 ref,使用上并无差别。

还是同样的示例:

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

function App() {
  const [value, setValue] = useState("hellow hooks");
  //创建 refs 只有这里有修改
  const inputRef = useRef();

  // 日志A
  console.log(inputRef.current) 
  useEffect(() => {
    // 日志B
    console.log(inputRef.current);
    inputRef.current.focus();
  });

  function inputHandler(val) {
    setValue(val.target.value);
  }

  return (
    <>
      <input type="text" value={value} ref={inputRef} onChange={inputHandler} />
    </>
  );
}
export default App;
  • API:和 createRef 非常类似。useRef 也可以接受一个参数作为 ref 的 current值。有个细微区别是 createRef 的 current 默认值是 null,而 useRef 中默认是 undefined。
  • 性能:重新渲染时,inputRef 不会再被初始化。日志A 和 日志B 都打印 <input type="text" value="Hello Reac"></input>,并且其中 value 是当前最新值。
  • 原理:和 useState useEffect 一样,每个组件都有一个 “内存单元”,首次渲染的时候初始化,把Hooks的数据按顺序存入各个内存单元格,重新渲染的时候再按顺序依次从单元格读取。

暴露 Ref 给父组件

在实际复杂业务场景中,你可能希望在父组件中引用子节点的 DOM 节点。也就是我们不光需要 Ref,还要把 Ref 的控制权也交给父组件。

官方不建议如此,因为它会打破组件的封装,逻辑分散。所以我们要慎用,然后该用还得用。

当你决定如此,这里有三种办法:

  • ref属性传递: 支持所有 React 版本,不支持函数子组件。不推荐
  • props传递: 支持所有 React 版本,也支持函数子组件。
  • 转发refs: 支持 React16.3及以上版本。也支持函数子组件。

父组件是类组件还是函数组件,只决定你是用createRef 还是 useRef 创建 refs。

ref属性传递

前面在介绍 createRef 的时候有提到:当 ref 属性用于自定义 class 子组件时,ref 对象接收组件的挂载实例 作为其 current 属性。

注意了,我们获取到的是子组件的实例,而不是具体的 DOM 元素。这个时候,你想操作子组件,还需要在子组件也创建 ref, 并把相关操作封装成一个方法供父组件调用。这也是不推荐这种方式的原因。

比如在某些场景,我们想在父组件让子组件中的 input 输入框聚焦:

import React from "react";

class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 子组件的实例
    this.inputText = React.createRef();
  }
  // 按钮事件处理中,实现聚焦。
  // 你也可以在 componentDidMount 中处理,实现自动聚焦。
  focusInputText = () => {
    // 注意:这里 “ref” 的 "current" 是子组件的实例 
    // 只能通过调用实例的 doFocus 方法达到控制目的
    this.inputText.current.doFocus();
    // 更不优雅的方式如下
    this.inputText.current.inputRef.current.focus();
  };

  render() {
    return (
      <>
        <InputText ref={this.inputText} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusInputText}
        />
      </>
    );
  }
}
// 注意 仅在 InputText 组件声明为 class 的时候有用
class InputText extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: ""
    };
    // 子组件也需要创建一个ref
    this.inputRef = React.createRef();
  }
  // 提供一个给父组件调用的方法
  doFocus = () => {
    this.inputRef.current.focus();
  };
  render() {
    return <input type="text" value={this.state.value} ref={this.inputRef} />;
  }
}
export default APP;

缺点很明显:

  • 不支持函数子组件,很致命。
  • 父子组件都需要创建 refs,性能不佳。

props传递

顾名思义,既然 ref 属性作用于子组件,是返回整个组件实例。那么我们可以通过 props 传递 refs。你只需要给它起一个独一无二的名字。

上面的示例修改后是这样的:

import React from "react";
class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 子组件的实例
    this.inputRef = React.createRef();
  }
  // 按钮事件处理中,实现聚焦。
  focusInputText = () => {
    // 注意:这里 “ref” 的 "current" 在子组件挂载的时候 已经指向 input 元素
    this.inputRef.current.focus();
  };

  render() {
    return (
      <>
        <InputText inputRef={this.inputRef} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusInputText}
        />
      </>
    );
  }
}
// 注意 这里用函数组件也同样可以
class InputText extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: ""
    };
    // ref 通过参数获取
    this.inputRef = props.inputRef;
  }
  render() {
    return <input type="text" value={this.state.value} ref={this.inputRef} />;
  }
}
export default APP;

复制上述代码到 codesandbox 验证,功能ok。发现没有,代码相对于 “ref属性”,精简优雅了许多。而且,也支持函数组件。

我们用 函数组件 + hooks 来实现上述示例,并且使用 useEffect 实现自动focus:

import React, { useState, useEffect, useRef } from 'react';
const App = () => {
  const [greeting, setGreeting] = useState('Hello React!');
  const ref = useRef();
  useEffect(() => ref.current.focus(), []);
  const handleChange = event => setGreeting(event.target.value);
  return (
    <div>
      <h1>{greeting}</h1>
      <Input value={greeting} handleChange={handleChange} inputRef={ref} />
    </div>
  );
};
const Input = ({ value, handleChange, inputRef }) => (
  <input
    type="text"
    value={value}
    onChange={handleChange}
    ref={inputRef}
  />
);
export default App;

转发refs - forwardRef

  • Ref转发 是通过 React 提供的一个顶层 API - forwardRef 来实现。

  • forwardRef 通过包装子组件的形式,允许子组件能接收 ref 作为第二个参数,并将其向下传递(换句话说,“转发” 它)给子组件。

  • 前面提到过,将 ref 作为子组件的 JSX 属性,是没法把 ref 传递下去。函数组件不支持,类组件也只能获取到子组件的实例。现在通过 forwardRef 包装即可实现。

    请看示例:

import React, {
  useState,
  useEffect,
  useRef,
  forwardRef,
} from 'react';

const App = () => {
  const [greeting, setGreeting] = useState('Hello React!');
  const handleChange = event => setGreeting(event.target.value);
  const ref = useRef();
  useEffect(() => ref.current.focus(), []);
  return (
    <div>
      <h1>{greeting}</h1>
      <Input value={greeting} handleChange={handleChange} ref={ref} />
    </div>
  );
};

const Input = forwardRef(({ value, handleChange }, ref) => (
  <input
    type="text"
    value={value}
    onChange={handleChange}
    ref={ref}
  />
));

export default App;

以下是对上述示例发生情况的逐步解释:

  • 我们通过调用 React.useRef 创建了一个 ref 并将其赋值给 ref 变量。
  • 我们通过指定 ref 为 JSX 属性,将其向下传递给 <Input ref={ref}>。
  • React 传递 ref 给 forwardRef 内函数 (props, ref) => ...,作为其第二个参数。
  • 我们向下转发该 ref 参数到 <input ref={ref}>,将其指定为 JSX 属性。
  • 当 ref 挂载完成,ref.current 将指向 <input/> DOM 节点。

forwardRef + useImperativeHandle

我们通过 forwardRef 进行 refs转发,并配合 useImperativeHandle hooks,可以将函数子组件的指定元素和方法暴露给父组件使用。这在很多稍复杂的业务场景非常有用。

API 可抽象为: useImperativeHandle(refParam, arrowFunction, [depsArr])

  • refParam: 必须。通过 forwardRef转发的父组件传递的 ref,也就是forwardRef里函数的第二个参数。
  • arrowFunction: 必须。回调函数,该函数返回的对象将暴露给父组件访问。
  • depsArr: 非必须。一个依赖项数组。当依赖项改变的时候会重新调用 arrowFunction。

我们改一下上面的示例,让父组件可以直接调用子组件的方法来聚焦子组件中的 input。

import React, {
  useState,
  useEffect,
  useRef,
  forwardRef,
  useImperativeHandle
} from 'react';

const App = () => {
  const [greeting, setGreeting] = useState('Hello React!');
  const handleChange = event => setGreeting(event.target.value);
  const ref = useRef();

  useEffect(() => ref.current.doFocus(), []);

  return (
    <div>
      <h1>{greeting}</h1>
      <Input value={greeting} handleChange={handleChange} ref={ref} />
    </div>
  );
};

const Input = forwardRef( ({ value, handleChange }, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({ doFocus }), []);

  const doFocus = () => {
    inputRef.current.focus();
  }

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
      ref={inputRef}
    />
  )
});

export default App;

可变对象

前面提到,refs 不光可以用于引用和操作Dom,还可以用于创建可变对象。
我们在函数组件中使用useRef hook 来创建一个看看:

import React, { useState, useRef } from "react";
function App() {
  const valRef = useRef(0);// React.createRef don't work
  let normalVal = 0;
  const [, setChange] = useState();
  return (
    <div style={{ padding: "100px 200px" }}>
      refValue: {valRef.current} | 
      normalValue: {normalVal}
      <button
        onClick={() => {
          valRef.current += 80;
          normalVal += 1;
          setChange({});
        }}
      >
        +
      </button>
    </div>
  );
}
export default App;
  • 可变对象的改变,不会触发组件的重新渲染。所以示例增加调用了 setChange({}) 来触发组件重新渲染,来展示可变对象的变化。
  • 点击 + 号,你会发现 refValue 会不断递增,而 normalValue 始终为0.
  • 这是一个有趣的功能,暂时还不知道会在哪些场景会用到它。了解一下即可。

最佳实践

所谓的最佳实践,也就是对一些规则的总结:

  • 尽量在必要的时候使用refs,除了个别需要手动获取/操作Dom的场景:管理焦点,媒体播放,文本选择,触发强制动画,集成第三方 DOM 库等。
  • 不要再使用String类refs,类似 this.refs.XX 这种形式。
  • 类组件中:React16.3 及以上版本建议使用React.createRef 这个API,低版本使用回调refs。
  • 函数组件中:为性能考虑,建议都使用 useRef 这个 hook。即便它无状态,无副作用。
  • 对于 refs 在父子组件间传递的情况,慎用但该用还得用,然后首推 refs转发,其次 props传递。

参考资料
官网-refs
官网-refs转发
官网-状态提升
React Function Components

拥抱 React Hooks (一)基础

发表于 2020-03-18 | 分类于 React

React Hooks 起源

  • React 一直都提倡使用函数式组件。更轻便,更优雅,性能更佳。函数式组件又称无状态组件(FSC)。
  • 以前,需要使用 state ,生命周期等React 特性,必须重构为 class 组件。
  • Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
  • 现在,你可以直接在现有的函数式组件中使用 Hooks,而无须重构为 class 组件。
  • 全新的思维方式。no magic, just javascript and some rules。

类组件被诟病

  • 类(累):自js开天辟地,就是面向函数式编程(FP), 面向对象编程(OOP)为何物。烦人的构造函数。super是什么?……
  • this绑定:类方法不会自动绑定 this 到实例上。现有四种bind方式。不优雅,易出错,bind还影响性能(使用箭头函数后有所改善)。
  • setState(): 异步更新机制,state浅合并机制。不理解这些概念,很容易踩坑。
  • 生命周期耦合:每个生命周期方法通常包含一堆不相关的逻辑;不同生命周期中的逻辑又有关联。

下面组件来自实际项目,经过简化和微调(方便演示和直观感受),基本上暴露出了上面所有问题。业务逻辑严谨性不用推敲:

class NumberInput extends React.Component {
  constructor(props) {
    // 为什么必须super,不传props会怎样
    super(props)
    this.state = {
      focus: false
    }
    this.tradingpwd = ''
    // 第一种bind,官方推荐
    ;['onBlur'].forEach(method => {
      this[method] = this[method].bind(this)
    })
  }
  // 下面两个生命周期得相互配合,实现某些功能
  componentDidMount() {
    this.tradingPwdHideInput.focus()
    // 处理某类兼容问题
    let bodyTop = document.body.getBoundingClientRect().top
    const styleText = 'position: fixed; width: 100%; top: ' + bodyTop + 'px'
    document.body.style.cssText = styleText
  }

  componentWillUnmount() {
    this.tradingPwdHideInput.blur()
    document.body.style.position = 'static'
  }

  tradingPwdChange(e) {
    // ...
    this.tradingpwd = e.target.value
    this.props.inputChangeCallback(e.target.value)
    // ...
  }
  // 第二种bind
  onFocus = () => {
    this.setState({
      focus: true
    })
  }
  onBlur() {
    this.setState({
      focus: false
    })
  }

  render() {
    return (
      <div className={classNames('NumberInput')}>
        <input
          type='tel'
          ref={ref => {
            this.tradingPwdHideInput = ref
          }}
          id='tradingPwdHideInput'
          /* 第三种bind */
          onClick={() => {
            this.tradingPwdHideInput.focus()
          }}
          onBlur={ this.onBlur }
          onFocus={ this.onFocus }
          /* 第四种bind,不推荐,在每次 render() 方法执行时绑定类方法,消耗性能*/
          onChange={ this.tradingPwdChange.bind(this) }
        />
      </div>
    )
  }
}

proposal-class-fields 新提案会改善上述情况,目前处于第三阶段。

随着类组件趋于复杂,还有其他诟病:

  • 难拆分,本地state逻辑到处都是,当组件越来越复杂,想拆分比较难。
  • 状态逻辑难复用:需要引入高阶特性进行代码重构,需要调整组件结构,成本高。
  • 抽象地狱:大型React往往使用render props ,HOC,Context 等高阶特性,形成大量包装组件(wrapping components)。层级冗余,逻辑难追踪。

深度包装的组件长这样:

import { compose } from 'recompose';
import { withRouter } from 'react-router-dom';
function App({ history, state, dispatch }) {
  return (
    <ThemeContext.Consumer>
      {theme =>
        <Content theme={theme}>
          ... 
        </Content>
      }
    </ThemeContext.Consumer>
  );
}
export default compose(
  withRouter,
  withReducer(reducer, initialState)
)(App);

确实很抽象。这是 React 中典型的抽象地狱(Abstraction hell)问题,也叫包装地狱(The wrapper hell)。

Hooks 优越性

Hooks 引入的一个重要的原因,就是类组件存在着种种诟病。那他必然存在一些优越性。

在说明这些优越性之前,先了解一个概念:

副作用:React 中主要指那些没有发生在数据向视图(M-V)转换过程中的逻辑,如 Ajax 请求、访问原生 DOM 元素、本地持久化缓存、绑定/解绑事件、添加/取消订阅、设置定时器、记录日志等。

Hooks 的优越性:

  • 函数式编程:No class, No super, No this。对于不了解 OOP 的 React 初学者更友好。
  • 有状态逻辑易复用:可以通过 Custom Hook(后面讲解)重构,而不用修改组件结构。
  • 易拆分:状态管理和副作用管理松耦合,原子性强。很容易将一些相关联的逻辑拆分成更小的函数。
  • 可逐步引入:Hooks 向后兼容,与现有代码可并行工作,因此我们可以逐步采用它们。
  • 副作用分组:很多副作用逻辑分散在类组件生命周期函数中。而 Hooks 可以将每个副作用的设置和清理封装在一个函数中。
  • 副作用分离:副作用操作都在页面渲染之后。

抛弃类组件?

既然 Hooks 存在这么多优越性。那是不是就到了抛弃 class 组件的时候了。

对此,官方说:

  • 新版本依然支持 class 相关API,在相当一段时期内,class 组件 和 Hooks 组件并存。
  • 向后兼容,是加法。注意,是函数组件的加法,即 Hooks 只能用在函数组件中。
  • 推荐使用 函数组件 + Hooks。

个人觉得:

  • 当下:不抛弃,不放弃。class 组件将我们带到了 OOP 的世界,OOP在编程界举足轻重,其思想是值得学习的。即便 class 组件已然成为一种历史产物,但他的存量巨大,依然需要去维护,去慢慢消化。
  • 未来:有可能弃用 class 组件及其生命周期。一方面,前端的世界本来变化就快。另一方面,class 组件确实存在一些弊端。随着 Hooks的不断成熟(或新的技术诞生), 使得开发效率,代码可读性,维护性,性能等综合优势比较明显的时候,弃用是必然。

所以:

  • 对于 React 老司机:拥抱Hooks,是拥抱变化。这个变化,是加法,是学习新的API,新的技能,新的思想。
  • 对于 React 新手:拥抱Hooks,降低了学习门槛,可以更快入门。但是类组件也非常有必要去了解,理解。知己知彼,重构不殆。

说了这么多,来,我们先来和这些 React Hooks 的 API 见个面:

基础 Hook

  • useState
  • useEffect

其他 Hook

  • useContext
  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue
  • … 还会增加

是不是有点多,其实useState useEffect 这两个已经能应付多数场景了。Let‘s go👇

useState hook

  • 功能:在函数组件中用来进行状态管理,创建一些本地 state。
  • API:const [currentState, setFunction] = useState(initialState);。传一个参数,返回一个数组(包含两个值)- 三要素。
  • initialState:参数,state 初始值。可以是任何类型: String,Object,Array,Bool,Number等。
  • currentState:返回值,state 当前最新值。可自主命名。
  • setFunction:返回值,state 更新函数。可自主命名。你可以在任意位置调用,来改变 state 的值。每次调用,会触发组件重新渲染(这也是返回值用 const 非 let 的原因)。
  • 特点:可使用多个useState,彼此独立。而类组件,只有一个 state,每次setState 要进行浅合并(内部实现)。

这个API很简单,请看下面示例(24行代码):

import React, { useState } from 'react';
function Form() {
  // ES6 解构
  const [name, setName] = useState('Mary');              // State 变量 1
  const [surname, setSurname] = useState('Poppins');     // State 变量 2
  const [width, setWidth] = useState(window.innerWidth); // State 变量 3

  function handleNameChange(e) {
    setName(e.target.value);
  }

  function handleSurnameChange(e) {
    setSurname(e.target.value);
  }

  return (
    <>
      <input value={name} onChange={handleNameChange} />
      <input value={surname} onChange={handleSurnameChange} />
      <p>Hello, {name} {surname}</p>
      <p>Window width: {width}</p>
    </>
  );
}
export default Form;

<></> 是 React.Fragment 的简写语法。

用class类实现的话(31行代码),上述代码相当于:

import React from 'react';
class Form extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: 'Mary',
      surname:'Poppins',
      width: window.innerWidth
    };
  }
  handleNameChange = (e) => {
    this.setState({ name: e.target.value });
  }

  handleSurnameChange = (e) => {
    this.setState({ surname: e.target.value });
  }

  render() {
    const { name, surname, width } = this.state
    return (
      <>
        <input value={name} onChange={ this.handleNameChange } />
        <input value={surname} onChange={ this.handleSurnameChange } />
        <p>Hello, {name} {surname}</p>
        <p>Window width: {width}</p>
      </>
    );
  }
}
export default Form;

useState「粒度」问题

看到这里,对于写过class组件的我们,很容易产生一个疑问。 实际工作中,一个类组件的 this.state 中往往有十几项,用 Hooks 改写的话难道要写十几个 useState 么?

对于这个常见问题,官方文档有解答。

根据官方文档,总结下来,有几点:

  • 建议将 state 分割为多个 useState。粒度更细,更易于管理,更好复用。
  • 可能一起改变的 state 可合并成一个useState( 比如Dom元素的 top left)。
  • 当 state 逻辑趋于复杂,建议使用 reducer 或 Custom Hook 管理(后面介绍)。

当组件的 state 很多的时候,为了提高代码的可读性,也可以把逻辑相关的一些 state 合并为一个 useState( 比如分页参数 )。但这些 state 并不是一起改变的,所以当其中一个 state 改变,调用对应的 setFunction 的时候。你需要做对象合并(不合并就丢了):

const [ pageData, setPageDate ] = useState({ pageSize: 20, current: 1, total:0, })

const onPageChange = current => {
  // 常规操作
  setPageDate( Object.assign( {}, pageData, { current } ) )
  // 官方建议
  setPageDate(currentPageData => ({ ...currentPageData, current}));
}

知识点:调用 useState 的更新函数时,可以传一个箭头函数,这个函数的参数是当前最新的 state, 返回值是要设置的 state 。

useEffect hook

API 可抽象为: useEffect(arrowFunction, [depsArr])

  • arrowFunction: 必须。执行函数,执行副作用操作。它决定了做什么。
  • depsArr: 非必须。一个依赖项数组。它决定了什么时候做(下面示例中介绍)。

根据实际情况,可细分为三种:

// 第一种
// 最基础的,只有箭头函数。没有依赖项,所以组件每次渲染都会执行。
// 相当于  componentDidMount + componentDidUpdate
useEffect(() => { 
  //side-effect 
})
// 第二种
// 有依赖项,是一个空数组,因为它永远不会变,所以只会首次执行。
// 相当于 componentDidMount
useEffect(() => { 
  //side-effect 
}, [])
// 第三种
// 有第二个参数,且非空数组。首次渲染会执行。重新渲染时,只有当依赖项的值改变了才会执行。
useEffect(() => { 
  //side-effect 
}, [...state])

相当是 == 而非 ===。

总结下来:

  • 功能:管理 React 函数组件的副作用,赋予生命周期能力。
  • 怎么管:组件每次渲染到屏幕之后,根据依赖项的情况判断是否调用执行函数。
  • 二要素:执行函数,依赖项。
  • 清理机制:你可以在执行函数中返回另一个函数-清理函数,清理函数会在组件卸载的时候,会在组件重新渲染,且useEffect的依赖项值改变的时候调用。起到了 class 组件中componentWillUnmount的作用, 后续会在场景实例中介绍。
  • 使用上:和 useState 一样,可使用多个。建议一个副作用对应一个 useEffect。

根据副作用是否需要清理,useEffect 可分为 不需要清理的 useEffect,和 需要清理的 useEffect。下面,我们分别通过一些示例来直观的感受一下。

不需要清理的场景

有时,我们希望在 React 渲染页面之后运行一些额外的代码。 网络请求、手动修改DOM 和日志记录都是不需要清理 副作用 的常见例子。可以这么说,我们运行它们,然后可以马上忘记它们。

就拿官网的例子来说,一个计数器组件,计数发生改变以后,更新 Dom 标题。类组件是这样实现的:


class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`; 
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

用 useEffect 实现如下:

import { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);
  // 第一种useEffect
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

规则:每一次渲染后都去运行所有的 effects 可能并不高效。(并且在某些场景下,它可能会导致无限循环。)– Dan Abramov

这条规则告诉我们,在写无依赖的 useEffect 的时候,多一点思考。上面代码现在看没有问题,后续增加了其他 state 和功能以后,这个 useEffect 就不高效了,可改写为:

import { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  },[count]);// 看这里
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

需要清理的场景

有一些场景,我们需要做副作用的清理,保证引起不必要内存泄漏。比如,手动绑定事件,订阅,定时器等。

以定时器为例,让我们来实现一个秒表组件。这是一个学习和理解 useEffect 非常有意思的例子。

这里,我们直接用 Hooks 来实现:

import React, { useState, useEffect } from 'react';

function App() {
  // 秒表开关
  const [isOn, setIsOn] = useState(false);
  // 计数
  const [timer, setTimer] = useState(0);

  useEffect(() => {
    let interval;
    //开关打开的时候才执行
    if (isOn) {
      // 通过定时器增加计数
      interval = setInterval(
        () => setTimer(timer + 1),
        1000,
      );
    }
    // 需要清除定时器
    // 不清理会如何?codesandbox中尝试,页面直接卡死
    return () => clearInterval(interval);
  }); 

  return (
    <>
      <p>{timer}</p>

      {!isOn && (
        <button type="button" onClick={() => setIsOn(true)}>
          Start
        </button>
      )}

      {isOn && (
        <button type="button" onClick={() => setIsOn(false)}>
          Stop
        </button>
      )}
    </>
  );
}

export default App;

运行代码,你会发现,秒表效果实现了。但是,同样的错误,故意犯了2次:既然用了定时器,为什么还要 effect 每次执行。让我们来分析下上面代码的执行流程:

  • 首次加载:effect执行。因为isOn是false,所以 定时器 没有创建。
  • 点击 start 打开开关(setIsOn(true))。isOn 这个 state 改变,组件重新渲染。effect再次执行,此时创建定时器。
  • 定时器生效,1秒后执行 setTimer(timer + 1),timer 这个 state 改变,触发组件重新渲染(定时器也会清除)。effect再次执行,重新创建定时器。
  • 一直重复上面步骤。

有没有发现问题,定时器在循环创建,清除。用什么定时器,用延时器(setTimeout)好了。最糟糕的是,如果你忘了清除定时器,不光计数会错乱,页面也会奔溃。

怎么优化呢?同样的解决方案。很显然,我们的 effect 依赖 isOn 这个 state,所以我们可以把它作为 useEffect 的依赖项:

//...
useEffect(() => {
  let interval;
  if (isOn) {
    interval = setInterval(
      () => setTimer(timer + 1),
      1000,
    );
  }
  return () => clearInterval(interval);
  },[isOn]); // 看这里!!!!!!!!!
// ...

这样是不是就ok了?拷贝代码到 codeSandBox 验证一下。what?点击 start ,计数器增加到 1 以后不动了!。
页面卡死了么?我们再分析一下流程:

  • 首次加载:effect 执行。因为 isOn 是 false,所以定时器没有创建。
  • 点击 start 打开开关。isOn 改变,组件重新渲染。effect 的依赖项 isOn 也改变了,effect 再次执行。此时,isOn 是 true,定时器创建。
  • 定时器生效,1秒后执行 setTimer(timer + 1),timer 改变,触发组件重新渲染。注意了,此时 effect 的依赖项isOn 并没有改变,所以定时器在重新渲染后不会清除,effect 也不会再次执行。看上去这就是我们想要的,定时器还在工作。那为什么一直是 1 。

规则:React 约定 Effect 拿到的总是定义它的那次渲染中的 props 和 state。– Dan Abramov
我也注意到,上面的代码在 codeSandBox 中执行会看到一条告警信息:React Hook useEffect has a missing dependency: 'timer'. Either include it or remove the dependency array. You can also do a functional update 'setTimer(t => ...)' if you only need 'timer' in the 'setTimer' call. (react-hooks/exhaustive-deps) -- eslint

疑惑解开。这其实就是js常见的闭包,你也可以理解为这是 useEffect 的约定。这个非常非常重要,划重点。
上面的告警信息,已经明确的告诉了我们如何解决这个问题。

办法一: 增加依赖项 timer ,这样timer 改变也会触发重新渲染,然后 effect 都、会再次执行,定时器会拿到新的 timer。

useEffect(() => {
  let interval;
  if (isOn) {
    interval = setInterval(
      () => setTimer(timer + 1),
      1000,
    );
  }
  return () => clearInterval(interval);
  },[isOn,timer]); // 看这里!!!!!!!!!

规则: 我鼓励你诚实地告知 effect 依赖作为一条硬性规则,并且要列出所有依赖。– Dan Abramov

办法二:采用 更新函数 来改变 state。前面提到过,useState 的 setFunction中 ,可以传一个箭头函数(更新函数),这个函数的参数是当前最新的 state, 返回值是要设置的 state。

useEffect(() => {
  let interval;
  if (isOn) {
    interval = setInterval(
      () => setTimer(val => val + 1),// 看这里!!!!
      1000,
    );
  }
  return () => clearInterval(interval);
  },[isOn]); // 看这里

发现没有,使用更新函数后,我们相当于去除了对 timer 的依赖。

规则: 当我们不想增加更多依赖,可以尝试修改 effect 使得依赖更少。– Dan Abramov

所以 方法二 优于 方法一。

我们来看一个实际项目中常见的副作用 - Ajax请求。在class 组件中,我们经常会用生命周期 componentDidMount来处理一些初始化的 Ajax 数据请求,现在我们用 useEffect 来实现。

比如用 axios 请求一个列表:

import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
  const [data, setData] = useState([]);
  useEffect(() => {
    // 更优雅的方式
    const fetchData = async () => {
      const result = await axios(
        'https://hn.algolia.com/api/v1/search?query=redux',
      );
      setData(result.data);
    };
    fetchData();
  }, []);
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}
export default App;

规则:useEffect 不能接收 async 作为执行函数。useEffect 接收的函数,要么返回一个能清除副作用的函数,要么就不返回任何内容。而 async 返回的是 promise。
useEffect 调用的函数如果依赖 state 或者 props。最好在执行函数中定义。这样依赖容易追踪。

useEffect 的使用,看起来很简单。但是要做到不滥用,正确使用也不是那么容易。主要在使用之前要多一些思考。

Custom Hooks

终于讲到它了, 前面已经提到过。它并不是 React hooks 的 API,而是自定义 hook。顾名思义,React允许你构建自己的 hooks。在学习完前面两个最受欢迎的 hooks 以后,你完全具备了实现自定义 hooks 的能力。

官网定义: 自定义 Hook 是一个 JavaScript 函数,其名称以 ”use” 开头,可以调用其他 Hook。

为什么需要Custom Hooks?

  • useState 解决了函数组件无状态的问题。
  • useEffect 实现了副作用管理,生命周期的功能。
  • Custom Hooks 将解决有状态(stateful)逻辑共享的问题(相当于类组件中Hoc的功能)。👇

我们来到一个实际场景。如今 HTML5 移动应用或 Web app 中越来越普遍的使用了离线浏览技术,所以用 JS 检测浏览器在线/离线状态非常常见。首先,我们用 React Hooks 来实现这个功能:

import React, { useState, useEffect } from 'react';
function App() {
  const [isOffline, setIsOffline] = useState(window.navigator.onLine);
  // 离线事件处理方法
  function onOffline() {
    setIsOffline(true);
  }
  // 在线事件处理方法
  function onOnline() {
    setIsOffline(false);
  }
  useEffect(() => {
    // 事件监听
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);
    // 清理函数
    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []); // 只需要首次执行
  return (
    <>
       { 
         isOffline
         ? <div>网断已断开 ...</div>
         : <div>网络已连接 ...</div>
       }
    </>
  )
}
export default App;

无论浏览器是否在线,navigator.onLine 属性都会提供一个布尔值。 如果浏览器在线,则设置为 true ,否则设置为 false 。

OK,我们实现了一个很不错的功能。很明显,这个功能是可复用的,应该共享的。
我们把功能逻辑提取出来,把它封装成一个 Custom hook 就可以了:

import React, { useState, useEffect } from 'react';
// 自定义 hook
function useOffline() {
  const [isOffline, setIsOffline] = useState(window.navigator.onLine);
  function onOffline() {
    setIsOffline(true);
  }
  function onOnline() {
    setIsOffline(false);
  }
  useEffect(() => {
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);
    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []);
  return isOffline; // 只暴露一个 state
}

// 函数组件
function App() {
  const isOffline = useOffline();
  return (
    <>
       { 
         isOffline
         ? <div>网断已断开 ...</div>
         : <div>网络已连接 ...</div>
       }
    </>
  )
}
export default App;

现在,你应该对 custom hooks 有了一个直观的认识:

  • 一个函数。
  • 一个use开头的函数。
  • 一个使用 React hooks 封装的,处理副作用的函数。
  • 一个在函数组件中引入简单,不需要调整组件结构的函数。

从重构层面来说,就是把组件中的一些 hooks 抽离到一个函数中,再使用这个函数。这个函数就是 React custom hooks。

既然是函数,那肯定可以传参。我们来看一个常见场景:很多时候,为了用户体验,页面会本地存储用户数据,然后在页面返回的时候自动填充。现在,我们用一个传参的 custom hooks 来实现该场景:

import React, { useState, useEffect } from 'react';
// 自定义 hook,接收一个 localStorageKey 参数
const useStateWithLocalStorage = localStorageKey => {
  const [value, setValue] = useState(
    localStorage.getItem(localStorageKey) || '',
  );
  useEffect(() => {
    localStorage.setItem(localStorageKey, value);
  }, [value]);
  return [value, setValue];
};
const App = () => {
  // 使用带参数的 自定义 hooks
  const [value, setValue] = useStateWithLocalStorage(
    'myValueInLocalStorage',
  );
  const onChange = event => setValue(event.target.value);
  return (
    <div>
      <input value={value} type="text" onChange={onChange} />
      <p>{value}</p>
    </div>
  );
};

书写 custom hooks 需要注意些什么呢?看官网怎么说:

  • 自定义 Hooks 是一种惯例,它自然地遵循 Hooks 设计的约定。即遵循所有你用到的 Hooks 的规则。
  • 请使用 use 开头。这个习惯非常重要。如果没有它,我们就不能自动检查该 Hook 是否违反了 Hooks 的规则,因为我们无法判断某个函数是否包含对其内部 Hooks 的调用。

原理

顺序调用:每个组件都有一个 “内存单元” 的内部列表。它们只是 JavaScript 对象,你可以想象它是一个数组(实际上是一个单向链表),我们可以在其中放置一些数据。当调用 useState() 这样的 Hook 时,它读取当前单元格(或在第一次呈现时初始化它),然后将指针移动到下一个单元格。这就是多个 useState() 调用各自获取独立本地状态的方式。

  • Hooks 的状态值都被挂载在组件实例对象 FiberNode 的属性中。
  • Hooks 是用链表来保存状态的,属性保存的实际上是这个链表的头指针。
  • useState / useReducer 的信息保存在 FiberNode.memoizedState属性.
  • useEffect 也是以链表的形式挂载在 FiberNode.updateQueue 属性中。
// react-reconciler/src/ReactFiberHooks.js
export type Hook = {
  memoizedState: any, // 最新的状态值
  baseState: any, // 初始状态值,如`useState(0)`,则初始值为0
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null, // 临时保存对状态值的操作,更准确来说是一个链表数据结构中的一个指针
  next: Hook | null,  // 指向下一个链表节点
};

 const effect: Effect = {
    tag, // 用来标识依赖项有没有变动
    create, // 用户使用useEffect传入的函数体
    destroy, // 上述函数体执行后生成的用来清除副作用的函数
    deps, // 依赖项列表
    next: (null: any),
};

想更详细的理解,请点击:
React Hooks 揭秘
React Hooks 原理剖析

缺点

需要开发者遵从许多规则。理解并合理运用这些规则,能写出优雅的,可读性高的,性能好的代码。反之,很容易出现死循环,数据重复请求等问题。最让人担心的是性能,很多时候业务功能实现了,但是其实存在很多不必要的开销。


参考资料
官网-Hooks
What Are React Hooks
React Hooks 详解 + 项目实战

关于fetch

发表于 2020-03-11 | 分类于 web

Fetch 作为浏览器提供的原生 AJAX 接口。还是值得我们去探究一下的。这里有一篇关于Fetch应用特别好的博文,所以,我啥也不说了👇
传统 Ajax 已死,Fetch 永生


你可能会说,我们用axios就好了,它也基于promiss。其实我也是,目前项目中都是用axios,还没有应用过Fetch。但是它们直接的区别还是应该了解一下的。发现一篇非常全面的好文,所以,我啥也不说了👇
Ajax,jQuery ajax,axios和fetch介绍、区别以及优缺点


有人吐槽Fetch:

function addUser(details) {
  return fetch('https://api.example.com/user', {
    mode: 'cors', //fetch 默认不启用 CORS
    method: 'POST',
    credentials: 'include', // fetch 默认情况下不会发送 cookie
    body: JSON.stringify(details), //JSON 必须先转换成字符串
    headers: {
      //必须设置 'Content-Type' 头部,指出实体的类型是 JSON,否则服务器会把它当做普通的字符串处理
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      // 用于防御xsrf攻击的 X-XSRF-TOKEN 头部 必须手动添加
      'X-XSRF-TOKEN': getCookieValue('XSRF-TOKEN')
    }
  }).then(response => {
    return response.json().then(data => {
      // 只会在网络错误的情况下reject 错误状态码(比如404 500 )依然resove
      if (response.ok) {
        return data;
      } else {
        return Promise.reject({status: response.status, data});
      }
    });
  });
}

吐槽帖


个人觉得,Fetch作为原生API,纯粹是非常正确的。实际应用场景千差万别,你不能保证你默认增加的配置适应任何场景。我们只需要在实际项目做一些本土化的封装,这样所有配置均可见。即便我们用axios,我们也经常这么做。


官方API文档

shim和polyfill

发表于 2020-03-11 | 分类于 工程化

在前端的世界里,shim和polyfill经常被提到。它们指的都是什么, 又有什么区别呢。我的理解是

shim

中文翻译为楔子,垫子。指一些做兼容性的库,用来弥补旧浏览器对新特性支持的不足,仅靠旧浏览器已有的API实现一些新特性,一般都会预先加载,这样新旧浏览器可以使用同一套包含新特性的代码。比如es5-shim是一个shim,它在ECMAScript 3的引擎上实现了ECMAScript 5的新特性, 而且在Node.js上和在浏览器上有完全相同的表现。

polyfill

中文翻译为填充物,国内称为腻子。把旧的浏览器想象成一面有了裂缝的墙,polyfills会帮助我们把这面墙的裂缝抹平。polyfill可以理解为一个用在浏览器API上的shim。我们通常的做法是先检查当前浏览器是否支持某个API,如果不支持的话就加载对应的polyfill。
作为前端,我们理解和使用polyfill其实就可以了。

参考资料

https://www.html.cn/archives/8339

JS控制页面滚动

发表于 2020-03-11 | 分类于 兼容性

前言

最近解决一个 wkwebview 的兼容性问题,需要JS控制页面的滚动,踩了不少坑。

通常我们控制页面滚动用到下面几个api。

  • window.scrollTo(x,y)
  • window.scrollX/window.scrollY
  • window.pageYOffset/window.pageXOffset
  • document.documentElement.scrollTop
  • document.body.scrollTop

window.scrollTo(x,y)

设置页面滚动。IE9+ 、firefox、chrome,opera均支持该方式获取页面滚动高度值,忽略Doctype规则。

window.scrollX/Y

只读属性 firefox、chrome,opera支持,IE不支持,忽略Doctype规则

window.pageXOffset

只读属性 IE9+ 、firefox、chrome,opera均支持,忽略Doctype规则

document.documentElement 和 document.body(划重点)

网上清一色:使用DTD定义文档时(<!DOCTYPE ...>),使用document.documentElement,否则 使用document.body。除此之外,无其他兼容问题。
发现问题:公司ios移动端使用wkwebview,h5页面均定义DTD<!DOCTYPE html>,所以使用了document.documentElement.scrollTop。结果发现部分ios手机不兼容,比如:iphone xs 12.4.1使用document.body才可以。

至少得出结论:关于document.documentElement 和 document.body,safari内核不符合DTD规则,某些版本仅支持document.body控制滚动

解决方案

  document.scrollingElement.scrollTop = 200 
  // 等价于
  document.documentElement.scrollTop = 200 
  document.body.scrollTop = 200 

Document.scrollingElement可以解决上述safari不兼容性问题, 而且更清爽。虽然这个APIIE从ie12-Edge才兼容,但是移动端使用不存在兼容性问题。

通用兼容性方案(支持桌面 移动端 IE等)

移动端建议使用Document.scrollingElement即可,想全端兼容,大而全的写法如下:

//以获取scrolltop以及设置scrolltop为例
    function getScrollTop() {
        return window.pageYOffset
           ||  document.documentElement.scrollTop  
             ||  document.body.scrollTop;
    }

    function setScrollTop(height) {
        document.documentElement.scrollTop = height;
        document.body.scrollTop = height;
        window.pageYOffset = height;
    }

GitLab-CI/CD 基础教程(转)

发表于 2020-03-11 | 分类于 工程化

最近听朋友公司在使用 GitLab CI/CD 平台,K8s集群,对我来说还是很新鲜的东东。虽然是偏后端的内容,前端也很有必要了解和学习一下的。这里转载几遍专业后端经过实践总结的文章。

基础教程(一): 基本概率,配置流程
基础教程(二): GitLab Runner以及在Docker/k8s中部署
基础教程(三): 与日常开发部署流程结合

Git Hooks & Husky

发表于 2020-03-03 | 分类于 工程化

众所周知,javascript一开始就是一种非常灵活的语言,随着新特性不断增加,框架层出不穷,加之个人的编程风格。前端代码的可维护性问题越来越突出。做一个有工程素养的前端的开发,我们要注重编码规范。做为架构或者团队的leader,更应该考虑编码规范的约束机制。所以做好代码风格检查(Code Linting,简称 Lint)已经成为前端团队必修课,是保障代码规范一致性的重要手段。 做好link可以有效的减少bug,提升开发效率和代码的可读性。其中,提交前lint更有效率。 如何着手呢?自然是从我们的代码管理工具。

Git hooks

现在最流行的版本管理工具非Git莫属,Git本身也增加了一些hooks(钩子),在git命令前置执行来阻止一些不规范的操作。

$ cd .git/hooks
$ ls -a
  • git hooks是在.git/hooks目录下的一些脚本文件,用于控制git工作的流程。
  • 内置的脚本示例都是shell脚本,其中一些还混杂了Perl代码,不过,任何正确命名的可执行脚本都可以正常使用 —— 你可以用Ruby或Python,当然我们前端也可以用Node来编写。
  • 钩子分为客户端钩子和服务端钩子。
  • 客户端钩子:pre-commit、prepare-commit-msg、commit-msg、post-commit等,主要用于控制客户端git的提交工作流。
  • 服务端钩子:pre-receive、post-receive、update,主要在服务端接收提交对象时、推送到服务器之前调用。
  • 钩子都是以.sample结尾的文件名。注意这些示例脚本是不会执行的,只有重命名去掉.sample后才会生效。

但是直接使用git hooks不方便在团队内推广。需要有工具自动把脚本安装到每个人的本地项目上才能生效。所以我们需要借助一些其他工具库。

Husky

前端常用的git hooks工具有 pre-commit 和 Husky。这里我只介绍 Husky(更全面一些)。

原理

husky利用 git hooks会在相关命令执行前执行的特性,取而代之。

#!/bin/sh
# husky
export HUSKY_GIT_PARAMS="$*"
node_modules/run-node/run-node ./node_modules/husky/lib/runner/bin `basename "$0"`
...
  • husky 使用了自定义的安装过程:node lib/installer/bin install(在node_modules/husky/package.json里)。执行的时会在项目的.git/hooks 目录生成所有 hook 的脚本(你自定义的hook脚本,husky不会覆盖)。

  • 每个hook脚本都是一样的, 关键的部分是 bashname "$0",这样可以拿到当前的 hook名,如pre-commit、pre-push。

  • 最后根据package.json的配置,执行我们定义的,相对应的hook脚本(我们可以用node写)。

安装

npm install husky --save-dev
// 或者
yarn add husky --dev

配置

//package.json文件
"husky": {
  "hooks": {
    "pre-commit": "eslint",
    "commit-msg": "node preCommit.js"  // 可以集成到自己框架的的cli中,比如:luna preCommit
  }
}
  • 当你git commit的时候,会触发hook(pre-commit),husky将会执行对应配置(pre-commit)里的eslint命令,没有问题才提交。
  • 当你git commit的时候,会触发hook (commit-msg),husky将会执行对应配置(commit-msg)里的preCommit.js脚本,没有问题才提交。

实际应用场景一:Commit message格式校验

上面配置中的preCommit.js文件是按公司要求,校验提交的message必须符合规定格式的脚本,代码如下:

/*
* 功能: git commit时,自动验证提交信息是否符合规范
* 提交规范: 范式 {ir_key}:  {subject_content}.例如:"STY-ABCD-TY-76379:某个功能开发"
* 主要是读取 .git/COMMIT_EDITMSG 这个文件,文件记录了当前commit之后的信息
*/
const fs = require('fs');
const chalk = require('chalk');

const warning = chalk.keyword('red');
const msg = chalk.keyword('yellow');
// const link = chalk.hex('#00bfff');

const pattern = /^((STY|DTK)-ABCD-TY-)\d{5}:[^]/;
const commitMsg = fs.readFileSync(process.env.HUSKY_GIT_PARAMS, 'utf-8').trim();

if (!pattern.test(commitMsg.toUpperCase())) {
  console.log(msg(`\nYour commit message: ${commitMsg}\n`));
  console.log(warning('-----------------------Git提交Message不符合规范------------------------------\n'));
  console.log(msg('范式:{ir_key}: {subject_content}。“ir_key”不区分大小写,“冒号”必须半角英文\n'));
  console.log(msg('示例1:DTK-ABCD-TY-76379: 某个bug fix\n'));
  console.log(msg('示例2:STY-ABCD-TY-76379: 某个功能开发\n'));
  console.log(warning('-----------------------------------------------------------------------------\n'));
  process.exit(1);
}
process.exit(0);

实际应用场景二:Commit msg自动格式化

我们公司commit msg要求范式:{ir_key}: {subject_content}。即具体信息前面要加ir_key:(需求号)。那我们如何自动在msg前面添加{ir_key}:。只需解决两个问题:

  • 如何修改commit msg
  • ir_key从哪里读取

解决思路:

  • 从场景一代码了解到,当前commit的msg是通过git/COMMIT_EDITMSG 这个文件获取。那我们可以通过hooks修改这个文件的内容,便可以修改msg。
  • 同样,我们可以参考以上git的策略,我们只要把ir_key存在一个本地文件中,commit的时候读取即可。

具体方案:

我在项目的公共工具库项目新增了两个cli命令。

  • irk <ir_key>: 独立使用。后面带参数,且参数符合msg规范,则存入本地数据文件.git/msg(这样不会提交到远程,也无需再加.gitignore);无参数,则读取.git/msg,打印当前分支的ir_key存储情况。数据文件中ir_key是按分支存储的。
  • commit-msg-init: 配合hookcommit-msg使用。首先校验当前msg是否合规,如果不合规,且存在当前分支的ir_key缓存数据,则在当前msg前拼接ir_key, 存入git/COMMIT_EDITMSG文件。
    使用效果如下:

irk核心代码如下:

  try {
    // 获取当前分支号
    const branchName = getBranchName()
    // 读取本地数据缓存文件: .git/msg
    let dataObj = getFileObj(tempFilePath);
    // ir_key 需符合格式
    const pattern = /^((STY|DTK|BUG)-ABCD-(TY|GJ)-)\d{5}/;
    // 读取参数
    const param = process.argv[2];
    // 有参数set,无参数get
    if ( param ) {    
      if (!pattern.test(param.toUpperCase())) {
        // 不符合格式报错
        console.log(warning('Wrong irk, Need to match:/^((STY|DTK|BUG)-ABCD-(TY|GJ)-)\d{5}/\n'))
      } else {
        // 写入或复写
        dataObj[branchName] = param;
        setIrk(dataObj)
      }
    } else {
      //读取显示
      if (dataObj[branchName]) {
        console.log(msg(`irk: ${dataObj[branchName]} \n`))
      } else {
        console.log(msg("No irk for current branch"))
      }
    }
  } catch (err) {
    console.log(warning('Process err: ' + err))
  }

commit-msg-init核心代码如下:

const fs = require('fs');
const chalk = require('chalk');
const { getBranchName, getFileObj, printMsgRuleLog } = require('../utils/utils')
const { tempFilePath } = require('../config/config')
// msg格式
const pattern = /^((STY|DTK|BUG)-ABCD-(TY|GJ)-)\d{5}:[^]/;
// 当前commit msg
const commitMsg = fs.readFileSync(process.env.HUSKY_GIT_PARAMS, 'utf-8');
// 获取当前分支号
const branchName = getBranchName()
// 如果当前msg不符合规范
if (!pattern.test(commitMsg.toUpperCase())) {
  // 读取本地数据缓存文件: .git/msg
  const fileObj = getFileObj(tempFilePath);
  // 如果文件有数据,并且存在当前分支的数据
  if ( fileObj && fileObj[branchName] ) {
    // 拼接
    const newCommitMsg = `${fileObj[branchName]}:${commitMsg}`
    // 保险起见,二级校验拼接好的msg
    if (pattern.test(newCommitMsg.toUpperCase())){
      // 新msg写入文件
      fs.writeFileSync(process.env.HUSKY_GIT_PARAMS,newCommitMsg)
      console.log(chalk.green(`\n Formatted Message:${newCommitMsg}\n`))
      process.exit(0);
    } else {
      //打印规范日志
      printMsgRuleLog(commitMsg); 
      process.exit(1);
    }
  } else {
    printMsgRuleLog(commitMsg);
    process.exit(1);
  }
} 
process.exit(0);

实际应用场景三:Commit 文件校验

比如限制某些类型文件,某个特定目录的文件不允许修改,删除。

以下代码来自:https://github.com/y8n/git-hooks-node/blob/master/xgfe-ma/pre-commit.js#L45-L73

var child_process = require('child_process');
var execSync = child_process.execSync;
var spawnSync = child_process.spawnSync;
var path = require('path');

var files = getDiffFiles();
if (!files.length) {
    quit();
}
var libFiles = files.filter(function (file) {
    return isLibFiles(file.subpath) && ~['d', 'm', 'c', 'r'].indexOf(file.status);
});
if (libFiles.length) {
    console.log('[WARNING] You cannot delete/modify/copy/rename any file in lib directory!!\n' +
        'Listed below are thus files:');
    var libFilePaths = libFiles.map(function (file) {
        return file.subpath;
    }).join('\n');
    console.log(libFilePaths + '\n');
    quit(1);
}
// 待检查的文件相对路径
var lintFiles = files.filter(function (file) {
    return !isLibFiles(file.subpath)
        && !isDistFiles(file.subpath)
        && ~['a', 'm', 'c', 'r'].indexOf(file.status);
}).map(function (file) {
    return file.subpath;
});
if (!lintFiles.length) {
    quit();
}

var argv = ['lint'];
argv = argv.concat(lintFiles);
argv = argv.concat(['-c', './.lintrc']);
var result = spawnSync('xg', argv, {stdio: 'inherit'});
quit(result.status);

/**
 * 获取所有变动的文件,包括增(A)删(D)改(M)重命名(R)复制(C)等
 * @param [type] {string} - 文件变动类型
 * @returns {Array}
 */
function getDiffFiles(type) {
    var DIFF_COMMAND = 'git diff --cached --name-status HEAD';
    var root = process.cwd();
    var files = execSync(DIFF_COMMAND).toString().split('\n');
    var result = [];
    type = type || 'admrc';
    var types = type.split('').map(function (t) {
        return t.toLowerCase();
    });
    files.forEach(function (file) {
        if (!file) {
            return;
        }
        var temp = file.split(/[\n\t]/);
        var status = temp[0].toLowerCase();
        var filepath = root + '/' + temp[1];
        var extName = path.extname(filepath).slice(1);

        if (types.length && ~types.indexOf(status)) {
            result.push({
                status: status, // 文件变更状态-AMDRC
                path: filepath, // 文件绝对路径
                subpath: temp[1], // 文件相对路径
                extName: extName // 文件后缀名
            });
        }
    });
    return result;
}
/**
 * 是否是lib目录下的文件
 */
function isLibFiles(subpath) {
    return subpath.match(/^src\/lib\/.*/i);
}
/**
 * 是否是dist目录下的文件
 */
function isDistFiles(subpath) {
    return subpath.match(/^dist\/.*/i);
}
/**
 * 退出
 * @param errorCode
 */
function quit(errorCode) {
    if (errorCode) {
        console.log('Commit aborted.');
    }
    process.exit(errorCode || 0);
}

Eslint

ESLint是一个用来识别 ECMAScript 并且按照规则给出报告的代码检测工具,使用它可以避免低级错误和统一代码的风格。它附带有大量的规则.
运行 eslint --init 之后,.eslintrc 文件会在你的文件夹中自动创建。你只要在文件的rules属性中配置你想要的规则,利用pre-commit钩子触发校验即可。它主要的特点是:

  • 使用 Espree 解析 JavaScript。
  • 使用 AST 去分析代码中的模式。
  • 完全插件化的。每一个规则都是一个插件并且你可以在运行时添加更多的规则。
    eslint中文网

Lint-staged

直接触发eslint进行代码检测有一个问题:引入初期,你只改了文件 A,但是文件 B、C、D …中也有大量错误。你基本上没有时间和勇气去fix所有lint错误。这个时候,很多同学(包括我)选择 git commit -m "fix bug" --no-verify来逃避。只是‘很负责任’的把文件A的错误解决。
如果每次提交只检查本次提交所修改的文件,上面的痛点就解决了。lint-staged的开发者就是基于这个想法,其中 staged 是Git里面的概念,指待提交区,使用 git commit -a,或者先 git add 然后 git commit 的时候,你的修改代码都会经过待提交区。
安装依赖:

npm install lint-staged --save-dev
// 或者
yarn add lint-staged --dev

引入Lint-staged之后, 之前的husky配置升级如下:

//package.json文件
"husky": {
  "hooks": {
    "pre-commit": "lint-staged",  // 看这里
    "commit-msg": "node preCommit.js" 
  }
},
"lint-staged": {                  // 和这里
  "src/**/*.js": "eslint"
}

prettier

prettier 是业界主流的代码风格格式化工具。虽然用eslint —fix也可以进行代码格式化,但是eslint已经配置繁多。我们还是用eslint检查代码,用prettier来格式化代码。术业有专攻。

  • 你可以在vscode安装Prettier- Code formatter插件。默认快捷键是alt + shift + f。安装成功后,编辑器的配置setting.json会出现prettier插件的相关配置节点,同时也能看到一些默认的配置信息。
  • 实际项目中推荐在根目录创建.prettierrc文件配置(比配置在Package.json更独立)来使用,这样配置可以集成到脚手架,保证所有项目规则统一。

常用配置如下:

module.exports = {
  "printWidth": 80, //一行的字符数,如果超过会进行换行,默认为80
  "tabWidth": 2, //一个tab代表几个空格数,默认为80
  "useTabs": false, //是否使用tab进行缩进,默认为false,表示用空格进行缩减
  "singleQuote": false, //字符串是否使用单引号,默认为false,使用双引号
  "semi": true, //行位是否使用分号,默认为true
  "trailingComma": "none", //是否使用尾逗号,有三个可选值"<none|es5|all>"
  "bracketSpacing": true, //对象大括号直接是否有空格,默认为true,效果:{ foo: bar }
  "parser": "babylon" //代码的解析引擎,默认为babylon,与babel相同。
}

更多配置,可以参考官网: prettier 官网

参考资料

Git hooks文档
用 Node.js 写前端自己的 Git-hooks
阮一峰-Node.js 命令行程序开发教程

兼容问题笔记

发表于 2020-02-25 | 分类于 兼容性

本文是记录一些自己在工作中实际遇到和解决的一些兼容性问题。

【safari】window.open无效

window.open被广告商滥用,严重影响用户的使用,Safari安全机制将其默认拦截。

  解决方案

  • window.location.assign() 新开页面(add一个 history)
  • window.location.replace(或改变 href) 替换当前页

【Android】物理返回键 H5监听拦截

拦截场景

网页本身有返回按钮,有特殊返回逻辑。
用户为了方便,会使用安卓手机自带的物理返回键,页面就会按照你浏览器history栈存储的路径来一层层返回。
网页本身设计期望的返回逻辑没有执行,被破坏。

pushState方法

window.history.back():移动到上一个访问页面,等同于浏览器的后退键。
window.history.forward():移动到下一个访问页面,等同于浏览器的前进键。
window.history.go(num):接受一个整数作为参数,移动到该整数指定的页面,比如go(1)相当于forward(),go(-1)相当于back()。
window.history.pushState():HTML5新增,在页面中创建一个 history 实体。直接添加到历史记录中。
window.history.replaceState():HTML5新增,用来在浏览历史中修改记录。

window.history.pushState(state, title, utl)
state:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null。
title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null。
url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。
注:pushState方法不会触发页面刷新,只是导致history对象发生变化,地址栏会有反应。

popstate事件

  1. 当活动历史记录条目更改时,将触发popstate事件。
  2. 调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在JS代码中调用history.back()或者history.forward()方法)。
  3. 不同的浏览器在加载页面时处理popstate事件的形式存在差异。页面加载时Chrome和Safari通常会触发(emit)popstate事件,但Firefox则不会。

拦截原理:

利用pushState方法和他不会触发popstate事件的特点(安卓物理返回键会)。

  1. 页面A,先调用pushState(),创建一个历史(B1),页面不会刷新。
  2. 监听popstate事件。
  3. 物理返回,回到A(拦截目的达到)。再次返回就会到A的上一个页面,所以需要4如下。
  4. 此时触发popstate事件,再处理方法中再次调用pushState(),创建一个历史(B2)。
  5. 下次物理返回,总是在A页面,如此循环
  window.history.pushState(null, null, "#");
  window.addEventListener("popstate", function(e) {
    window.history.pushState(null, null, "#");
    // 拦截了物理返回 ,同时也拦截了浏览器返回,history(back forward go)返回
  })

缺陷

  1. 如果项目本身使用了pushState,则历史记录会有瑕疵(多了一个历史)
  2. 浏览器的后退按钮点击以及调用history.back()也会被当成按下了返回键

【iOS】页面返回不刷新

场景-转盘抽奖

  1. 页面A点击抽奖,转盘转动,同时调用抽奖接口。
  2. 接口返回401,跳页面B登录,登录成功,或点击返回按钮,返回页面A。
  3. 页面A转盘依然在转动,期望是已经停止转动。

解决方案一

页面在非激活状态(hidden)的时候,触发visibilitychange事件,注入停转逻辑。

  const hiddenProperty = 'hidden' in document ? 'hidden' :    
      'webkitHidden' in document ? 'webkitHidden' :    
      'mozHidden' in document ? 'mozHidden' :    
      null;
  const visibilityChangeEvent = hiddenProperty.replace(/hidden/i, 'visibilitychange');
  const onVisibilityChange = function(){
    if (document[hiddenProperty]) {    
      console.log('页面非激活');
      // 转盘停止
      if (that.turning) {
        that.stopTurning()
      }
    }
  }
  document.addEventListener(visibilityChangeEvent, onVisibilityChange);

解决方案二(来自网友,未亲自验证)

window.onpageshow事件 :在每次加载页面时都会触发,类似于 onload 事件,但是onload 事件在页面第一次加载时触发,在页面从浏览器缓存中读取时不触发。

...
isIOS &&
window.onpageshow = function(event) {
  if (event.persisted) {
      window.location.reload()
  }
};

【Wkwebview】虚拟键盘将页面顶出视窗,收起后页面未下移

  • 页面无滚动条的情况存在该问题,有滚动条正常
  • input/textarea触发软键盘,输入完成失焦后出现

解决方案:基类或者AppContainer中监听全局blur事件

isIOS && 
document.addEventListener('blur', event => {
  // 当页面没出现滚动条时才执行,因为有滚动条时,不会出现这问题
  // input textarea 标签才执行,因为 a 等标签也会触发 blur 事件
  if (
    document.documentElement.offsetHeight <= document.documentElement.clientHeight 
    && ['input', 'textarea'].includes(event.target.localName)
  ) {
    document.body.scrollIntoView 
    ? document.body.scrollIntoView() 
    : window.scrollTo(0,0) // 回顶部
  }
}, true ) // blur事件不冒泡,切记在捕获阶段执行

【Wkwebview】软键盘遮挡input

出现场景

弹出Dialog(fixed在页面底部),input密码输入框自动聚焦,调出软键盘,Dialog未上移被软件盘遮挡。
历史代码,UIwebview正常。APP升级WKwebview出现的兼容性问题。

Dialog 核心精简代码:

  // 封装的滑入动画ui组件,css3(animation transform)
  import Transform from '../Transform/Transform'
  import Mask from '../Mask/Mask' // 遮罩蒙层ui组件
  ...
  return (
    <div
      className={classNames('ActionDialog', className)}
    >
      <Mask className={this.state.maskStatus} />
      <Transform {...this.props}>
        <div className='body'>
          <div className='title line-bottom'>
            <i className='iconfont iconClose stat_closeTransform' onClick={this.onClose} />
            {title}
          </div>
          <div className='content'>
            <NumberInput
              labelName="please input password "
              showBotton={true}
              className="password-input"
              inputChangeCallback={(text, flag) => {
                ...
              }}
            />
          </div>
        </div>
      </Transform>
    </div>
  )

NumberInput 核心精简代码:

  ...
  componentDidMount() {
    this.tradingPwdHideInput.focus()
  }
  onFocus() {
    this.setState({
      focus: true
    })  
  }
  onBlur() {
    this.setState({
      focus: false
    })
  }
  ...
  render() {
    const { focus } = this.state
    const { labelName, errMsg } = this.props
    let arryDigits = [...'123456']

    return (
      <div className='NumberInput'>
        {labelName}
        <input
          type='tel'
          id='tradingPwdHideInput'
          ref={ref => {
            this.tradingPwdHideInput = ref
          }}
          onClick={() => {
            this.tradingPwdHideInput.focus()
          }}
          onChange={this.tradingPwdChange.bind(this)}
          onBlur={() => {
            this.onBlur()
          }}
          onFocus={() => {
            this.onFocus()
          }}
        />
        <ul className={classNames('numberbox', focus ? 'focus' : null)}>
          {arryDigits.map((value, index) => {
            return (
              <li className={classNames({ border: focus })} key={index}>
                <i className='passWord'>{this.tradingpwd.charAt(index)}</i> 
              </li>
            )
          })}
        </ul>
        <div className="input-bottom">
          <div className="err-msg">{errMsg}</div>
        </div>
      </div>
    )
  }

主要css:

/*--------ActionDialog--------------*/
.ActionDialog {
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  width: 100vw; 
  height: 100vh; /*注意这里*/
  z-index: 11;

  .animationEnd{
    position: absolute;
    width: 100%;
    bottom: 0;
    height: 60%;
    z-index: 12;
  }
  .content {
    width: 100%;
    height: 100%;
  }
  .body {
    position: absolute;
    z-index: 99;
    width: 100%;
    height: 100%;
  }
}
/*--------Transform--------------*/
@keyframes down-in {
  from {transform: translateY(-100%);}
  to {transform: translateY(0%);}
}
@keyframes down-out {
  from {transform: translateY(0%);}
  to {transform: translateY(100%);}
}
.down-in {
   animation:down-in .4s
}
.down-out {
   animation:down-out .4s;
}
/*---------------Mask--------------------*/
.Mask {
  position: fixed;
  z-index: 11;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
}
/*---------------NumberInput--------------------*/
.NumberInput {
  overflow: hidden;
  position: relative;
  padding-top: 40px;
  padding-bottom: 1px; /*no*/
  width: 100%;
  background: #fff;

  input {
    position: absolute;
    left: 0; /*no*/
    z-index: 1;
    width: 80%;
    display: block;
    overflow: hidden;
    padding: 0 !important;
    background-clip: padding-box;
    font-family: Courier, monospace;
    opacity: 0.01;
    border: 0 none !important;
    box-sizing: content-box !important;
    outline: none;
    -webkit-appearance: none;
    /*解决ios光标问题 */
    color: transparent;
    text-indent: -100px;
    -webkit-transform: scale(2);
  }
  .numberbox {
    display: -webkit-box !important;
    display: -ms-flexbox !important;
    display: flex !important;
    padding: 0 !important;
    box-sizing: border-box;
    display: block;
    width: 100%; /*no*/
    height: 50px;/*no*/
    font-size: 24px; /*no*/
    border: 1px solid $color-input-border; /*no*/
    background-color: $color-bg;
    background-clip: padding-box;
    overflow: hidden;
    li {
      -webkit-box-flex: 1;
      -ms-flex: 1;
      box-flex: 1;
      position: relative;
      width: 17%;
      line-height: 50px;/*no*/
      margin-right: -1px; /*no*/
      border-right: 1px solid $color-input-border; /*no*/
      overflow: hidden;
      text-align: center;
    }
    .tradingInputshow {
      visibility: visible;
    }
    .tradingInputhide {
      visibility: hidden;
    }
    .numberboxItem {
      display: inline-block;
      width: 14px; /*no*/
      line-height: 50px;/*no*/
      font-style: normal;
      text-align: center;
      overflow: hidden;
    }
    .passWord {
      line-height: 44px; /*no*/
      font-style: normal;
    }
    .numberboxItem:empty {
      width: 12px; /*no*/
      height: 12px; /*no*/
      border-radius: 12px; /*no*/
      background-clip: padding-box;
      background-color: $color-input-text;
    }
  }
  .focus {
    border: 1px solid $color-input-border-focus; /*no*/
  }
  .border {
    border-right: 1px solid $color-input-border-focus !important;/*no*/
  }
}

开始怀疑是Dialog弹出后自动聚焦,虚拟键盘弹出,由于Dialog有一个CSS3滑入动画(0.4s)导致。但发现手动点击input触发也有同样的问题,此时Dialog已经完全展示。
后续解决办法如下:

解决办法一

  • Iphone7 iOS 13.1.2 表现为Dialog不上移被遮挡
  • Iphone8 iOS 11.1.0 iphone xs 12.4.1 表现为Dialog上移250+,超出可视区域。
    onFocus() {
      if (Platform.getOS().name === 'iOS') {
        // 200其实是虚拟键盘的高度,网上建议用ScrollHeight,但是Dialog上移会太厉害
        document.scrollingElement.scrollTop = 200;
      }
      ...
    }
    onBlur() {
      if (Platform.getOS().name === 'iOS') {
        // 失焦以后必须设为0,否则input会停留在上移以后的位置下不来
        // 副作用:Dialog关闭以后,页面也回到顶部
        // 如果要保留原来滚动位置,需要在Dialog前后增加scrollTop存储赋值逻辑
        document.scrollingElement.scrollTop = 0;
      }
      ...
    }
    why scrollingElement

解决办法二(iphoneX ios11.1.1)

  1. iphoneX ios11.1.1机器,上述解决办法一无效,发现根本无法设置document.documentElement.scrollTop
  2. 发现原来的Dialog顶层DIV设置了 height:100vh 样式, 删除后正常。

[IOS] 页面重定向 哈希丢失

  • 场景:iOS手机( 包括微信,safari浏览器.chrome浏览器) 都出现。安卓手机都正常。
  • 表象:访问公司短链,重定向到对应页面的长链的时候,丢失哈希。应该到详情页,结果到列表页(默认路由)。
  • 原因:生成短链的长链URl使用了http,短链是https。页面重定向的时候会出现二次重定向(短链 > http 长链 > https 长链)。第二次重定向,哈希会丢失。
  • 解决办法:长链URl使用https重新生成短链。网上看到别人解决该办法是通过哈希前面加/。自己测试,不成功。
  • 根本原因:ios的HSTS安全机制?
http://{domain}/path/knowledge/#/detail?id=101 🔴 
http://{domain}/path/knowledge?locale=en_us/#/detail?id=101 🔴 
https://{domain}/path/knowledge?locale=en_us#/detail?id=101 ✅ 

参考资料,上述场景验证无效

[Wkwebview] a链接无法跳转

  • 原因:target='_blank'
  • 解决:删除 target
  • window.open(url) 同样无法跳转,改用 window.location.assign(url)

参考资料

常用命令

发表于 2020-02-25 | 分类于 工具

系统MAC/Linux

$ cd
$ Pwd
$ Mkdir
$ Rm -rf
$ Ls -a
$ Chmod 777 *    // 文件/文件夹权限修改为 777 (可读可写可执行)
$ Sudo           // 使用管理员权限执行
$ alias          // 别名情况
$ curl -v <url>  // 模拟get请求
$ lsof           // 查看所有端口
$ lsof -i:<端口>  // 端口使用情况
$ kill -9 <PID>  // 杀进程

Control + Command + D // mac 自带词典取词翻译快捷键

Git

// 生成ssh key
$ ssh-keygen
$ cat ~/.ssh/id_rsa.pub

$ Git clone 
$ Git status
$ Git branch < -a >
$ Git checkout < branch >
$ Git pull < origin  branch>
$ Git push < origin  branch>
$ Git tag < tag >
$ Git tag|grep 2020 // 根据2020关键字搜索tag
$ Git stash  // 备份暂存当前分支修改
$ Git log
$ Git rebase // 修改commit信息,对多个 commit 进行合并处理等
$ Git revert // 替换某次提交
$ Git reset // 撤销 某次操作

Npm

$ npm init
$ npm install <package>
$ npm config list
$ npm config set registry  <镜像地址>
$ npm install -g cpm —registry=https://registry.npm.taobao.org  // 安装cnpm
& cnpm sync packageName // cnpm 特有 同步包的最新版本
& npm info packageName // 包信息
$ npm link
$ npm login // 同 adduser, add-user
$ npm whoami
$ npm publish
$ npm owner ls <projectName>  // 已经在项目目录下 可省略projectName 下面一样
$ npm owner add <userName> demo
$ npm owner rm <userName> demo
$ npm version patch | minor | major 更改版本号

Yarn

$ Yarn < install >
$ Yarn add
$ Yarn global add
$ Yarn install -- force // 重新拉取所有包 无需删除node_modules
$ yarn install  -- no-lockfile // 不读取或生成yarn.lock文件
$ yarn link
$ Yarn config get registry
$ Yarn cache dir
$ Yarn cache clean
$ yarn cache list —pattern vue
$ yarn login
$ yarn publish

webpack基础实践

发表于 2020-02-09

简介


  • webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。用于前端代码的工程化。
  • webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle。
  • webpack + loader 可以支持多种语言和预处理器语法编写的模块。loader 向 webpack 描述了如何处理非原生模块,并将相关依赖引入到你的 bundles中。
  • webpack 还支持可自由扩展的插件( plugin)体系。这是 webpack 的 支柱 功能。插件目的在于解决 loader 无法实现的其他功能。
  • webpack 天生支持如下模块类型:ECMAScript CommonJS AMD Assets``WebAssembly.

接下来,我们以一个 React + SASS 的简单web项目,来学习webpack的基础配置。

以下配置都是基于 webpack 4.30.0 。

基础配置


首先,我们要安装webpack系列npm包 (这些 loader 和 plugins,后面会逐步介绍):

// package.json
{
  "devDependencies": {
    /** webpack **/
    "webpack": "4.30.0",  
    "webpack-cli": "3.3.12",
    "webpack-dev-server": "^3.7.2", // 本地调试用
    /** loader **/
    "css-loader": "2.1.1",
    "style-loader": "^2.0.0",
    "file-loader": "^6.2.0",
    "node-sass": "^4.0.0",
    "sass-loader": "^7.1.0",
    "@babel/preset-env": "7.4.3",
    "@babel/preset-react": "7.0.0",
    /** plugins **/
    "html-webpack-plugin": "4.0.0",
    "mini-css-extract-plugin": "^0.6.0",
    "webpack-bundle-analyzer": "^4.4.0"
  }
}

webpack打包,需要做一系列的配置,而且 本地调试 和 生产打包 的配置是有一些差异的。一般情况下,我们在项目的根目录下设置两个配置文件:

|
|- webpack.config.js  # 生产打包
|- webpack.config.dev.js # 本地调试

// package.json
{
  //...
  "scripts": {
    "start": "webpack-dev-server --config webpack.config.dev.js",
    "build": "webpack-cli"
  }
  //...
}

webpack配置文件可以直接返回一个json对象,或者一个返回json对象的函数。

// webpack.config.js
export default {
  //...
}

//or

export default () => {
  return {
    //...
  }
}

接下来,我们开始具体配置:

entry

web项目中,我们一直有主JS,或者入口JS的概念。webpack要打包,构建依赖图谱。肯定也需要一个入口文件。
webpack 通过 entry 来配置一个(或多个)入口。默认值是 ./src/index.js。

const path = require('path')

module.exports = {
  entry: './src/pages/app.js'
}

或者对象,可以标示入口文件name(一般用于多个)

module.exports = {
  entry: {
    app: './src/pages/app.js'
  }
}

output

有打包输入(entry),就有打包输出。
webpack通过 output 来配置输出文件信息。默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中。

module.exports = {
  output: {
    path: path.resolve(__dirname, 'build'),  // 指定输出目录
    filename: '[name].js', // 指定输出文件名称;  [name] 动态使用entry指定的名称,对应上面是:app
  }
}

高阶配置

实际项目中,我们静态资源会使用CDN,或者通过其他特有目录引入。
这个时候,我们需要通过 publicPath来配置.

编译时指定:

module.exports = {
  output: {
    path: path.resolve(__dirname, 'build'), 
    filename: '[name].js',
    publicPath: '/my-project/static/resource/',
  }
}

运行时指定:

module.exports = {
  output: {
    path: path.resolve(__dirname, 'build'), 
    filename: '[name].js',
    publicPath: './', // 可随意填写,运行时会覆盖为 __webpack_public_path__ 
  }
}
// 重点在这里:
__webpack_public_path__ = window.global_info.staticCDN; 
// 如果项目在一个Java/Node容器,staticCDN 的值一般是服务端路由中读取环境变量赋予。
// 如果项目是纯前端,staticCDN的值一般是通过 域名+规则 判断确定。

Loaders

  • 面向现代JS,我们大都不是通过直接编写原生JS来开发项目,而是运用各种框架(React,Vue ……),各种JS+语言(TS,JSX ……),ES5+ 语法糖等。
  • 但是我们的浏览器只识别原生JS,虽然主流浏览器也开始兼容绝大多数ES5+语法,但是我们也得考虑兼容性问题。
  • 所以,webpack 提供了 loaders 机制,我们可以通过使用 loader 用于对模块的源代码进行转换。
  • 不同的模块类型,应用不同的 loaders。
  • loaders 是通过 module 对象下的 rules 数组来配置。

简单示例:

module.exports = {
  module: {
    rules: [
      { test: /\.css$/, use: 'css-loader' }, // 对每个 .css 使用 css-loader
      { test: /\.ts$/, use: 'ts-loader' }  // 对所有 .ts 文件使用 ts-loader
    ]
  }
}; 
  • test: RegExp,用来匹配需要处理的文件类型。
  • use:String | Object | Array,指定具体的 loader。上述示例,使用 单个默认配置的 loader,可以使用String类型指定即可。

** loader 可以通过 options 对象配置更多参数。带参数配置的单个loader示例:**

module.exports = {
  module: {
    rules: [
       // 图片资源处理
      {
        test: /\.(png|jpg|jpeg|gif)$/,
        use: [
          {
            loader: require.resolve('file-loader'),
            options: {   // loader参数配置
              esModule: false,
              name: './image/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
}

负责图片处理的 loader 有两种, 一般一起使用:

  • file-loader 可以把js和css中导入的图片替换成正确的地址,并把图片文件输出到对应的位置。文件名是根据文件内容计算出的hash值。

  • url-loader 可以把文件通过 base64 编码后注入到JS 或者 css中去。图片的数据量太大,会导致JS和CSS文件变大,一般利用url-loader把页面需要的小图片注入到代码中去,以减少加载次数。url-loader 通过 limit 参数来控制,小于 limit的图片才会处理。

多个 loader 配置,将按照相反的顺序执行。以支持 scss 为例:

module.exports = {
  module: {
    rules: [
      // 处理sass样式文件
      {
        test: /\.(css|scss)$/,
        // 使用多个 loaders;都使用默认配置;
        use: ["style-loader", "css-loader","sass-loader"]  
      } 
    ]
  }
}
  • sass-loader 负责把Scss源码转换为CSS代码,再交给css-loader处理。
  • css-loader 会找出 @import 和 URL() 这样的导入语句,告诉webpack依赖这些资源。同时还支持css modules,压缩css等功能。
  • style-loader 会把css代码转换成字符串,然后注入到JS代码中。通过JS给DOM增加样式。也可以通过plugin( MiniCssExtractPlugin )把css提取到单独的文件中。

插件 (plugin) 可以为 loader 带来更多特性:

module.exports = {
  module: {
    rules: [
      {
        test:  /\.(js|jsx)$/,
        exclude: /(node_modules)/, // 正则表达式,设置该loader需要忽略/排除的目录/文件
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env','@babel/preset-react'],
            cacheDirectory: true,
            plugins: ['@babel/plugin-transform-runtime','@babel/plugin-proposal-class-properties']
          }
        }
      }
    ]
  }
}
  • @babel/runtime和@babel/plugin-transform-runtime:babel 编译时只转换语法,几乎可以编译所有时新的 JavaScript 语法,但并不会转化BOM(浏览器)里面不兼容的API。比如 Promise,Set,Symbol,Array.from,async 等等的一些API。这2个包就是来搞定这些api的。

  • @babel/plugin-proposal-class-properties:用来解析类的属性的。

  • 在babel执行编译的过程中,会从项目的根目录下的 .balelrc 文件中读取配置。.balelrc是一个json格式的文件,当babel loader配置项很多的时候可以使用。

由此可见, plugins 可以在 loader 配置中配合使用。当然,也可以单独配置使用。下面,我们来详解了解一下plugins。

点击这里查看常用 Loader List

Plugins

  • 功能: 用于解决 loader 无法实现的其他事,进行功能扩展。

  • 原理:webpack plugins 是一个具有 apply 方法的 JavaScript 对象。apply 方法会被 webpack compiler 调用,并且在整个编译生命周期都可以访问 compiler 对象。你可以基于次编写自定义插件。

  • 用法: 可以通过在 webpack.config.js 增加Plugins配置使用。也可以通过 NodeAPI 的形式使用。

下面,我们通过几个常用的 plugin 来了解如何配置使用:

// 该插件将为你生成一个 HTML5 文件, 在 body 中使用 script 标签引入你所有 webpack 生成的 bundle。 
const HtmlWebpackPlugin = require('html-webpack-plugin')

// 一般,plugins都是通过 new 一个自身的实例来使用
// 支持option对象参数配置
module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './temp.html',  // 可以自定义用于生成Html的模版文件,非必填
      filename: 'home.html'  //  指定html文件名称;默认为 index.html
    })
  ]
}

** 使用默认配置生成的 html 文件如下:**

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>webpack App</title>
  </head>
  <body>
    <script src="index_bundle.js"></script>
  </body>
</html>

多个 entry,对应多个 script 标签。

前面有提到,有的 plugins 可作为有些 loader 的 option 配置项使用。

还有一些插件需要在Plugins和Loader中都需要增加配置:

// 本插件会将 CSS 提取到单独的文件中
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.(css|scss)$/,
        use: [MiniCssExtractPlugin.loader, "css-loader","sass-loader"]
      },
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: '[name].css'})
  ]
}

使用MiniCssExtractPlugin 生成的css文件会在生成的Html文件中通过<link>引入。所以不再需要使用 style-loader。
style-loader: 将模块导出的内容作为样式并添加到 DOM 中。

mode

  • production or development.用来指定是生产模式还是开发模式。
  • 在代码中可以通过process.env.NODE_ENV获取,用来做两种模式的兼容处理。

到此,我们这个简单项目的完整webpack.config.js配置如下:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = () => {
  return {
    mode: 'production',
    entry: {
      app: path.resolve(__dirname, 'src/pages/app.js'),
    },
    output: {
      path: path.resolve(__dirname, 'build'),
      publicPath: '/my-project/static/resource/',
      filename: '[name].js',
    },
    resolve: {
      extensions: ['.js', '.jsx', '.ts'],
    },
    module: {
      rules: [
        {
          test: /\.(css|scss)$/,
          use: [MiniCssExtractPlugin.loader, "css-loader","sass-loader"]
        },
        {
          test:  /\.(js|jsx)$/,
          exclude: /(node_modules)/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env','@babel/preset-react'],
              cacheDirectory: true,
              plugins: ['@babel/plugin-transform-runtime','@babel/plugin-proposal-class-properties']
            }
          }
        },
        {
          test: /\.(png|jpg|jpeg|gif)$/,
          use: [
            {
              loader: require.resolve('file-loader'),
              options: {
                esModule: false,
                name: './image/[name].[hash:8].[ext]'
              }
            }
          ]
        }  
      ]
    },
    plugins: [
      new HtmlWebpackPlugin({template: './temp.html', filename: 'home.html'}),
      new MiniCssExtractPlugin({ filename: '[name].css'})
    ],
    optimization: {
      splitChunks: {
        cacheGroups: {
          default: {
            name: 'common',
            chunks: 'initial'
          }
        }
      }
    }
  }
}

resolve.extensions : 如果文件引入的时候没有后缀名,将自动按配置的文件名匹配查找。
optimization: 用于提取公共js配置。

开发模式


我们在本地调试的时候,webpack 配置和生产会有一些差异。比如:

  • 生产可能配置特殊的publicPath,本地则不能配置。
  • 本地调试专用的 devServer 配置。
  • 开发模式专用的一些plugins。

我们可以设置单独的配置文件(如 webpack.config.dev.js):

// 下面只展示差异点配置
// new plugin 用来做打包后文件的成分分析,以做某些优化。
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 

module.exports = () => {
  return {
    mode: 'development', // 指定为开发环境
    // 下无 publicPath配置
    output: {
      path: path.resolve(__dirname, 'pcDist'),
      filename: '[name].js',
    },
    // 专用plugin
    plugins: [
      new BundleAnalyzerPlugin()
    ],
    // 本地调试专用配置
    devServer: {
      contentBase: path.join(__dirname, 'pcDist'),
      port: 8009,
      hot:true,
      open: true
    }
  }
}

DevServer 其实是一个方便开发的小型http服务器,是基于webpack-dev-middleware 和 Express 实现的。webpack-dev-middleware 会导出一个函数。该函数接收一个 webpack 的 Compiler 实例作为参数,导出一个 Express 中间件。该中间件具有以下功能:

  • 接收 Compiler 实例输出的文件,但不会存在硬盘,而是放入内存。
  • 往 Express app 上注册路由,拦截http请求,根据请求路径响应对应的文件。

明显,开发和生产配置大部分还是通用。所以在实际项目中我们使用一个通用配置文件,然后在自有的cli工具库中去抽象,处理,复用。

module,chunk,bundle理解

  • moule 是模块,webpack中一切皆模块,所以 module 就是我们编写的一个个文件。
  • chunk 是指 webpack 根据文件引用关系生成的 chunk 文件。一般来说,一个 entry 对应一个 chunk 文件。
  • bundle 是指 webpack 最终生成的浏览器可以直接运行的 bundle 文件。一般来说,一个 chunk 对应一个 bundle 文件。但是也有例外,比如我们使用 MiniCssExtractPlugin 插件会从一个 chunk 中抽取出单独的 css bundle文件。

webpack 异步加载


在使用 webpack 打包的应用中,我们可以使用 require.ensure 进行异步加载,也有人称为代码切割。他其实就是将指定的 js 模块独立导出一个.js 文件,然后使用这个模块的时候,再创建一个 script 对象,加入到 document.head 对象中,浏览器会自动帮我们发起请求,去请求这个 js 文件,然后写个回调函数,让请求到的 js 文件做一些业务操作。

require.ensure 这个函数是一个代码分离的分割线,表示回调里面的 require 是我们想要进行分割出去的,webpack 会打包成单独 js 文件。它的语法如下:

// 语法如下:
`require.ensure(dependencies: String[], callback: function(require), chunkName: String)`

按需加载

webpack4 官方文档提供了模块按需切割加载,配合 es6 的按需加载 import() 方法,可以做到减少首页包体积,加快首页的请求速度,只有其他模块,只有当需要的时候才会加载对应 js。

import()的语法十分简单。该函数只接受一个参数,就是引用包的地址,并且使用了 promise 式的回调,获取加载的包。在代码中所有被 import()的模块,都将打成一个单独的包,放在 chunk 存储的目录下。在浏览器运行到这一行代码时,就会自动请求这个资源,实现异步加载。

常用的优化手段

  • 优化loader配置: 通过 include 等配置,不需要处理的文件尽量不处理。

  • 异步按需加载:(上面有专门提到)。

  • 分包:提取公共的一些模块 + 缓存提高性能。

    • SplitChunksPlugin: 提取公共模块,问题是业务模块依赖改变也会影响公共包,哈希会改变,缓存会失效。
    • Dllplugin & DllReferencePlugin: 将指定的公共模块打包成动态链接库形式的js文件。其他模块引用到这些指定的公共模块会直接在公共js文件中加载,不会打包在业务模块中。问题是这些动态链接库文件要独立先行打包,并提前引入。
  • Tree Shaking: webpack依赖静态的 ES6 模块化语法,分析出都要哪些功能被用到了,然后剔除没有的代码。可以在启动 Webpack 时带上 --optimize-minimize 参数,快速接入 Tree Shaking;也可以使用 UglifyJSPlugin 来处理。

  • Scope Hoisting(作用域提升): 分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。因此只有那些被引用了一次的模块才能被合并。

    • 使用内置的 ModuleConcatenationPlugin() 即可开启。

参考文献


  • webpack官网
  • webpack中文网
  • 深入浅出webpack
  • 轻松理解webpack热更新原理

微前端入门Micro-frontends

发表于 2020-01-19 | 分类于 架构

微前端资料

我认为比较好的微前端思想技术体系文献资料:
参考资料:https://github.com/xitu/gold-miner/blob/master/TODO1/micro-frontends-1.md
原文资料:https://martinfowler.com/articles/micro-frontends.html
文章大量引用了上述文献资料的第一部分,对许多翻译生硬的地方加以润色,难理解的点增加注释,并穿插加入一些个人观点。

引言

众所周知,前端的世界瞬息万变。新的语言,框架库,思想层出不穷,让人应接不暇。其中许多了解一下即可,但是对去现在比较火热的“微前端(MircroFrontends)”,还是值得去了解和研究一下的。大家对于前端的不断探索研究甚至创造,我觉得无非两个目的。一是尝试拓展前端的边界(创造更多可能);二是前端已有领域的不断优化(效率,维护性,质量,管理成本)。很明显,微前端是后者。那接下来,我们了解一下什么是微前端,探讨一下它如何解决和优化前端领域一些问题的。

什么是微前端

微前端首先是一种(管理)思想。将前端整体分解为小而简单模块的一种模式。这些块可以独立开发、测试和部署。
与此同时,我们仍然需要把模块聚合为一个产品出现在客户面前。我们将这种技术也称为微前端。
所以微前端是一种管理思想和集成技术结合的产物,我们将其定义为:

一种将多个可独立交付的小型前端应用聚合为一个整体的架构风格

简而言之,微前端都是将巨大的东西分成更小、更易于管理的小部分,然后明确它们之间的依赖关系。我们的技术选择、代码库、团队以及发布流程都应该能够彼此独立地运行和开发,不需要过多的协调。我认为好的微前端要做好两件事情(微前端也分两步):

  • 分解(业务层面):保证业务的合理拆分,并理清它们直接的依赖交互关系。
  • 聚合(技术层面):架构层面,要在技术上做到很好的聚合,保证彼此独立,不相互污染,又能合理通信。

使用场景主要有:

  • 拆分巨型应用,使应用变得更加可维护。
  • 兼容历史应用,实现增量开发。

优点

  • 体积小、易拼合且易于维护的代码库
  • 更具扩展性的互相解耦且独立的团队
  • 和以前相比能采用增量的方式,更易于对前端的某些部分进行升级、更新甚至重写

增量升级,逐步翻新。

很多公司都普遍存在一个历史问题:过时的技术栈,赶工完成的代码质量,五花八门的代码风格。随着员工的更迭,新功能的不断堆积,后人维
护起来,看不懂,改不动,只想重写的情况日益严重。这个时候,你才明白,长江后浪推前浪,前浪原来是被后浪咒死在沙滩上的。如果这个时候,你发现这居然还是一个巨石应用……后浪也想在沙滩上自杀了。
当然了,喜欢折腾的前端没那么脆弱,当然还是要解决这个问题。我相信,这个时候就已经诞生了微前端,或者说应用了微前端的管理思想-细
分:随着新的功能,将业务逐步在多个新应用中重构翻新。当然,也有在原应用中或者在一个新应用中想办法逐步翻新,这基本上是重蹈覆辙。

简单、解耦的代码库

每个单独的微前端项目的源代码库,会远远小于一个单体前端项目的源代码库。这些小的代码库将会更易于开发。更值得一提的是,我们避免了不相关联的组件之间无意造成的不适当的耦合。通过增强应用程序的边界来减少这种意外耦合的情况的出现(领域驱动设计(DDD)中抽象的限界上下文(BoundedContext)概念,不懂也不用太纠结)。

当然了,一个独立的、高级的架构方式(例如微前端),不是用来取代规范整洁的优秀老代码的。我们不是想要逃避代码优化和代码质量提升。相反,我们降低做出错误决策的可能,增加正确决策的几率,从而使我们Falling Into The Pit of Success进入成功之坑(该思想大致的意思是说设计者要通过精心设计的系统或者API,让用户做正确的事变的容易,而不过于为容易犯错而苦恼。用户犯错,那就是设计者的错)。微前端会促使您明确并慎重地了解数据和事件如何在应用程序的不同部分之间传递(跨应用通信),这本是我们(微前端架构设计师)早就应该开始做的事情!

独立部署

与微服务一样,微前端的独立可部署性是关键。它减少了部署的范围,从而降低了相关风险。无论您的前端代码在何处托管,每个微前端都应该有自己的连续交付通道,该通道可以构建、测试并将其一直部署到生产环境中。我们应当能够在不考虑其他代码库或者是通道的情况下来部署每个微服务。
做到即使原来的单体项目是固定的按照季度手动发布版本,或者其他团队提交了未完成的或者是有问题的代码到他们的主分支上,也不会对当前项目产生影响。如果一个微前端项目已准备好投入生产,它应该具备这种能力,而决定权就在构建并且维护它的团队手中。

图示:三个彼此独立的应用从源代码控制开始,经过构建、测试直至部署到生产环境

图 2 : 每个微前端都独立的部署到生产环境上

自主的团队

将我们的代码库和发布周期分离的更高阶的好处,是使我们拥有了完全独立的团队,可以参与到自己产品的构思、生产及后续的过程。每个团队都拥有为客户提供价值所需的全部资源,这就使得他们可以快速且有效地行动。为了达到这个目的,我们的团队需要根据业务功能纵向地划分,而不是根据技术种类。一种简单的方法是根据最终用户将看到的内容来分割产品,因此每个微前端都封装了应用程序的单个页面,并由一个团队全权负责。与根据技术种类或“横向”关注点(如样式、表单或验证)来组成团队相比,这会使得团队工作更有凝聚力。

图示:根据三个应用构成三个团队,提醒大家不要根据“样式”分队

图 3:每个应用都由一个团队负责

缺点

负载体积

独立构建,会造成公共依赖的重复,增加了用户所需下载依赖的体积。
一种解决方案是将我们编译后代码的常见依赖外置(如下代码)。一旦我们沿着这条路走下去,我们将重新引入一些微前端之间构建过程的耦合。现在它们之间有着一个隐含的合约:“我们都必须使用这些依赖的明确版本”。如果其中一个依赖产生重大改动,我们可能最终需要一个大的协调升级工作以及一次性的同步发版。这是我们使用微前端最初想要避免的一切。

但并不全是坏消息。首先,即便我们对于重复的依赖不采取任何措施,每个单独页面仍可能比我们构建整个前端更快地加载。原因是通过独立编译每个页面,我们有效地以我们自己的形式实现了代码分割。在传统的前端中,应用中的任何页面加载完成时,我们通常会一次性下载所有页面的源码和依赖。通过独立构建,任何单独的页面加载将只会下载那个页面的源码和依赖。这可能导致更快的首页加载,但随后的导航速度会变慢,因为用户必须在每个页面上重新下载相同的依赖。如果我们严格地不用不必要的依赖使我们的微前端膨胀,或者我们知道用户在应用中通常访问的一两个页面,即便有重复依赖,我们也很可能在性能方面达到净增益。

在前一段有很多“可能”和“也许”,表明了每个应用通常都有它们自己独特的性能特征。如果你想确切地知道特定的变化会造成什么性能影响,只能靠实际测量,而且最好是在生产环境中。我们见过很多团队仅仅为了下载数兆大小的高清图像或者对一个运行非常慢的数据库进行昂贵的查询额外多写几千字节的 JavaScript 代码。因此,尽管考虑每个架构决策的性能影响很重要,但请确保你知道真正的瓶颈在哪里。

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
    <script src="%REACT_APP_CONTENT_HOST%/react.prod-16.8.6.min.js"></script>
    <script src="%REACT_APP_CONTENT_HOST%/react-dom.prod-16.8.6.min.js"></script>
  </body>

环境差异

我们应该能够开发一个单一的微前端,而无需考虑其他团队正在开发的所有其它微前端。我们可能甚至应该在“独立”模式下,在空白页面上运行我们的微前端,而不是运行在将在生产环境中承载微前端的容器应用内部。这可以使开发变得更加简单,特别是当真正的容器是一个复杂的遗留代码库的时候,而通常情况下我们使用微前端来逐步从旧世界迁移到新世界。但是,在与生产环境完全不同的环境中开发存在风险。如果我们的开发时容器与生产容器的行为不同,那么我们可能会发现我们的微前端被破坏,或者在我们部署到生产环境时表现不同。特别值得关注的是可能由容器或其他微前端带来的全局样式。

这里的解决方案与我们不得不担心环境差异的任何其他情况没有什么不同。如果我们在一个与生产环境不同的本地环境开发,我们需要确保定期将我们的微前端集成和部署到像生产环境的环境中,并且我们应该在这些环境中进行测试(手动以及自动化)以尽早发现集成问题。这不会完全解决问题,但最终这是一个取舍:简化开发环境的生产力提升是否值得冒集成出问题的风险?答案取决于项目!

运维复杂度

最后的缺点是与微服务直接平行的缺点。作为一个更加分散的架构,微前端将不可避免地导致需要管理更多的东西 —— 更多的存储库,更多的工具,更多的构建/部署管道,更多的服务器,更多的域等等。因此,在采用这样的架构之前,你应该考虑几个问题:

  • 你是否有足够的自动化可行地提供以及管理额外所需的基础设施?
  • 你的前端开发、测试和发布进程是否会扩展到许多应用中?
  • 你是否对围绕工具和开发的实践变得更加分散且不易控制的决策感到满意?
  • 你将如何确保你的多个独立前端代码库中的最低代码质量,一致性或代码管理?
    我们可能会另写一篇文章讨论这些主题。我们希望提出的主要观点是,当你选择微前端时,根据定义,你选择创建许多小东西而不是一个整体。你应该考虑你是否有采用这种方法所需的技术和组织成熟度,从而不造成混乱。

技术点

主要难点在于集成方式(公共依赖)和跨应用通信。有兴趣可以研读文章开始的参考资料2-4章节。

React 高阶组件(HOC)

发表于 2019-08-09 | 分类于 React

高阶函数


如果一个函数 接受一个或多个函数作为参数或者返回一个函数 就可称之为 高阶函数。

// ES5 
function isSearched(searchTerm) { 
  return function(item) { 
    return item.title.toLowerCase().includes(searchTerm.toLowerCase()); 
  } 
} 

// ES6 
const isSearched = searchTerm => item => 
  item.title.toLowerCase().includes(searchTerm.toLowerCase());

React HOC


在实际项目中,组件总是趋于复杂,会包含很多逻辑。当发现在很多组件都需要处理相同逻辑的时候,就应该想办法抽象复用。在React中,具有复用功能的方式主要有:

  • 公共组件库
  • 公共方法库
  • 类组件:HOC / Render Props
  • 函数组件:Hooks

当你没法把组件中的重复逻辑抽象成公共组件或者公共方法的情况下,在类组件中,就可以考虑引入HOC。Render Props看这里。

和高阶函数类似,React HOC就是把组件(也可增加一些可选参数)作为输入,然后输出一个新的组件,新组件内部处理一些通用逻辑,并使用输入的组件进行渲染。你可以将其视为参数化容器组件。

我们知道,在React中函数组件本质上就是一个函数。所以高阶函数组件就是一个HOC。

高阶组件应用


首先,我们先创建HOC容器:

// 无状态函数组件
function HigherOrderComponent(WrappedComponent) {
    return props => <WrappedComponent {...props} />;
}
// or
// 类组件
function HigherOrderComponent(WrappedComponent) {
    return class extends React.Component {
        render() {
            return <WrappedComponent {...this.props} />;
        }
    };
}
// 类组件特有的通过继承的方式使用
// 也叫 反向继承(Inheritance Inversion)
function HigherOrderComponent(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            return super.render();
        }
    };
}

通过继承输入组件形式实现的HOC又称为 反向继承(Inheritance Inversion)

以上HOC只是一个容器,和直接使用 WrappedComponent 组件无二。那么,我们可以在HOC中做点什么来达到共享复用的目的呢?主要通过以下三种方式:

  • 操作参数(props / state)
  • 条件渲染
  • 组件包装

操作参数

我们可以通过HOC对一个通用组件的 props 做一些操作达到复用的目的,而不用去修改这个通用组件。

举个简单的例子,一个列表展示组件,在新需求中的某些场景只希望展示列表中 status === '1' 的数据,那我们可以封装一个HOC在这些场景使用,其他场景直接使用。


function HigherOrderComponent(WrappedComponent) {
    return props => {
      let newProps =  props || []
      if (newProps.length > 0){
        newProps = newProps.filter((item) => item.status === '1')
      }
      return <WrappedComponent {...newProps} />
    };
}

当然,你也可以通过修改这个通用组件来适配新场景。但是一直通过修改通用组件来适配所有场景,一方面存在兼容风险,另一方面适配逻辑太多,组件会变得臃肿,很难维护。

在举一个常见的应用:很多页面初始化的时候,都需要通过一个接口获取一些初始化数据。我们也可以通过HOC封装,统一获取,然后作为扩展参数传递给页面组件。

 function HigherOrderComponent(WrappedComponent) {
    return props => {
      const fetchData = async () => {
        const result = await axios(
          'https://hn.algolia.com/api/v1/search?query=redux',
        );
        return result.data;
      };
      const newProps = fetchData();
      return <WrappedComponent {...props} {...newProps} />
    };
}

条件渲染

在实际React项目中,大多采用前端渲染模式。组件的渲染依赖Ajax请求的数据。所以,我们经常会看到(我自己也写过)如下代码:

class BadComp extends React.Component {
    state = {
      isDateReady: false,
      dataA: null,
      dataB: null,
    }
    //...
    render() {
      if (!isDateReady) return false
      const { isDateReady, hasDataA, hasDataB }= this.state
      return (
        <>
          { dataA && <A {...dataA}/> }
          { dataB && <B {...dataB}/> }
        </>
      );
    }
};

上述代码并不是一无是处,至少是严谨的,规避了无数据或者数据异常导致页面奔溃的情况。但是上述组件有两个问题:

  • 通过 isDateReady 阻断页面渲染,接口返回慢的话,白屏时间会很长。
  • 页面充斥着太多类似 { dataB && <B {...dataB}/> } 这种逻辑,不优雅,影响可读性。

解决上述问题,我们一般是通过增加 loading 和 占位 来优化。在页面主接口返回数据之前,增加 loading 效果。需要数据才能渲染的组件模块可以先显示 占位框(css),取得数据以后再重新渲染。很明显,这个解决方案比较通用,最好做成可复用的。这个时候,我们就可以通过HOC的条件渲染来实现:

页面loading效果的高阶组件,通过 反向继承 + 条件渲染 实现:


const withLoadingComponent = (WrappedComponent) => {
  return (props) =>  {
    render() {
        if(this.state.isLoading) {
          return <Loading />;
        } else {
          return super.render();
        }
    }
  };
}

组件占位效果的HOC。额外的 options 参数可以决定占位组件 Placeholder 的高度等信息。

const withPlaceholderComponent = (WrappedComponent, options) => {
  return (props) =>  {
    return props ? <WrappedComponent {...props}> : <Placeholder {...options}>
  };
}

组件包装

如果你总是和相同的元素包裹使用一个通用组件,那么你可以通过抽象一个HOC来复用。比如下面的HOC在组件外面包裹一层背景色为 #fafafa 的 div 元素:

const withOtherComponent = (WrappedComponent) => {
  return (props) =>  {
    return (
      <div style={{ backgroundColor: '#fafafa' }}>
          <WrappedComponent {...this.props} {...newProps} />
      </div>
    )
  };
}

这里只是举了一个简单的例子,实际项目中,也可以通过其他组件来包裹,或是组合使用。

Recompose 库


Recompose 是一个为函数式组件和高阶组件开发的 React 工具库。可以看作是 React 的 Lodash。

复杂场景下,会存在多个 HOC 层层嵌套的情况:

const TodoListWithConditionalRendering = withLoadingIndicator(
  withTodosNull(
    withTodosEmpty(TodoList)
  )
);

可以使用 recompose 库的 compost 方法优化:

import { compose } from 'recompose';
const withTodosNull = (Component) => (props) =>
  ...
const withTodosEmpty = (Component) => (props) =>
  ...
const withLoadingIndicator = (Component) => ({ isLoadingTodos, ...others }) =>
  ...
function TodoList({ todos }) {
  ...
}
const withConditionalRenderings = compose(
  withLoadingIndicator,
  withTodosNull,
  withTodosEmpty
);
const TodoListWithConditionalRendering = withConditionalRenderings(TodoList);
function App(props) {
  return (
    <TodoListWithConditionalRendering
      todos={props.todos}
      isLoadingTodos={props.isLoadingTodos}
    />
  );
}

compose 方法用于组合多个高阶组件。注意, props 流向是自上而下的。

另外,recompose 库的 pure 高阶组件,用于控制只在需要的时候重新呈现组件,即除非 props 发生了更改才重新重现组件。 withStateHandler高阶组件,用于将组件状态和组件本身隔离开来……了解更多

Hoc问题


抽象地狱

也叫包装地狱。过多使用HOC层层嵌套,会导致层级冗余,逻辑难追踪,很难维护。所以要避免Hoc滥用,充分考虑引入HOC的必要性,尤其HOC多层封装的情况。对于复杂的嵌套结构,最好增加充分的注释。

不要在render中使用 HOC。

每次render渲染都会创建新的 HOC。diff算法会对新旧子树进行 ==== 比较。如果不相等,子树会进行卸载,和重新挂载的操作。而每次重新渲染创建的 HOC 前后是 !== 的。

render() {
  // 每次调用 render 函数都会创建一个新的 EnhancedComponent
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 这将导致子树每次渲染都会进行卸载,和重新挂载的操作!
  return <EnhancedComponent />;
}

Refs 不会被传递。

虽然HOC的约定是将所有 props 传递给被包装组件,但这对于 refs 并不适用。那是因为 ref 实际上并不是一个 prop- 就像 key 一样,它是由 React 专门处理的。如果将 ref 添加到 HOC 的返回组件中,则 ref 引用指向容器组件,而不是被包装组件。

这个问题的解决方案是通过使用 React.forwardRef(React 16.3 中引入)。想要了解更多,可以阅览我的另一篇博文拥抱React-Hooks(二)-Refs

静态方法丢失。

因为原始组件被包裹在一个容器组件内,也就意味着新组件会没有原始组件的任何静态方法。为了解决这个问题,你可以在返回之前把这些方法拷贝到容器组件上:

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // 必须准确知道应该拷贝哪些方法 :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

参考文档


官网-HOC
React Higher-order Component
React 中的高阶组件及其应用场景

123
祁连

祁连

59 日志
8 分类
10 标签
大牛👇
  • 阮一峰
  • Dan Abramov
  • 寸志
  • Robin Wieruch
© 2022 祁连
由 Hexo 强力驱动
|
主题 — NexT.Pisces v5.1.4