图片懒加载的三种解决方法

写作提高思考

前言

我想写一个系列,关于图片懒加载、React 渲染十万条数据、无限下拉方案文章,因为其三者有共性,都有使用了 与 DOM 相关的 offsetTop、innerHeight、getBoundingClientRect、IntersectionObserver 等,这些知识点如果单独放在一篇文章中,其价值点就是1,如果相互连接,价值点就是3。用好梅特卡夫定律,能让自己的效率提升不少

正文

学习前端,三板斧,HTML、CSS、JavaScript。JavaScript 基础由 DOM、BOM、ECMAScript 组成,其中 ECMAScript 为规范语言,现在说的 ES6(ES2016~ES2022) 指的就是它,隔段时间就会发布,目前一年发布一次,以年份来说,现在是 ECMAScript 2022。BOM是什么,BOM是浏览器对象模型(Browser Object Model)。它有六大对象

  • document: DOM(对 BOM 包含了DOM,但是 DOM 重要,其地位和 BOM 一样)
  • event:事件对象
  • history:浏览器的历史记录
  • location:窗口的 url 地址栏信息
  • screen:显示设备的信息
  • navigator:浏览器的配置信息

DOM我们也很了解,文本对象模型,指操作HTML(超级文本标识语言)的API。DOM 会将文档解析为一个由节点和对象(包含属性和方法的对象)组件的结构集合

以前开发页面时,我们在 script 标签中,先获取节点(DOM Api),再操作 DOM ,所以以前是 《JavaScript 面向对象编程》,《JavaScript dom编程艺术》,但操作 DOM 的 API 太长,不易书写,JQuery 集大成,简化Api,统一了操作写法。如果展开,会有很多可以延伸,而我铺垫了这么多,就是想引出 document

这次我们要讲的 offset、scroll、client 就是出自“ document 家”。先配两张图来看看这三个到底是什么

document

document2

client

client 指元素本身的可视内容。不包括 overflow 被折叠部分,不包括滚动条、border,包括 padding

有四属性:

  • clientHeight:对象可见的高度
  • clientWidth:对象可见宽度
  • clientTop:元素距离顶部的厚度,一般为0,因为滚动条不会出现在顶部
  • clientLeft:元素距离左侧的厚度,一般为0,因为滚动条不会出现在左侧

offset

offset 指偏移。包括这个元素在文档中占用的所有显示宽度,包括滚动条、padding、border,不包括 overflow 隐藏的部分

有五属性:

offsetHeight:该对象自身的绝对高度

  • offsetHeight: = border-width * 2 + padding-top + height + padding-bottom

offsetWidth:该对象自身的绝对宽度

  • offsetWidth = border-width * 2 + padding-left + width + padding-right

offsetParent:返回一个对象的引用,字面意思,相对父元素的偏移

  • 如果当前元素的父元素没有 CSS 定位(position为absolute/relative),offsetParent 为 body
  • 如果当前元素的父元素有 CSS 定位(position为absolute/relative),offsetParent 取父级中最近的元素

offsetTop:相对版面或 offsetParent 属性指定父坐标的顶部距离

  • offsetTop = offsetParent 的 padding-top + 中间元素的 offsetHeight + 当前元素的 margin-top

offsetLeft:相对版面或 offsetParent 属性指定父坐标的左部距离

  • offsetLeft = offsetParent 的 padding-left + 中间元素的 offsetWidth + 当前元素的 margin-left

Scroll

Scroll 指滚动。包括这个元素没有显示出来的实际宽度,包括 padding,不包括滚动条、border

scrollHeight:获取对象的滚动高度,对象的实际高度

scrollWidth:获取对象的滚动宽度

scrollTop:当前元素与窗口最顶端的距离

scrollLeft:当前元素与窗口最左端的距离

其他

innerHeight 和 clientHeight 有什么区别

准确来说,clientHeight 是针对 body,innerHeight 是 window 的

document.body.clientHeight:网页可见区域高

window.innerHeight:可视窗口高度,不包括浏览器顶部工具栏

监听图片高度实现懒加载

通过图片的 offsetTop(偏移高度)和 window 的 innerHeight、scrollTop 判断图片是否位于可视区域

即很多图片,先显示视窗中的图片,没看见的先不展示,加快页面加载速度。当你向下滚,当后续图片的 offsetTop(偏移高度) 小于 innerHeight(视窗高度) + scrollTop(滚动高度) 时,意味着此图片已经出现在视窗中,将真正图片替换loading

关键代码在于

function lazyload() {
  let seeHeight = window.innerHeight;
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
  for (let i = n; i < img.length; i++) {
    if (img[i].offsetTop < seeHeight + scrollTop) {
      // 对比图片的偏移高度和屏幕高度+滚动高度
      if (img[i].getAttribute("src") === "loading.gif") {
        img[i].src = img[i].getAttribute("data-src");
      }
      n = i + 1;
    }
  }
}

效果如下:

监听图片高度实现懒加载

Element.getBoundingClientRect

Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置

getBoundingClientRect 返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,即与该元素相关的 CSS 边框集合

语法

domRect = element.getBoundingClientRect();

返回坐标、宽高、在视口中的位置

  • x
  • y
  • width
  • height
  • top
  • right
  • bottom
  • left

rect

如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing:border-box ,元素的尺寸等于 width/height

我们用这个 API 来获取每张图片的 top 值,如果 top 值小于可视区的高度就视为已经进入可视区,直接加载图片即可

function lazyload() {
  let seeHeight = document.documentElement.clientHeight;
  for (let i = n; i < img.length; i++) {
    if (img[i].getBoundingClientRect().top < seeHeight) {
      if (img[i].getAttribute("src") === "loading.gif") {
        img[i].src = img[i].getAttribute("data-src");
      }
      n = i + 1;
    }
  }
}

效果如下:

getBoundingClientRect实现懒加载

通过 IntersectionObserver 实现懒加载

IntersectionObserver 接口(从属于 Intersection Observer API)提供了一种异步观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法。祖先元素与视窗(viewport)被称为根(root)

IntersectionObserver 可以不用监听 scroll 事件,做到元素一可见便调用回调,在回调里面我们来判断元素是否可见

if (IntersectionObserver) {
  let lazyImageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry, index) => {
      let lazyImage = entry.target;
      // 如果元素可见
      if (entry.intersectionRatio > 0) {
        if (lazyImage.getAttribute("src") === "loading.gif") {
          lazyImage.src = lazyImage.getAttribute("data-src");
        }
        lazyImageObserver.unobserve(lazyImage);
      }
    });
  });
  for (let i = 0; i < img.length; i++) {
    lazyImageObserver.observe(img[i]);
  }
}

上述代码表示,遍历所有的图片,对其进行观察原生是否可见,如果元素可见,就把真正图片替换loading

IntersectionObserver 可以自动“观察”元素是否可见,其本质是目标元素与视窗产生一个交叉去,所以这个 API 叫做“交叉观察器”

使用方式

let io = new IntersectionObserver(callback, option);

上面代码中, IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数:callback 是可见性变化时的回调函数,option 是配置对象(该参数可选)

构造函数的返回值是一个观察器实例。实例的 observe 方法可以指定观察哪个 DOM 节点

// 开始观察
io.observe(document.getElementById("example"));

// 停止观察
io.unobserve(element);

// 关闭观察器
io.disconnect();

callback 参数

目标元素的可见性变化时,就会调用观察器的回调函数 callback

callback 一般会触发两次。一次时目标元素刚刚进入视窗(开始可见),另一次时完全离开视窗(开始不可见)

let io = new IntersectionObserver((entries) => {
  console.log(entries);
});

上面代码中,回调函数采用的是箭头函数的写法。callback 函数的参数(entries)是一个数组,每个成员都是一个 IntersectionObserverEntry 对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。

IntersectionObserverEntry 对象

IntersectionObserverEntry 对象提供目标元素的信息,一共有六个属性

  • time: 可见性发生变化的时间,单位毫秒
  • target:被观察的目标,是个 DOM 节点对象
  • rootBounds:根元素的矩形区域的信息,getBoundingClientRect() 方法的返回值,如果没有根元素(即直接相对于视窗滚动),则返回null
  • boundingClientRect:目标元素的矩形区域的信息
  • intersectionRect:目标元素与视窗(或根元素)的交叉区域的信息
  • intersectionRatio:目标元素的可见比例,即 intersectionRect 占 boundingClientRect 的比例,完全可见时为1,完全不可见时小于等于 0

如图所示:

IntersectionObserverEntry参数

兼容性如何

caniuse 兼容性报告目前支持率是 93.67%,但是iOS的支持度要在 iOS12.2 以上,如果是iPhoneX(2018.11)之后的手机都是支持的,如果是之前的,升级系统才支持,考虑到一些人是不会升级,所以这个兼容性还不支持大众化的场景,但它的能力和性能都非常的好

image-20220125104012150

总结

面试的时候被问到懒加载,我那个时候没做过相关的准备,我说不知道,途虎的面试官会引导,其实引导才能测试出一个人真正的水平,但是那个时候我竟然连 scroll 都想不起来。现在回想起来,实在是准备的方向搞错了。

说到图片懒加载,有两种方法:

  • 监听图片高度
    • 技术要点:监听scroll,滚动的时候遍历所有的图片,如果图片的偏移高度小于屏幕高度+滑动高度,说明已经出现在视窗,就替换图片
    • 优点:兼容性好
    • 缺点:单纯使用 scroll 滑动来监听高度,会引发性能问题,所以要搭配节流
  • Element.getBoundingClientRect
    • 技术要点:与监听图片无太大区别,无非视把图片的偏移高度改成 getBoundingClientRect().top,对比每张图片的自身高度是否出现在视窗(视口)中,有就替换图片
    • 优点:兼容性好,代码相对监听图片高度少了一些
    • 缺点:也是使用 scroll 滑动来监听,会引发性能问题
  • 使用 IntersectionObserver Api
    • 技术要点:通过 IntersectionObserver Api 来实现,图片元素一可见就调用回调,在回调中判断元素是否可见
    • 优点:写起来方便,性能好
    • 缺点:兼容性适配iOS12.2以上,安卓5以上

