跳到主要内容

记一次误操作删除800G数据的经历

· 阅读需 4 分钟

前因后果

2021年2月5日,我正在尝试运行一份示例代码。该脚本类似这样:

# 这里有检查$REC_ROOT,但本脚本内并未处理,所以只会输出缺少环境变量$REC_ROOT,但继续执行
./config.sh
if [ ! -d $WAV_ROOT ]; then
echo "Cannot find wav directory $WAV_ROOT"
exit 1
fi

data="$REC_ROOT/data"

# 其他代码

if [ $stage -le 0 ]; then
echo ""
echo "Stage 0: Preparing data"
rm -rf $data/*
local/chime1_prepare_data.sh || exit 1
fi

由于脚本来自知名开源项目,我并没有仔细审查。另外由于对相关代码并不熟悉,我也没有正确配置相关环境变量,所以脚本中的WAV_ROOTREC_ROOT理所当然是未定义的。

我就这么冒失地执行了脚本,而它在打印两句警告后并没有停止执行,所以我认为环境变量并不是必须的,于是放任它继续执行。由于该脚本执行的是耗时任务,我将控制台隐藏到后台,去完成其他任务。

过了十几分钟,我收到一个应用程序的崩溃报告,因为相关文件不存在。我疑惑地检查,发现数百GB的数据已经不翼而飞。此时我才想起那个正在执行的脚本,切换过去后发现它还没有停止执行,正在疯狂删除我的文件。我赶忙杀掉了脚本,但包括由于没有root权限而删除失败的,原本800GB+的数据只剩5.6G。

让我们来看看发生了什么。

首先,脚本调用config.sh,检测到REC_ROOT环境变量不存在并打印警告。

然后脚本继续执行,if [ ! -d $WAV_ROOT ]; then这里是在检测WAV_ROOT是否是一个目录,如果不是,就退出脚本。按理说我并没有配置任何环境变量,此处应该退出。但bash脚本神奇地,当WAV_ROOT为空或者不存在时,这个检测会认为这是一个目录,从而通过检测。即:

unset NOT_EXIST
if [ -d $NOT_EXIST ]; then
echo "this is a directory"
fi

上面的脚本是会输出的。

或许是因为参数为空时bash默认检测当前目录,以至于目录检测总是通过。

再然后,由于REC_ROOT未定义,$data=/data,然后相当于:

rm -rf /data/*

非常不巧和不幸的是,我将一块1TB的数据盘挂载在了/data上,于是迎来了降维打击。该数据盘中有800G+的数据,文件量大于10万,因此非常耐删,过了十几分钟还给我剩了几个G。而大量读写操作将数据恢复的难度推到了地狱级。

抢救措施

在杀掉脚本之后,我尝试卸载数据盘,但卸载失败,提示正忙。情急之下我忘记了可以通过正在运行的进程恢复它们打开的文件,而是想到先关机避免更多的读写。关机前发现VSCodium还在运行并且有未关闭的文件,于是抢救出几个正在编辑的代码文件。而这成了本次事故中我唯一抢救成功的文件。

之后,通过U盘刻录的系统修改原系统的配置,取消掉自动挂载数据盘,然后系统启动后以只读方式挂载数据盘尝试恢复数据。

正如前面所说,大量的读写操作让我失去了恢复的机会,尝试了不少恢复工具,但都没法扫描出目录结构,唯一可能有效的方法是通过特殊的文件头结构进行特征扫描,但这只能恢复一些特殊格式的文件,而对我最重要的都是纯文本数据,至于一些视频文件,由于尺寸太大数据分散在不同的块,也是基本没戏的。

还好,云端备份让我不至于一无所有,但还是痛失最近一个月的活动数据和大量不可描述之物。

总结

数据无价,谨慎操作。备份得当,也别太浪。

DOM更新与浏览器事件循环

· 阅读需 5 分钟

通常我们说DOM更新发生在一个宏任务执行完之后,但并不是每次宏任务执行后都会进行DOM更新。浏览器会维护一个相对稳定的帧率,根据硬件条件和页面性能表现。那么事件循环如何判断是否应该更新DOM呢?

先上一波HTML规范指定的事件循环处理章程

  1. 从事件循环的任务队列中取一个任务队列taskQueue,这里的任务队列指的是宏任务队列。注意,一个事件循环可能并不只有一个宏任务队列。具体有哪些以及这里怎么选由用户代理决定。
  2. taskQueue中出队一个(宏)任务oldestTask,并设置事件循环的当前执行的任务taskQueue
  3. 执行taskQueue
  4. 设置事件循环的当前执行的任务为null。
  5. 执行微任务队列中的(task)。
  6. 处理DOM更新。

上面的描述省略了一些步骤,完整版参考规范。注意worker环境的事件循环不在本文讨论范畴之内。

可以发现DOM更新确实发生在两个宏任务执行之间,但如果我们深入DOM更新步骤,会发现还有个渲染机会(rendering opportunity的概念。DOM更新时会忽略没有渲染机会的文档。

那么什么时候一个DOM文档(准确说是其浏览上下文)拥有渲染机会呢?前面说过,浏览器会维护一个相对稳定的帧率,例如30fps,那么1秒内应该最多拥有30次渲染机会。浏览器根据这些条件和是否有DOM更新(DOM操作,动画等),决定文档是否应该拥有渲染机会相对稳定的意思是,当硬件条件或者页面性能导致无法维持较高帧率时可能会降帧。虽然这并不是规范规定的,但规范中以此为例,而且主流浏览器应该都是这么实现的。

了解这些有什么用呢?我们可以更精细地把握自己的代码。考虑以下代码:

setTimeout(() => {
//依赖DOM的操作
}, 0);

我们可能会用定时器来等待dom更新后执行一些操作,因为定时器创建宏任务,而DOM会在当前宏任务执行结束后更新(错误)。
有趣的是,这往往也不会出大问题。一来一般这时候已经执行了不少代码,可能正好浏览器在当前宏任务执行完后更新了DOM,二来DOM操作往往会导致DOM强制更新(如读取DOM元素几何相关的数值等,但不绝对触发强制更新),也可能会得到想要的结果。
然后结果是,bug若有若无的,全看人品。啊,我测试的时候好好的,演示的时候怎么就...😅

上面的例子不够具体,来个更实际的。假设要实现一个DOM元素从远处飞回来的动画,我们不用css animation,用transform。
首先我们需要给DOM元素一个初始偏移量,否则起点就是终点,就没有动画效果了。然后给它设置transition样式,并把偏移量还原。

// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
// ...???
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';

为了让动画从初始偏移量开始,我们需要在???前后代码执行之间触发一次DOM更新。

最简单的就是触发DOM强制更新。

// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
el.getBoundingClientRect();
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';

通过getBoundingClientRect()读取元素的几何信息,迫使DOM重新计算元素的属性。但这可能造成额外的运算,带来性能损失。虽然一般场景下问题不大。

如果我们想将???后面的代码延迟到下一帧之后执行呢?靠setTimeout()的话,timeout设置小了就回到bug与人品的故事了,设置大了又会产生卡顿影响体验。这时候我们可以通过一个神奇的API来解决:requestAnimationFrame

// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
requestAnimationFrame(() => {
setTimeout(() => {
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
}, 0);
});

首先,requestAnimationFrame()的回调会在下一帧绘制之前执行。我的理解是,执行requestAnimationFrame的回调函数列表作为一个宏任务被调度,并且此时该DOM文档获得渲染机会,因此回调执行结束后DOM将更新,而当回调函数中设置的定时器任务被执行的时候已经处于新的宏任务中,此时DOM文档已更新。

注意:对于处理一个浏览上下文的requestAnimationFrame回调时的宏任务和下一个宏任务之间,该浏览上下文一定获得会渲染机会是否成立,尚未找到规范证明,仅作参考。

本文链接:https://blog.mattuy.top/archives/dom-update-and-browser-event-loop

LeetCode之旅──696.计数二进制子串

· 阅读需 2 分钟

题目:给定一个字符串 s,计算具有相同数量0和1的非空(连续)子字符串的数量,并且这些子字符串中的所有0和所有1都是组合在一起的。
重复出现的子串要计算它们出现的次数。

示例:

输入: "00110011" 输出: 6 解释: 有6个子串具有相同数量的连续1和0:“0011”,“01”,“1100”,“10”,“0011” 和 “01”。

请注意,一些重复出现的子串要计算它们出现的次数。

另外,“00110011”不是有效的子串,因为所有的0(和1)没有组合在一起。

最先想用栈来解决,但发现行不通。然后慢慢发现规律了,我们可以用一个指针对准第一个数字的起点(最先是0),然后用另一个指针对准另一个数字的起点(如示例中第一个1的位置),然后两个指针一起跑,只要它们对应位置的值不相等,那答案就加1。值相等后必然是其中一个数字跑完了,可以根据和前一个位置对比得出是跑在前面的指针跑完了还是跑在后面的,如果是前面的先跑完了就让后面的指针直接跑到下一个起点(即前面的指针本次的起点),接着开始下一轮,直到有一个指针到达末尾。

好吧上面一段其实并不用看,我们只需要将相同的数字看作一组,然后遍历每组的数字个数组成的数组,相邻元素取最小值,加起来就得到结果了。比如示例的数组为[2, 2, 2, 2],相邻取最小再加起来就是6。

其实理论上说两个方法是差不多的,后者像是前者的抽象化。我们刷算法题总是容易下意识的用人类思维思考,然后用计算机去“模拟”,所以经常会遇到将思维转化为代码时不流畅和容易出错。我认为所谓算法思想,就是一种将人类思维抽象成计算机思维的能力

当绝对定位遇上层叠上下文

· 阅读需 3 分钟

css中position: absolute可以将元素指定为绝对定位,那么绝对定位元素的几何位置是相对于谁计算呢?

MDN上的描述是:

绝对定位元素相对于最近的非 static 祖先元素定位。当这样的祖先元素不存在时,则相对于ICB(inital container block, 初始包含块)。

也即是说,绝对定位元素应该相对其position为非static的最近祖先定位,或者这样的祖先不存在时相对初始包含快(一般为body)定位。而position默认为static。

但似乎有时候也有例外。

今天想在一个scroller组件中使用绝对定位突破到更顶层,但意外的发现,即使从该元素的父元素到body都没有指定position属性,它竟然仍是相对scroller容器定位而不是body或者更上层的具有position: relative属性的元素。

实验了半天发现是因为scroller使用了css的transform属性来变换滚动元素的位置以提升性能。而这直接导致了具有有效transform属性的元素成了绝对定位元素的基准。

考虑如下html:

<div style="line-height: 50px;">
<style>
.relative-container {
background: lightcoral;
position: relative;
height: 300px;
}
.midware {
background: lightcyan;
height: 200px;
}
.absolute-block {
background: black;
height: 30px;
position: absolute;
top: 0;
width: 30px;
}
</style>
<div class="relative-container">
------把下面的div撑下去一点方便观察差异-------
<div class="midware">
<div class="absolute-block"></div>
</div>
</div>
</div>
------把下面的div撑下去一点-------

如上,absolute-block成功突破了midwaretop: 0是相对于relative-container而不是其直接父元素midware。但是当我们为midware添加transform属性后:

.midware {
background: lightcyan;
height: 200px;
transform: translateZ(0);
}
------把下面的div撑下去一点-------

absolute-block已经相对其父元素midware定位了!但midwareposition并没有被显示指定。transform设置为其他有效值效果也一样。

为了测试transform属性是否隐式改变了position属性,我添加了如下代码:

const el = document.querySelector('.midware');
console.log(getComputedStyle(el).position); // static

并没有。这个现象应该是由其他机制引起的。

最后查了下资料,有人说是由层叠上下文导致的。但又不是所有的层叠上下文都会造成这样的影响,不同内核的浏览器也有不同的表现。所以应该将这个特性当作bug来对待?

参考:
不受控制的 position:fixed

JS省略分号导致的灾难

· 阅读需 4 分钟

挺长一段时间在纠结写JavaScript代码要不要打分号。这是一个个人风格问题,以下观点仅代表个人喜好。

不要省略分号!不要省略分号!不要省略分号!

虽然js引擎执行代码时会自动插入分号,但有些时候省略分号可能导致一些隐蔽的问题。

return语句

return后面会被自动插入分号,所以下面的代码返回undefined而不是一个字符串。

function foo() {
return
"this will return undefined"
}

当然,这完全可以避免,我们可以将返回值写在一行或者用括号包起来。

function foo() {
return (
"this is ok."
)
}

似乎这点也不是不省略分号就能避免的,如果您不清楚这条规则,下面的代码可能会带来疑惑:

function foo() {
return
'a long expresion' +
'another long expresion';
}
foo(); // undefined

OK,这不是重点,事实上现代编辑器足够聪明,这样的情况应该会将return后面的语句给你标注为灰色,你很容易就可以看到它们没有正确的被执行。这里只是顺带一提。我们来看更隐蔽的坑。

函数执行

考虑下面的代码:

var foo = 0
var bar = 1

foobar()

('some condition' && 'another condition') ? foo = -1 : bar = -1

console.log(foo, bar)

function foobar() {
return function () { return false }
}

请问上面的代码输出什么?答案是0 -1

我们的本意是在两个条件满足时给foo变量赋值-1,否则给bar变量赋值-1。这里本该输出-1 1,但由于语句以括号开头,js执行引擎误以为上一行的语句还没有结束,正好foobar()返回了一个函数,于是上面的代码相当于(为了更清晰我引入了一个变量):

var condition = foobar()('some condition' && 'another condition');
condition ? foo = -1 : bar = -1;

foobar()返回的函数总是返回false,所以被赋值的总是bar变量,这完全改变了我们的初衷!而且编辑器不会有提示:没有语法错误。甚至运行时也不会直接出错,而是在某个case下才命中bug。

解构赋值

接下来是我今天亲身经历的的场景。

ES2015(ES6)新增了一个解构赋值的特性,用于交换两个变量的值那是非常的方便。例如交换a,b变量的值:[a, b] = [b, a]。当然解构赋值还有很多其他特性,可以参考MDN

考虑如下代码:

var m = n = 0;
var swap = -1;
var matrix = [
[1, 2],
[3, 4]
];

//do something...

[matrix[m][n], swap] = [swap, matrix[m][n]]
[m, n] = [n, m]

console.log(matrix) // [[0, 2], [3, 4]]

OK,我们不用管代码是要做什么,反正就是想交换matrix[m][n]swap的值,再交换mn。期望的输出应该是[[-1, 2], [3, 4]]。然鹅...

事实上在写这篇博客之前我是赞成省略分号的。前面两种情况我都习惯性会注意避免,也没出过类似的错误。但终于还是不留神栽在了解构赋值上。

还是解释一下,上面的解构赋值语句等价于:

[matrix[m][n], swap] = [swap, matrix[m][n]][m, n] = [n, m]

//不太清晰?由于m == n == 0,逗号表达式m,n的值为0,所以再等价于:
[matrix[m][n], swap] = [swap, matrix[m][n]] [0] = [n, m]
//即
[matrix[m][n], swap] = swap = [n, m]
//即
[matrix[m][n], swap] = [n, m]
//即
maxtrix[0][0] = 0;
swap = 0;

同样,由于没有语法错误,IDE同样不会检测到。

总结

暂时没有遇到其他情况,不过有时候是防不胜防。咱一朝被蛇咬,十年怕井绳。添上分号他不香嘛。

webpack模块加载机制

· 阅读需 9 分钟

前端项目的规模越来越庞大,模块化开发已经是普遍需求。早期的打包工具将所有模块化的代码打包到一个bundle文件中,在一个简单的html文件中引入脚本。webpack允许输出为多个bundle文件,从而实现按需加载,更好的利用浏览器缓存,提升用户体验。

这里不讨论如何配置webpack,只说webpack如何加载模块。考虑一个多页面程序,有page1和page2两个页面,都用到一个工具模块util,page1直接引用util,而page2在页面加载后满足一定条件时动态加载util。

//util.js
console.log('util')
export function log(arg) {
console.log(arg)
}
//page1.js
import { log } from './util'
//...
log('page1')
//page2.js
//...
//条件满足时动态加载util
import('./util').then((util) => {
util.log('page2')
})

首先明确webpack中的两个概念:

  • module:一般是源代码中的一个文件,打包后被包裹在一个函数中,webpack模拟了模块环境
  • chunk:一个chunk即一个输出bundle文件,不考虑其它分割规则,每个入口将产出一个chunk。一个chunk包含若干module

假设我们已经配置好webpack,让它输出三个chunk:

0.js //util.js
page1.js //页面1
page2.js //页面2

下面从webpack的打包输出来分析webpack模块加载机制。

静态依赖

webpack中,一个js源文件作为一个模块(也可以是其他文件)。webpack将模块代码包裹在一个函数中,返回模块的导出内容。注意,每个模块最多仅被执行一次,第二次请求时会直接从缓存返回。

(打包后的)page1.js:

//立即执行函数初始化webpack运行时,然后加载入口模块
(function (modules) {
//modules是被打包到page1.js这个文件(chunk)中的模块列表,此处有page1和util两个模块
//已加载的模块的缓存
var installedModules = {};
// webpack require函数,用来加载模块
function __webpack_require__(moduleId) {
//检查模块是否已装载,是则返回缓存的模块
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
//将模块添加到缓存
var module = installedModules[moduleId] = {
i: moduleId, //模块ID
l: false, //模块是否已装载
exports: {} //模块的导出内容
};
// 执行模块初始化
modules[moduleId].call(
module.exports, //将模块代码的this指向其自己的exports
module, //相当于commonjs中的module,对模块自身的引用
module.exports, //相当于commonjs中的exports,模块的导出内容
__webpack_require__ //相当于commonjs中的require,提供加载同一chunk中其他模块的方法
);
// 将模块标识为已加载
module.l = true;
// 返回模块的导出内容
return module.exports;
}

//加载入口模块,即我们的page1.js
return __webpack_require__(__webpack_require__.s = "./src/page1.js");
})({
//page1模块
"./src/page1.js":
function (module, __webpack_exports__, __webpack_require__) {
let util = __webpack_require__('./src/util.js')
util.log('page1')
},

//util模块
"./src/util.js":
function (module, __webpack_exports__, __webpack_require__) {
function log (arg) {
console.log(arg)
}
//util模块导出log函数
__webpack_exports__['log'] = log
}
});

代码被我简化了一下,webpack有很多兼容性和安全性方面的考虑,我将那部分修改了让代码看起来清晰。

可以看到,util和page1被函数包围,模拟模块环境,然后作为参数传递给立即执行函数。注意,模块只有被加载时才会执行内部代码。

在立即执行函数中,先进行了webpack初始化,即提供核心的模块加载函数__webpack_require__函数,并在函数上配置一些静态字段记录必要的信息(如__webpack_require__.s为入口模块ID),然后装入入口模块,开始执行我们自己的逻辑代码。

按需加载

在page2中我们使用了动态import()方法来加载util模块。webpack足够智能,无需配置它也聪明的自动将util独立到单独的文件(chunk),在需要时再进行请求。

webpack通过jsonp机制加载不同chunk中的模块。

page2.js:

(function(modules) {
//模块加载函数,同page1。注意,此函数仅用于加载已被
function __webpack_require__(moduleId) {
//......
}

//可用的模块列表,包含本chunk中的模块和已经请求完毕的,来自其他chunk的模块
__webpack_require__.m = modules;
//基路径,用于拼接其他chunk的url
__webpack_require__.p = "";
//已装载的模块缓存,注意区分__webpack_require__.m,前者应该是后者的子集
var installedModules = {};
//这是相对page1中多出来的东西,用来记录其他chunk的加载情况
//值可能有3种:
// undefined, 即该chunk还没有被请求过
// 0, 该chunk中的模块已经正确的添加到__webpack_require__.m中,可以通过__webpack_require__加载了
// [resolve, reject, promise], chunk正在请求中。promise在chunk请求结束后才被resolve或reject
var installedChunks = {
"page2": 0 //page2即页面2的chunk自身,当然已经加载完成
};

//jsonp函数,chunk请求成功时由被请求的chunk调用。它的任务为将chunk中的模块加入可用模块列表
//data参数由被请求的chunk传过来,包含模块信息。这也是jsonp的核心部分
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var moduleId, chunkId, i = 0, resolves = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
//表示chunk已经加载好啦,下次不用去请求了
installedChunks[chunkId] = 0;
}
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
//把chunk中的模块们加入可用列表(__webpack_require__.m == modules √)
modules[moduleId] = moreModules[moduleId];
}
}
//原数组的push函数,详见后文
if(parentJsonpFunction) parentJsonpFunction(data);
//resolve请求时创建的promise,执行回调
while(resolves.length) {
resolves.shift()();
}
};

//异步请求chunk
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
//检查chunk是否已经加载。0表示已加载
if(installedChunkData !== 0) {
//正在加载...有点不清楚为啥要重复记录一次promise?
if(installedChunkData) {
//installedChunkData[2]是之前请求的promise
promises.push(installedChunkData[2]);
} else {
//没有请求过,开始请求chunk
//标识chunkId这个chunk正在请求,并记录promise相关信息
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
//现在installedChunks[chunkId] = [resolve, reject, promise]了
//用script标签加载chunk
var script = document.createElement('script');
var onScriptComplete;
script.charset = 'utf-8';
script.timeout = 120;
script.src = jsonpScriptSrc(chunkId);
//...请求错误处理...省略
//...超时处理
var timeout = setTimeout(function(){
}, 120000);
document.head.appendChild(script);
}
}
return Promise.all(promises);
};

//拼接被请求chunk的url
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".js"
}
//通过window.webpackJsonp数组记录加载成功的chunk数据。其他chunk被加载后会将自己的chunk数据push到这个数组
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
//注意,webpack覆盖了window.webpackJsonp数组的push函数。将原生的push函数替换成了webpackJsonpCallback
//在webpackJsonpCallback函数中我们看到有调用parentJsonpFunction,其实那个函数才是原来的数组的push函数
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
//理论上说这时jsonpArray应该是空的。。但如果不是空的就手动调用下webpackJsonpCallback装载chunk
//window["webpackJsonp"]已经被webpack污染啦,所以它复制了一下?
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

//加载入口模块
return __webpack_require__(__webpack_require__.s = "./src/page2.js");
})
({
//页面2
"./src/page2.js":
(function(module, exports, __webpack_require__) {
//我们的动态import()被webpack转化后大致代码
//先请求包含util的chunk
__webpack_require__.e(0)
//请求成功之后通过__webpack_require__加载util模块
.then(__webpack_require__.bind(null, 0))
//执行我们自己的逻辑代码
.then((util) => {
util.log('page2')
})
})
});

代码仍然有点长,不过已经删除和修改了很多边缘代码,并且调整了一下顺序,结构比较清晰了。代码中的注释解释了整个模块加载过程。先通过jsonp请求得到chunk数据,然后缓存chunk中的模块到可用列表(未装载),这时同步的模块加载已经可用,直接调用__webpack_require__加载相关模块,步骤就和page1一样了。

被加载的chunk结构就很简单了,只是简单的调用jsonp函数传入模块数据。

//0.js

//注意,前面说了,window["webpackJsonp"]数组的push函数被webpack重写了
//所以实际上这里调用了主模块那边定义的webpackJsonpCallback函数
//一个chunk可能包含多个模块,所以参数为 [moduleIds],moreModules
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0], {
"./src/util.js":
(function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_exports__['log'] = function (arg) {
console.log(arg)
}
})
}]);

总结

当项目规模增长到一定程度,模块化已是刚需。在ESModule标准制定之前广泛使用的模块化标准有commonjs,AMD,以及综合两者的UMD等。在ES6中模块化终于得到标准的支持,然而各平台对其的支持有限,暂时还难以替代传统解决方案。万幸,webpack提供了综合各种模块化标准的机制,让我们能够平稳的过渡。

LeetCode之旅——字母异位词分组(Group Anagrams)

· 阅读需 2 分钟

题目

给定一个字符串数组,要求将相同字母组成的字符串分组返回。字符串只由小写字母组成。

示例:

Input: ["eat", "tea", "tan", "ate", "nat", "bat"],
Output:
[
["ate","eat","tea"],
["nat","tan"],
["bat"]
]

思路一

将输入的每个字符串拆成字符排序,然后利用Map分组。

/**
* @param {string[]} strs
* @return {string[][]}
*/
var groupAnagrams = function(strs) {
const map = new Map()
for (let i = 0; i < strs.length; ++i) {
const key = strs[i].split('').sort().join()
map.has(key)
? map.set(key, map.get(key).concat(strs[i]))
: map.set(key, [strs[i]])
}
return Array.from(map.values())
};

