Coder Social home page Coder Social logo

blog's People

Contributors

jaredcen avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

blog's Issues

关于 Javascript 尾调用优化的错误论证

最近在研究 JS 调用栈原理时,发现网上用于说明尾调用优化的部分例程不太恰当,缺乏说服力,接下来我会用大量的例子来印证我的看法。先容我简单介绍下相关概念知识。

调用栈( call stack

JS 代码在执行时都有其所在的执行上下文,而调用栈就是用于管理这些执行上下文的(每个压入调用栈的执行上下文也称为 调用帧),其管理流程大致如下:

  1. 首先,JS 引擎在开始解释执行代码时会先创建一个全局执行上下文并压入调用栈中;
  2. 每当有函数被调用时,引擎会为该函数创建一个新的执行上下文并压入调用栈的栈顶,该函数执行完成后,其对应的执行上下文就会从调用栈中弹出;
  3. 整个 JS 程序执行结束后,全局执行上下文弹出,调用栈被清空;
// call stack demo
function fa(x) {
  return x;
}

function fb(x) {
  const b = 1;
  return fa(x) + b;
}

console.log(fb(5));

fb(5);    // 6

call stack demo 调用栈操作

PS: 对于每个执行上下文,都包含三个重要属性: 变量对象作用域链this,详细分析请看 JavaScript深入之执行上下文

尾调用及尾调用优化

尾调用就是某个函数的最后一步操作是调用另一个函数并返回。想仔细了解哪些表达式和写法属于尾调用,可以看看这篇 Tail call optimization in ECMAScript 6

我们改造一下上面的 demo 使其属于尾调用:

// call stack demo 的尾调用改造
function fa(x, y) {
  return x + y;
}

function fb(x) {
  const b = 1;
  return fa(x, b);    // 
}

console.log(fb(5));

由于 fa 函数执行所需要的 xb 变量都已传递给 fa 的执行上下文,不需要沿着作用域链在 fb 的执行上下文中查找任何变量值;另外 fb 函数的最后一步操作仅仅是返回 fa 函数的返回值,完全可以把 fb 的返回位置直接设为 fa 的返回位置,从而在调用 fa 时就弹出 fb 的执行上下文,这就是 尾调用优化。可见尾调用优化是需要 JS 引擎配合进行的。

尾调用改造后的调用栈操作

尾调用优化可以使递归产生的调用栈零增长,避免出现调用栈过长占用太多内存甚至堆栈溢出的问题。

尾递归论证误区

尾递归就是尾调用函数本身。网上经常使用斐波拉契数列的例子来说明尾递归:

// 非尾递归的斐波拉契数列
function fib(n) {
  return n <= 1 ? 1 : fib(n - 1) + fib(n - 2);
}

fib(100);    // 真的会堆栈溢出么?

事实是,chrome 控制台并没有扔出堆栈溢出的错误,而是程序一直在执行直至页面无响应!。我们来单步 debug 看看 fib函数的执行流程,以 fib(4) 为例:

如图,fib 函数的所有执行上下文可以绘成一颗二叉树,且执行顺序按照二叉树的先序遍历,根据调用栈的原理,**fib 函数的部分执行上下文在执行过程中就已经从调用栈中弹出!**譬如,在 fib(0) 叶子结点执行完成后,fib(0)fib(2) 都会被弹出调用栈,所以该递归在 n = 100 的量级不会导致堆栈溢出,只会因为函数调用次数过多而程序无响应。

我们看看 chrome 浏览器大概的调用栈长度是多少:

// 获取调用栈深度
function getDeep() {
  try {
    return 1 + getDeep();
  } catch(e) {
    return 1;
  }
}

getDeep();    // 12507

fib(40) 的时候 fib 函数到底被调用了多少次呢?

// 计算fib执行次数
let count = 0;
function fib(n) {
  count++;
  return n <= 1 ? 1 : fib(n - 1) + fib(n - 2);
}

fib(40);    // 165580141
console.log(count);    // 331160281

结果是 fib(40) 能成功地执行完,花费了 1864ms。如果 fib 函数的执行上下文是线性关系的话,按照这个调用次数应该早已经堆栈溢出了;另外,fib(40) 的结点数已经达到了 331160281 个,根据二叉树的特性,fib(100) 的结点数将会非常庞大,程序超时也是理所当然了。

那么按照 12507 的堆栈来算,n 为多少时 fib 函数会堆栈溢出呢?可见fib(n) 的二叉树深度为 n,所以 n >= 12507 时就会直接堆栈溢出。其实由于 fib 执行上下文中需要储存一定的变量对象和作用域链,以及受到 JS 内存回收周期的影响,n = 11000 时就已经堆栈溢出了。

因此,斐波那契数列的例子用于说明尾递归并没有代表性,甚至还会造成一定的干扰。有人或许会问,怎么解释斐波拉契数列的尾递归写法确实能优化执行效率?其实只是其尾递归写法大大减少了函数的调用次数,从而避免程序超时而已,本应该堆栈溢出的还是得堆栈溢出......

// 尾递归的斐波拉契数列
let count = 0;
function fib(n, ac1 = 1, ac2 = 1) {
 count++;
 return n <= 1 ? ac2 : fib(n - 1, ac2, ac1 + ac2);
}


try {
 fib(40);    // 165580141
 console.log(count);    // 40
} catch(e) {
 console.log(count);
}

try {
 fib(10000);    // 堆栈溢出  
 console.log(count);
} catch(e) {
 console.log(count);    // 7339
}

同样是 n = 40,非尾递归版本的 fib 函数被调用了 331160281 次,而尾递归版本只被调用了 40 次,对于程序执行效率的优化不言而喻了;但 n = 10000时,依然是 Maximum call stack size exceeded

PTC(proper tail calls)现状

我们看看各平台对于尾调用优化的支持程度

桌面浏览器除了 Safari 之外一致飘红,我们在 Safari 中跑一下这个尾递归demo:

// 尾调用优化只会出现在严格模式中
'use strict';

function factorial(n, r = 1) {
  return n === 1 ? r : factorial(n - 1, n * r);
}

factorial(100000);    // Infinity

我们看看 Safari 控制台的 Call Stack 信息:

可以看出断点之前创建的调用帧的状态都已被置灰,也就是已经被弹出调用栈,所以说引擎是有进行尾递归优化的。

PS:Safari引擎在这里应该使用了影子堆栈(shadow stack)来恢复已被删除的调用帧。

PTC 带来的问题

实际上,V8 引擎已经实现了尾调用优化,但是默认不开启,可以看看 V8 blog 里这篇文章 ES2015, ES2016, and beyond 的解释。V8 的这个做法是有原因的,PTC会带来一些问题:

  1. 由于尾调用优化是隐式的,开发者很难辨别到底哪个函数被尾调用优化了,如果开发者写了一个尾调用的死循环递归,引擎将不会抛出堆栈溢出异常,线程被阻塞。

  2. 调用栈信息丢失,开发者在断点调试时会难以理解不连续的堆栈信息;另外一些通过 Error.stack 或者 console.trace 收集异常的工具收集到的信息量将会非常少,看看下面的demo:

'use strict';    // 非严格模式可以关闭尾调用优化

function fa() {
  throw new Error();
}

function fb() {
  return fa();
}

fb();

没有尾调用优化的堆栈信息

尾调用优化的堆栈信息

Syntactic Tail Calls (STC)

为了解决上述的问题,V8 团队提出了语法级尾调用(STC)的提案,定义一种特殊语法 return continue 来告知引擎是否需要开启尾调用优化。详细提案可以看这里 Syntactic Tail Calls (STC)

不幸的是,该提案目前已经被 TC39 置于 Inactive Proposals 中(提案被遗弃),看来尾调用优化的浏览器实现已经遥遥无期了。

替代方案

递归函数都可以通过循环来实现,但在很多场景下递归改循环并不是一件容易的事,这里提供一种折中方案 蹦床函数

// 蹦床函数
function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

function factorial(n, r = 1) {
  return n === 1 ? r : factorial.bind(null, n - 1, n * r);
}

trampoline(factorial(100000));    // Infinity

蹦床函数其实只是递归改循环的一种跳板工具,需要注意的是,蹦床函数会带来一定的性能额外开销,而且需要对目标函数作侵入式修改,会影响程序可读性。但相较于手动改循环带来的开发难度和 bug 风险,蹦床函数不失为一种很好的选择。

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

Mac 终端设置代理

我们通常使用 ss 来科学上网,但我们在终端中使用 curlwget 等命令获取网络资源时是不会自动生效的,需要手动为终端设置代理。

# socks5 的默认端口为 1080
$ export ALL_PROXY=socks5://127.0.0.1:1080

可以通过 nmap localhost 命令来获取 socks5 的本地端口。

检查代理是否生效:

$ curl cip.cc

IP	: 192.xxx.xxx.xxx
地址	: 美国  加利福尼亚州  费利蒙
运营商	: linode.com

数据二	: 美国 | Linode数据中心

数据三	: 美国加利福尼亚费利蒙

URL	: http://www.cip.cc/192.xxx.xxx.xxx

另外,ALL_PROXY 囊括了 httphttpsftpdns 等协议的代理,我们可以针对某种协议单独设置:

$ export HTTP_PROXY=socks5://127.0.0.1:1080

简化代理操作

通过在 .bashrc 或者 .zshrc 中设置 alias 来注册指令别名,可以简化代理开关的操作:

$ vim ~/.zshrc

添加下面内容:

alias proxy='export ALL_PROXY=socks5://127.0.0.1:1080'
alias unproxy='unset ALL_PROXY'
$ source ~/.zshrc

# 开启代理
$ proxy

# 关闭代理
$ unproxy

PS: 文章首发于 简书 ,欢迎大家关注。

NaN 和 isNaN 的彩蛋

NaN 是一个全局对象的属性(和 Number.NaN 的值一样),表示不是一个数字(Not-A-Number)。

NaN 的产生

以下场景下都有可能产生 NaN 值:

  • 不定式
0/0;
Infinity/Infinity;
0 * Infinity;
Infinity - Infinity;
Math.pow(1, Infinity);
  • 产生复数结果的运算
// 负数开偶次方
Math.sqrt(-1);
  • 类型转换
'a' - 1;
undefined - 1;
parseInt('a1');
parseFloat('a1');

这里需要注意 parseInt()parseFloat() 的特殊性,与 Number() 的整体转换不同,这两者只转换第一个无效字符之前的字符。

parseInt('1a');    // 1
parseInt('a1');    // NaN

isNaN()

日常编码中很少会直接操作 NaN 值,一般会在数值运算出现异常时出现,那么我们如何判断一个值是否为 NaN 呢?

NaN == NaN;     // false
NaN === NaN;    // false

可怕!NaN 自身居然不等于自身(这也是 JS 值中唯一一个如此特殊的)。所以等号运算符是不能正确判断的,只能通过 isNaN() 函数。

然而,isNaN() 函数也并不如其名 —— 判断是否为(数字类型下的)非法数字,更准确的说是 判断一个值在转换成数字类型后是否为非法数字

isNaN('1');      // 1
isNaN([]);       // 0
isNaN(false);    // 0

这是因为,如果 isNaN() 函数的参数不是 Number 类型, isNaN 函数会首先尝试将这个参数转换为数字类型,然后才会对转换后的结果是否是 NaN 进行判断。

这里收集了一些特殊值在转换为数字类型和 NaN 判断时的表现。除了 Number()isNaN() 函数在不传参数的情况下有差异之外,其他表现都是符合预期的。

参数 Number isNaN
0 true
undefined NaN true
null 0 false
true 1 false
false 0 false
'' 0 false
{} NaN true
[] 0 false
[1] 1 false
[1, 2] NaN true

但我们可以通过预判断参数是否为数字类型来优化 isNaN 函数:

var _isNaN = function(v) {
  return typeof v === 'number' && isNaN(v);
}

Number.isNaN()

ES6 提供了 Number​.isNaN() 函数,不会强制将参数转换为字。也就是说,只有在参数为数字类型且值为 NaN 时才会返回 true

Number.isNaN(null);    // false
Number.isNaN('1');     // false

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

Mac/Linux svn 命令行批量处理及自动化部署

Windows 系统下,通常我们使用 TortoiseSVN 来处理 svn 客户端工作流,而 Mac 下第三方 svn GUI 工具一般都需要收费,这里我给大家友情链接一个破解版的 Cornerstone(声称 Mac 上最好用的 svn 客户端),当然更欢迎大家支持正版。

Mac OS 自带了 svn 的服务端和客户端环境,我们完全可以使用 svn 命令行快狠准地解决客户端工作流。

简单粗暴版

假设需要把项目的生产目录文件 distDir/* 转移到 svn 目录 svnDir 并提交到目标服务器,我们可以执行命令:

cd svnDir
svn update
svn delete *
cp -rf distDir/* svnDir
svn add *
svn commit -m "commit info"

这样粗暴覆盖上一个 version,基本能避免冲突,另外不建议用 svn 来处理开发过程中的代码冲突问题。


批量处理

实际上,我们经常会遇到 svn 目录下存在各种不同状态的文件或文件夹,譬如:

$ svn status

M       index.html
!       static/js/0.c1af89c249789814f99b.js
?       static/js/0.cbb2833152f342a96f84.js
!       static/js/1.62e25d5cba0e0b90bbe7.js
?       static/js/1.ae9f8f72ced58a1b92eb.js
?       static/js/2.5b9ab989656feaabcc44.js
!       static/js/2.f852e8b7f0bebe09723e.js
!       static/js/3.1f1f9a6192673180de94.js

PS: 常见的 svnstatus 及其含义

status 含义
M 目标被修改
! 目标遗失或不完整
? 目标未纳入版本控制
A 目标添加
D 目标删除
C 目标冲突

这个时候如果我们执行 svn add * 会失败

$ svn add *

svn: warning: W150002: 'index.html' is already under version control
svn: warning: W150002: 'static' is already under version control
svn: E200009: Could not add all targets because some targets are already versioned
svn: E200009: Illegal target for the requested operation

由于 index.html 已经在版本控制内,是无法被 add 的,所以我们需要把未纳入版本控制的项目筛选出来再添加:

# 筛选出未纳入版本控制的记录
# grep "^?" -- 匹配以`?`开始的行并输出
$ svn st | grep "^?"

?       static/js/0.cbb2833152f342a96f84.js
?       static/js/1.ae9f8f72ced58a1b92eb.js
?       static/js/2.5b9ab989656feaabcc44.js

# 过滤出文件名
# awk '{print $2}' -- 行匹配语句,输出每行第二个字段
$ svn st | grep "^?"| awk '{print $2}'

static/js/0.cbb2833152f342a96f84.js
static/js/1.ae9f8f72ced58a1b92eb.js
static/js/2.5b9ab989656feaabcc44.js

# 把上条命令的输出插入到`svn add`命令的后面即可
$ svn add `svn st | grep "^?"| awk '{print $2}'`

A         static/js/0.cbb2833152f342a96f84.js
A         static/js/1.ae9f8f72ced58a1b92eb.js
A         static/js/2.5b9ab989656feaabcc44.js

PS:awk 是 linux 系统下用于文本和数据处理的一种编程语言,有兴趣的可以看看教程文档

批量添加

svn add `svn st | grep "^?"| awk '{print $2}'`

批量删除

批量删除和批量添加的流程类似,只需要把遗失状态(status == !)的文件名筛选出来并插入到 svn delete 后面即可。

svn delete `svn st | grep ^! | awk '{print $2}'`

自动化

不妨把 svn 客户端工作流编成自动化脚本,实现一键部署。这里我设计了一个简单的脚本程序 demo,接受以下参数配置:

option required param desc
-s PATH true 源目录,通常为项目生产代码所在目录
-d PATH true svn目录
-m INFO false svn的提交信息
# svn-deploy.sh
#!/bin/bash

# 使用`getopts`解析命令行参数
while getopts :s:d:m: opt
do
  case "$opt" in
    s) SRC_DIR=$OPTARG ;; 
    d) SVN_DIR=$OPTARG ;;
    m) COMMIT_INFO=$OPTARG ;;
    *) 
      echo "ERROR: unknown option" 
      exit 0 ;;
  esac
done

# 设置-s,-d参数必填
if [ -z $SRC_DIR ] || [ -z $SVN_DIR ]
  then
  echo "ERROR: options -s, -d is null";
  exit 0;
fi

cd $SVN_DIR
svn update
svn delete *
cp -rf $SRC_DIR/* $SVN_DIR
svn add *
svn commit -m "$COMMIT_INFO"

# # 批量增删版本
# cd $SVN_DIR
# svn update
# rm -rf *
# cp -rf $SRC_DIR/* $SVN_DIR
# svn add `svn st | grep "^?"| awk '{print $2}'`
# svn delete `svn st | grep "^!" | awk '{print $2}'`
# svn commit -m $COMMIT_INFO
# 运行脚本
$ sh ./svn-deploy.sh -s ~/Documents/demo/dist -d ~/Documents/demo/svn -m "提交信息"

我们可以把脚本制作成可执行文件,并放到环境变量中支持全局调用:

$ cat svn-deploy.sh > svn-deploy

# 授予可执行权限
$ chmod +x svn-deploy

# 获取svn-deploy脚本所在目录
$ pwd
/path/to/svn-deploy

# 添加环境变量,往`.bash_profile`文件中添加:
# export PATH=/path/to/svn-deploy:$PATH
$ vim ~/.bash_profile
$ source ~/.bash_profile

接下来就可以全局使用 svn-deploy 命令了

svn-deploy -s ~/Documents/demo/dist -d ~/Documents/demo/svn

其他

如果在 svn commit 时遇到冲突或者其他异常,建议直接回滚取消当前修改:

svn revert . -R

PS: 文章首发于 简书 ,欢迎大家关注。

Vue 和 jQuery 的图片容错处理方案

网页在获取图片资源的时候,经常会由于 资源路径无效网络环境不理想服务器过载 等原因导致资源加载超时或失败。为了保证良好的用户体验,我们需要对图片加载做容错处理。

最简单的方式就是通过绑定 img 元素的 error 事件,在图片加载失败时显示备用图片 error.jpg

若采用此方式,需要在 error 事件触发时取消事件的绑定,避免当 error.jpg 也加载失败时死循环拉取资源。

<img src="image-path.jpg" onerror="this.onerror=null;this.src='error.jpg'">

如果需要对 背景图片background-image) 实施容错处理呢?

如果需要在图片加载过程中加 loading 效果呢?

这个时候就需要 “替身” 发挥作用了 ——

我们可以利用一个对用户不可见的 img 元素,来验证图片资源的合法性。

jQuery 环境

额外创建一个不插入 DOM 节点树的 img 元素 “替身”,src 属性值为目标资源的 url。若 “替身” 能成功获取目标资源(load 事件触发),即让目标节点加载该资源,否则(error 事件触发)加载备用资源。

注意,重复加载同一个资源时并不会产生额外的网络消耗,浏览器会从本地缓存获取该资源。

在 jQuery 环境下,建议以插件的形式扩展,维持链式调用的特性:

$.fn.img = function(opts) {
  const $img = document.createElement('img');
  // 未加载完成时,显示 loading
  this.css('background-image', `url('${opts.loading}')`);
  $img.onload = () => {
    this.css('background-image', `url('${opts.src}')`);
  };
  $img.onerror = () => {
    this.css('background-image', `url('${opts.error}')`);
  };
  $img.src = opts.src;
  return this;
}

$('.image').img({
  src: 'success.jpg',
  error: 'error.jpg',
  loading: 'loading.gif',
});

预览地址:https://codepen.io/JunreyCen/pen/WBapBw

jQuery + 模板引擎

如果使用了模板引擎(譬如 lodash_.template 函数)的渲染方式,且希望维持 数据驱动视图 的模式时,可以参考下面的处理:

<div id="app"></div>
<template id="tpl">
  <div class="image" style="background-image: url('<%= loading %>')"></div>
  <img 
    style="display: none"
    src="<%= src %>"
    onload="$(this).siblings('.image').css('background-image', 'url(<%= src %>)')"
    onerror="$(this).siblings('.image').css('background-image', 'url(<%= error %>)')">
</template>

<script>
$(function() {
  $('#app').append(_.template($('#tpl').html())({
    src: 'success.jpg',
    error: 'error.jpg',
    loading: 'loading.gif',
  }));
});
</script>

题外话,推荐把 demo 中的处理封装成模板组件,配合 CSS 命名空间 就可以实现组件化了。鉴于 jQuery + 模板引擎 这类技术栈流行度已经没那么高了,这里不再单独提供例程。

Vue 环境

我们通过绑定 vue 指令 来实现图片的容错处理。其好处在于,每当图片资源的 src 被初始化或更新时,vue 指令都可以捕捉到变化,并容错处理后再响应式地作用于目标节点。

vue 官方文档 中对于 自定义指令 有详细的教程,这里就不多说了,直接贴代码。

<div id="app"></div>
<template id="tpl">
  <div class="image" v-img="{
    src: 'success.jpg',
    error: 'error.jpg',
    loading: 'loading.gif',
  }"></div>
</template>

<script>
  function imgHandler($el, binding) {
    const opts = binding.value;
    const $img = document.createElement('img');
    $el.style.backgroundImage = `url('${opts.loading}')`;
    $img.onload = () => {
      $el.style.backgroundImage = `url('${opts.src}')`;
    };
    $img.onerror = () => {
      $el.style.backgroundImage = `url('${opts.error}')`;
    };
    $img.src = opts.src;
  }

  Vue.directive('img', {
    inserted: imgHandler,
    update: imgHandler,
  });

  new Vue({
    el: '#app',
    template: '#tpl',
  });
</script>

踩坑点

基于上面提供的 vue demo,我们来模拟一个场景:

  • 目标节点的 src 被更新;
    譬如,src 先被赋值为 path-to-image-A.jpg,再更新为 path-to-image-B.jpg
  • 图片资源的加载速度不理想;

此时,万一资源 A 的加载速度比资源 B 还要慢,就会出现历史资源(A)把最新资源(B)覆盖掉的问题。我们稍微修改下 demo 来实现这个场景:

<div class="image" v-img="{
  // ...
  src,
  delay,
}"></div>

<script>
  function imgHandler($el, binding) {
    // ...
    if (opts.delay) {
      // 模拟图片加载延迟
      setTimeout(() => {
        $img.src = opts.src;
      }, opts.delay);
    } else {
      $img.src = opts.src;
    } 
  }

  new Vue({
    data() {
      return {
        src: '',
        delay: 0,
      };
    },
    mounted() {
      this.delay = 200;
      this.src = 'success.jpg';
      this.$nextTick(() => {
        this.delay = 0;
        this.src = 'success_2.jpg';
      });
    },
  });
</script>

或者直接看 demo 效果:https://codepen.io/JunreyCen/pen/JqmNQP

优化方案

解决方案也很简单,我们只需要把所有 “替身” 都记录下来,在更新时把上一次创建的 “替身” 清理掉。即使出现历史资源的捕获时机比最新资源的还要靠后的情况,由于历史资源的 onloadonerror 方法已经被重置,不会产生影响。

最终方案例程:https://github.com/JunreyCen/blog-demo/blob/master/image-handler/vue.2.html

PS: 文章首发于 简书 ,欢迎大家关注。

Mac 设置 Git 代理

http/https 协议

设置全局 git 代理。注意这里不需要设置 https.proxyGit Documentation 中没有这个参数。

# 走 ss 代理,其中 socks5 的默认本地端口为 1080
$ git config --global http.proxy socks5://127.0.0.1:1080

# 走 http 代理
$ git config --global http.proxy http://<ip>:<port>

推荐使用 socks5h 协议,速度更快:

$ git config --global http.proxy socks5h://127.0.0.1:1080
  • socks5h:域名由socks服务器解析;
  • socks5:域名由本地解析;

若只针对 https://github.com 设置代理:

$ git config --global http.https://github.com.proxy socks5://127.0.0.1:1080

取消代理:

$ git config --global --unset http.proxy

ssh 协议

$ vim ~/.ssh/config

添加以下内容:

Host <host>

# 走 ss 代理
ProxyCommand nc -X 5 -x 127.0.0.1:1080 %h %p

# 走 http 代理
ProxyCommand nc -X connect -x <ip>:<port> %h %p

这里使用了 nc (netcat) 命令,具体的参数解析可以通过 nc -h 查阅。

查看本地 sock5 端口

nmap 是一款网络扫描和主机检测工具,可以用来扫描本地 sock5 占用的端口:

$ nmap localhost

Starting Nmap 7.70 ( https://nmap.org ) at 2019-04-10 15:29 CST
Nmap scan report for localhost (127.0.0.1)
Host is up (0.00023s latency).
Other addresses for localhost (not scanned): ::1
rDNS record for 127.0.0.1: www.fs.com
Not shown: 996 closed ports
PORT     STATE SERVICE
80/tcp   open  http
443/tcp  open  https
1080/tcp open  socks
8090/tcp open  opsmessaging

Nmap done: 1 IP address (1 host up) scanned in 6.50 seconds

PS: 文章首发于 简书 ,欢迎大家关注。

CSS 精确绘制三角形

通常情况下,用 CSS 来实现一些简单的图形会比使用图片更有优势,譬如:

  • CSS 可以随时调整图形的颜色;
  • CSS 可以给图形加复杂动画;
  • 图片会消耗额外的网络资源;
    ...

接下来介绍如何用 CSS 精确绘制三角形及其衍生图形。

盒模型

所有元素都可以被描述为一个个矩形的盒子,盒子由 4 个部分组成:内容区域(content area)内边距区域(padding area)边框区域(border area)外边距区域(margin area)

配图摘自 https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Box_Model/Introduction_to_the_CSS_box_model

一个边框宽度不为零的矩形元素是这样的:

<style>
.box {
  width: 50px;
  height: 50px;
  border-width: 30px;
  border-style: solid;
  border-color: #07C160 #FA5151 #409eff #e6a23c;
}
</style>
<div class="box"></div>

如果把 内容区域 的宽高都置为 0

<style>
.box {
  /*
    ...  
   */
  width: 0;
  height: 0;
}
</style>