附上线上 demo:

参考资料

芝麻开门,显示全文!

部署实战记录

写博客是为了让自己不那么快忘记

把一个 node 服务部署上线是怎么个流程?

本人在做个人公众号微信分享服务时因为看到别人的个人订阅号能突破微信认证的界限,所以也想尝试一下,结果是失败了,但是又温习了下部署的整一流程,以此记录

现在开发通过

本地部署测试

上阿里云/腾讯云开启安全组规则,开放端口(我的服务是3003)

上服务器开放防火墙

用nginx 做域名映射

  • server {
        listen 80;
        server_name example.azhubaby.com;
        location / {
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   Host      $http_host;
            proxy_pass         http://0.0.0.0:3003;
        }
    }
    

完毕

因为我的牵扯到微信JS-SDK,所以要去公众号配置JS安全域名

此后去微信开发者工具中测试

防火墙配置

测试命令:

telnet ip地址 端口

在本地window系统 cmd命令窗口输入该命令。ip地址为远程服务器的公网ip地址。

命令案例:

telnet 47.102.152.19 3003

查看防火墙状态

systemctl status firewalld.service

开启防火墙

systemctl start firewalld.service

关闭防火墙

systemctl stop firewalld.service

禁用防火墙

systemctl disable firewalld.service

查看防火墙已开放端口列表

firewall-cmd --list-all

防火墙添加端口

[root@localhost ~]# firewall-cmd --permanent --add-port=3003/tcp
success
[root@localhost ~]# firewall-cmd --reload
success
[root@localhost ~]# firewall-cmd --list-all
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: ens33
  sources:
  services: dhcpv6-client ssh
  ports: 8080/tcp 3306/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

防火墙关闭端口

[root@localhost ~]# firewall-cmd --permanent --remove-port 3003/tcp
success
[root@localhost ~]# firewall-cmd --reload
success
[root@localhost ~]# firewall-cmd --list-all
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: ens33
  sources:
  services: dhcpv6-client ssh
  ports: 8080/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

参考资料

芝麻开门,显示全文!

0.1+0.2!==0.3,为什么?

先说结论

为什么不等于?

因为浮点数表示小数的时候有精准度损失

为什么会有精准度损失

因为计算机硬件存储数据时,是以二进制(10101010)形式进行

比如说每个字节是 8 位,int 类型占 4 个字节,也就是 32 位精度;那么 32 位的计算机精度可以存 2 的 32次方个数据。如下图:

例子

每位上面可以放两个二进制数据也就是 0 或者 1;一般最高位上是符号位(1表示负数,0表示正数),所以带符号的类型数据应该是 31 个 2

2 _ 2 _ 2 _ … _ 2(31个2),加上符号范围就是 -2147483648 ~ 2147483647;当然也有无符号整形,暂不讨论

那么小数怎么存呢?小数在计算机当中叫浮点型,JS 最终会由浏览器引擎转成 C++,但是 JS 当中只有一种数值类型,那就是 number,那么 number 在 C++ 是什么类型呢;

我们暂且认为它是双精度类型,也就是 double,C++ 中占四个字节,也就是 64 位存储,整数存储参考上面即可,重点说说浮点存储

同样 64 位可分为三部分,它的制定格式是以 IEEE 754 为标准:

第一部分:符号位(S),占 1 位即第 63 位;

第二部分:指数域(E),占 11 位即 52 位到 62 位,含 52 和 62;

第三部分:尾数域(F),占 52 位即第 0 位到 51 位,含 51;

64-bit