思路二

由于字符串限定只由小写字母组成,可以构建一个长为26的“桶”,每一格存储对应的字母出现的次数。这样,每个字符串都被转化为一个这样的桶。然后把“桶”重新转为字符串,作为Map的键对原字符串数组进行分组。

/**
* @param {string[]} strs
* @return {string[][]}
*/
var groupAnagrams = function (strs) {
const list = []
for (const str of strs) {
//为每个字符串构造一个“桶”
const layer = []
for (const s of str) {
c = s.charCodeAt(0)
let value = layer[c - 0x60]
value = value ? value + 1 : 1
layer[c - 0x60] = value
}
list.push(layer.join(' '))
}
//把桶字符串化后作为key对原数组进行分组
const res = new Map()
for (let i = 0; i < list.length; ++i) {
res.has(list[i])
? res.set(list[i], res.get(list[i]).concat(strs[i]))
: res.set(list[i], [strs[i]])
}
return Array.from(res.values())
};

总结

思路一更简洁,但排序可能会消耗更多的时间。思路二利用了桶排序的思想,将排序复杂度降到了线性复杂度。在字符串较长时思路二应该会表现更好。

彻底搞懂JavaScript怪异函数——bind

· 阅读需 7 分钟

2020.4.30 事实上存在new.target这个变量,在函数中会指向构造函数。如果不是以new操作符调用的构造函数,new.target为undefined。因此我们可以通过new.target判断函数是否是new操作符调用。不过各浏览器对bind的支持比对new.target要早,所以在bind函数的polyfill中使用new.target可能不太合适。