此时内容、内外边距区域都为空,盒模型相当于被等宽的边框区域瓜分成四个等腰直角三角形。那么,只需要将其他三个三角形都透明化,我们要的三角形就呼之欲出了。

实心三角形

<style>
.triangle {
  width: 0;
  height: 0;
  border-width: 0 50px 50px 50px;
  border-style: solid;
  border-color: transparent transparent #409eff transparent;
}
</style>
<div class="triangle"></div>

PS:假如我们只需要下方的三角形,那么上边框是没有起任何作用的,不妨也将其置为 0。

我们可以通过 调整各边框的宽度,来控制三角形的形状

<style>
.triangle {
  width: 0;
  height: 0;
  border-width: 0 50px 50px 50px;
  border-style: solid;
  border-color: transparent transparent #409eff transparent;
}
</style>
<div class="triangle"></div>

空心三角形

在实心三角形的基础上,把两个大小不一的三角形叠在一起,就绘制出空心三角形了。

但这里其实是有讲究的,我们通常期望空心三角形的三边宽度是相等的,那么为了符合要求我们需要如何调整两个三角形的位置?偏移量又是多少呢?

这里利用伪元素来绘制第二个三角形:

<style>
.hollow {
  position: relative;
  width: 0;
  height: 0;
  border-width: 0 50px 50px 50px;
  border-style: solid;
  border-color: transparent transparent #409eff transparent;
}
.hollow:before {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  border-width: 40px;
  border-style: solid;
  border-color: transparent transparent #e6a23c transparent;
}
</style>
<div class="hollow"></div>

对应的模型如下图所示,其中 内三角形 的坐标轴原点位于 外三角形 的顶点处(位于父元素的内容区域)。我们先挪动内三角形使两者的顶点重合,那么内三角形需要左移和上移各$H_{2}$像素。

然后调整内三角形在纵坐标轴上的位置。我们期望的最终效果是这样的

$A_{1}A_{2}$是待求的纵坐标偏移值

$$A_{1}A_{2} = \frac{x}{\sin ∠O} $$

由于内外三角形的三边都是分别平行的,我们可以建立等式

$$H_{1} = A_{1}A_{2} + H_{2} + x =\frac {\sin ∠O + 1}{\sin ∠O}x + H_{2} $$

求出$x$

$$x = \frac{\sin ∠O(H_{1} - H_{2})}{\sin ∠O + 1} $$

所以纵坐标轴上的总偏移量$S$为

$$S = 0 - H_{2} + A_{1}A_{2} = \frac{(H_{1} - H_{2})}{\sin ∠O + 1} - H_{2}$$

假设内外三角形都是等腰直角三角形,则$\sin ∠O = \frac {\sqrt 2}{2}$,那么总偏移量$S$为

$$S = \frac{\frac {\sqrt 2}{2} (H_{1} - H_{2})}{\frac {\sqrt 2}{2} + 1} - H_{2} = (2 - \sqrt 2)(H_{1} - H_{2}) - H_{2}$$

将 demo 中的参数$H_{1} = 50$,$H_{2} = 40$代入方程式,求得

$$S = (2 - \sqrt 2)\times 10-40 \approx -34$$

所以横坐标偏移量为 -40px,纵坐标偏移量为 -34px

箭头

箭头的实现原理和空心三角形如出一辙,只是得顺便把外三角形的下边给覆盖住,因此这里 把 内三角形 的边框宽度调整为与 外三角形 的相等(或者与 外三角形 的边框宽度相近即可)

我们期望的最终模型是这样的

我们最终需要关心的是$x$的值,它代表着箭头的宽度。假设我们需要绘制 1px 宽的箭头

$$x = A_{1}A_{2}\sin ∠O = 1$$

参考对空心三角形纵轴偏移值的计算,纵轴上的总偏移量$S$为

$$S = 0 - H_{2} + A_{1}A_{2} = \frac {1}{\sin ∠O} - H_{2}$$

假设内外三角形都是等腰直角三角形,则$\sin ∠O = \frac {\sqrt 2}{2}$,那么总偏移量$S$为

$$S = \sqrt 2 - H_{2}$$

将 demo 中的参数$H_{2} = 40$代入方程式,求得

$$S = \sqrt 2 - 40 \approx -39$$

所以横坐标偏移量为 -50px,纵坐标偏移量为 -49px

<style>
.arrow {
  position: relative;
  width: 0;
  height: 0;
  border-width: 0 50px 50px 50px;
  border-style: solid;
  border-color: transparent transparent #409eff transparent;
}
.arrow:before {
  content: '';
  display: block;
  position: absolute;
  top: -49px;
  left: -50px;
  border-width: 50px;
  border-style: solid;
  border-color: transparent transparent #fff transparent;
}
</style>
<div class="arrow"></div>

实现等腰直角箭头的另一种方式

等腰直角箭头 无非就是正方形的两条邻边,我们只需要将另外两条邻边透明化,同时按需求旋转图形指向就可以了。

这里继续利用伪元素实现。为了优化元素的可用性,我们使伪元素的宽高百分比于父元素,建立以下模型

那么,伪元素的宽度和父元素宽度的关系为

$$W_{伪} = \frac{\sqrt 2}{2}W \approx 0.7W$$

<style>
.arrow {
  position: relative;
  width: 50px;
  height: 50px;
}
.arrow:before {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 50%;
  width: 70%;
  height: 70%;
  border-width: 1px;
  border-style: solid;
  border-color: #409eff transparent transparent #409eff;
  transform: rotate(45deg);
  transform-origin: 0 0;
}
</style>
<div class="arrow"></div>

所有 demo 详见:https://codepen.io/JunreyCen/pen/LovdaM

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

Vue 实现无缝轮播

很多网站都会有轮播图的需求,而简单的轮播图实现通常会在展示完最后一个子项后停止轮播,或者跳回到第一个子项重复轮播过程,这样的交互效果往往是存在断层的。接下来介绍如何实现一个无缝的轮播图,达到这样的效果:

预览地址:https://codepen.io/JunreyCen/pen/OYBvdw