如果将一个小数转换成二进制 64 位怎么表示,以 12.52571 为例

  • 先转换成二进制(十进制转换成二进制)(站长工具二进制转换
    • 12.52571 => 1100.100001101001010011101110001110010010111000011111
  • 将其小数点向左偏移三位
    • 1.100100001101001010011101110001110010010111000011111 * 2^3

得出结论

  1. 因为是整数,所以符号位 S 是 0;
  2. 因为向左偏移了三位,所以 E = 1023 + 3 = 1026(转化为二进制) => 10000000010,有 11 位,不够前面补 0
    • 为什么要加1023?为什么左移是加3,不是减3
  3. 尾数是(F)(小数点后面)100100001101001010011101110001110010010111000011111;

最终表示: 0 10000000010 100100001101001010011101110001110010010111000011111;

上面总长度是63位,差一位,最后面补零,即

0 10000000010 1001000011010010100111011100011100100101110000111110;

那么12.52571的64位计算机存储形式就是上面了;

回过头看 0.1 + 0.2

上面的表达可能有些疑惑,肯定的,毕竟笔者也是参考的(权当笔记,供以后温习),暂且不表;那么 0.1 和 0.2 是怎么转的

这里就有一个问题,0.1 和 0.2 转成二进制小数点后面是循环的

// 0.1 转化为二进制
0.0 0011 0011 0011 0011...(0011无限循环)
// 0.2 转化为二进制
0.0011 0011 0011 0011 0011...(0011无限循环)

由于尾数只有52位(52位之后的被计算机截掉了)

E = -4; F =1001100110011001100110011001100110011001100110011010 (52位)
E = -3; F =1.1001100110011001100110011001100110011001100110011010 (52位)

要让两个数相加,首先E需要相同,于是得出下面

E = -3; F =0.1100110011001100110011001100110011001100110011001101 (52位) //多余位截掉
E = -3; F =1.1001100110011001100110011001100110011001100110011010 (52位)

上面两个相加得出

E = -3; F = 10.0110011001100110011001100110011001100110011001100111
-------------------------------------------------------------------
E = -2; F = 1.00110011001100110011001100110011001100110011001100111

得出的结论就是

2 ^ (-2 * 1.00110011001100110011001100110011001100110011001100111);

这个值转换成真值,结果为: 0.30000000000000004

如何做到精准度

JavaScript 的类型 bigInt (ES8)中

TypeScript 也有这样的类型

有解决精准度问题的 big.js、bigInt 库

同样有精准度缺失的语言

python

总结

因为 JavaScript 到最后会转换为 C++ 去执行

在 IEEE754 标准中常见的浮点数数值表示有:单精准度(32位)和双精准度(64位),JS 采用的是后者。浮点数与整数不同,一个浮点数既包含整数部分,又包含小数部分,因为其表示法的不同,需要分析为整数和小数部分,然后相加得到结果。0.1 和 0.2 先转成二进制,在转换为同一维度计算,得到二进制后,再转换为十进制后,就成了0.30000000000000004

芝麻开门,显示全文!

移动端法门:自适应方案和高清方案

笔者从毕业开始做前端到现在,90% 的项目是移动端打交道,所以当简历上写了“移动H5”几个字时,必会被问到自适应方案与高清方案

”自适应“讲的是一套UI(例如750*1334),在多端下展示近乎一样的效果;而”高清“是因为 DPR 提升而所做的各种精度适配

这篇文章讲讲笔者理解的自适应方案和高清方案

先说结论

自适应方案

  • rem
    • 适配思路
      • 选择一个尺寸作为设计和开发基准
      • 定义一套适配规则,自动适配剩余的尺寸
      • 特殊适配效果给出设计效果
    • 属于历史产物,CSS 视窗单位未得到主流浏览器的支持
    • 原理
      • 根据视窗宽度动态调整根元素 html 的 font-size 的值
      • 把总宽度设置为 100 份,每一份被称为一个单位 x,同时设置 1rem 单位为 10x
    • 缺点
      • 需要加载 js 脚本,而且根据设备的视窗宽度进行计算,影响性能
    • 影响力:从2015年出世至今,在 H5 适配领域占据一定比例
    • 相关技术库:flexiblepx2rem
  • vw
    • 适配思路(如上)
    • 原理
      • 利用 CSS 视窗的特性,总宽度为 100vw,每一份为一个单位 1vw,设置 1rem 单位为 10vw
    • 缺点
      • 因为是根据视图的宽度计算,所以不适用平板和PC
    • 影响力:2018年出的方案,目前 H5 适配主流
    • 相关技术库:postcss-px-to-viewport
  • px + calc + clamp
    • 适配思路
      • 根据 CSS 的新特性:css变量、calc()函数、clamp()、@container函数实现
    • 特点
      • 解决了rem、vw布局的致命缺点:失去像素的完美性,而且一旦屏幕低于或高于某个阈值,通常就会出现布局的移动或文字内容的溢出
      • 大漠在2021年提出,最先进,但没看到大厂使用(clamp函数浏览器支持率暂且不高),具体可以看看大漠的这篇:如何构建一个完美缩放的UI界面
    • 缺点
      • 因为方案先进,暂没看到大厂使用

高清方案

  • 1 像素问题的解决方案
  • 不同 DPR 下图片的高清解决方案

综上,自适应方案是解决各终端的适配问题,高清方案是解决Retina屏的细节处理

写在前面

在说移动端适配方案之前先整明白一些技术概念

设备独立像素

设备独立像素(DIP)=== CSS 像素 === 逻辑像素,在 Chrome 中能直接看到 375* 667

chrome中查看css像素

当你看到设备独立像素时,不要慌,它表示 CSS 像素,而它的长宽就是在 Chrome 中所查到的。可这样记忆,“设备独立像素”,字数长,文绉绉就是 CSS 像素,也是理论上人为给定的指标,也叫逻辑像素

物理像素

物理像素可以理解为手机厂商在卖手机时宣传的分辨率,即物理像素 = 分辨率,它表示垂直和水平上所具有的像素点数

也就是说设备屏幕的水平方向上有 1920 像素点,垂直方向有 1080 像素点(假设屏幕分辨率为1920*1080),即屏幕分辨率表示物理像素,它在出厂时就定下来,单位为 pt,1pt=0.376mm

手机分辨率

物理像素,又被称为设备像素,即表示 设备像素 === 物理像素。可这样记忆,设备在物理世界能测量的长度

DPR(Device Pixel Ratio)

而设备像素比(DPR)是什么?

DPR = 设备像素 / 设备独立像素,它通常与视网膜屏(Retina 屏)有关

以 iPhone7 为例子,iPhone7 的 DPR = iPhone7 物理像素 / iPhone7 设备独立像素 = 2

宽 1334 / 667 = 2

高 750 / 375 = 2

得到 iPhone7 的 DPR 为 2,也就是我们常说的视网膜屏幕,而这就是营销术语,它就是因为技术的进步,使得一个 CSS 像素塞入更多的物理像素

营销术语还有哪些:农夫山泉的大自然的搬运工、元气森林的“気”

笔者是这么记忆的:

  • CSS 像素(设备独立像素)就像一个容器,以前是一比一塞入,所以 DPR 为 1,后来技术发展进步了,一个容器中能塞入更多的真实像素(物理像素)

  • DPR = 设备像素 / 设备独立像素

  • DPR = 物理像素(真实)/ CSS 像素(虚的)

在视网膜屏幕中,以 DPR = 2 为例,把 4(2x2)个物理像素当一个 CSS 像素使用,这样让屏幕看起来更加清晰(精致),但是元素的大小(CSS像素)本身不会改变

DPR对比

随着硬件的发展,像 iPhone13 Pro 等手机的 DPR 已经为 3,未来 DPR 突破 4 不是问题

说回来,DPR 为 2 或 3 会有什么问题?我们以 CSS 为最小单位来写代码的,展示在屏幕上也是以 CSS 为最小单位来展示,也就是说在 DPR 为 2 时,我们想要模拟 1 单位物理像素是做不到的(如果浏览器支持用 0.5px CSS 的话,可以模拟,但是DPR为 3 呢,用 0.333px?);又因为手机的设备独立像素(CSS 像素)固定,使用传统静态布局(固定 px)时,会出现样式的错位

iPhone 5/SE: 320 * 568 DPR: 2

iPhone 6/7/8: 375 * 667 DPR: 2

iPhone 6/7/8 Plus: 414* 736 DPR: 3

iPhone X: 375 * 812 DPR: 3

所以我们要适配各终端的 CSS 像素以及不同 DPR 下,出现的 1 像素问题、图片高清问题等。随着技术的发展,前端们摆脱了 IE 的兼容,同时陷入了各大手机品牌的兼容沼泽

自适应方案

Rem 布局——天下第二

简介:rem 就是相对于根元素 html 的 font-size 来做计算

与 rem 相关联的是 em:

em 作为 font-size 单位时,其代表父元素的字体大小,em 作为其它属性单位时,代表自身字体大小

rem 作用于非根元素时,相对于根元素字体大小,rem 作用于根元素字体时,相对于其初始字体大小

本质:等比缩放,是通过 JavaScript 来模拟 vw 的特性

假设将屏幕宽度平均分为 100 份,每一份的宽度用 x 表示,x = 屏幕宽度 / 100,如果将 x 作为单位,x 前面的数值就代表屏幕宽度的百分比

p {
  width: 50x;
} /* 屏幕宽度的 50% */

如果想要页面元素随着屏幕宽度等比变化,我们就需要上面的 x,这个 x 就是 vw,但是 vw 是在浏览器支持后才大规模使用,在此之前,js + rem 可模拟这种效果

之前说了,rem 作用于非根元素时,相对于根元素字体大小,所以我们设置根元素单位后,非根元素使用 rem 做相对单位

html {
  font-size: 16px;
}
p {
  width: 2rem;
} /* 32px */

html {
  font-size: 32px;
}
p {
  width: 2rem;
} /* 64px */

问题来了,我们要获取到一个动态的根元素 font-size,并以此变化各个元素大小

有趣的是,我司两个项目目前的做法是通过媒体查询设置根元素,分为四档,默认16px

笔者对这种做法表示不理解,原开发人员说我们这套运行了6年,UI适配也没人说什么问题。这里就有个疑问了,真的如他所说UI适配的很好吗,”媒体查询根元素+rem“也能适配好吗?是否完美呢?

后续笔者也会在 demo 中展示这种做法

但是根元素的 font-size 怎么变化,它不可能一直是 16px,在中大屏下还可以,但是在小屏下字体就太大了,所以它的大小也应该是动态获取的。如何让其动态化,就是上文所说,让根元素的 font-size 大小恒等于屏幕宽度的 1/100

html {
  font-size: width / 100;
}

如何设置 html 的字体大小恒等于屏幕宽度的百分之一呢?可以通过 js 来设置,一般需在页面 dom ready、resize 和屏幕旋转中设置

document.documentElement.style.fontSize =
  document.documentElement.clientWidth / 100 + "px";

flexible 源码就如以上思路写的

我们设置了百分之一的宽度后,在写 css 时,就需要利用 scss/less 等 css 处理器来对 css 编译处理。假设给出的设计图为 750 * 1334,其中一个元素宽度为 200 px,根据公式:

width: 200 / 750 * 100 = 26.67 rem

在 sass 中,需要设置设计图宽度来做换算:

@use "sass:math";

$width: 750px;

@function px2rem($px) {
  @return #{math.div($px, $width) * 100}rem;
}

上面编译完后

div {
  width: 26.667rem;
}

在不同尺寸下,它的宽度不同

机型尺寸width
iPhone 5/SE320 * 568170 * 170
iPhone 6/7/8375 * 667200 * 200
iPhone 6/7/8 Plus414 * 736220.797 * 220.797
iPhone X375 * 812200 * 200

效果如下(特意说明:图中演示的是引入 flexible 库,它的根元素的 font-size 为屏幕的 1/10)

rem布局

REM 布局(flexible)demo

优点:rem 的兼容性能低到 ios 4.1,android 2.1

缺点:

  • 等比放大(可以说优点也可以理解为缺点,不同场景下使用)

    • 用户选择大屏幕有几个出发点,有些人想要更大的字体,更大的图片,有些人想要更多的内容,并不想要更大的图标
  • 字体大小不能使用 rem(一般使用媒体查询控制 font-size 大小)

  • 在 PC 端浏览破相,一般设置一个最大宽度

var clientWidth = document.documentElement.clientWidth;
clientWidth = clientWidth < 780 ? clientWidth : 780;
document.documentElement.style.fontSize = clientWidth / 100 + "px";
body {
  margin: auto;
  width: 100rem;
}
  • 如果用户禁止 js 怎么办?

    • 添加 noscripe 标签提示用户

    • <noscript>开启JavaScript,获得更好的体验</noscript>

    • 给 HTML 添加一个 默认字体大小

相关技术方案:flexible(amfe-flexible 或者 lib-flexible) + postcss-pxtorem

Viewport 布局——天不生我VW,适配万古如长夜

vw 是基于 Viewport 视窗的长度单位,这里的视窗(Viewport) 指的是浏览器可视化的区域,而这个可视区域是 window.innerWidth/window.innerHeight 的大小

根据 CSS Values and Units Module Level 4: vw 等于初始包含块(html元素)宽度的1%,也就是

  • 1vw 等于 window.innerWidth 的数值的 1%
  • 1vh 等于 window.innerHeight 的数值的 1%

看图理解

屏幕的宽高

在说 rem 布局时,曾经举过 x 的例子,x 就是 vw

/* rem 方案 */
html {
  font-size: width / 100;
}
div {
  width: 26.67rem;
}

/* vw 方案 */
div {
  width: 26.67vw;
}

vw 还可以和 rem 方案结合,这样计算 html 字体大小就不需要 js 了

html {
  font-size: 1vw;
}
div {
  width: 26.67rem;
}

效果如下:

vw适配

vw 适配是 CSS 原生支持,而且目前兼容性大多数手机是支持的,也不需要加载 js ,也不会因为 js引发性能问题

vw 确实看上去很不错,但是也存在一些问题

  • 也没能很好的解决 1px 边框在高清屏下的显示问题,需要自行处理
  • 由于 vw 方案是完全的等比缩放,在 PC 端上会破相(和 rem一样)

相关技术方案:postcss-px-to-viewport

VW 布局demo

px适配——一力降十会

不用 rem/vw,用传统的响应式布局也能在移动端布局中使用,需要设计规范

使用css 变量适配(篇幅原因暂不详细介绍,可直接看代码

使用场景:新闻、内容型的网站,不太适用 rem,因为大屏用户想要看到更多的内容,如网易新闻、知乎、taptap

PX + CSS变量 demo

媒体查询——可有我一席?

上文讲到我司原先H5端采用媒体查询的方式来做适配,笔者尝试复刻了下,只能说大差不差,能看出媒体查询想做成这件事,但还是心有余而力不足

采用rem、vw、px等方法能实现非标准尺寸(375 _ 667设计稿)下 header 的高度为 165.59px,而 media 因为大屏,将根font-size 设置为17px,结果 header 的高度成为 159.38px(17 _ 9.375rem)

如下GIF所示:

媒体查询布局与其他布局对比

所以说仅用媒体查询还是差强人意

媒体查询布局demo

各种适配的对比

vw、rem 适配的本质都是等比例缩放,px 直接写,孰优孰劣看自己

REM布局VW布局PX + css变量布局
容器最小宽度支持不支持支持
容器最大宽度支持不支持支持
高清设备1px边框支持支持支持
容器固定纵横比支持支持支持
优点1.老牌方案
2.支持高清设备1px边框时,可按以往方式直接写
1.无需引入js
2. 天然支持,写法规范
同VW
缺点1. 需要引入 js 设置 html 的font-size
2. 字体大小不能使用 rem
3. 在 PC 端浏览会破相,一般需设置最大宽度
1.在PC端会破相
2.不支持老旧手机
同VW

除此之外,还有搭配 vw 和rem 的方案

  • 给根元素大小设置随着视窗变化而变化的vw单位,动态变化各元素大小
  • 限制根元素字体大小的最大最小值,配合body加上最大宽度和最小宽度
// rem 单位换算:定为 75px 只是方便运算,750px-75px、640-64px、1080px-108px,如此类推
$vm_fontsize: 75; // iPhone 6尺寸的根元素大小基准值
@function rem($px) {
  @return ($px / $vm_fontsize) * 1rem;
}
// 根元素大小使用 vw 单位
$vm_design: 750;
html {
  font-size: ($vm_fontsize / ($vm_design / 2)) * 100vw;
  // 同时,通过Media Queries 限制根元素最大最小值
  @media screen and (max-width: 320px) {
    font-size: 64px;
  }
  @media screen and (min-width: 540px) {
    font-size: 108px;
  }
}
// body 也增加最大最小宽度限制,避免默认100%宽度的 block 元素跟随 body 而过大过小
body {
  max-width: 540px;
  min-width: 320px;
}

高清方案

1像素问题

1像素指在 Retina 屏显示 1单位物理像素

很好理解,CSS 像素(设备独立像素)是我们人为规定的,当 DPR 为 1 时,1像素(指我们写的 CSS 像素) 等于 1物理像素;但当 DPR 为 3 时,1像素就为 3 物理像素

  • DPR = 1,此时 1 物理像素 等于 1 CSS 像素
  • DPR = 2,此时 1 物理像素等于 0.5 CSS 像素
    • border-width: 1px,这里的 1px 其实是 1 CSS 像素宽度,等于 2 物理像素,设计师其实想要的是 border-width: 0.5px
  • DPR = 3,此时 1 物理像素等于 0.33 CSS 像素
    • 设计师想要的是 border-width: 0.33px

1像素问题

解决思路

使用 0.5px 。有局限性,iOS 8及以上,苹果系统支持,但是 iOS 8以下和 Android(部分低端机),会将0.5px 显示为 0px

既然 1 个 CSS 像素代表 2(DPR 为2)、3(DPR为3)物理像素,设备又不认识 0.5px 的写法,那就画 1px,然后想办法将宽度减少一半

方案

  • 渐变实现
    • background-image: linear-gradient(to top, ,,,)
  • 使用缩放实现
    • transform: scaleY(0.333)
  • 使用图片实现
    • base64
  • 使用 SVG 实现
    • 嵌入 background url
  • border-image
    • 低端机下支持度不好

以上都是通过 CSS 的媒体查询来实现的

@media only screen and (-webkit-min-device-pixel-ratio: 2),
  only screen and (min-device-pixel-ratio: 2) {
}
@media only screen and (-webkit-min-device-pixel-ratio: 3),
  only screen and (min-device-pixel-ratio: 3) {
}

图片适配和优化

图像通常占据了网页上下载资源绝大部分,优化图像通常可以最大限度地减少从网站下载的字节数以及提高网站性能

通常可以,有一些通用的优化手段:为不同 DPR 屏幕提供最适合的图片尺寸

各大厂商的适配分析

看了不少文章,类似如:大厂是怎么做移动端适配的

各大厂,有用rem适配的、也有用vm适配的、也有vm+rem结合适配的,纯用 px 方案的也有

  • 新闻、社区等可阅读内容较多的场景:px+flex+百分比
    • 如携程、知乎、TapTap
  • 对视觉组件种类较多,依赖性较强的移动端页面:vw+rem
    • 如电商、论坛

总结

rem 方案,引入 amfe-flexible

设计:设计出图是 750 * 1334,设计切好图后,上传蓝湖,按照尺寸写 px。

开发:

  • 使用 rem 方案
    • 引入 amfe-flexible
    • 安装 px2rem 之类的 px 转 rem 工具
    • 配置 px2rem
    • 在项目中写 px ,输出时是 rem
    • 适用任何场景
  • 使用 vw 方案
    • 安装 px2vw 之类的 px 转 vw 工具
    • 配置 px2vw
    • 在项目中写 px,输出时是 vw
    • 适用任何场景
  • 使用 px 方案
    • 该怎么样就怎么写,不过因为有设计规划,按钮的大中小尺寸固定、icon 的尺寸有标准、TabBar 的高度也是写死的,当一切都有标准后,写页面就方便了
    • 例如
      • 左边固定 100 * 50,右边 flex 布局
      • 左边固定 100 * 50,右边 calc(100% - 100px)(使用 CSS3 中的 calc 计算)

其他

caniuse 网站测试CSS属性与浏览器的兼容性问题

疑问

Q:为什么 H5 移动端UI库单位大都是用 px?这样不会有适配问题吗?

其实我们写好 px 后,如果项目采用 rem 写业务,引入 px2rem(已经六年没有维护了) 即可转换。

在有赞 vant 库中,它对浏览器适配的介绍是:

Viewport 布局

Vant 默认使用 px 作为样式单位,如果需要使用 viewport 单位(vw、vh、vmin、vmax),推荐使用 postcss-px-to-viewport 进行转换

postcss-px-to-viewport 是一款 PostCSS 插件,用于将 px 单位转化为 vw/vh 单位

Rem 布局

如果需要使用 rem 单位进行适配,推荐使用以下两个工具:

  • postcss-pxtorem 是一款 PostCSS 插件,用于将 px 单位转化为 rem 单位
  • lib-flexible 用于设置 rem 基准值

demo 合集:线上demo

参考资料

芝麻开门,显示全文!

搞轮子:SortBar排序栏的升级

前言

在此之前,曾经写过两篇关于组件嵌套关系的组件文章:

搞轮子:从业务到组件Tabs

搞轮子:TabBar标签栏的自救

Tabs 组件首次使用 React.Children.map 来”解构“子组件,释放子组件写法

<Tabs value={value} swipeable>
  <Tabs.Panel title="标签1">内容 1</Tabs.Panel>
  <Tabs.Panel title="标签2">内容 2</Tabs.Panel>
  <Tabs.Panel title="标签3">内容 3</Tabs.Panel>
  <Tabs.Panel title="标签4">内容 4</Tabs.Panel>
</Tabs>

TabBar 组件再次基础上,又使用了 React.cloneElement,使组件的状态变化由父组件提供

<TabBar
  activeKey={activeKey}
  onChange={(key: any) => {
    setActiveKey(key);
  }}
>
  <TabBar.Item itemKey="home" title="主页" icon={<IconTabbarHome />} />
  <TabBar.Item
    itemKey="financial"
    title="理财"
    icon={<IconTabbarFinancial />}
  />
  <TabBar.Item itemKey="user" title="我的" icon={<IconTabbarUser />} />
</TabBar>

TabBar 是底部的菜单栏,有选中和非选择两种状态,所以要给父组件设置”当前选中项“,每个子组件设置唯一标识符,这样才能判断那个元素被选中了

而今天说的 SortBar 状态变为三种,分别为未选择,降序、升序

正文

我们要做成什么样子呢?如下图所示:

image-20211223091530386

有筛选、和三个有状态的子组件。其实和 TabBar 组件很像,无非是多了筛选组件把两种状态改为三种

先看书写结构:

<SortBar
  activeKey={activeKey}
  onChange={(key: string, status: string) => {
    setActiveKey(key);
  }}
  onClick={() => {
    console.log("点击筛选");
  }}
>
  <SortBar.Item title="年化" itemKey="annualized" />
  <SortBar.Item title="期限" itemKey="term" />
  <SortBar.Item title="价格" itemKey="price" />
</SortBar>

和 TabBar 可以说一摸一样,其中 onClick 是针对点击筛选组件的,其核心思路和 TabBar一样,核心代码是:

const items = React.Children.map(children, (element, index) => {
  if (!React.isValidElement(element)) return null;

  return cloneElement(element, {
    key: index,
    title: element.props.title,
    itemKey: element.props.itemKey || index,
    selected: activeKey === element.props.itemKey,
    onClick: (status: string) => onHandleClick(element.props.itemKey, status),
  });
});

React.Children 遍历子元素

React.isValidElement 判断子元素是否是 React 元素

cloneElement 在原来的子组件上添加其他元素

  • selected:新添加的元素,判断当前选中key和自身的 key 是否是同一个
  • onClick:点击的时候哪个key,什么status(状态)传给父元素,好让父元素做业务处理

选中和未选中此子组件由父组件控制,而选中此子组件的状态则在子组件中完成,请看代码:

const Item: FC<SortBarItemProps> = (props) => {
  const { title, selected, onClick } = props;
  const [status, setStatus] = useState("0");

  useEffect(() => {
    if (selected === false) {
      setStatus("0");
    }
  }, [selected]);

  const onHandleClick = () => {
    if (status === "0" || status === "2") {
      setStatus("1");
      onClick && onClick("1");
    } else if (status === "1") {
      setStatus("2");
      onClick && onClick("2");
    }
  };

  return (
    <div className={`${prefixCls}`} onClick={onHandleClick}>
      {title}
      {status === "0" && <IconFilterEmty size="sm" />}
      {status === "1" && <IconFilterDown size="sm" />}
      {status === "2" && <IconFilterUp size="sm" />}
    </div>
  );
};

默认为都未选择,当点击后改变状态。

总结

这一些告一段落,已经没什么好讲的了

芝麻开门,显示全文!

知识点:preventDefault、stopPropagation

JavaScript 冒泡和捕获是两种事件行为,使用 event.stopPropagation() 能起到阻止捕获和冒泡阶段中当前事件的进一步传播,使用 event.preventDefault() 可以取消默认事件

防止冒泡和捕获

w3c 的方法是 e.stopPropagation(),IE则是使用 e.cancelBubble = true

取消默认事件

w3c 的方法是 e.preventDefault(),IE则是使用 e.returnValue = false

preventDefault 及 stopPropagation

preventDefault 阻止元素的默认特性

stopPropagation 禁止冒泡

event.stopPropagation 阻止捕获和冒泡阶段

event.preventDefault 取消默认事件

http://caibaojian.com/javascript-stoppropagation-preventdefault.html

https://segmentfault.com/a/1190000008227026

Unable to preventDefault inside passive event listener invocation

addEventListener 不为人知的第三个参数 useCapture

https://juejin.cn/post/6844903593024159752

[筆記][JavaScript]所謂的「停止事件」到底是怎麼一回事?

https://ithelp.ithome.com.tw/articles/10198999

https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener#%E4%BD%BF%E7%94%A8_passive_%E6%94%B9%E5%96%84%E7%9A%84%E6%BB%9A%E5%B1%8F%E6%80%A7%E8%83%BD

在 react 如何解决

react 的事件是自己写的

在 组件中如何解决

{passive: true} 有什么用

如何查看web元素绑定的监听事件

https://cloud.tencent.com/developer/article/1705416

芝麻开门,显示全文!

搞轮子:不依赖外部DIY Toast 需要考虑什么

mask(遮罩)

import React, { FC, memo } from "react";
import classnames from "classnames";
import { MaskProps } from "./PropType";

const prefixCls = "jing-mask";

const Mask: FC<MaskProps> = (props) => {
  const { className, type, visible, style, onClick } = props;

  const classes = classnames(className, prefixCls, {
    [`${prefixCls}--${type}`]: !!type,
  });

  return visible ? (
    <div className={classes} style={style} onClick={onClick} />
  ) : null;
};

Mask.defaultProps = {
  type: "normal",
  visible: false,
};

export default memo(Mask);

缺点在于它

前提

animation(transition) 组件

mask(遮罩)组件(搞定)

portal 组件

最简单版本的

import React, { FC, useState, useEffect, memo } from "react";
import { createPortal } from "react-dom";
import { PortalProps } from "./PropType";

const Portal: FC<PortalProps> = (props) => {
  const { children, container, className } = props;

  const [containerEl] = useState(() => {
    const el = document.createElement("div");
    el.className += `jing-portal__container ${className}`;
    return el;
  });

  useEffect(() => {
    document.body.appendChild(container || containerEl);

    return () => {
      document.body.removeChild(containerEl);
    };
  }, []);

  return createPortal(children, containerEl);
};

export default memo(Portal);

定位 popup,弹出层

popup 组件

使用方式

当作静态函数使用

Toast(…)

Toast.loading()

组件本身要考虑什么功能

遮罩(mask)、内容(message)、是否禁止背景点击(forbidClick),支持自定义图标(icon)、自定义出现的位置(position)、是否在点击遮罩层后关闭(closeOnClickOverlay)

展示时长(duration)、关闭时的回调函数(onClose)

提供 Toast.success 表示成功,

Toast.fail 表示失败

Toast.loading 表示加载

单例模式

allowMultiple

梳理一下

Toast的基础是 Popup,Popup的基础是 Mask 和 Portal

Popup要做的就是弹出层,属于基础组件

Modal 要做之前 popup.alert 之类的事情(Vant 是 Dialog)

Toast 要实例化

无论是 Toast 还是 Modal 都需要使用静态方法调用

Modal 可以大写

popup 和 portal 放一起

不可见的时候看不到元素,

可见的时候渲染元素

动画

react-transition-group 和 portal 的结合

因为 portal 是return 出的组件,所以不会有动画

需要做 animation

https://stackoverflow.com/questions/54672784/animating-react-transition-group-with-reactdom-createportal

有人说给他加 animationDuration

https://codesandbox.io/s/stupefied-bouman-ehszt?file=/src/components/Portal/index.js:517-526

一定会有 div

节点从有到无

image-20211215181234263

animatedVisible 成为 true,显示Portal 组件,先渲染父组件,再渲染子组件

先执行

破除魔咒,取消

useEffect(() => {
  mountContainer?.appendChild(containerEl);

  return () => {
    mountContainer?.removeChild(containerEl);
  };
}, []);

react-transition-group 的用法

http://reactcommunity.org/react-transition-group/transition#Transition-prop-onExited

Toast 不是一个组件,而是一个 ”方法“, Toast("提示内容")

怎么把一个 <Toast>提示文字<Toast> 写法的组件改造成 Toast("提示内容")

查看了别人做的 Toast,

先做个 React 版本的 Toast,即组件时写法,然后再将它作为基础组件使用,怎么使用,

生成一个 div(document.createElement(‘div’))

插入 dom 中(bodyContainer.appendChild(container))

ReactDOM.render() 渲染它

我们先完成 Toast 组件

Toast.loading()

需要完成 loading 组件

loading 之后

要 useloading

show

hide

目的是把它当作一个静态函数使用

Loading.

const loading = Loading.useLoading();

<Button
  size="xs"
  onClick={() => {
    loading.show();
    setTimeout(() => {
      loading.hide();
    }, 3000);
  }}
>
  开启
</Button>

这里有个思考角度,loading 需不需要被 popup 包裹,我的想法是按照实际需求来做组件,我们可以做成像弹出层那样,但是 loading 的用法,一般是作为 Toast 的子组件来使用,所以这里,useLoading 对我们不适用

当然,做这个是为了铺垫 useToast,它也需要具备 Toast.show() 的用法

show 的时候 ReactDOM.render 过程

芝麻开门,显示全文!

搞轮子:TabBar标签栏的自救

说到 TabBar 标签栏,我们自然会想到放在 App 底部的标签栏。同时,因为之前开发 Tabs 标签页时,曾经写过一篇文章( 组件开发:从业务到组件Tabs ),讲诉开发 Tabs 的问题和困难。现在遇到的 TabBar 与之大差不差,为什么还要写一篇呢?

原因有二:一是为回顾知识,二是 TabBar 有必要写的知识点

无论是 Tabs 还是 TabBar 组件,都采用父组件控制标签和改变标签,子项提供具体内容的形式

// Tabs
<Tabs value={value}
    onChange={(index) => {
        index && setValue(index);
    }}
    >
    <Tabs.Panel title="标签1">内容 1</Tabs.Panel>
    <Tabs.Panel title="标签2">内容 2</Tabs.Panel>
    <Tabs.Panel title="标签3">内容 3</Tabs.Panel>
</Tabs>
// TabBar
<TabBar
    activeKey={activeKey}
    onChange={(key: any) => {
        setActiveKey(key);
    }}
    >
    <TabBar.Item
        itemKey="home"
        title="主页"
        icon={<IconTabbarHome />}
        />
    <TabBar.Item
        itemKey="financial"
        title="理财"
        icon={<IconTabbarFinancial />}
        />
    <TabBar.Item
        itemKey="user"
        title="我的"
        icon={<IconTabbarUser />}
        />
</TabBar>

思绪源码

他们都用了 React.Children ,这是我们要回顾的。

官网:React.Children 提供了用于处理 this.props.children 不透明数据结构的实用方法。

而 TabBar 不同的是,他多了 React.cloneElementReact.isValidElement

React.cloneElement 介绍

React.cloneElement(element, [config], [...children]);

官网:以 element 元素为样板克隆并返回新的 React 元素,config 中应包含新的 props,key 或 ref 。返回元素的 props 是将新的 props 与原始元素的 props 浅层合并后的结果。新的子元素将取代现有的子元素,如果在 config 中未出现 key 或 ref,那么原始元素的 key 和 ref 将被保留

简单来说,他是个拷贝API,一般与 React.Children 配合,在原 children 上加上其他属性

React.isValidElement 介绍

React.isValidElement(object);

官网:验证对象是否为 React 对象,返回值为 true 或 false

回过头看 TabBar。我们要做的是,不仅要赋予 TabBar.Item 本来就要有的属性,而且要多给它几个属性,如:

  • selected:判断它是否被选中。因为 TabBar 控制选择的 key
  • onChange:点击后的回调,这里要做”是否能切换标签“的判断,所以要处理

结合三个 React 的顶层 API 后的代码:

...
const items = React.Children.map(children, (element, index) => {
    if (!React.isValidElement(element)) return null;
    return cloneElement(element, {
        key: index,
        title: element.props.title,
        icon: element.props.icon,
        itemKey: element.props.itemKey || index,
        className: element.props.className,
        style: element.props.style,
        selected: getSelected(index, element.props.itemKey),
        onChange: () => onChildChange(element.props.itemKey),
    });
});
...

具体代码可以去看 jing-ui 的源代码,这里做分析,先把它所有的子项都遍历,判断它是否是 React 元素,如果不是,返回 null,如果是拷贝子项原来的数据,并给予两个新的 props。其中 getSelected 代码如下:

const getSelected = (index: number, itemKey: string | number) => {
  if (!activeKey) {
    if (!defaultActiveKey && index === 0) {
      return true;
    }
    return defaultActiveKey === itemKey;
  }
  return activeKey === itemKey;
};

传入当前子项的索引值以及子项(TabBar.Item)的 itemKey 值,判断 TabBar 的 属性 activeKey 是否不存在,再判断 defaultActiveKey是否有或者索引值是否为0,如果都满足,说明默认选中的key没有,那就赋予第一个子项选中;如果 defaultActiveKey 有,就与 itemKey 匹配;再如果 activeKey 存在,就让它与 itemKey 匹配。其实看代码就能明白

onChildChange 代码:

const onChildChange = (value: string | number) => {
  if (isFunction(beforeChange)) {
    let canClick = beforeChange(value);
    if (canClick) {
      canClick.then(() => {
        if (typeof onChange === "function") {
          onChange(value);
        }
      });
    }
    return;
  }
  if (isFunction(onChange)) {
    onChange(value);
  }
};

beforeChange 属性指:切换标签前的回调函数,返回 false 课阻止切换

安全区域的解决

两种方案

一是塞 padding-bottom 样式

  • .jing-safe-area-bottom {
      padding-bottom: constant(safe-area-inset-bottom);
      padding-bottom: env(safe-area-inset-bottom);
    }
    
  • 这个方法在 TabBar 上无效,因为 TabBar 注定是底部固定的

二是包个容器,给它一个 height

  • .iphonex-extra-height {
      height: constant(safe-area-inset-bottom);
      height: env(safe-area-inset-bottom);
    }
    
  • 这个方法解决了

在 props 上我们相对应提供

  • enableSafeArea:是否开启底部安全区适配,设置 fixed 时默认开启
  • fixed:是否固定在底部,默认固定

具体代码如下:

const enableSafeArea = () => safeAreaInsetBottom ?? fixed;
if (enableSafeArea()) {
    return (
        {/* 提供父容器包裹 */}
        <div className={classnames(`${prefixCls}-container`)}>
            <div className={classnames(prefixCls, className)}>{items}</div>
            <div className="jing-iphonex-extra-height" />
        </div>
    );
}

总结

TabBar 与 Tabs 的不同之处在于,它用了 React.cloneElement,赋予了 (TabBar 中的)children 新的属性,这样我们就能再 TabBar 上控制它是否在切换前使用回调函数,方便我们后续实际业务中的操作

芝麻开门,显示全文!

从 Redux 说起,到手写,再到状态管理

学习一个东西之前,首先在大脑中积累充分的“疑惑感”。即弄清面临的问题到底是什么,再浏览方法本身之前,最好先使劲问问都想到什么方法。一个公认的事实是,你对问题的疑惑越大,在之前做的自己的思考越多,当看到解答之后印象就越深刻

先说结论

  1. Redux 是状态管理库,也是一种架构
  2. Redux 与 React 无关,但它是为了解决 React 组件中状态无法共享而出的一种解决方案
  3. 单纯的 Redux 只是一个状态机, store 中存放了所有的状态 state,要想改变里面的状态 state,只能 dispatch 一个动作
  4. 发出去的 action 需要用 reducer 来处理,传入 state 和 action,返回新的 state
  5. subscribe 方法可以注册回调方法,当 dispatch action 的时候会执行里面的回调
  6. Redux 其实是一个发布订阅模式
  7. Redux 支持 enhancer,enhancer 其实就是一个装饰器模式,传入当前的 createStore,返回一个增强的 createStore
  8. Redux 使用 applyMiddleware 函数支持中间件,它的返回值其实就是一个 enhancer
  9. Redux 的中间件也是一个装饰器模式,传入当前的 dispatch,返回一个增强了的 dispatch
  10. 单纯的 Redux 是没有 View 层的

为什么出现 Redux?

我们默认使用 React 技术栈,当页面少且简单时,完全没必要使用 Redux。Redux 的出现,是为了应对复杂组件的情况。即当组件复杂到三层甚至四层时(如下图),组件 4 想改变组件 1 的状态

react 组件树

按照 React 的做法,状态提升,将状态提升至同一父组件(在图中为祖父组件)。但层级一多,根组件要管理的 state 就很多了,不方便管理。

所以当初有了 context(React 0.14 确定引入),通过 context 能实现”远房组件“的数据共享。但它也有缺点,使用 context 意味着所有的组件都可以修改 context 里面的状态,就像谁都可以修改共享状态一样,导致程序运行的不可预测,这不是我们想要的

facebook 提出了 Flux 解决方案,它引入了单向数据流的概念(没错,React 没有单向数据流的概念,Redux 是集成了 Flux 的单向数据流理念),架构如下图所示:

Flux 流程图

这里不表 Flux。简单理解,在 Flux 架构中,View 要通过 Action (动作)通知 Dispatcher(派发器),Dispatcher 来修改 Store,Store 再修改 View

Flux 的问题或者说缺点在哪?

store 之间存在依赖关系、难以进行服务器端渲染、 stores 混杂了逻辑和状态

笔者在学习的 React 技术栈时是 2018 年,那是已然流行 React + Redux 的解决方案,Flux 已经被淘汰了,了解 Flux 是为了引出 Redux

Redux 的出现

Redux 主要解决状态共享问题

官网:Redux 是 JavaScript 状态容器,它提供可预测的状态管理

它的作者是 Dan Abramov

其架构为:

Redux 流程图

可以看得出,Redux 只是一个状态机,没有 View 层。其过程可以这样描述:

  • 自己写一个 reducer(纯函数,表示做什么动作会返回什么数据)
  • 自己写一个 initState(store 初始值,可写可不写)
  • 通过 createStore 生成 store,此变量包含了三个重要的属性
    • store.getState:得到唯一值(使用了闭包老哥)
    • store.dispatch:动作行为(改变 store 中数据的唯一指定属性)
    • store.subscribe:订阅(发布订阅模式)
  • 通过 store.dispatch 派发一个 action
  • reducer 处理 action 返回一个新的 store
  • 如果你订阅过,当数据改变时,你会收到通知

按照行为过程,我们可手写一个 Redux,下文在表,先说特点

三大原则

  • 单一数据源
    • 整个应用的 全局 state 被储存在一棵 object tree 中,并且这个 object tree 只存在于唯一一个 store 中
  • State 是只读的
    • 唯一改变 state 的方法就是触发 action,action 是一个用于描述已发生时间的普通对象
  • 使用纯函数来执行修改
    • 为了描述 action 如何改变 state tree,你需要编写纯的 reducers

三大原则是为了更好地开发,按照单向数据流的理念,行为变得可回溯

让我们动手写一个 Redux 吧

手写 redux

按照行为过程和原则,我们要避免数据的随意修改、行为的可回溯等问题

基础版:23 行代码让你使用 redux

export const createStore = (reducer, initState) => {
  let state = initState;
  let listeners = [];

  const subscribe = (fn) => {
    listeners.push(fn);
  };

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach((fn) => fn());
  };

  const getState = () => {
    return state;
  };

  return {
    getState,
    dispatch,
    subscribe,
  };
};

搞个测试用例

import { createStore } from "../redux/index.js";

const initState = {
  count: 0,
};

const reducer = (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {
        ...state,
        count: state.count + 1,
      };
    case "DECREMENT":
      return {
        ...state,
        count: state.count - 1,
      };
    default:
      return state;
  }
};