我们可能遇到过实现bind函数这样的题目,但似乎并不存在完美模拟原生bind函数的可能。ECMAScript 2015中将bind创建的函数称为exotic function object(怪异函数对象),这很适宜,因为它的确存在一些“怪异”之处。

在继续之前我们需要先了解bind函数。这可以参考MDN的解释:
bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

另外还需要了解new操作符的实现,同样参考MDN的解释:

  1. 创建一个空的简单JavaScript对象(即{});
  2. 链接该对象(即设置该对象的构造函数)到另一个对象;
  3. 将步骤1新创建的对象作为this的上下文 ;
  4. 如果该函数没有返回对象,则返回this

第2步即为对象绑定隐式原型,将对象的__proto__属性指向构造函数的prototype,而函数默认的prototype拥有constructor属性指向构造函数,从而实现了所谓的“链接构造函数”。

这里约定一下,bind()方法的实现函数叫bind函数,创建的新函数叫做绑定函数,被封装的原函数叫做原函数

bind函数至少有这些特性:

  1. 除非是被new操作符调用,否则原函数执行环境中的this总是为bind函数被调用时传递的第一个参数;
  2. 绑定函数的prototype为undefined
  3. 通过new操作符调用绑定函数时,原函数执行环境的this仍为new操作符创建的新对象,即bind函数的this绑定被忽略;
  4. 通过new操作符调用绑定函数返回的对象是原函数的实例。即:
    function func() {}
    var fBound = func.bind({});
    var foo = new fBound();
    foo instanceof func; // true