核心**其实非常简单:

  1. 当轮播到边界子项(Item 3),并继续进行横移时,把即将要展示的子项(Item 1)挪到紧挨着 Item 3 的位置,执行横移,如下图 Step 1

  2. 由于此时活跃子项的索引(index > 2)已经超出范围,在下一次横移进行前,需要把索引调整到合理范围内,并重置子项的位置,如下图 Step 2。注意,这一步需要把 transition 关闭,不然 “偷梁换柱” 的过程会被一览无遗。

“偷梁换柱” 过程

这里提供一份完整的代码实现。我稍微做了点优化,支持

  • 左、右两个方向轮播
  • 一次切换多个子项

原理上无非是支持多个子项的同时 “偷梁换柱” 罢了,详细的可以关注代码中的 next 函数。

<html>
  <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
  <head>
    <style>
      ul {
        list-style: none;
      }
      .swipe {
        position: absolute;
        left: 0;
        right: 0;
        margin: 40px auto;
        width: 90%;
        max-width: 375px;
        height: 200px;
        overflow: hidden;
      }
      .swipe-group {
        display: flex;
        margin: 0;
        padding: 0;
        width: 100%;
        height: 100%;
      }
      .swipe-item {
        flex: 0 0 100%;
        height: 100%;
        line-height: 200px;
        text-align: center;
        font-size: 40px;
        font-weight: 600;
        color: #fff;
      }
      .swipe-item:nth-child(1) {
        background-color: aquamarine;
      }
      .swipe-item:nth-child(2) {
        background-color: chocolate;
      }
      .swipe-item:nth-child(3) {
        background-color: darksalmon;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>

    <template id="tpl">
      <div class="swipe">
        <ul class="swipe-group"
          :style="groupStyle">
          <li 
            class="swipe-item" 
            v-for="item in items"
            ref="item">
            {{item}}
          </li>
        </ul>
      </div>
    </template>

    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script>
      new Vue({
        el: '#app',
        template: '#tpl',
        computed: {
          groupStyle() {
            return {
              'transform': `translate3d(${this.offset}px, 0, 0)`,
              'transition-duration': `${this.duration}ms`,
            };
          },
        },
        data() {
          return {
            items: [1, 2, 3],
            index: 0,           // 当前轮播项索引
            offset: 0,          // swipe组的偏移量
            duration: 0,        // 过渡动画时长
            itemWidth: 0,       // 轮播项宽度
          };
        },
        mounted() {
          if (this.$el) {
            this.itemWidth = this.$el.getBoundingClientRect().width;
          }
          this.autoplay();
        },
        methods: {
          // index 超出范围时调整
          correctIndex() {
            this.duration = 0;
            const total = this.items.length;
            if (this.index < 0) {
              this.next(total, true);
            } else if (this.index > total - 1) {
              this.next(-total, true);
            }
          },
          // 移动到达目标 index 途中的所有 swipe-item
          moveItems(indexOffset) {
            const targetIndex = this.index + indexOffset;
            if (this.index < targetIndex) {
              // 向左
              for (let i = this.index; i < targetIndex; i++) {
                this.moveItem(i + 1);
              }
            } else {
              // 向右
              for (let i = targetIndex; i < this.index; i++) {
                this.moveItem(i);
              }
            }
          },
          // 移动 swipe-item
          moveItem(index) {
            const total = this.items.length;
            const itemIndex = index % 3 < 0 ? index % 3 + 3 : index % 3;
            // 目标 index 超出范围时调整对应 swipe-item 的偏移值
            if (index > total - 1) {
              this.$refs.item[itemIndex].style.transform = `translateX(${total * this.itemWidth}px)`;
            } else if (index < 0) {
              this.$refs.item[itemIndex].style.transform = `translateX(${-total * this.itemWidth}px)`;
            } else {
              this.$refs.item[itemIndex].style.transform = 'translateX(0px)';
            }
          },
          resetItems() {
            this.$refs.item.forEach($item => {
              $item.style.transform = 'translateX(0px)';
            });
          },
          // 向左/右方向切换 indexOffset 个 swipe-item
          next(indexOffset, isCorrect) {
            isCorrect ? this.resetItems() : this.moveItems(indexOffset);
            this.index += indexOffset;
            this.offset = -this.index * this.itemWidth;
          },
          autoplay() {
            this.player = setInterval(() => {
              this.duration = 0;
              this.correctIndex();
              // 30ms延时是为了屏蔽 reset 过程中的过渡动画
              setTimeout(() => {
                this.duration = 500;
                this.next(1);
              }, 30);
            }, 1000);
          },
        },
      });
    </script>
  </body>
</html>

PS: 文章首发于 简书 ,欢迎大家关注。

CSS 技巧收录【持续更新】

【本文会持续更新!】

1、color 影响 border-color

当只设置元素的边框宽度和样式时,边框的颜色会取当前元素 color 属性的计算值。

<div style="color: blue;border: 1px solid;">这里设置了 border,但不指定 border-color</div>

瞧瞧该实例元素的 border-color 属性的计算值:

可以看到,border-color 属性未设置值时为 initial 关键字,代表取该属性的默认值。那么,为什么这里取的是 color 属性的值而非浏览器对 border-color 的默认值(如果有的话)呢?

因为 border-color 属性的默认值就是 currentColor 关键字(CSS 3),也就是当前元素的 color 属性的计算值,详见 border-color | MDN

color 前景色

MDN 中对 color 属性是这样介绍的:

The color property sets the foreground color of an element's text content, and its decorations. It doesn't affect any other characteristic of the element

翻译过来就是,color 属性设置元素文本内容的 前景色修饰

HTML 元素的前景色包括:

  • 字体颜色,也就是狭义上的 color
  • border-color,边框的颜色;
  • outline-color,轮廓的颜色;
  • box-shadow,阴影的颜色;
  • text-shadow,文本阴影的颜色;

以及文本修饰中的 text-decoration-color,下划线的颜色。

<head>
  <style>
    p {
      color: blue;
      max-width: 500px;
    }
  </style>
</head>
<body>
  <p>这里没有做任何处理</p>
  <p style="border: 1px solid;">这里设置了 border,但不指定 border-color</p>
  <p style="outline: 1px solid;">这里设置了 outline,但不指定 outline-color</p>
  <p style="text-decoration: underline;">这里设置了 text-decoration,但不指定 text-decoration-color</p>
  <p style="box-shadow: 0 1px 2px 0;">这里设置了 box-shadow,但不指定颜色</p>
  <p style="text-shadow: 10px 10px 2px;">这里设置了 text-shadow,但不指定颜色</p>
</body>


2、移动端 H5 禁止显示系统菜单

当在移动端 H5 上长按一个目标元素时,浏览器会弹出一个关于目标元素的菜单信息。请看下面针对文本元素的实例:

<span>这是一段文字,请在移动端长按</span>

在某些场景下,我们并不希望浏览器弹出这样的菜单,应该怎么做呢?我在网上搜罗了几个常见方案:

  • 通过 -webkit-touch-callout 属性禁用 callout【无效】

    -webkit-touch-callout: none;

    -webkit-touch-callout 属性的兼容性非常差,之前只有在 Safari 浏览器上可用。而 现在应该是被废弃了,亲测在 ios Safari 浏览器上不起效Can I use 上也已经搜索不到了。

  • JS 屏蔽 contextmenu 事件的默认行为【无效】

    $elm.addEventListener('contextmenu', (e) => {
      e.preventDefault();
    });

    当用户尝试打开上下文菜单(通常是鼠标右键单击)时,contextmenu 事件会被触发,我们可以通过 preventDefault() 方法来屏蔽菜单的显示。那么这种方法是否适用于移动端 h5 呢?

    答案是否定的。不妨来看看 contextmenu 事件的兼容性:

    so,网上的部分教程就不要再误人子弟了。

  • 通过 user-select 属性让元素不可选中【有效】

    -webkit-user-select: none;
    -moz-user-select: none; 
    -ms-user-select: none;
    -o-user-select: none;
    user-select: none;

    在移动端,若用户不可选中元素,自然也就不能通过长按调起默认菜单了。但需要注意的是,user-select: none 在部分浏览器(如 Safari)中会使 <input><textarea> 等表单元素失效

  • JS 屏蔽 touchstart 事件的默认行为【有效】

    $elm.addEventListener('touchend', (e) => {
      e.preventDefault();
    });

    此方案和使用 user-select 是一样的原理,但同样 需要注意禁止默认行为所带来的负面影响,譬如作用于可滚动元素时。

所有方案的测试情况见:https://codepen.io/JunreyCen/pen/rEBYPV

总结一下,移动端的兼容性一直是非常棘手的问题,各种手机操作系统、各种浏览器应用没有一套统一的 web 标准,都喜欢 “各抒己见”。也因此,上面提及的方案在某些型号手机的浏览器中(譬如 Oppo 自带的浏览器)依然是不起作用的,这种情况下开发者只能 “见招拆招”,无招可使时也只能择 “较优解” 了。


3、文本溢出显示省略号

我们经常遇到单行文本溢出时显示省略号的场景,那多行文本的类似处理该如何实现?

单行文本

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;      /* 文本不换行 */

多行文本

多行文本溢出时,最后一行截断并显示省略号。这里提供两种实现方式:

  • 使用 line-clamp 属性

    line-clamp 是一个不规范的属性,没有出现在 CSS 规范草案中。而且,它必须结合旧版的 flexbox(伸缩盒)模型才起作用。

    overflow: hidden;
    display: -webkit-box;           /* 旧版伸缩盒模型 */
    display: -moz-box;
    -webkit-box-orient: vertical;   /* 子元素垂直排列 */
    -moz-box-orient: vertical;
    -webkit-line-clamp: 2;          /* 块元素显示的文本行数 */
    -moz-line-clamp: 2;

    如下图所示,line-clamp 在兼容性方面还是有点缺陷的。

    除此之外还需要注意的是,autoprefixer 等预处理插件会自动删除一些旧的样式属性(包括 -webkit-box-orient 属性),所以使用这种方式时要对 autoprefixer 插件进行配置,详见这个 issue

  • JS + CSS 手动加省略号

    处理流程:

    1. 元素的 height 值设置为 line-height 值的整数倍$n$;
    2. JS 获取元素的文本节点高度(scrollHeight),若高度$\geq n+1$倍 line-height 值则视为文本溢出;
    3. 文本溢出时,于元素右下方显示省略号,否则不显示;
    <style>
    #ellipsis {
      position: relative;
      display: block;
      overflow: hidden;
      line-height: 20px;
      height: 40px;           /* height 为整数倍 line-height */
      word-break: break-all;  /* 使文本填充满容器,利于省略号和文本的衔接 */
    }
    .show-ellipsis {
      padding-right: 12px;    /* 给省略号腾出空间 */
    }
    .show-ellipsis:after {
      content: '...';
      position: absolute;
      right: 0;
      bottom: 0;
    } 
    </style>
    
    <p id="ellipsis">JS 和 CSS 设置多行文本字数超出时最后一行显示省略号,JS 和 CSS 设置多行文本字数超出时最后一行显示省略号</p>
    
    <script>
      const $elm = document.getElementById('js-ellipsis');
      const style = window.getComputedStyle($elm);
      const limitHeight = +style.height.replace('px', '') + +style.lineHeight.replace('px', '');
      if ($elm.scrollHeight >= limitHeight) {
        // 文本溢出则显示省略号
        $elm.className = 'show-ellipsis';
      }
    </script>

    这种实现方式基本不存在兼容性问题(除了低版本 IE 浏览器),但效果肯定比不上 line-clamp 的方式,只能静待 W3C 制订一套规范的处理方案了。


4、图像自适应

我们写页面的时候经常会遇到,获取的图片尺寸与我们所期待的渲染尺寸不符。譬如在渲染用户头像的场景下,我们往往希望用户上传的图片都是统一的正方形尺寸,然而用户给的可能是这样的:

常见的处理方式是 适当的剪裁使图片填满容器而不被拉伸,我们可以利用背景图(background-size / background-position)来实现:

<style>
.avatar {
  width: 200px;
  height: 200px;
  border: 2px solid #07C160;
  background: url('./images/captain.jpeg') no-repeat;
  background-size: cover;
  background-position: center; 
}
</style>
<div class="avatar"></div>

CSS 3 中提供了新的属性:object-fit,可以对 <img> 标签作宽高自适应处理,效果类似于 background-size 属性。

object-fit 提供了5个取值:

  • none:内容保持原有尺寸;
  • contain:内容保持宽高比地缩放,内容和容器的宽高比不匹配时会 留下白边
  • cover:内容保持宽高比地填满容器,内容和容器的宽高比不匹配时会 被裁剪
  • fill:内容刚好填满容器,内容和容器的宽高比不匹配时会 被拉伸
  • scale-down:内容尺寸和 nonecontain 中的一个相同,最终会显示尺寸较小的那个;

background-size 和 object-fit 的效果对比

同样的,background-positionobject-position 的作用类似,比较明显的区别在于:

background-position 的默认值是 0% 0%,而 object-position 的默认值是 50% 50%

object-fit 的意义

  • 解放了 background-image 的能力;
    譬如可以同时利用 background-image<img> 标签完成图片的堆叠等。
  • object-fit 对所有 可替换元素 都有效;
    常见的可替换元素包括:<iframe><video><embed><img><input type="image">

兼容性

额,如果要兼容 IE 浏览器的话,还是乖乖用背景图的方式把。


5、块元素等比缩放

现在的网页开发都讲究响应式设计,以使在各种尺寸的设备上也能保持良好的 UI 呈现。在响应式布局中,我们经常需要实现随网页视窗宽度动态变化的元素,尤其是支持等比缩放的元素。

比较传统的做法,是通过 JS 监听 resize 事件,动态获取容器的宽度然后调整元素的宽高。这里提供一种纯 CSS 实现的方式,主要利用的是 padding 属性的百分比取值

padding 属性的百分比取值是相对于其包含块的宽度。

所以,当包含块的宽度发生变化时,子元素的 padding 属性的计算值会随之发生变化,也就是说子元素的总宽高都会被改变,且与包含块宽度成正比。

<style>
.wrapper {
  width: 200px;
  height: 300px;
  border: 2px solid #07C160;
}
.content {
  padding: 50%;
  width: 0;
  height: 0;
  background-color: #FA5151;
}
</style>

<div class="wrapper">
  <div class="content"></div>
</div>

接下来的工作,只需要让目标元素的宽高参考于 padding 子元素的宽高,就完成了元素的等比缩放。我们可以利用 绝对定位 来实现。

这里实现一个宽高比为 1:2、宽度保持为容器$\frac{1}{2}$的等比缩放元素:

<style>
.wrapper {
  width: 20%;
  height: 40%;
  border: 2px solid #07C160;
}
.container {
  position: relative;
  padding: 50%;
  width: 0;
  height: 0;
}
.content {
  position: absolute;
  top: 0;
  left: 0;
  width: 50%;
  height: 100%;
  background-color: #FA5151;
}
</style>

<div class="wrapper">
  <div class="container">
    <div class="content"></div>
  </div>
</div>

为了突出演示效果,稍微 “修饰” 了一下:

需要体验 Demo 的请跳转:https://codepen.io/JunreyCen/pen/agzoyG


6、垂直外边距合并

当两个垂直外边距邻接时,会合并成一个外边距。只有普通文档流中块元素的垂直外边距才会发生合并,不在同一个 BFC 内(譬如行内元素、浮动元素、绝对定位等)的垂直外边距不会合并,水平方向的外边距也不会合并

通常发生外边距合并的场景有:

  • 相邻的两个块元素,邻接的上/下外边距发生合并

    图片摘自 http://www.w3school.com.cn/css/css_margin_collapsing.asp

    发生合并时外边距的计算规则:

    • 两个外边距都是正数时,取两者中的较大值;
    • 两个外边距一正一负时,取两者之和;
    • 两个外边距都是负数时,比较两者的绝对值大小,谁大取谁;
  • 无内边距(padding)和边框(border)的父元素的垂直外边距会与子元素的垂直外边距合并

    图片摘自 http://www.w3school.com.cn/css/css_margin_collapsing.asp

  • 无内边距(padding)、边框(border)和内容(content)的空元素的上/下外边距会合并

    图片摘自 http://www.w3school.com.cn/css/css_margin_collapsing.asp