const store = createStore(reducer, initState);

store.subscribe(() => {
  let state = store.getState();
  console.log("state", state);
});

store.dispatch({
  type: "INCREMENT",
});

PS:俺是在 node 中使用 ES6 模块,需要升级 Node 版本至 13.2.0

第二版:难点突破:中间件

普通的 Redux 只能做最基础地根据动作返回数据,dispatch 只是一个取数据的命令,例如:

dispatch({
  type: "INCREMENT",
});
// store 中的 count + 1

但在开发中,我们有时候要查看日志、异步调用、记录日常等

怎么办,做成插件

在 Redux 中,类似的概念叫中间件

中间件

Redux 的 createStore 共有三个参数

createStore([reducer], [initial state], [enhancer]);

第三个参数为 enhancer,意为增强器。它的作用就是代替普通的 createStore,转变成为附加上中间件的 createStore。打几个比方:

  • 托尼·斯塔克本来是一个普通有钱人,加上增强器(盔甲)后,成了钢铁侠
  • 中央下发一笔救灾款,加上增强器(大小官员的打点)后,到灾民手上的钱只有一丢丢
  • 路飞用武装色打人,武装色就是一个中间件

enhancer 要做的就是:东西还是那个东西,只是经过了一些工序,加强了它。这些工序由 applyMiddleware 函数完成。按照行业术语,它是一个装饰器模式。它的写法大致是:

applyMiddleware(...middlewares);
// 结合 createStore,就是
const store = createStore(reudcer, initState, applyMiddleware(...middlewares));

所以我们需要先对 createStore 进行改造,判断当有 enhancer 时,我们需传值给中间件

export const createStore = (reducer, initState, enhancer) => {
    if (enhancer) {
        const newCreateStore = enhancer(createStore)
        return newCreateStore(reducer, initState)
    }

	let state = initState;
    let listeners = [];
    ...
}

如果有 enhancer 的话,先传入 createStore 函数,生成的 newCreateStore 和原来的 createStore 一样,会根据 reducer, initState 生成 store。可简化为:

if (enhancer) {
  return enhancer(createStore)(reducer, initState);
}

PS:为什么要写成这样,因为 redux 是按照函数式写法来写的

为什么 createStore 可以被传值,因为函数也是对象,也可以作为参数传递(老铁闭包了)

这样我们的 applyMiddleware 自然就明确了

const applyMiddleware = (...middlewares) => {
    return (oldCreateStore) => {
        return (reducer, initState) => {
            const store = oldCreateStore(reducer, initState)
            ...
        }
    }
}

这里的 store 表示的是普通版中的 store,接下来我们要增强 store 中的属性

我愿称之为:五行代码让女人为我花了 18 万