MDN推荐的bind函数的Polyfill如下(部分):

Function.prototype.bind = function(that) {
var target = this; //原函数
var args = Array.prototype.slice.call(arguments, 1); //绑定参数
var bound; //绑定函数
//实际上binder才是绑定函数,bound将其再次包装后返回
var binder = function () {
if (this instanceof bound) {
//如果是通过new操作符调用的绑定函数,则绑定函数执行环境的this指向new操作符创建的新对象,而这个对象的隐式原型指向构造函数bound的prototype
//new操作符调用,忽略bind函数绑定的this,以实际执行环境this调用原函数
var result = target.apply(
this,
args.concat(arguments)
);
if (Object(result) === result) {
//如果构造函数返回的是一个对象,返回该对象
return result;
}
return this;
} else {
//非new操作符调用,使用绑定的this
return target.apply(
that,
args.concat(arguments)
);
}
};
//计算bind函数调用时提供的绑定参数数量
var boundLength = Math.max(0, target.length - args.length);
var boundArgs = [];
for (var i = 0; i < boundLength; i++) {
boundArgs.push('$' + i);
}
//相当于返回binder。之所以用函数再包一层,应该是为了使函数的length拥有正确的值
bound = Function('binder', 'return function (' + boundArgs.join(',') + '){ return binder.apply(this,arguments); }')(binder);

//调整原型链,使绑定函数的实例仍然是能通过原函数的原型链检测(因为绑定函数实例的隐式原型(__proto__)的隐式原型指向原函数的prototype)
//如果原函数没有prototype,则绑定函数具有默认prototype,且不会被外界访问到(除非通过绑定函数的prototype属性)
if (target.prototype) {
//构造一个临时的空函数,防止绑定函数的prototype直接引用原函数的prototype。因为原函数的prototype对外界可见,具有不确定性。如果绑定函数被调用时传递一个隐式原型指向原函数prototype的this参数,直接引用原函数就会造成误判为new操作符调用
var Empty = function Empty() {};
//绑定函数.prototype->空函数实例;空函数实例.__proto__->原函数prototype
//从而实现了,通过new得到的绑定函数实例的原型链上有原函数的prototype(文章开头的特性4),但隐式原型为原函数prototype的对象不是绑定函数的实例,因为绑定函数的原型是一个内部的对象(Empty实例),不太可能作为外部某对象的隐式原型(除非是绑定函数被当作了构造函数[new操作]),从而在前面的binder函数中,根据判断绑定函数执行环境的this是否是绑定函数的实例,正确的判断出是否为new操作符调用的绑定函数
//当然这里的漏洞是,绑定函数的prototype并不是完全不可访问的,因为绑定函数是公开的,从而绑定函数的prototype也就可访问了。但这一般不会出问题,除非你写一些奇怪的代码(后面有分析到)
Empty.prototype = target.prototype;
bound.prototype = new Empty();
Empty.prototype = null;
}
return bound;
};