这里提供一个完整 Demo:https://codepen.io/JunreyCen/pen/zVxPWX


7、行内(块)元素空隙

试试执行这段代码:

<img src="./images/captain.jpeg" width="200">
<img src="./images/captain.jpeg" width="200">

奇怪,两张图片之间出现了一道缝隙……

这是因为 img 元素默认会被渲染成行内元素(display: inline;),而上述代码中两个 <img> 之间其实是存在一个 换行符 (以及若干个 制表符)的,这些都会被渲染成一个空白格,也就导致了缝隙的产生。

解决办法有俩:

  • 写 HTML 代码的时候避免行内元素间的空格、换行符等特殊字符;
    <img src="./images/captain.jpeg" width="200"><img src="./images/captain.jpeg" width="200">
  • 把行内元素所在行的字体大小设为 font-size: 0;
    <style>
    body { font-size: 0; }
    </style>
    
    <img src="./images/captain.jpeg" width="200">
    <img src="./images/captain.jpeg" width="200">

PS:行内块元素(display: inline-block)也会存在同样的问题。


8、pointer-events: none;

pointer-events 属性可以指定元素是否可以成为鼠标事件的 target ,通俗点讲就是该元素是否可以接收鼠标事件。

pointer-events 属性有多种取值,详见 MDN。这里着重介绍取值 none

pointer-events: none; 指定元素及其后代元素不会成为鼠标事件的 target,父元素不受影响。

实验 Demo

实验内容:三层 DOM 元素都监听了鼠标点击事件,其中 target 节点设置了 pointer-events: none,点击 child 元素,看看有哪层元素可以响应点击事件。

<div class="parent" onclick="alert('parent')">
  <div class="target" onclick="alert('target')">
    <div class="child" onclick="alert('child')"></div>
  </div>
</div>

左边不作处理的实例会 alert 三次;而右边的实例只会 alert 一次,内容为 parent

应用场景

  • 同层元素点击穿透

    我们在写 UI 基础组件时,多多少少会接触到 Field 输入框组件。比如下面的实例,需求是满足 0.5px 边框 + input 输入框

    <style>
    .field {
      position: relative;
      width: 250px;
      height: 50px;
      text-align: center;
    }
    .field:after {
      position: absolute;
      top: -50%;
      left: -50%;
      right: -50%;
      bottom: -50%;
      content: '';
      border: 1px solid #ccc;
      transform: scale(0.5);
    }
    </style>
    
    <div class="field">
      <input type="text" placeholder="请输入">
    </div>

    你会发现,左边的输入框无法聚焦。这其实跟 0.5px 边框的实现方式有关,实例中利用伪元素 :after 制造了一个2倍尺寸的子元素,然后通过 transform: scale(0.5) 缩放,从而实现 0.5px 边框。然而,**这个伪元素和 input 输入框属于同层元素,根据渲染的先后顺序伪元素是层叠于输入框之上的,所以鼠标点击事件是无法被输入框捕获的。

    我们只需要给伪元素设置 pointer-events: none;,使其无法成为鼠标事件的 target,input 输入框就可以成功被聚焦。

  • 阻止 :hover、:active 等鼠标行为状态的触发

    设置了 pointer-events: none; 的元素无法响应鼠标事件,自然也就无法触发相关的状态了。

想体验实例的请访问:https://codepen.io/JunreyCen/pen/NZqKOx


9、:first-child 和 :first-of-type 的区别

  • :first-child
    匹配其父元素的符合特定类型的首个子元素。条件更为苛刻,需要满足 首个子元素 + 符合特定类型

  • :first-of-type
    匹配其父元素的符合特定类型的第一个元素。条件较为宽松,在满足 符合特定类型 的范畴下寻找第一个元素即可。

<style>
  .group-1 h2:first-child,
  .group-1 h3:first-child,
  .group-2 h2:first-of-type,
  .group-2 h3:first-of-type {
    color: #FA5151;
  }
</style>

<div class="group-1">
  <h2>父元素的第一个元素,第一个 h2 元素</h2>
  <h3>父元素的第二个元素,第一个 h3 元素</h3>
</div>
<div class="group-2">
  <h2>父元素的第一个元素,第一个 h2 元素</h2>
  <h3>父元素的第二个元素,第一个 h3 元素</h3>
</div>


PS: 文章首发于 简书 ,欢迎大家关注。

CSS 权重详解

对于 CSSer 来说,多多少少都会遇到过 “样式规则不生效?”、“样式规则被覆盖?” 等等问题,这些都与 CSS 权重有关系。

选择器匹配原理

在此之前,容我先简单介绍浏览器是怎么通过各种选择器,把样式规则和 DOM 元素扯上关系的。

浏览器中存在着专门的渲染引擎来渲染 HTML 文档。这里以 Webkit 内核为例,在启动渲染流程时,引擎一方面会解析 HTML 文档,构建 DOM 节点树(DOM Tree,另一方面会解析样式文件生成 样式规则(Style Rules,然后结合分析 DOM 树和样式规则生成 渲染树(Render Tree,最后 布局绘制 出 UI 界面。

Webkit 渲染流程(摘自 https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/)

CSS 的选择器匹配就发生在 渲染树 的构建过程。浏览器会从 DOM 树的根节点开始遍历每个可见节点,对于每个可见节点都会在规则表中查找适配的样式规则。那么,如此庞大的样式数据和复杂的选择器结构,渲染引擎是怎么寻找到适配当前元素的样式规则呢?

请看下面这个复合选择器。如果引擎是按照从左向右的顺序匹配选择器,将会导致大量 回溯 的发生:先是在当前节点到 DOM 树跟节点的路径上寻找 div 元素,然后沿着分支路径继续往下找第二个 div 元素,如果当前路径找不到,就得回退到上一个 div 元素尝试另一条分支路径。如此往复,对性能损耗将会非常严重。

div div span .text {}

所以,引擎是采取 从右向左 的顺序来匹配选择器。也就是 从最具体的选择器开始,如果与当前节点不匹配,则直接抛弃该条规则;如果匹配,只需要沿着路径往上确认其他选择器是否也匹配,这样做可以大大减少无效的匹配数,提高性能。除此之外,引擎还会把不同类型的选择器(idclasstag 及其他类型)归类到哈希表中,进一步减少查找基数。

了解选择器的匹配原理,有利于我们理解其权重规则,对于编写简洁、高效的 CSS 代码非常有帮助。

CSS 权重

通过不同的方式(内联样式外部样式表)、不同类型的选择器组合针对某个元素声明样式规则时,如何决定最终哪个声明会被应用到元素上?这就涉及到 CSS 权重(也指优先级,Specificity)

围绕 CSS 权重主要有以下三条规则:

  • 权重不同的样式规则作用于同一元素时,权重高的规则生效;

  • 权重相同的样式规则作用于同一元素时,后声明的规则生效;

    选择器在 DOM 中的位置关系不会对规则产生影响。

    <html>
      <head>
        <style>
        body div {
          color: red;
        }
        html div {
          color: blue;
        }
        </style>
      </head>
    
      <body>
        <div>测试</div>
      </body>
    <html>

    这里的 body 标签元素在 DOM 中离目标 div 更近,但最后还是按照样式规则的声明顺序来决定。

  • 直接作用于元素的样式规则优先级高于从祖先元素继承的规则;

    <html>
      <head>
        <style>
        #parent {
          color: red;
        }
        span {
          color: blue;
        }
        </style>
      </head>
    
      <body>
        <div id="parent">
          <span>测试</span>
        </div>
      </body>
    <html>

CSS 权重等级

如何比较不同选择器的权重高低?这里划分成 5 个权重等级,按照等级 由高到低 的顺序:

  • !important 关键字

  • 内联样式

    <div style="color: #fff;">测试</div>
  • id 选择器

    #demo {}
  • 类选择器属性选择器伪类选择器

    .demo {}
    [type="text"] {}
    div:hover {}
    div:first-child {}

    需要注意,**否定伪类(:not())**比较特殊,它不会对权重产生影响,但是 否定伪类内部的选择器会影响权重

    <html>
      <head>
        <style>
          div#demo span {
            color: red;
          }
          div:not(#demo) span {
            color: blue;
          }
        </style>
      </head>
    
      <body>
        <div id="demo">
          <span>普通 demo</span>
          <div id="pseudo">
            <span>否定伪类 demo</span>
          </div>
        </div>
      </body>
    <html>
    

    实例中,:not(#demo) 的权重值和 #demo 的权重值是相等的,所以后面声明的样式规则成功生效。

  • 标签选择器伪元素选择器

    div {}
    div:before {}
    div:after {}

除了上述的选择器之外,通配符选择器(* 和 **结合符(+>~)**对优先级没有影响。

**对于复杂的复合选择器,我们需要逐个等级比较权重大小,不允许跨越等级比较。**为了方便计算,我们可以把权重值具象化,每出现一个选择器就在其对应的等级区间中权重值加 1,参考下面实例:

* {}               /* 权重值 0-0-0-0-0 */
div {}             /* 权重值 0-0-0-0-1 */
div h1+h2 {}       /* 权重值 0-0-0-0-3 */
div, ... div {}    /* 权重值 0-0-0-0-n */
#demo a:hover {}   /* 权重值 0-0-1-1-1 */

国外大神 把 CSS 权重的计算模拟成海洋生物链,选择器组合权重越大则在生物链位置越高,非常浅显生动,建议收藏。

图片转自 https://specifishity.com/

建议

在充分了解 CSS 选择器匹配原理和权重规则之后,在编写 CSS 代码时不妨多注意以下细节:

  • 尽量不要使用 !important,尤其是在 对外提供的插件全站范围的样式表 中,这会对模块代码中的样式覆盖带来非常大的麻烦。

    !important 关键字的权重值为 1-0-0-0-0,只需要按照权重规则继续累加权重值即可覆盖该样式属性。

    <html>
      <head>
        <style>
          div {
            color: red !important;
          }
          /* 通过 id选择器 增加权重 */
          #demo {
            color: blue !important;
          }
        </style>
      </head>
    
      <body>
        <div id="demo">测试</div>
      </body>
    <html>
  • 减少不必要的选择器嵌套,嵌套最好不要超过三级。大量的复合选择器,会影响选择器匹配的效率,同时也会增加 CSS 样式文件的体积,不易维护。

    当出现大量嵌套时,我们可以指定一个更具体的类选择器来替换复合选择器。

    body div ul li span {}
    .li {}

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

JS 运算符技巧【持续更新】

1. 转换成数字

  • + 运算符

使用 +运算符可以把其他类型转换成数字类型,但在使用时要注意表达式的结构,避免被解析成字符串连接符

+'123';             // 123

+'123'+'456';       // "123456"
+'123'+(+'456');    // 579
  • ~~ 位运算符

按位非(~)实质上是 对数字求负,然后减1。详细的处理过程请看 w3school: 位运算符

~1;    // -2
~-1;   // 0

那么再次执行按位非即可取回原值:$-(-n-1)-1=n$。

~~1;      // 1
~~'1';    // 1

需要注意的是,~~ 的方式只适合处理 32 位以下整数,若是浮点数会被取整

~~1.2;     // 1
~~-1.2;    // -1

+ 运算符相比,~~ 运算符会把 undefined 或者不能转换成数字的值处理成 0,在某些业务计算场景下这样的处理是有方便之处的。

+undefined;     // NaN
+'abc';         // NaN
~~undefined;    // 0
~~'abc';        // 0

实际上, + 运算符和 ~~ 运算符都相当于使用 Number() 函数来处理,所以我们仍然需要关注一些特殊值的转换:

+'123', ~~'123';            // 123
+undefined, ~~undefined;    // NaN
+null, ~~null;              // 0
+true, ~~true;              // 1
+false, ~~false;            // 0
+[1], ~~[1];                // 1

当被转换值本身就是数字类型时,我们需要 小心被当成八进制数处理

+010;      // 8
+'010';    // '10'

2. 转换成字符串

+ 运算符紧跟一个空字符串,就可以把其他类型转换成字符串类型。

1+'';            // "1"
undefined+'';    // "undefined"
null+'';         // "null"
true+'';         // "true"
[1,2]+'';        // "1,2"

当被转换对象存在 toString() 的原型方法时,这种转换方式相当于调用了 toString() 函数:

new Date()+'';            // "Thu May 16 2019 20:42:43 GMT+0800 (China Standard Time)"
new Date().toString();    // "Thu May 16 2019 20:42:43 GMT+0800 (China Standard Time)"

var o = {a: 1};
o+'';            // "[object Object]"
o.toString();    // "[object Object]"

3. 转换成布尔值

由于逻辑非(!)返回的一定是布尔值,所以通过双取反即可转换成布尔值。

!!0;            // false
!!undefined;    // false
!!'abc';        // true
!![]            // true
!!{}            // true

4. 短路求值

逻辑与(&&)和逻辑或(||)运算都是简便运算,即如果第一个运算数决定了结果,就不再计算第二个运算数,这就是短路求值。

利用短路求值可以大幅减少逻辑判断的代码量,但同时也会降低代码可读性。

var condition = true;

if (condition) {
  console.log('It is true');
}
condition && console.log('It is true');

if (!condition) {
  console.log('It is false');
}
condition || console.log('It is false');

逻辑与(&&)表达式会返回第一个与 false 相等的值,而逻辑非(||)表达式会返回第一个与 true 相等的值,都没有则返回最后一个运算数的值。

null && false;    // null
1 && 2 && 3;      // 3
1 || true;        // 1
0 || false;       // false

5. 浮点数取整

由于浮点数是不支持位运算的,所以在运算之前会把浮点数的小数部分去掉,也就相当于对浮点数进行了取整。

只需要满足位运算后不改变值的表达式,都可以视作快速取整的一种方式。

  • 按位或 |

整数与 0 进行位或运算时,整数值不变,可用于浮点数取整。取整行为取决于浮点数是正数还是负数,正数时作向下取整,负数时作向上取整

// 向下取整
Math.floor(12.3);    // 12
12.3|0;              // 12

// 向上取整
Math.ceil(-12.3);    // -12
-12.3|0;             // -12

需要注意位或运算取整和 Math.floor() 等取整函数在特殊值处理上的差异:

Math.floor(NaN);         // NaN
NaN|0;                   // 0

Math.floor(Infinity);    // Infinity
Infinity|0;              // 0
  • ~~ 运算符

~~ 运算符利用的就是两次位非运算(~)后取回原值的特性,取整行为与位或运算一致。

~~12.3;     // 12
~~-12.3;    // -12

6. 奇偶判断

我们通常用取模运算符(%)来判断奇偶性:

n % 2 === 1 ? 'n是奇数' : 'n是偶数';

当数字转成二进制表达时,判断奇偶性只需要看最后一位是 1(奇数)还是 0(偶数),所以我们可以通过与 1 进行按位与运算来判断奇偶性。

1 & 1;    // 1
2 & 1;    // 0

n & 1 ? 'n是奇数' : 'n是偶数';

7. 幂运算(ES7)

** 是 ES7 新增的幂运算符,详见 tc39提案

Math.pow(2, 3);    // 8
2**3;              // 8

8. 整数交换

异或(^)运算具有这样的性质:

  • 满足交换律 $$a \oplus b \oplus c = a \oplus c \oplus b $$
  • 满足结合律 $$(a \oplus b) \oplus c = a \oplus (b \oplus c) $$
  • 自反性 $$a \oplus a = 0, a \oplus 0 = a$$

所以通过三次异或运算可以完成两个整数值(AB)的交换:

$A = A \oplus B$
$B = A \oplus B = (A \oplus B) \oplus B = A$
$A = A \oplus B = (A \oplus B) \oplus A = B$

var a = 1;
var b = 2;

a = a ^ b;    // 3
b = a ^ b;    // 1
a = a ^ b;    // 2

注意,异或运算不适合于浮点数及其他基本类型的变量交换。

除此之外,利用自反性异或运算还可以用于整数值的比较:运算结果为0则等值,非0则不等值。

1^1 = 0;
1^2 = 3;

9. void 0

很多 JS 框架、类库都会出现 void 0 这样的写法。void 运算符会对给定的表达式求值并返回 undefined,所以

void 0 === undefined;    // true

void 0 来替代 undefined 主要出于两点考虑:

  • 节省代码的字节数;
  • undefined 可以被重写,而 void作为 JS 的关键字不能被重写;

ps: undefined 在 ES5 中已经是全局对象的一个只读属性,但在局部作用域下依然能被局部变量覆盖。

undefined = 1;
console.log(undefined);      // undefined

(function() {
  var undefined = 1;
  console.log(undefined);    // 1
})();

PS: 文章首发于 简书 ,欢迎大家关注。

深入浅出 CSS 层叠上下文

我们在编写 CSS 样式时不少遇到这样的疑惑:“为什么这个元素会被覆盖?”、“为什么设置较高的 z-index 值还是不起效果?”

不妨先想想这个问题,当多个 HTML 元素在浏览器视窗中发生重叠时,浏览器会怎么安排哪个元素显示在上、哪个显示在下呢?其实所有元素在发生层叠时的表现都是按照一定的优先级顺序的,这些顺序规则都是建立在 层叠上下文(The Stacking Context) 这个三维概念中,我们一起来了解下。

层叠上下文特性

  • 在同一个层叠上下文中,子元素按照 层叠顺序 规则进行层叠;

    <style>
    .wrapper {
      position: relative;
      z-index: 1;
    }
    .wrapper div:nth-of-type(1) {
      position: relative;
      z-index: -1;
    }
    .wrapper div:nth-of-type(2) { display: block; }
    .wrapper div:nth-of-type(3) { float: left; }
    .wrapper div:nth-of-type(4) { display: inline-block; }
    .wrapper div:nth-of-type(5) { position: relative; }
    .wrapper div:nth-of-type(6) {
      position: absolute;
      z-index: 1;
    }
    </style>
    
    <body>
      <div class="wrapper">
        <div>position: relative;<br>z-index: -1;</div>
        <div>display: block;</div>
        <div>float: left;</div>
        <div>display: inline-block;</div>
        <div>position: relative;</div>
        <div>position: absolute;<br>z-index: 1;</div>
      </div>
    </body>

    关于 层叠顺序 的详解请往下看,完整代码已上传 CodePen:https://codepen.io/JunreyCen/pen/WqoLmr

    另外,当不同层叠顺序的元素相比较时,不关心元素在 DOM 树中的结构关系:

    <style>
    .child {
      position: relative;
      z-index: -1;
    }
    </style>
    
    <div class="parent">
      <div class="child"></div>
    </div>
    <div class="parent" style="opacity: 0.6;">
      <div class="child"></div>
    </div>

    左边实例中 Parent 元素不是层叠上下文元素,所以和 Child 元素是处于同一个层叠上下文中,而根据层叠顺序,普通流块级元素 是叠在 z-index 定位元素 之上的。右边实例中 Parent 元素指定属性 opacity: 0.6,创建了一个层叠上下文,从而使 Child 元素包含在内,根据层叠顺序 z-index 定位元素 会叠在 根元素背景和边框 之上。这样的表现说明,ParentChild 元素的父子节点关系不会影响层叠关系。

  • 在同一个层叠上下文中,当元素的层叠顺序相同时,按照 元素在 HTML 中出现的顺序 进行层叠;

    <style>
    .box-1 {
      position: relative;
    }
    .box-2 {
      position: absolute;
      top: 50px;
      left: 50px;
    }
    </style>
    
    <div class="wrapper">
      <div class="box-1"></div>
      <div class="box-2"></div>
    </div>
    <div class="wrapper">
      <div class="box-2"></div>
      <div class="box-1"></div>
    </div>

    所有 z-index 属性值为 0 / auto 的定位元素的层叠顺序都是相同的,如实例中的 box-1box-2 元素,可以看出它们发生层叠时遵循的是 在 HTML 中出现的先后顺序

  • 层叠上下文支持嵌套,子级层叠上下文对于父级层叠上下文是一个独立单元,对于兄弟元素来说也是相互独立的;

  • 每个层叠上下文都是自包含的,无论在子级层叠上下文内如何 “翻云覆雨”,也是基于父级层叠上下文的层叠顺序下;

    <style>
    .parent-1,
    .parent-2,
    .child-1, 
    .child-2 {
      position: relative;
    }
    .parent-1 { z-index: 2; }
    .parent-2 { z-index: 1; }
    .child-1 { z-index: -1; }
    .child-2 { z-index: 10; }
    </style>
    
    <div class="parent-1">
      <div class="child-1"></div>
    </div>
    <div class="parent-2">
      <div class="child-2"></div>
    </div>

    Child-1Child-2 元素分别处于各自的父级层叠上下文元素 Parent-1Parent-2 中。由于 Parent-1 是层叠于 Parent-2 之上的,即使 Child-1z-index: -1 属性值小于 Child-2z-index: 10Child-2 也不能逾越其父级上下文覆盖在 Child-1 上面的。换句话说,子级层叠上下文的层级讨论只在其父级层叠上下文中有意义。

创建层叠上下文

满足以下 12 个条件中任意一个的元素会创建一个层叠上下文(摘自 MDN ):

1、根元素 <html>
2、z-index 值不为 auto 的相对定位(position: relative)和绝对定位(position: absolute);
3、固定定位(position:fixed);
4、z-index 值不为 auto 的伸缩盒模型中的 flex 项目;
5、opacity 属性值小于 1
6、transform 属性值不为 none
7、filter 属性值不为 none
8、mix-blend-mode 属性值不为 normal
9、perspective 属性值不为 none
10、isolation 属性值为 isolate
11、will-change 属性 指定了上述任意一个 CSS 属性(即便没有直接指定这些属性的值)
12、-webkit-overflow-scrolling 属性值为 touch

关于第 11 点中的 will-change 属性:该属性主要用于告知浏览器元素会发生哪些变化,有利于浏览器在元素真正变化之前提前做好优化准备。所以当 will-change 指定了能创建层叠上下文的 CSS 属性(包括 z-index 属性)时,浏览器也会为该元素创建层叠上下文。

层叠顺序

层叠顺序就是元素在层叠上下文中的显示规则。在此之前需要提前认识一个重要概念:z-index 只会对定位元素(非普通流)有效

当元素发生层叠时,会按照下面的 7 阶层叠顺序来决定元素显示的前后顺序:

1、根元素的背景与边框;
2、z-index < 0 的定位元素;
3、普通流中的块元素(display: block);
4、浮动块元素;
5、普通流中的行内元素(display: inline / display: inline-block);
6、z-index 属性值为 0 / auto 的定位元素、其他子级层叠上下文元素;
7、z-index > 0 的定位元素;

  • 普通流:不指定 position 或者 position: static
  • 上面第 6 点中的 其他 子级层叠上下文元素:不依赖 z-index 属性就能创建层叠上下文的元素;

7 阶层叠顺序图

注意事项

  • 层叠顺序的第 6 阶中,z-index 属性值为 0 / auto 的定位元素不依赖 z-index 的子级层叠上下文元素 顺序相同,会遵循 在 HTML 中出现的先后顺序 来层叠;

    <div class="wrapper">
      <div class="box-1" style="opacity: 0.8;"></div>
      <div class="box-2" style="position: absolute;"></div>
    </div>
    <div class="wrapper">
      <div class="box-2" style="position: absolute;"></div>
      <div class="box-1" style="opacity: 0.8;"></div>
    </div>

  • 伸缩盒没有创建层叠上下文,伸缩盒中 z-index 值不为 auto 的 flex 项目才会创建层叠上下文。换句话说,使用这种方式创建层叠上下文需要同时满足两个条件:

    1. 父元素设置了display: flex / display: inline-flex
    2. 自身 z-index 属性值不为 auto;
    <style>
    .child {
      position: relative;
      z-index: -1;
    }
    </style>
    
    <div class="container">
      <div class="item">
        <div class="child"></div>
      </div>
    </div>
    <div class="container" style="display: inline-flex;">
      <div class="item" style="z-index: 1;">
        <div class="child"></div>
      </div>
    </div>
    <div class="container" style="display: inline-flex;">
      <div class="item" style="z-index: -1;">
        <div class="child"></div>
      </div>
    </div>

    左边实例,ContainerItem 元素都没有创建层叠上下文,这两者与 Child 元素都处于同一个层叠上下文中,按照层叠顺序渲染;

    中间实例,Container 元素设置 display: inline-flex 成为 Flex 容器,Item 元素设置 z-index: 1,则 Item 元素创建了层叠上下文,所以即使 Child 元素的 z-index 值为负数,却依然层叠在 Item 元素之上;

    右边实例,把 Item 元素的 z-index 值改为 -1,由于 Container 元素作为 Flex 容器是没有创建层叠上下文的,所以 Item 元素会层叠在 Container 元素之下。

固定定位(position: fixed)的特殊性

我们发现,单纯是绝对/相对定位元素是无法创建一个层叠上下文的,需要同时满足 z-index 值不为 auto 的条件。然而,固定定位元素就不需要满足这个条件,显得尤为特殊。

  • 固定定位元素无需满足 z-index 值不为 auto 的条件就可以创建层叠上下文;同样地,设置 z-index: auto 并不能撤销固定定位元素所创建的层叠上下文。

    <style>
    .fixed {
      position: fixed;
      z-index: auto;
    }
    .child {
      position: relative;
      z-index: -1;
    }
    </style>
    <div class="fixed">
      <div class="child"></div>
    </div>

  • 正常情况下,固定定位是相对于浏览器视窗(viewport)进行定位的,但是当其祖先元素中存在符合以下任意一个条件的元素时,固定定位元素会相对于该元素进行定位:

    1、transform 属性值不为 none;
    2、transform-style: preserve-3d
    3、perspective 属性值不为 none
    4、will-change 属性 指定了上面 3 个 CSS 属性中的任意一个

    然而,这种表现在不同的浏览器中有差异,譬如在 Safari 中只有上述第 1 点会对固定定位元素产生影响。

建议

通过对层叠上下文和层叠顺序的了解,我们知道,要控制元素间的层叠关系除了使用 z-index 属性外还有很多途径,而且比使用 z-index 要优雅得多。滥用 z-index 往往只会把层叠关系复杂化,造成代码难以维护。

友情链接

学习完 CSS 层叠上下文,如果对 BFC(块格式化上下文)、IFC(行内格式化上下文)等相关概念有兴趣的,可以学习这篇文章:视觉格式化模型 | MDN

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

Commonjs、esm、Amd 和 Cmd 的循环依赖表现和原理

a 模块执行时依赖 b 模块,b 模块的执行又反过来依赖 a 模块,此时就发生了循环依赖。循环依赖在平常的业务代码里比较罕见,一般遇到就意味着代码架构是时候认真梳理一下了。

但在依赖关系复杂的系统里,是有可能出现循环依赖的情况。让我们一起来看看在 Commonjsnodejs)、ES moduleAmdRequireJS)和 CmdSeajs)各种主流模块标准下的循环依赖表现及其背后的原理。