export const applyMiddleware = (...middlewares) => {
  return (oldCreateStore) => {
    return (reducer, initState) => {
      const store = oldCreateStore(reducer, initState);
      // 以下为新增
      const chain = middlewares.map((middleware) => middleware(store));
      // 获得老 dispatch
      let dispatch = store.dispatch;
      chain.reverse().map((middleware) => {
        // 给每个中间件传入原派发器,赋值中间件改造后的dispatch
        dispatch = middleware(dispatch);
      });
      // 赋值给 store 上的 dispatch
      store.dispatch = dispatch;
      return store;
    };
  };
};

现在写几个中间件来测试一下

// 记录日志
export const loggerMiddleware = (store) => (next) => (action) => {
  console.log("this.state", store.getState());
  console.log("action", action);
  next(action);
  console.log("next state", store.getState());
};

// 记录异常
export const exceptionMiddleware = (store) => (next) => (action) => {
  try {
    next(action);
  } catch (error) {
    console.log("错误报告", error);
  }
};

// 时间戳
export const timeMiddleware = (store) => (next) => (action) => {
  console.log("time", new Date().getTime());
  next(action);
};

引入项目中,并运行

import { createStore, applyMiddleware } from "../redux/index.js";
import {
  loggerMiddleware,
  exceptionMiddleware,
  timeMiddleware,
} from "./middleware.js";