上面的实现已经接近完美了,几乎也没有更好的实现方案了。我们来看看它实现了文章开头列出的哪些特性。

特性2,绑定函数的prototype为undefined。其实这个很容易实现,但为了实现识别new操作符,只好让绑定函数拥有prototype。幸运的是,这对实际使用几乎没有影响。

特性3,通过new操作符调用绑定函数时忽略bind函数绑定的this。OK,通过new调用时一定可以识别到。

特性4,new操作符调用绑定函数返回的对象是原函数的实例。已实现,虽然原型链上多了一个节点,但并不影响原型链判断。 原生bind函数实例的原型链:原函数prototype->Object.prototype->undefined polyfill实现的bind函数实例的原型链:内部临时函数Empty.prototype->原函数prototype->Object.prototype->undefined

特性1就耐人寻味了,显然关键在于对new操作符调用的精确判断。如果是new操作符调用,显然绑定函数执行环境的this即绑定函数的实例,会被当做new操作符调用对待(忽略bind函数绑定的this)。但非new操作符调用的情况,是否一定能够被当作一般情况对待?考虑下面的代码:

function foo(name) {
this.name = name;
}
var obj = {};
var bar = foo.bind(obj);
const mock = Object.create(bar.prototype ? bar.prototype : null);
bar.call(mock, 'Jack');
console.log(obj.name);
var alice = new bar('Alice');
console.log(obj.name);
console.log(alice.name);
console.log(mock);