Commonjs

我们来看看node官方文档里提供的 循环依赖demo。

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

执行 main.js,输出如下:

$ node main.js

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

这里在执行 a.js 时,依赖 b.js,而执行 b.js 时,反过来又依赖 a.js 的输出,造成了循环依赖,然而程序并不会陷入无限循环,这里到底发生了什么?根据官方原文:

In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module.

翻译过来就是,模块被循环依赖时,只会输出当前执行完成的导出值。也就是说,b.js 在依赖未执行完成的 a.js 时,并不会等待 a.js 执行完,而是直接输出当前执行过的 export 对象,也就是例程中的第二行:

// a.js
exports.done = false;

除此之外,我们还注意到一点,main.js 在执行 require('./b.js') 时,为什么 log 都没打印出来?很显然 node 在这里做了缓存,而且缓存时机必须是模块执行完成之后,毕竟 main.js 最后输出的 a.doneb.done 都是 true


ES module

关于 ES module 的循环依赖表现,我这里提供了2个比较有代表性的 demo,都是运行在 node 端。

demo1

// a.mjs
console.log('a starting');
export default {
  done: true,
}
import b from './b.mjs';
console.log('in a, b.done = %j', b.done);
console.log('a done');
// b.mjs
console.log('b starting');
export default {
  done: true,
}
import a from './a.mjs';
console.log('in b, a.done = %j', a.done);
console.log('b done');

执行 a.mjs,输出如下

$ node --experimental-modules a.mjs

b starting
ReferenceError: a is not defined

如果 ES moduleCommonjs 一样都是运行时加载/导出,那么按照 js 代码的执行顺序,b.mjs 读取 a.done 时不应该抛出 undefined 异常;另外,虽然入口模块是 a.mjs,但先打印出的是 b starting,所以不难猜想:

ES module 不是动态解析,且依赖模块优先执行

demo2

// a.mjs
import b from './b.mjs';
console.log('a starting');
console.log(b());
export default function () {
  return 'run func A';
}
console.log('a done');
// b.mjs
import a from './a.mjs';
console.log('b starting');
console.log(a());
export default function () {
  return 'run func B';
}
console.log('b done');

执行 a.mjs,输出如下

$ node --experimental-modules a.mjs

b starting
run func A
b done
a starting
run func B
a done

啥情况?怎么把导出对象 object 改为导出函数 function 就不会报 undefined 异常?接下来让我们带着以上的结论和问题来探究 ES module 原理。

ES module 原理

这里只会简短阐述原理,详见 ES modules: A cartoon deep-dive。实际上 ES module 从加载入口模块到所有模块实例的执行主要经历了三步:构建实例化运行

  • 构建

从入口模块开始,根据 import 关键字遍历依赖树,每遍历一个模块则生成该模块的 模块记录(module record),最后生成整个 模块图谱(module graph)

解析模块生成模块记录

注意,这一步是 ES moduleCommonjs 的本质区别:

因为 ES module 需要支持浏览器端,而构建过程要获取所有的模块文件来绘制模块依赖图谱,如果参考 Commonjs 的做法把模块解析和运行放在一起,那么冗长的下载过程将会严重阻塞主线程导致应用长时间不可用,所以 ES module 在构建过程不会实例化和执行任何的js代码,也就是所谓的 静态解析 过程

这同时也解释了为何不支持使用表达式/变量的 import 语句:

// 报错
let module = 'my_module';
import { foo } from module;

所有的模块记录都会被缓存在 模块映射(module map) 中,被依赖多次的模块也只会存在唯一一条映射记录,从而避免模块的重复下载和实例化。

模块映射

  • 实例化

根据模块记录的关系,在内存中把模块的导入 import 和导出 export 连接在一起,也称为 活绑定(live bindings)

JS引擎会为每个模块记录创建 模块环境记录(module environment record),用来关联模块实例和模块的导入/导出值。**引擎会先采用 深度优先后序遍历(depth first post-order traversal),将模块及其依赖的导出 export 连接到内存中(直到依赖树末端),然后逐层返回再把模块相对应的导入 import 连接到内存的同一位置。**这也解释了为什么导出模块的值变更时,导入模块也能捕捉到该值的变更。

模块实例通过导入/导出变量在内存中建立关系

需要注意的是,实例化只是JS引擎在内存中绑定模块间关系,并没有执行任何代码,也就是说这些连接好的内存空间中并没有存储变量值,然而,在此过程中导出函数将会被初始化,即所谓的 函数具有提升作用

这使循环依赖的问题自然而然地被解决:

JS引擎不需要关心是否存在循环依赖,只需要在代码运行的时候,从内存空间中读取该导出值。

我们回到上面提供的 ES module 循环依赖的例程。

第一个例程 b.mjs 模块(简称 b 模块)在获取 a.mjs 模块(简称 a 模块)的导出值时,a 模块的对象 { done: true } 并没有被声明和赋值,所以会抛出 undefined 异常。

第二个例程,由于函数具有提升作用,b 模块获取 a 模块导出值时,a 模块的 foo 函数已经被声明,不会抛出异常。

  • 运行

也就是往内存空间中填充真实值。

JS引擎会采用和实例化时一样的深度优先后序遍历来执行模块及其依赖的顶级代码(即除函数声明之外的代码),所以会出现 demo1 中的 log 顺序。