const initState = {
  count: 0,
};

const reducer = (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {
        ...state,
        count: state.count + 1,
      };
    case "DECREMENT":
      return {
        ...state,
        count: state.count - 1,
      };
    default:
      return state;
  }
};

const store = createStore(
  reducer,
  initState,
  applyMiddleware(loggerMiddleware, exceptionMiddleware, timeMiddleware)
);

store.subscribe(() => {
  let state = store.getState();
  console.log("state", state);
});

store.dispatch({
  type: "INCREMENT",
});

运行发现已经实现了 redux 最重要的功能——中间件

测试代码

来分析下中间件的函数式编程,以 loggerMiddleware 为例:

export const loggerMiddleware = (store) => (next) => (action) => {
  console.log("this.state", store.getState());
  console.log("action", action);
  next(action);
  console.log("next state", store.getState());
};

在 applyMiddleware 源码中,

const chain = middlewares.map((middleware) => middleware(store));

相当于给每个中间件传值普通版的 store

let dispatch = store.dispatch;
chain.reverse().map((middleware) => (dispatch = middleware(dispatch)));

相当于给每个中间件在传入 store.dispatch,也就是 next,原 dispatch = next。这个时候的中间件已经本成品了,代码中的 (action) => {...} 就是函数 const dispatch = (action) => {}。当你执行 dispatch({ type: XXX }) 时执行中间件这段(action) => {...}

PS:柯里化一开始比较难理解,用多习惯就慢慢能懂

第三版:结构复杂化与拆分

中间件理解起来或许有些复杂,先看看其他的概念换换思路

一个应用做大后,单靠一个 JavaScript 文件来维护代码显然是不科学的,在 Redux 中,为避免这类情况,它提供了 combineReducers 来整个多个 reducer,使用方法如:

const reducer = combinReducers({
  counter: counterReducer,
  info: infoReducer,
});

combinReducers 中传入一个对象,什么样的 state 对应什么样的 reducer。这就好了,那么 combinReducers 怎么实现呢?因为比较简单,不做多分析,直接上源码:

export const combinReducers = (...reducers) => {
  // 拿到 counter、info
  const reducerKey = Object.keys(reducers);
  // combinReducers 合并的是 reducer,返回的还是一个 reducer,所以返回一样的传参
  return (state = {}, action) => {
    const nextState = {};
    // 循环 reducerKey,什么样的 state 对应什么样的 reducer
    for (let i = 0; i < reducerKey.length; i++) {
      const key = reducerKey[i];
      const reducer = reducers[key];
      const previousStateForKey = state[key];
      const nextStateForKey = reducer(previousStateForKey, action);
      nextState[key] = nextStateForKey;
    }
    return nextState;
  };
};

同级目录下新建一个 reducer 文件夹,并新建 reducer.jsinfo.jsindex.js

// reducer.js
export default (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {
        count: state.count + 1,
      };
    case "DECREMENT": {
      return {
        count: state.count - 1,
      };
    }
    default:
      return state;
  }
};
// info.js
export default (state, action) => {
  switch (action.type) {
    case "SET_NAME":
      return {
        ...state,
        name: action.name,
      };
    case "SET_DESCRIPTION":
      return {
        ...state,
        description: action.description,
      };
    default:
      return state;
  }
};

合并导出

import counterReducer from "./counter.js";
import infoReducer from "./info.js";

export { counterReducer, infoReducer };

我们现在测试一下

import {
  createStore,
  applyMiddleware,
  combinReducers,
} from "../redux/index.js";
import {
  loggerMiddleware,
  exceptionMiddleware,
  timeMiddleware,
} from "./middleware.js";
import { counterReducer, infoReducer } from "./reducer/index.js";

const initState = {
  counter: {
    count: 0,
  },
  info: {
    name: "johan",
    description: "前端之虎",
  },
};

const reducer = combinReducers({
  counter: counterReducer,
  info: infoReducer,
});

const store = createStore(
  reducer,
  initState,
  applyMiddleware(loggerMiddleware, exceptionMiddleware, timeMiddleware)
);

store.dispatch({
  type: "INCREMENT",
});

combinReducers 也完成了

测试代码

既然拆分了 reducer,那么 state 是否也能拆分,并且它是否需要传,在我们平时的写法中,一般都不传 state。这里需要两点改造,一是每个 reducer 中包含了它的 state 和 reducer;二是改造 createStore,让 initState 变得可传可不传,以及初始化数据

// counter.js 中写入对应的 state 和 reducer
let initState = {
  counter: {
    count: 0,
  },
};

export default (state, action) => {
  if (!state) {
    state = initState;
  }
  switch (action.type) {
    case "INCREMENT":
      return {
        count: state.count + 1,
      };
    case "DECREMENT": {
      return {
        count: state.count - 1,
      };
    }
    default:
      return state;
  }
};
// info.js
let initState = {
  info: {
    name: "johan",
    description: "前端之虎",
  },
};

export default (state, action) => {
  if (!state) {
    state = initState;
  }
  switch (action.type) {
    case "SET_NAME":
      return {
        ...state,
        name: action.name,
      };
    case "SET_DESCRIPTION":
      return {
        ...state,
        description: action.description,
      };
    default:
      return state;
  }
};

改造 createStore

export const createStore = (reducer, initState, enhancer) => {

    if (typeof initState === 'function') {
        enhancer = initState;
        initState = undefined
    }
    ...
    const getState = () => {
        return state
    }
	// 用一个不匹配任何动作来初始化store
    dispatch({ type: Symbol() })

    return {
        getState,
        dispatch,
        subscribe
    }
}

主文件中

import { createStore, applyMiddleware, combinReducers } from "./redux/index.js";
import {
  loggerMiddleware,
  exceptionMiddleware,
  timeMiddleware,
} from "./middleware.js";
import { counterReducer, infoReducer } from "./reducer/index.js";

const reducer = combinReducers({
  counter: counterReducer,
  info: infoReducer,
});

const store = createStore(
  reducer,
  applyMiddleware(loggerMiddleware, exceptionMiddleware, timeMiddleware)
);

console.dir(store.getState());

到此为止,我们已经实现了一个七七八八的 redux 了

完整体的 Redux

退订

const subscribe = (fn) => {
  listeners.push(fn);
  return () => {
    const index = listeners.indexOf(listener);
    listeners.splice(index, 1);
  };
};

中间件拿到的 store

现在的中间件能拿到完整的 store,他甚至可以修改我们的 subscribe 方法。按照最小开放策略,我们只用给 getState 即可,修改下 applyMiddleware 中给中间件传的 store

// const chain = middlewares.map(middleware => middleware(store))
const simpleStore = { getState: store.getState };
const chain = middlewares.map((middleware) => middleware(simpleStore));

compose

在我们的 applyMiddleware 中,把 [A, B, C] 转换成 A(B(C(next))),效果是:

const chain = [A, B, C];
let dispatch = store.dispatch;
chain.reverse().map((middleware) => {
  dispatch = middleware(dispatch);
});

Redux 提供了一个 compose ,如下

const compose = (...funcs) => {
  if (funcs.length === 0) {
    return (args) => args;
  }
  if (funcs.length === 1) {
    return funcs[0];
  }
  return funcs.reduce(
    (a, b) =>
      (...args) =>
        a(b(...args))
  );
};

2 行代码 replaceReducer

替换当前的 reudcer ,使用场景:

  • 代码分割
  • 动态加载
  • 实时 reloading 机制
const replaceReducer = (nextReducer) => {
  reducer = nextReducer;
  // 刷新一次,广播 reducer 已经替换,也同样把默认值换成新的 reducer
  dispatch({ type: Symbol() });
};

bindActionCreators

bindActionCreators 是做什么的,他通过闭包,把 dispatch 和 actionCreator 隐藏起来,让其他地方感知不到 redux 的存在。一般与 react-redux 的 connect 结合

这里直接贴源码实现:

const bindActionCreator = (actionCreator, dispatch) => {
  return function () {
    return dispatch(actionCreator.apply(this, arguments));
  };
};

export const bindActionCreators = (actionCreators, dispatch) => {
  if (typeof actionCreators === "function") {
    return bindActionCreator(actionCreators, dispatch);
  }

  if (typeof actionCreators !== "object" || actionCreators === null) {
    throw new Error();
  }

  const keys = Object.keys(actionCreators);
  const boundActionCreators = {};
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    const actionCreator = actionCreators[key];
    if (typeof actionCreator === "function") {
      boundActionCreators[key] = bindActionCreator(actionCreator, dispatch);
    }
  }
  return boundActionCreators;
};

以上,我们就已经完成了 Redux 中所有的代码。大体上这里 100 多行的代码就是 Redux 的全部,真 Redux 无非是加了些注释和参数校验

总结

我们把与 Redux 相关的名词列出来,梳理它是做什么的

  • createStore
    • 创建 store 对象,包含 getState、dispatch、subscribe、replaceReducer
  • reducer
    • 纯函数,接受旧的 state、action,生成新的 state
  • action
    • 动作,是一个对象,必须包括 type 字段,表示 view 发出通知告诉 store 要改变
  • dispatch
    • 派发,触发 action ,生成新的 state。是 view 发出 action 的唯一方法
  • subscribe
    • 订阅,只有订阅了,当派发时,会执行订阅函数
  • combineReducers
    • 合并 reducer 成一个 reducer
  • replaceReudcer
    • 代替 reducer 的函数
  • middleware
    • 中间件,扩展 dispatch 函数

砖家曾经画过一张关于 Redux 的流程图

流程图

换种思考方式理解

我们说过, Redux 只是一个状态管理库,它是由数据来驱动,发起 action,会引发 reducer 的数据更新,从而更新到最新的 store