理想的输出是:

Jack Jack Alice [Object: null prototype]

原生bind函数的输出符合预测,但polyfill实现的版本输出:

undefined undefined Alice foo { name: 'Jack' }

bar.call(Object.create(bar.prototype), 'Jack')被当作了new操作符调用。因此绑定函数忽略了绑定的this,而使用了传入的this参数调用原函数,因此原函数中this.name = namethis指向mock,所以赋值操作发生在mock上而非obj

bind函数被成为怪异对象,怪异就怪异在其内部机制无法完全用合法的JavaScript逻辑模拟(至少我没有找到方法)。不过上面的实现已经可用了,最后的不一致行为其实属于“明知故犯”的怪癖代码,实际生产活动中完全可以杜绝。

参考

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

https://zhuanlan.zhihu.com/p/38968174

JavaScript微任务与宏任务(浏览器)

· 阅读需 3 分钟

问题描述

最近在用Ionic框架(基于Angular),有这么一个需求:
先调用history.go(-delta)返回到某个页面,再调用Angular的Router#navigate()导航到新的页面。

大致代码如下:

go(delta, url) {
history.go(delta)
this.router.navigateByUrl(url, { replaceUrl: true })
}

问题出来了,代码执行后只返回到了之前的页面,并没有正确导航到url参数指定的页面。