nodejs 已经实现了对 ES module 的支持,目前只是作为一个实验特性,我会找时间研究 node 实现 CommonjsES module 的底层源码,大家敬请期待。


RequireJS

RequireJSSeajs 都是主要针对浏览器端的模块加载器,模块加载流程离不开这几点:

  1. 根据加载器规则寻找模块,并通过插入script标签异步加载;
  2. 在模块代码中通过词法分析找出依赖模块并加载,递归此过程直到依赖树末端;
  3. 绑定 load 事件,当依赖模块都加载完成时执行回调函数;

当然加载器还涉及缓存机制、容错处理和一些复杂的配置等,有兴趣的同学可以看看源码自行研究,这里就不详细说了。

这里我们把 Commonjs 的 demo 稍微改动下,使其运行在浏览器端:

<!-- index.html  -->
<html>
  <body>
    <script data-main="./app.js" src="./require.js"></script>
  </body>
</html>
// app.js
define(['./a', './b'], function(a, b) {
  console.log('app starting');
  console.log('in app', a, b);
});
// a.js
define(['./b', 'exports'], function(b, exports) {
  console.log('a starting');
  exports.done = false;
  console.log('in a, b.done =', b.done);
  console.log('a done');
  exports.done = true;
});
// b.js
define(['./a', 'exports'], function(a, exports) {
  console.log('b starting');
  exports.done = false;
  console.log('in b, a.done =', a.done);
  console.log('b done');
  exports.done = true;
});

启动 http-server:

# npm install -g http-server
$ http-server

打开 chrome,查看 console 控制台输出:

b starting
b.js:4 in b, a.done = undefined
b.js:5 b done
a.js:2 a starting
a.js:4 in a, b.done = true
a.js:5 a done
app.js:2 app starting
app.js:3 in app {done: true} {done: true}

首先打印的是 b 模块中的 console.log('b starting'),而不是 app 模块中的 console.log('app starting'),可以看出 Requirejs 是遵循 依赖前置 原则:demo 中 a 模块依赖 b 模块,在 a 模块回调执行前,会先确保 b 模块执行完毕,所以 b 模块中 a.done = undefined。需要注意的是,如果不使用 exports 包来导出模块返回值而选择直接 return 的话,b 模块中访问 a 模块导出值将会报 undefined 异常,相当于说 exports 包为模块的导出预置了一个空对象(详见 RequireJS API)。

所以 RequireJS 在解决循环依赖时,假设模块都没有执行过(没有缓存记录)的前提下,总会有其中一个模块读取依赖值是 空对象 或者 undefined


Seajs

那么同样的 demo 运行在 Seajs 框架下是什么效果呢?稍微改动下代码使其符合 Cmd 规范:

<!-- index.html -->
<html>
  <body>
    <script src="./sea.js"></script>
    <script>
      seajs.use('./app.js');
    </script>
  </body>
</html>
// app.js
define(function(require) {
  var a = require('./a');
  var b = require('./b');
  console.log(a, b);
});
// a.js
define(function(require, exports) {
  console.log('a starting');
  exports.done = false;
  var b = require('./b');
  console.log('in a, b.done =', b.done);
  console.log('a done');
  exports.done = true;
});
// b.js
define(function(require, exports) {
  console.log('b starting');
  exports.done = false;
  var a = require('./a');
  console.log('in b, a.done =', a.done);
  console.log('b done');
  exports.done = true;
});

控制台输出:

app.js:2 app starting
a.js:2 a starting
b.js:2 b starting
b.js:5 in b, a.done = false
b.js:6 b done
a.js:5 in a, b.done = true
a.js:6 a done
app.js:5 in app {done: true} {done: true}

RequireJS 的 log 不一样(但和 Commonjs 的 demo 输出完全一致),这里是先打印 app starting,印证了 Seajs 所遵循的 依赖就近 原则,就是模块只有在被 require 的时候才会执行。所以 SeajsCommonjs 解决循环依赖的办法都是一样的简单粗暴,需要的时候就去缓存中实时取副本,取到什么就是什么

无论是哪一种规范,都没有局限于在哪一端运行,譬如 CommonjsES module 都支持在 node 端或浏览器端运行。为了解决各大浏览器对于这些模块化标准的支持度不一的问题,我们一般使用 webpack、browserify 等构建工具处理模块代码,下一期会着重讲解 webpack 是如何实现 CommonjsES module 等模块标准的。

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

数学的 H5 应用:拖动阻尼

我们在 ios 应用(特别是浏览器)中经常看到这样的 “橡皮筋” 效果:当页面滚动到边缘时若继续拖动,其位移变化量和拖动距离成反比;就像橡皮筋一样,拉动的距离越大则继续发生形变的难度越大,也就是所受到的阻尼力越大:

接下来我会基于 vue 和 移动端 Touch 事件实现这样的 “橡皮筋” 效果。

阻尼曲线

以横坐标为 拖动距离,纵坐标为 实际位移 建立坐标轴。如此,符合 “橡皮筋” 效果的数学函数模型并不难找,我在这里提供两个基础函数模型,对数函数幂函数

$$f(x)=\log_a x,a > 1 $$ $$f(x)=x^a,0<a<1$$

各自对应的函数图像趋势大致如下:

为了满足 H5 向下拖动的实际场景,我们需要对函数体进行微调。此外,还需要设置一个 容器高度值$M$ 作为被拖动元素的位移最大值的参考。那么函数调整为:

$$f_{1}(x)=0.08M\ln (x+1)$$ $$f_{2}(x)=0.12M\sqrt[5]{x} $$

不妨设$M=500$,绘制函数图像:

可见曲线差距不大,我们选择基于幂函数$f_{2}(x)$来制作 demo:

如 gif 图所示,在刚开始往下拖动的阶段,元素发生了较大幅度的跳动,这是由于该阶段的函数值$f(x)>x$,也就是元素的位移甚至比手指拖动的距离还要大,从而产生不合理的 “跳动”。

使$60\sqrt[5]{x}=x$,借助 WolframAlpha计算引擎 求解得 $x=60\sqrt{2} \sqrt[4]{15}\approx 167$,因此在$x\subseteq (0, 167)$的区间内,$f(x)$都是比$x$大的。

换句话说,我们需要 降低函数图像曲线首段的陡度,使元素随手指拖动的变化幅度更加平缓。由于数学水平有限,我在这里仅提供一种比较麻烦的方式 —— 分段线性函数

以 ios 原生的 “橡皮筋” 效果为参考,经过大量的测试,我刻画出了一套较为合理的分段线性函数:

$$f(x)=0.18x,0\leq x \leq M$$ $$f(x)=0.14(x-M)+f(M)=0.14x+0.04M,M < x \leq 2M$$ $$f(x)=0.1(x-2M)+f(2M)=0.1x+0.12M,2M < x \leq 3M$$ $$f(x)=0.05(x-3M)+f(3M)=0.05x+0.27M,3M < x \leq 5M$$ $$f(x)=0.025(x-5M)+f(5M)=0.025x+0.395M,5M < x \leq 12M$$ $$f(x)=0.005(x-12M)+f(12M)=0.005x+0.635M,x > 12M$$

同样地使$M=500$,绘制函数图像:

demo 实际效果:

函数效率

对于 JS 引擎来说,简单的线性四则运算要比复杂的幂函数、对数函数等运算耗时更短,性能损耗更低。但是在拖动阻尼的场景下,由于实现分段线性函数需要利用循环和声明更多的临时变量,代码性能往往比单单调用 Math.pow()Math.log() 方法要低很多。

我对上述中的三种函数模型都分别提供了代码实现及 测试用例

linear: 分段线性函数,log: 对数函数,pow: 幂函数

性能差距惨不忍睹…

那么,我们能否找出一个合适的数学表达式,既能符合或近似于上面提出的分段线性函数的图像曲线,又能降低性能损耗呢?

曲线拟合

在分段线性函数的图像上取样关键点:

x 0 500 1000 1500 2500 6000 8000 10000 12000
y 0 90 160 210 260 347.5 357.5 367.5 377.5

通过 在线曲线拟合神器,使用 四参数方程模型 拟合曲线,得

$$f(x)=\frac{411.1554946226406}{1+(\frac{x}{1474.56037919441})^-1.14790922934943}-0.5843254514396$$

如果有条件的话,这里建议使用 matlab 做曲线拟合。

舍去$-0.5843254514396$,其他常数四舍五入,并化简表达式,得

$$f(x)=\frac{411.155}{1+\frac{4338.47}{x^1.14791}}$$

通过 Wolfram Cloud平台 绘制该表达式在$x\subseteq [0, 12000]$范围的图像曲线:

Prefect!

然而这个表达式是在$M=500$的条件下的,我们需要还原$M$值,最终表达式为

$$f(x)=\frac{M}{500}*\frac{411.155}{1+\frac{4338.47}{x^1.14791}}= \frac{0.82231M}{1+\frac{4338.47}{x^1.14791}}$$

瞧瞧 性能表现

curve: 拟合函数,linear: 分段线性函数,log: 对数函数,pow: 幂函数

多点触控

在元素拖动的交互场景里,实现多点触控其实非常简单,主要围绕 TouchEvent 事件中的

  • TouchEvent.touches 对象
    包含所有当前接触触摸平面的触点的 Touch 对象;
  • TouchEvent.changedTouches 对象
    包含从上一次触摸事件到此次事件过程中状态发生改变的触点的 Touch 对象。譬如某个触点从触摸平面中释放时,touchend 事件中的 changedTouches 对象就会包含该触点;

处理流程如下:

  1. 当有新触点接触平面时,touchstart 事件被触发,以 Touch.identifierid 缓存触点起始坐标;
  2. 触点移动时,touchmove 事件被触发,根据 id 计算各个触点当前位置与起始坐标的偏移值并求和;
  3. 当有触点从平面中释放时,touchend 事件被触发,记录该触点所“贡献”的偏移值,若所有触点都已释放则重置;

代码实现

提供的 demo 仅支持在移动端预览:https://codepen.io/JunreyCen/pen/LoryNp

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
    <style>
      body, ul {
        margin: 0;
        padding: 0;
      }
      ul {
        list-style: none;
      }
      .wrapper {
        position: absolute;
        top: 50%;
        left: 0;
        right: 0;
        margin: 0 auto;
        height: 80%;
        width: 80%;
        max-width: 300px;
        max-height: 500px;
        border: 1px solid #000;
        transform: translateY(-50%);
        overflow: hidden;
      }
      .list {
        background-color: #70f3b7;
        transition-timing-function: cubic-bezier(.165, .84, .44, 1);
      }
      .list-item {
        height: 40px;
        line-height: 40px;
        width: 100%;
        text-align: center;
        border-bottom: 1px solid #ccc;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
  
    <template id="tpl">
      <div
        class="wrapper"
        ref="wrapper"
        @touchstart.prevent="onStart"
        @touchmove.prevent="onMove"
        @touchend.prevent="onEnd"
        @touchcancel.prevent="onEnd">
        <ul
          class="list"
          ref="scroller"
          :style="scrollerStyle">
          <li 
            class="list-item"
            v-for="item in list">
            {{item}}
          </li>
        </ul>
      </div>
    </template>

    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script>
      new Vue({
        el: '#app',
        template: '#tpl',
        computed: {
          list() {
            const list = [];
            for (let i = 0; i < 100; i++) {
              list.push(i);
            }
            return list;
          },
          scrollerStyle() {
            return {
              'transform': `translate3d(0, ${this.offsetY}px, 0)`,
              'transition-duration': `${this.duration}ms`,
            };
          },
        },
        data() {
          return {
            wrapper: null,
            scroller: null,
            minY: 0,
            maxY: 0,
            wrapperHeight: 0,
            offsetY: 0,
            duration: 0,
            pos: {},
            cacheOffsetY: 0,
          };
        },
        mounted() {
          this.$nextTick(() => {
            this.wrapper = this.$refs.wrapper;
            this.scroller = this.$refs.scroller;
            const { height: wrapperHeight } = this.wrapper.getBoundingClientRect();
            const { height: scrollHeight } = this.scroller.getBoundingClientRect();
            this.wrapperHeight = wrapperHeight;
            this.minY = wrapperHeight - scrollHeight;
          });
        },
        methods: {
          onStart(e) {
            this.duration = 0;
            this.stop();
            // 是否为第一个触点,若是则需要重置 cacheOffsetY 值
            let isFirstTouch = true;
            Array.from(e.touches).forEach(touch => {
              const id = touch.identifier;
              if (!this.pos[id]) {
                this.pos[id] = touch.pageY;
                return;
              }
              isFirstTouch = false;
            });
            if (isFirstTouch) {
              this.cacheOffsetY = this.offsetY;
            }
          },
          onMove(e) {
            let offset = 0;
            Array.from(e.touches).forEach(touch => {
              const id = touch.identifier;
              if (this.pos[id]) {
                offset += Math.round(touch.pageY - this.pos[id]);
              }
            });
            offset = this.cacheOffsetY + offset;
            // 超出边界时增加阻尼效果
            if (offset < this.minY || offset > this.maxY) {
              this.offsetY = this.damping(offset, this.wrapperHeight);
            } else {
              this.offsetY = offset;
            }
          },
          onEnd(e) {
            Array.from(e.changedTouches).forEach(touch => {
              const id = touch.identifier;
              if (this.pos[id]) {
                this.cacheOffsetY += Math.round(touch.pageY - this.pos[id]);
              }
            });
            // 当所有触点都离开平面
            if (!e.touches.length) {
              this.cacheOffsetY = 0;
              this.pos = {};
              this.resetPosition();
            }
          },
          stop() {
            // 获取当前 translate 的位置
            const matrix = window.getComputedStyle(this.scroller).getPropertyValue('transform');
            this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]);
          },
          // 超出边界时重置位置
          resetPosition() {
            let offsetY;
            if (this.offsetY < this.minY) {
              offsetY = this.minY;
            } else if (this.offsetY > this.maxY) {
              offsetY = this.maxY;
            }
            if (typeof offsetY !== 'undefined') {
              this.offsetY = offsetY;
              this.duration = 500;
            }
          },
          // 阻尼函数
          damping(x, max) {
            let y = Math.abs(x);
            y = 0.82231 * max / (1 + 4338.47 / Math.pow(y, 1.14791));
            return Math.round(x < 0 ? -y : y);
          },
        },
      });
    </script>
  </body>
</html>

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

Git 使用技巧【持续更新】

1、.gitignore 不起作用

.gitignore 中设置的忽略规则只会对未跟踪的文件起作用。如果要让 git 忽略已被跟踪的文件,需要使用 git rm --cached 命令,详见 官方解释

The purpose of gitignore files is to ensure that certain files not tracked by Git remain untracked.
To stop tracking a file that is currently tracked, use git rm --cached.

git rm --cached <file> 命令用于删除暂存区中的 <file>,工作区中的 <file> 不受影响。我们可以先把暂存区中的所有文件删除(回到未跟踪状态),然后重新添加,从而使 .gitignore 的所有规则都生效。

$ git rm -r --cached .
$ git add .

PS: 文章首发于 简书 ,欢迎大家关注。

如何正确证明 Commonjs 模块导出是值的拷贝,而 ES module 是值的引用?

关于 CommonjsES module 模块导出的区别,一般流行一种说法:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用,而我发现,绝大部分用于证明 Commonjs 模块导出值的例程都是有问题的,我们一起来看下:

// b.js
let count = 1;
module.exports = {
  count,
  add() {
    count++;
  },
  get() {
    return count;
  }
};
// a.js
const { count, add, get } = require('./b');
console.log(count);    // 1
add();
console.log(count);    // 1
console.log(get());    // 2

b.js 中,module.exports 被赋值为一个对象(暂称为导出对象),而导出对象的 count 属性源自 count 变量,由于 count 变量是数值类型,属于 js 的基本类型之一,是按值传递的,所以 count 属性得到的只是 count 变量的拷贝值,也就是说从赋值之后开始 count 变量的任何变化都与导出对象的 count 属性毫无关系。so,这个例程根本证明不了 Commonjs 模块导出值是值的拷贝还是引用。