与 React 结合

拿着刚做好的 Redux,放到 React 中,试试什么叫 Redux + React 集合,注意,这里我们先不使用 React-Redux,单拿这两个结合

先创建项目

npx create-react-app demo-5-react

引入手写的 redux 库

App.js 中引入 createStore,并写好初始数据和 reducer,在 useEffect 中监听数据,监听好之后当发起一个 action 时,数据就会改变,看代码:

import React, { useEffect, useState } from "react";
import { createStore } from "./redux";
import "./App.css";

const initState = {
  count: 0,
};

const reducer = (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {
        ...state,
        count: state.count + 1,
      };
    case "DECREMENT":
      return {
        ...state,
        count: state.count - 1,
      };
    default:
      return state;
  }
};

const store = createStore(reducer, initState);

function App() {
  const [count, setCount] = useState(store.getState().count);

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setCount(store.getState().count);
    });
    return () => {
      if (unsubscribe) {
        unsubscribe();
      }
    };
  }, []);

  const onHandle = () => {
    store.dispatch({
      type: "INCREMENT",
    });
    console.log("store", store.getState().count);
  };
  return (
    <div className="App">
      <div>{count}</div>
      <button onClick={onHandle}>add</button>
    </div>
  );
}

export default App;

点击 button 后,数据跟着改变

效果图

PS:虽然我们可以用这种方式订阅 store 和改变数据,但是订阅的代码重复过多,我们可以用高阶组件将他提取出去。这也是 React-Redux 所做的事情

与原生 JS+HTML 结合

我们说过,Redux 是个独立于 Redux 的存在,它不仅可在 Redux 充当数据管理器,还可以在原生 JS + HTML 中充当起职位

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div class="container">
      <div id="count">1</div>
      <button id="btn">add</button>
    </div>
    <script type="module">
      import { createStore } from "./redux/index.js";

      const initState = {
        count: 0,
      };

      const reducer = (state, action) => {
        switch (action.type) {
          case "INCREMENT":
            return {
              ...state,
              count: state.count + 1,
            };
          case "DECREMENT":
            return {
              ...state,
              count: state.count - 1,
            };
          default:
            return state;
        }
      };

      const store = createStore(reducer, initState);

      let count = document.getElementById("count");
      let add = document.getElementById("btn");
      add.onclick = function () {
        store.dispatch({
          type: "INCREMENT",
        });
      };
      // 渲染视图
      function render() {
        count.innerHTML = store.getState().count;
      }
      render();
      // 监听数据
      store.subscribe(() => {
        let state = store.getState();
        console.log("state", state);
        render();
      });
    </script>
  </body>
</html>

效果如下:

效果图

状态生态

我们从 Flux 说到 Redux,再从 Redux 说了各种中间件,其中 React-saga 就是为解决异步行为而生的中间件,它主要采用 Generator(生成器)概念,比起 React-thunk 和 React-promise,它没有像其他两者将异步行为放在 action creator 上,而是把所有的异步操作看成“线程”,通过 action 触发它,当操作完成后再次发出 action 作为输出

function* helloWorldGenerator() {
  yield "hello";
  yield "world";
  yield "ending";
}

const helloWorld = helloWorldGenerator();

hewlloWorld.next(); // { value: 'hello', done: false }
hewlloWorld.next(); // { value: 'world', done: false }
hewlloWorld.next(); // { value: 'ending', done: true }
hewlloWorld.next(); // { value: undefined, done: true }

简单来说:遇到 yield 表达式,就暂停执行后面的操作,并将紧跟 yield 后面的那个表达式的值,作为返回值 value,等着下一个调用 next 方法,再继续往下执行

Dva

Dva 是什么?

官网:Dva 首先是一个基于 Redux + Redux-saga 的数据流方案。为了简化开发体验,Dva 还额外内置了 react-router 和 fetch,所以可以理解为一个轻量级的应用框架

简单来说,它是整合了现在最流行的数据流方案,即一个 React 技术栈:

dva = React-Router + Redux + Redux-saga + React-Redux

它的数据流图为:

Dva 流程图

view dispatch 一个动作,改变 state(即 store),state 与 view 绑定,响应 view

其他不表,可去 Dva 官网查看,这里讲讲 Model ,它包含了 5 个属性

  • namespace
    • model 的命名空间,同时也是他在全局 state 上的属性,只能用字符串,不支持通过 . 的方式创建多层命名空间
  • state
    • 初始值
  • reducers
    • 纯函数,以 key/value 格式定义 reducer。用于处理同步擦做,唯一可以修改 state 的地方,由 action 触发
    • 格式为:(state, action) => newState[(state, action) => newState, enhancer]
  • effects
    • 处理异步操作和业务逻辑,以 key/value 格式定义 effect
    • 不直接修改 state。由 action 触发
    • call:执行异步操作
    • put:发出一个 Action,类似于 dispatch
  • subscriptions
    • 订阅
    • app.start() 时被执行,数据源可以是当前的时间、服务器的 websocket 链接、 keyboard 输入、history 路由变化、geolocation 变化等等

Mobx

View 通过订阅也好,监听也好,不同的框架有不同的技术,总之 store 变化, view 也跟着变化

Mobx 使用的是响应式数据流方案。后续会单独写一篇,此篇太长,先不写

补充:单向数据流

先介绍 React 中数据传递,即通信问题

  • 向子组件发消息
  • 向父组件发消息
  • 向其他组件发消息

React 只提供了一种通信方式:传参。

即父传值给子,子不能修改父传的数据,props 具有不可修改性。子组件想把数据传给父组件怎么办?通过 props 中的事件来传值通知父组件

仓库地址:https://github.com/johanazhu/jo-redux

芝麻开门,显示全文!

三句话测试你是否懂git

同事和组长的一番对话引起了笔者对 git 的思考

先介绍一下我司小工坊式的 git 提交流程,本地打包,删除 dist 文件,重建 dist 文件,git add .git commit -m 'XX'git push origin 分支名

和传统公司的 git 提交不同,我司打包是本地打包,而且是把 dist 文件直接上传到仓库

事故现象

同事把代码推上去后,浏览器访问的还是原来的 js 和 css。

同事说:组长,需要你把 dist 删掉,重新再从仓库里拉一下最新的

组长:git 提交后不就把原来的 dist 替换了吗,你让我删 dist 有什么意义

扯皮了一会儿,组长还是删了然后重新拉,没想到好了

组长说:你的 dist 现在是最新的,所以现在就好了

同事具体说了什么笔者忘记了,大致上在辩护 git 提交不会把原来的 dist 文件删除问题,不过他没说服组长,组长也没说服他,反正已经安全上线而不了了之。

我正好在旁边听到了,要是两年前我也许会一直提出问题参与辩论,申援同事。但笔者没动,不是怕 PUA,而是表达能力太差,即使是对的,也说不好。其根本原因是笔者对这块知识了解的不深刻,所以不敢说大话

理论知识

按照理论知识,你 push 整个 dist 文件,即使远程仓库中有 dist,也不会把整个 dist 文件夹替换,只会替换其中相同的数据,而因为打了 hash 值,所以 css 和 js 都是不同的,所以一直这样做,dist 中的文件会越来越多,而因为 index.html 文件只有一个,所以不会出现替换了还引用之前文件的问题,如果出现,清除下浏览器的缓存就能解决

实战检验

因为生产环境和测试环境发布代码流程不同,所以先要把环境配置成一致先

需要做的事情很简单,把 nginx 中指向仓库地址,到时候从远端拉下代码即可

先修改 nginx 中的配置

server {
    listen 7000;
    # root /usr/share/nginx/html/dist
    root /home/xxx/dist
    ...
}

再检查一下 nginx 配置是否 ok

nginx -t

接着重启 nginx

nginx -s reload

接着把代码提交到远端仓库,再上服务器进入 /home/xxx 目录下,git pull origin XX ,进入 dist 文件,查看打包后的 js

原js的hash值

我们修改在项目中打印一些日志,表示文件改动,这样 build 之后会打出不同 hash 的 js

git push origin XX

再次登录服务器,进入 /home/xxx 目录,再拉代码git pull origin xx

再次提交后的代码

发现,umi.b0f5511b.js 被删掉了,新生成的 umi.f8280c0e.js 在其中,dist 中是干净的源文件,这是为什么呢?

你 build 之后,是先删掉 dist 文件,生成的是一个干净的 dist,然后我的操作是:

  • git add .
  • git commit -m ‘XX’
  • git push origin ‘XX 分支’

我的操作中没有 pull 代码,而是直接 push 代码,这就意味着 dist 就是我本地的 dist,而非合并之后的

想想这种做法的缺点是多人开发时,pull 别人的代码后,merge 之后还要重新 build,才能再次提交

好险,还好没有逞英雄

谨言慎行是一辈子的学问

三句话测试你是否懂 git

这触发了笔者对 git 的新认知,结合平时经验,笔者觉得三个问题能测试别人对 git 的理解程度

  1. 你和同事基于同一 commit 开发,后续合并时,如何按照时间顺序显示提交记录
    • git rebase master XX(分支)
    • 获得更优雅的提交树
  2. 代码如何回滚
    • git reset —hard XX
    • 把当前代码指向另一个 commit 上
  3. 你开发代码,提交了好几个 commit,后续使用 git reset --hard xxxxx 把代码指针指回原始 commit ,并在这个 commit 上开发了一个功能,并提交了一个 commit,怎么找回之前提交的那好几个 commit
    • 首先使用 git reflog ,它能展示你之前所有的 git 操作
      • 比较 git log,它不仅包括了 git log 上的操作,而且它记录了被删除的 commit 记录和 reset 操作
    • git reset --hard XX
      • 将 git 指针指向回到原始代码前的那个 commit
    • git cherry-pick XX
      • 合并二次开发时的 commit
      • cherry-pick 意为取出,将二次开发时的 commit 取出放入主分支上

芝麻开门,显示全文!