原因大概是,Angular监听了popstate事件以响应浏览器的回退操作。然而浏览器的事件触发是异步的,即history.go(delta)执行后,要等到下一轮事件循环Angular才能捕捉到回退事件,并渲染对应的组件。然而这时router.navigateByUrl()已经执行完毕,因此看到的是回退后的页面而不是希望导航到的新页面。

原理分析

JavaScript的事件循环主要是靠两个队列:宏任务(macro task)队列和微任务(micro task)队列。浏览器按下面的顺序执行队列中的任务:

macro task 1 micro task 1 micro task 2 micro task 3 ...... macro task 2 micro task 1 micro task 2 micro task 3 ......

即,在执行完宏任务后必须执行完当前的所有微任务才能执行下一个宏任务。而浏览器环境下,UI事件是宏任务,Promise#then()是微任务。

当go()函数被执行时,history.go(delta)执行后,popstate事件被推入宏任务队列,然后执行navigationByUrl()。Angular的路由导航也是异步的,但在没有路由守卫的情况下,navigateByUrl()函数发起的整个工作流程都只是微任务(可以认为是一连串立即被resolve的Promise),所以在下一个宏任务被读取之前,新页面的导航已经先于回退操作完成。

go函数执行过程如下:

go history.go() 宏任务队列推入popstate事件 navigateByUrl() 微任务队列推入NavigationStart ... ... 微任务队列弹出NavigationStart ... 微任务队列推入NavigationEnd ... 微任务队列弹出NavigationEnd,新页面导航完成 ... 微任务队列空 ... 宏任务弹出popstate事件,解析history.go()回退到的url 渲染回退到的页面

解决方法

知道了原理就好解决问题了。只需要等到下一次宏任务再执行新页面的导航就好了。

go(delta, url) {
history.go(delta)
setTimeout(() => {
this.router.navigateByUrl(url, { replaceUrl: true })
}, 0)
}

注意,setTimeout和setInterval函数创建的都是宏任务。另外,这里说的都是浏览器环境下的JavaScript,部分说法对nodejs并不适用。

Javascript的in操作符

· 阅读需 1 分钟

javascript的in操作符用于判断某个名称的属性是否存在于某个对象的原型链中。

语法:

prop in object

prop是string类型或Symbol类型,其他类型会被转化为string,返回值是布尔值,如果object.prop存在则返回true,否则返回false。

需要注意的是,object必须是一个对象。最容易被误用的场景是对字符串使用in操作符,这将直接抛出一个异常:

> 'length' in 'mystring' // Uncaught TypeError: Cannot use 'in' operator to search for 'length' in mystring

如果要判断字符串是否包含另一个字符串,请使用includes方法(ES6)。

参考:MDN Operator in