为了确保严谨性,我们跑一遍该 demo 在 ES module 下的实现,看看输出是否是一致的:

// b.mjs
let count = 1;
export default {
  count,
  add() {
    count++;
  },
  get() {
    return count;
  }
}
// a.mjs
import b from './b.mjs';
console.log(b.count);    // 1
b.add();
console.log(b.count);    // 1
console.log(b.get());    // 2

Commonjs 提供的导出规范不同,ES module 支持以下的导出语法,这易于证明 ES module 模块导出是值的引用,在原始值改变时 import 的加载值也会随之变化:

// b.mjs
export let count = 1;
export function add() {
  count++;
}
export function get() {
  return count;
}
// a.mjs
import { count, add, get } from './b.mjs';
console.log(count);    // 1
add();
console.log(count);    // 2
console.log(get());    // 2

上面代码中,add 函数执行使 count 变量自增,这个变化能在 a 模块中体现,这是由于 b 模块中 count 变量和导出的 count 共用同一个内存空间(准确地说,是模块 export 连接的内存空间地址就是 count 变量的内存地址),所以说 ES module 导出是值的引用。至于详细的导出原理,大家可以浏览这篇文章中对于 ES module 原理的阐述:Commonjs、esm、Amd和Cmd的循环依赖表现和原理

那么问题来了,我们应该如何证明 Commonjs 模块导出是值的拷贝呢?

目前想到了两个比较靠谱的方案:

  • 直接翻看 node 中关于 Module 类的源码实现;
  • 参考 Webpack 等构建工具是如何处理 Commonjs 模块的;

第一种方案后续会找时间剖析源码给大家分享,我们先来瞧瞧 Webpack 是如何构建下面的 Commonjs 模块 demo 的:

// a.js
const b = require('./b');
console.log(b.count);

// b.js
module.exports = {
  count: 1,
};

Webpack 输出的 bundle,这里省去了注释和部分无关代码:

(function(modules) {
  // webpackBootstrap
  // ...

  // webpack实现的require函数
  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 模块缓存id、加载状态和导出值
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}    // 关键点:模块导出预置了一个空对象
    };
    // 模块代码执行
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  // ...
  return __webpack_require__(__webpack_require__.s = 0);
})([
  // a.js
  (function(module, exports, __webpack_require__) {
    const b = __webpack_require__(1);
    console.log(b.count);
  }),

  // b.js
  (function(module, exports) {
    module.exports = {
      count: 1,
    };
  })
])

从编译后的 bundle 看出,Commonjs 模块导出在这里其实只是对 installedModules[moduleId].exports 属性的赋值操作,所以针对以下情况

// 在预置的`installedModules[moduleId].exports`空对象上新增一个基本类型的`count`属性,相当于基本类型的拷贝。
let count = 1;
exports.count = count;

// `installedModules[moduleId].exports`被赋值一个新的包含`count`属性的对象,相当于对象浅拷贝。
module.exports = {
  count,
};

这就可以说明 Commonjs 模块导出的是值的拷贝了。

PS: 文章首发于 简书 ,欢迎大家关注。

物理学的 H5 应用:模拟惯性滑动

在移动端 H5 中,时间选择器(date-picker)、省市区选择器(area-picker)等组件经常会使用这样的交互效果:

微信原生 date-picker 效果

这个 gif 是在【微信钱包 - 账单】中录制的 ios 原生时间选择器。可见,**当用户手指在选择器上先是滑动再从屏幕上移开,内容会继续保持一段时间的滚动效果,并且滚动的速度和持续的时间是与滑动手势的强烈程度成正比。**这种交互思路源于 ios 系统原生元素的滚动回弹(momentum-based scrolling),来看 H5 的一个普通列表在 ios 上的滚动表现:

社区上大部分的移动端组件库的选择器组件都采取了这种交互方式,看看效果:

weui 的选择器实现了惯性滑动,但滑动动画结束得有点突兀,效果一般。

vant 的选择器压根没有做惯性滑动,当手指从屏幕上移开后,选择器的滑动会立刻停止。可见这样的交互体验是比较差的。

接下来我会从设计层面剖析和模拟惯性滑动的交互效果。

物理学应用

不难想象,惯性滑动非常贴合现实生活中的一些场景,如汽车刹车等。除此之外,与物理力学中的滑块模型也十分相似,由此我会参考滑块模型来剖析惯性滑动的全过程。

惯性 来源于物理学中的惯性定律(即 牛顿第一定律):一切物体在没有受到力的作用的时候,运动状态不会发生改变,物体所拥有的这种性质就被称为惯性。我们不妨把惯性滑动模拟成滑动滑块然后释放的过程(以下讨论中用户滑动的目标皆模拟成 滑块),主要划分为两个阶段:

  • 用户滑动滑块使其从静止开始做加速运动;

  • 用户释放滑块使其只在摩擦力的作用下继续滑动,直至静止;

惯性滑动距离

描述滑块的惯性滑动,首先需要求出滑动的距离。在上述二阶段中,滑块受摩擦力$F_{摩}$作 匀减速直线运动。假设滑动距离为$s_{2}$,初速度为$v_{0}$,末速度为$0m/s$。根据位移公式

$$s_{2} = \frac{0 + v_{0}}{2}t_{2}$$

加速度公式

$$a = \frac{0 - v_{0}}{t_{2}}$$

可以算出惯性滑动距离

$$s_{2} = - \frac{v_{0}^2 }{2a}$$

由于匀减速运动的加速度为负,不妨设一个加速度常量$A$,使其满足$A = -2a$,那么

$$s_{2} = \frac{v_{0}^2}{A}$$

这里$A$为正数。也就是说,我们只需要求出初始速度即可。

实际计算时,$v_{0}^2$会导致计算出的惯性滑动距离过大,因此公式调整为$s_{2} = \frac{v_{0}}{A}$。

关注第一个阶段,假设用户滑动滑块的距离为$s_{1}$,滑动的持续时间是$t_{1}$,那么二阶段的初速度$v_{0}$可以根据位移公式求得

$$v_{0} = \frac{2s_{1}}{t_{1}}$$

综上,求惯性滑动的距离我们需要记录用户滑动滑块的 距离$s_{1}$持续时间$t_{1}$,并设置一个合理的 加速度常量$A$

经测试,加速度常量的合适值为 $A=0.003$

注意,这里的距离和持续时间并不是用户滑动滑块的总距离和时长,而是触发惯性滑动范围内的距离和时长,详见【惯性滑动的启动条件】。

惯性滑动速度曲线

针对二阶段的匀减速直线运动,时间段$\Delta t$产生的位移差$\Delta s = at^2$,其中$a<0$。也就是说时间越往后,同等时间间距下通过的位移越来越小,也就是动画的推进速度越来越慢。

这与 CSS3 transition-timing-function 中的 ease-out 速度曲线相吻合,ease-out (即 cubic-bezier(0, 0, .58, 1))的贝塞尔曲线为

上图来自 在线绘制贝塞尔曲线网站。图表中的纵坐标是指 动画推进的进程;横坐标是指 时间;原点坐标为 (0, 0),终点坐标为 (1, 1),假设动画持续时间为2秒,(1, 1)坐标点则代表离动画开始2秒时动画执行完毕(100%)。根据图表可以得出,时间越往后动画进程的推进速度越慢,符合匀减速直线运动的特性。

然而这样的速度曲线过于线性平滑,减速效果不明显。我们基于 ios 滚动回弹的效果,调整贝塞尔曲线的参数为 cubic-bezier(.17, .89, .45, 1)

回弹

滑块滑动不是无边界的,我们来考虑这样的场景:当滑块向下滑动,其顶部正要接触容器上边界时速度还没有降到$0m/s$,此时如果让滑块瞬间停止运动,这样的交互效果是不理想的。

我们可以把上边界想象成一条与滑块紧密贴合的固定弹簧,当滑块到达临界点而速度还没有降到$0m/s$时,滑块会继续滑动并拉动弹簧使其往下形变,同时会受到弹簧的反拉力作减速运动(动能转化为内能);当滑块速度降为$0m/s$,此时弹簧的形变量最大,由于弹性特质弹簧会恢复原状(内能转化成动能),从而拉动滑块反向运动

回弹过程也可以分为两个阶段:

  • 滑块拉动弹簧作变减速运动。此阶段滑块受摩擦力$F_{摩}$和越来越大的弹簧反拉力$F_{弹}$共同作用,加速度越来越大,所以速度降为$0m/s$的时间非常短;

  • 弹簧恢复原状,拉动滑块作先变加速后变减速运动。此阶段滑块受到的摩擦力$F_{摩}$和越来越小的弹簧拉力$F_{弹}$相互抵消,刚开始$F_{弹}>F_{摩}$,滑块作加速度越来越小的变加速运动;随之$F_{弹}<F_{摩}$,滑块作加速度越来越大的变减速运动,直至静止。这里为了交互效果我们可以营造一个理想状态:滑块静止时弹簧刚好恢复形变。

回弹距离

根据上述分析,回弹的第一阶段作加速度越来越大的变减速直线运动,设此阶段的初速度为$v_{1}$,可以与$v_{0}$建立以下关系

$$l_{滑块} = \frac{v_{1}^2 - v_{0}^2}{2a}$$

那么回弹距离为

$$S_{回弹}=\int_{0}^{t}v(t)dt$$

微积分都来了,简直没法算好吧…

我们可以根据运动模型来简化$S_{回弹}$的计算,由于该阶段的加速度大于 非回弹惯性滑动 的加速度,设 非回弹惯性滑动 的总距离为$S_{滑}$,那么

$$S_{回弹}<S_{滑}-l_{滑块}$$

所以可以设置一个合理的常量$B$,使其满足

$$S_{回弹} = \frac{S_{滑}-l_{滑块}}{B}$$

经测试,常量$B$的合理取值为 10。

回弹速度曲线

整个触发回弹的惯性滑动模型包括三个运动阶段:

然而把 阶段a 和 阶段b 描绘成 CSS 动画是有一定复杂度和风险的:

  • 阶段b 中的变减速运动难以描绘;
  • 两个阶段运动方向相同但动画速度曲线不连贯,容易造成用户体验的断层;

出于简化的考虑,可以将 阶段a、b 合并为一个运动阶段:

对于合并后的 阶段a 末段,由于反向加速度越来越大,因此滑块减速的效率会比 非回弹惯性滑动 同期更大,对应的贝塞尔曲线末段也会更陡,参数调整为 cubic-bezier(.25, .46, .45, .94)

在 阶段b 中,滑块先变加速后变减速,尝试 ease-in-out 的动画曲线:

可以看出,由于 阶段b 初始的 ease-in 曲线使 阶段a、b 的衔接段稍有停留,效果体验一般。所以我们选择只描绘变减速运动这一段,调整贝塞尔曲线为 cubic-bezier(.165, .84, .44, 1)

由于 mp4 转 gif 格式会掉帧,所以示例效果看起来会有点卡顿,建议直接体验 demo

动画时长

PS:以下取值都是基于对 ios 滚动回弹实例的测量。

一次惯性滑动可能会出现两种情况:

  • 没有触发回弹
    滑动动画的持续时间为 2500ms

  • 触发回弹
    阶段a 中,当$S_{回弹}$大于某个阈值时,为 强回弹,动画时长设为 400ms,反之为 弱回弹,时长设为 800ms
    阶段b 持续时间为 500ms

惯性滑动启停

  • 启动条件

惯性滑动的启动需要有足够的动量。我们可以简单地认为,当用户滑动的距离足够大(大于 15px)和持续时间足够短(小于 300ms)时,即可产生惯性滑动。也就是说,最后一次 touchmove 事件触发的时间和 touchend 事件触发的时间间隔小于 300ms,且两者产生的距离差大于 15px 时认为启动惯性滑动。

  • 暂停时机

当惯性滑动未结束(包括处于回弹过程),用户再次触碰滑块时会暂停滑块的运动。原理上是通过 getComputedStylegetPropertyValue 方法获取当前的 transform: matrix() 矩阵值,抽离出水平 y 轴偏移量后重新调整 translate 的位置。

完整代码

demo 基于 vuejs 实现,预览地址:https://codepen.io/JunreyCen/pen/arRYem

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
    <style>
      body, ul {
        margin: 0;
        padding: 0;
      }
      ul {
        list-style: none;
      }
      .wrapper {
        position: absolute;
        top: 50%;
        left: 0;
        right: 0;
        margin: 0 auto;
        height: 80%;
        width: 80%;
        max-width: 300px;
        max-height: 500px;
        border: 1px solid #000;
        transform: translateY(-50%);
        overflow: hidden;
      }
      .list {
        background-color: #70f3b7;
      }
      .list-item {
        height: 40px;
        line-height: 40px;
        width: 100%;
        text-align: center;
        border-bottom: 1px solid #ccc;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
  
    <template id="tpl">
      <div
        class="wrapper"
        ref="wrapper"
        @touchstart.prevent="onStart"
        @touchmove.prevent="onMove"
        @touchend.prevent="onEnd"
        @touchcancel.prevent="onEnd"
        @mousedown.prevent="onStart"
        @mousemove.prevent="onMove"
        @mouseup.prevent="onEnd"
        @mousecancel.prevent="onEnd"
        @mouseleave.prevent="onEnd"
        @transitionend="onTransitionEnd">
        <ul
          class="list"
          ref="scroller"
          :style="scrollerStyle">
          <li 
            class="list-item"
            v-for="item in list">
            {{item}}
          </li>
        </ul>
      </div>
    </template>

    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script>
      new Vue({
        el: '#app',
        template: '#tpl',
        computed: {
          list() {
            const list = [];
            for (let i = 0; i < 100; i++) {
              list.push(i);
            }
            return list;
          },
          scrollerStyle() {
            return {
              'transform': `translate3d(0, ${this.offsetY}px, 0)`,
              'transition-duration': `${this.duration}ms`,
              'transition-timing-function': this.bezier,
            };
          },
        },
        data() {
          return {
            wrapper: null,
            scroller: null,
            minY: 0,
            maxY: 0,
            wrapperHeight: 0,
            offsetY: 0,
            duration: 0,
            bezier: 'linear',
            startY: 0,
            pointY: 0,
            startTime: 0,                 // 惯性滑动范围内的 startTime
            momentumStartY: 0,            // 惯性滑动范围内的 startY
            momentumTimeThreshold: 300,   // 惯性滑动的启动 时间阈值
            momentumYThreshold: 15,       // 惯性滑动的启动 距离阈值
            isStarted: false,             // start锁
          };
        },
        mounted() {
          this.$nextTick(() => {
            this.wrapper = this.$refs.wrapper;
            this.scroller = this.$refs.scroller;
            const { height: wrapperHeight } = this.wrapper.getBoundingClientRect();
            const { height: scrollHeight } = this.scroller.getBoundingClientRect();
            this.wrapperHeight = wrapperHeight;
            this.minY = wrapperHeight - scrollHeight;
          });
        },
        methods: {
          onStart(e) {
            const point = e.touches ? e.touches[0] : e;
            this.isStarted = true;
            this.duration = 0;
            this.stop();
            this.pointY = point.pageY;
            this.momentumStartY = this.startY = this.offsetY;
            this.startTime = new Date().getTime();
          },
          onMove(e) {
            if (!this.isStarted) return;
            const point = e.touches ? e.touches[0] : e;
            const deltaY = point.pageY - this.pointY;
            // 浮点数坐标会影响渲染速度
            let offsetY = Math.round(this.startY + deltaY);
            // 超出边界时增加阻力
            if (offsetY < this.minY || offsetY > this.maxY) {
              offsetY = Math.round(this.startY + deltaY / 3);
            }
            this.offsetY = offsetY;
            const now = new Date().getTime();
            // 记录在触发惯性滑动条件下的偏移值和时间
            if (now - this.startTime > this.momentumTimeThreshold) {
              this.momentumStartY = this.offsetY;
              this.startTime = now;
            }
          },
          onEnd(e) {
            if (!this.isStarted) return;
            this.isStarted = false;
            if (this.isNeedReset()) return;
            const absDeltaY = Math.abs(this.offsetY - this.momentumStartY);
            const duration = new Date().getTime() - this.startTime;
            // 启动惯性滑动
            if (duration < this.momentumTimeThreshold && absDeltaY > this.momentumYThreshold) {
              const momentum = this.momentum(this.offsetY, this.momentumStartY, duration);
              this.offsetY = Math.round(momentum.destination);
              this.duration = momentum.duration;
              this.bezier = momentum.bezier;
            }
          },
          onTransitionEnd() {
            this.isNeedReset();
          },
          momentum(current, start, duration) {
            const durationMap = {
              'noBounce': 2500,
              'weekBounce': 800,
              'strongBounce': 400,
            };
            const bezierMap = {
              'noBounce': 'cubic-bezier(.17, .89, .45, 1)',
              'weekBounce': 'cubic-bezier(.25, .46, .45, .94)',
              'strongBounce': 'cubic-bezier(.25, .46, .45, .94)',
            };
            let type = 'noBounce';
            // 惯性滑动加速度
            const deceleration = 0.003;
            // 回弹阻力
            const bounceRate = 10;
            // 强弱回弹的分割值
            const bounceThreshold = 300;
            // 回弹的最大限度
            const maxOverflowY = this.wrapperHeight / 6;
            let overflowY;

            const distance = current - start;
            const speed = 2 * Math.abs(distance) / duration;
            let destination = current + speed / deceleration * (distance < 0 ? -1 : 1);
            if (destination < this.minY) {
              overflowY = this.minY - destination;
              type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce';
              destination = Math.max(this.minY - maxOverflowY, this.minY - overflowY / bounceRate);
            } else if (destination > this.maxY) {
              overflowY = destination - this.maxY;
              type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce';
              destination = Math.min(this.maxY + maxOverflowY, this.maxY + overflowY / bounceRate);
            }

            return {
              destination,
              duration: durationMap[type],
              bezier: bezierMap[type],
            };
          },
          // 超出边界时需要重置位置
          isNeedReset() {
            let offsetY;
            if (this.offsetY < this.minY) {
              offsetY = this.minY;
            } else if (this.offsetY > this.maxY) {
              offsetY = this.maxY;
            }
            if (typeof offsetY !== 'undefined') {
              this.offsetY = offsetY;
              this.duration = 500;
              this.bezier = 'cubic-bezier(.165, .84, .44, 1)';
              return true;
            }
            return false;
          },
          stop() {
            // 获取当前 translate 的位置
            const matrix = window.getComputedStyle(this.scroller).getPropertyValue('transform');
            this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]);
          },
        },
      });
    </script>
  </body>
</html>

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

JS 稀疏数组

稀疏数组是指 索引不连续,数组长度大于元素个数的数组,通俗地说就是 有空隙的数组。我们可以通过下面的方式生成稀疏数组:

// 构造函数声明一个没有元素的数组
var a = new Array(5);    // [empty × 5]

// 指定的索引值大于数组长度
var a = [];
a[5] = 4;                // [empty × 5, 4]

// 指定大于元素个数的数组长度
var a = [];
a.length = 5;            // [empty × 5]

// 数组直接量中省略值
var a = [0,,,,];         // [0, empty × 3]

// 删除数组元素
var a = [0, 1, 2, 3, 4];
delete a[4];             // [0, 1, 2, 3, empty]

可见,写代码时稍不注意,是很容易产生稀疏数组的。

empty vs undefined

稀疏数组在控制台中的表示:

var a = new Array(5);
console.log(a);    // [empty × 5]

这里表示数组 a 有5个空隙。 empty 并非 JS 的基础数据类型,那它到底是什么东西?我们试着访问数组元素:

a[0];    // undefined

难道 emptyundefined 是一个含义?其实不然,我们来看看这个数组:

var b = [undefined, undefined, undefined];
console.log(b);    // [undefined, undefined, undefined]
b[0];              // undefined

a.forEach(i => { console.log(i) });    // 无 log 输出
b.forEach(i => { console.log(i) });    // undefined undefined undefined

数组 a 和 数组 b 只有访问具体元素的时候输出一致,其他情况都是存在差异的。遍历数组 a 时,由于数组中没有任何元素,所以回调函数不执行不会有 log 输出;而遍历数组 b 时,数组其实填充着元素 undefined,所以会打印 log。

这里的数组 b 其实是一个 密集数组

至于为什么访问稀疏数组的缺失元素时会返回 undefined,是因为 JS 引擎在发现元素缺失时会临时赋值 undefined,类似于 JS 变量的声明提升:

console.log(a);    // undefined
var a = 0;

转化为密集数组

// 稀疏数组
var a = new Array(5);

Array.apply(null, a);    // ES5
Array.from(a);           // ES6

稀疏数组特性

稀疏数组跟密集数组相比具有以下特性:

  • 访问速度慢
  • 内存利用率高

这与 V8 引擎构建 JS 对象的方式有关。V8 访问对象有两种模式:字典模式快速模式

稀疏数组使用的是字典模式,也称为 散列表模式,该模式下 V8 使用散列表来存储对象属性。由于每次访问时都需要计算哈希值(实际上只需要计算一次,哈希值会被缓存)和寻址,所以访问速度非常慢。另一方面,对比起使用一段连续的内存空间来存储稀疏数组,散列表的方式会大幅度地节省内存空间。

而密集数组在内存空间中是被存储在一个连续的类数组里,引擎可以直接通过数组索引访问到数组元素,所以速度会非常快。

这里提供一个 jsperf 测试

// Sparse Array
var a = [];
a[10000] = 1;
a.forEach(function(){});

// Dense Array
var b = Array.from(a);
b.forEach(function(){});

测试结果:

可见密集数组的访问性能明显比稀疏数组的高,因此建议日常编码中能避免稀疏数组的尽量避免。

遍历稀疏数组

大部分 Array.prototype 上的数组遍历方法,譬如 forEachmapfilter 等方法,在遍历到稀疏数组的缺失元素时,回调函数是不会执行的

var a = [1,,,,];
a.forEach(i => { console.log(i) });    // 只会打印一次

除此之外,我们需要注意稀疏数组在这些场景下的特殊表现:

  • for-in 语句

for-in 语句只会遍历对象的可枚举属性,不会遍历稀疏数组中的缺失元素。

var a = [1,,,,5];
for (var i in a) { console.log(a[i]) };    // 1 5
for (var i of a) { console.log(i) };       // 1 undefined undefined undefined 5
  • 判断数组中是否存在 undefined 元素
var a = [1,,,,];
var b = new Array(5);
var c = [];

// 结果符合预期的
a.findIndex(i => i === undefined);    // 1
b.every(i => i === undefined);        // true

// 结果不符合预期的
a.indexOf();                          // -1
a.some(i => i === undefined);         // false

// 比较特殊的
a.includes();                         // true
b.includes();                         // true
c.includes();                         // false

someevery 方法会出现这样的结果,是因为这两者都不会遍历数组中的缺失元素,所以 some 只会返回 false (数组内找不到 undefined 元素)。而 every 在遍历空数组时,会返回 true

findIndex 方法与 forEach 等遍历方法有所不同,findfindIndex 是使用 for 循环实现的,所以无论数组元素是否缺失都可以被遍历到。

includes 方法则比较特殊,我们可以理解为当数组为空时,只会返回 false;而当数组非空(指长度不为0的数组,其中包括全部元素都缺失的数组),且函数调用参数为空时会返回 true

  • map 方法

不会遍历缺失元素,但返回的结果具有与源数组相同的长度和空隙。

var a = [1,,,,5];
a.map(i => i);       // [1, empty × 3, 5]
a.filter(i => i);    // [1, 5]
  • sort 方法

不会遍历缺失元素,数组能正常排序,同时会返回与源数组相同的长度。

var a = [5,,,,1];
a.sort();    // [1, 5, empty × 3]
  • join 方法

缺失元素占的坑还是会被保留。

var a = new Array(5);
a.join();    // ",,,,"

其他未提及的数组原型方法,在稀疏数组下调用的表现基本与密集数组的一致。

PS: 文章首发于 简书 ,欢迎大家关注。

利用栈实现四则混合运算引擎

浮点数问题

尝试在浏览器端做算术运算的同学,多多少少都会遇到过这样奇怪的问题:

0.1 + 0.2    // 0.30000000000000004
1 - 0.9    // 0.09999999999999998
19.9 * 100    // 1989.9999999999998
0.3 / 0.1    // 2.9999999999999996

这是由于 JS 是一门弱类型语言,不像 Java 有 intfloatdouble 等丰富的数据类型,JS 中所有数字(无论整数或小数)都只有 Number 一种类型。其采用 IEEE 754 的 64 位双精度浮点数,由 1 位符号位、11 位的指数位和 52 位的尾数位组成。由于有限的存储位数,当数字从十进制转换成二进制的尾数无穷时,多出的部分就会被截断,在计算结束转换为十进制时就会出现浮点误差。为了更好理解下面例子的换算过程,建议先读这篇文章: JavaScript 浮点数陷阱及解法

0.1+0.2 的例子( 此网站 支持十进制与双精度浮点数的转换):

0.1 的双精度浮点数为 0 01111111011 1001100110011001100110011001100110011001100110011010,指数位转换成十进制是 10191019 - 1023 = -4,补上整数部分 1 再小数点前移4位,得 0.1 的二进制数为 0.00011001100110011001100110011001100110011001100110011010。按照同样的转换方法:

// 0.1 + 0.2
  0.00011001100110011001100110011001100110011001100110011010
+ 0.0011001100110011001100110011001100110011001100110011010
= 0.0100110011001100110011001100110011001100110011001100111

// 由于双精度的位数限制,最后一位舍去进1,得
0 01111111101 0011001100110011001100110011001100110011001100110100

// 转换成十进制
0.30000000000000004

解决方案

浮点数运算的解决方案很简单,就是把小数转换成整数后再进行相应的运算。譬如:

(0.1 * 10 + 0.2 * 10) / 10    // 0.3

代码实现:

function operate(left, right, operator) {
  /** 固定小数位精度的写法 **/
  const precision = 2;
  const factor = +'1000000000000'.slice(0, precision + 1);

  /** 自动获取最大小数位的写法 **/
  // const lPrecision = (left.toString().split('.')[1] || '').length;
  // const rPrecision = (right.toString().split('.')[1] || '').length
  // const factor = Math.pow(10, Math.max(lPrecision, rPrecision));

  if (operator === '+') {
    return Math.round(left * factor + right * factor) / factor;
  } else if (operator === '-') {
    return Math.round(left * factor - right * factor) / factor;
  } else if (operator === '*') {
    return Math.round(left * factor * right * factor) / Math.pow(factor, 2);
  } else if (operator === '/') {
    return Math.round(left / right * factor) / factor;
  }
}

operate(0.1, 0.2,'+');    // 0.3
operate(0.3, 0.1,'/');    // 3

然而这样简陋的处理在复杂的四则运算场景中使用会非常麻烦,我们可以实现一个简单的计算引擎,让其可以处理基本的四则混合运算(如 1 * ( 2 - 3 ) )。

实现四则混合运算

四则混合运算涉及加、减、乘、除及括号的优先级,如果用正常的 中缀表达式 的计算思维来实现会比较复杂,所以我们引入逆波兰表达式。

逆波兰表达式

逆波兰表达式 中,所有运算符都被置于操作数后面,因此也称为后缀表示法

// 正常算术式 -> 逆波兰表达式
`a + b`  ->  `a b +`
`a - b + c`  ->  `a b - c +`
`a * ( b - c )`  ->  `a b c - *`

逆波兰表达式的计算一般依赖堆栈结构,过程非常简单:

如为操作数则入栈,如为运算符则把栈内操作数弹出并计算,再重新入栈,遍历直至栈内只剩一个元素即为表达式的值。

逆波兰表达式不需要关心运算符之间的优先级问题,也不存在括号,可以极大限度地减小程序复杂度。

调度场算法

借助 调度场算法 ,我们可以简单地把中缀表达式转换成逆波兰表达式。为了更好地描述调度场算法的流程,这里初始化了三个对象:

  • Input queue:输入队列,一般是预处理表达式字符串后提取出的元素数组;
  • Operator stack:用于存储操作符的栈;
  • Output queue:逆波兰表达式元素队列;

调度场算法流程如下,其中简称 Output queue 为队列 qOperator stack 为栈 s

  1. Input queue中取出一个元素 x
  2. 如果 x 是操作数,直接添加到队列 q 中;
  3. 如果 x 是左括号 ( ,直接压入栈 s 中;
  4. 如果 x 是右括号 ),从栈 s 中弹出运算符添加到队列q中,直至栈顶元素为左括号,弹出左括号并丢弃掉(如果直至栈 s 清空依然找不到左括号,则表达式格式错误);
  5. 如果 x 是除括号外的运算符,与栈 s 的栈顶元素 o1 比较:如果 x 的优先级大于 o1,则把 x 压入栈 s;反之,从栈 s 中不断弹出操作符并添加到队列 q 中,直至栈顶元素的优先级低于 x,或栈顶元素为左括号 (,然后把 x 压入栈 s
  6. 重复步骤 1 - 5,当 Input queue 中已没有元素时,将栈 s 中的元素逐个弹出放入队列 q 中。

JS 实现

function Calculate(options = {}) {
  // 运算符权重
  this.weight = options.weight || {
    '+': 1,
    '-': 1,
    '*': 2,
    '/': 2,
  };
  // 小数位精度
  this.decimal = options.decimal || 2;
  this.operatorStack = [];    // 运算符栈
  this.outputQueue = [];      // 逆波兰表达式队列
}

Calculate.prototype = {

  /**
   * @desc 四则运算,浮点数处理
   */
  operate(left, right, operator) {
    const factor = +'1000000000000'.slice(0, this.decimal + 1);
    if (operator === '+') {
      return Math.round(left * factor + right * factor) / factor;
    } else if (operator === '-') {
      return Math.round(left * factor - right * factor) / factor;
    } else if (operator === '*') {
      return Math.round(left * factor * right * factor) / Math.pow(factor, 2);
    } else if (operator === '/') {
      return Math.round(left / right * factor) / factor;
    }
  },

  /**
   * @desc 调度场算法
   */
  shuntingYard(token) {
    if (!isNaN(+token)) {
      this.outputQueue.push(+token);
    } else if (token === '(') {
      this.operatorStack.push(token);
    } else if (token === ')') {
      let top = this.operatorStack.pop();
      while (top && top !== '(') {
        this.outputQueue.push(top);
        top = this.operatorStack.pop();
      }
      if (!top) throw new Error('表达式格式错误:括号不闭合');
    } else {
      let top = this.operatorStack.pop();
      while (top && top !== '(' && this.weight[token] <= this.weight[top]) {
        this.outputQueue.push(top);
        top = this.operatorStack.pop();
      }
      top ? this.operatorStack.push(top, token) : this.operatorStack.push(token);
    }
  },

  /**
   * @desc 逆波兰表达式求值
   */
  calRpn() {
    const stack = [];
    while (this.outputQueue.length) {
      let token = this.outputQueue.shift();
      if (typeof token === 'number') {
        stack.push(token);
      } else {
        const right = stack.pop();
        const left = stack.pop();
        if (!right || !left) throw new Error('计算错误');
        stack.push(this.operate(left, right, token));
      }
    }
    if (stack.length !== 1) throw new Error('计算错误');
    return stack[0];
  },

  /**
   * @desc 校验表达式合法性,预处理等
   * @todo 更详细的格式校验
   */
  preValid(expStr) {
    return expStr;
  },

  run(expStr) {
    this.operatorStack = [];
    this.outputQueue = [];

    let chars;
    const str = this.preValid(expStr);
    const reg = /([\d\.]+|[\(\)\+\-\*\/])/g;
    while ((chars = reg.exec(str)) !== null) {
      this.shuntingYard(chars[0]);
    }
    while (this.operatorStack.length) {
      this.outputQueue.push(this.operatorStack.pop());
    }
    return this.calRpn()
  }
}

const cal = new Calculate();
cal.run('1-(2+3*8)');    // -25

Reference

PS: 文章首发于 简书 ,欢迎大家关注。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.