跳到主要内容

技巧-常用技巧

1. 防抖与节流

1.1 防抖

防抖: 在一定的间隔时间内, 该事件(函数)只能触发一次, 在间隔时间内调用函数会重置计时器.

下面是一个带立即执行选项的防抖函数:

/**
* 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {boolean} immediate 设置为ture时,是否立即调用函数
* @return {function} 返回客户调用函数
*/
function debounce(func, wait = 50, immediate = true) {
let timer, context, args;
// 延迟执行函数
const later = () =>
setTimeout(() => {
// 延迟函数执行完毕,清空缓存的定时器序号
timer = null;
// 延迟执行的情况下,函数会在延迟函数中执行
// 使用到之前缓存的参数和上下文
if (!immediate) {
func.apply(context, args);
context = args = null;
}
}, wait);

// 这里返回的函数是每次实际调用的函数
return function(...params) {
// 如果没有创建延迟执行函数(later),就创建一个
if (!timer) {
timer = later();
// 如果是立即执行,调用函数
// 否则缓存参数和调用上下文
if (immediate) {
func.apply(this, params);
} else {
context = this;
args = params;
}
} else {
// 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
// 这样做延迟函数会重新计时
clearTimeout(timer);
timer = later();
}
};
}

应用: 搜索引擎的接口调用和一些用户输入比较高频的场景

1.2 节流

防抖和节流的本质是不一样的, 节流指的是连续触发事件, 但是在 n 秒中只执行一次.

对于节流可以用时间戳和定时器两种方式进行实现:

时间戳实现如下:

function throttle(func, wait) {
let previous = 0;
return function() {
let now = Date.now();
let context = this;
let args = arguments;
//判断调用时间大于间隔, 执行函数并更新时间戳
if (now - previous > wait) {
func.apply(context, args);
previous = now;
}
};
}

定时器实现如下:

function throttle(func, wait) {
let timer;
return function() {
// 保存上下文
let context = this;
// 更新参数
let args = arguments;
if (!timer) {
timeout = setTimeout(() => {
func.apply(context, args);
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
}, wait);
}
};
}

添加对开始函数和结尾函数调用的控制, 就是下面 underscore 中比较复杂的实现了:

/**
* underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {object} options 如果想忽略开始函数的的调用,传入{leading: false}。
* 如果想忽略结尾函数的调用,传入{trailing: false}
* 两者不能共存,否则函数不能执行
* @return {function} 返回客户调用函数
*/
_.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
// 之前的时间戳
var previous = 0;
// 如果 options 没传则设为空对象
if (!options) options = {};
// 定时器回调函数
var later = function() {
// 如果设置了 leading,就将 previous 设为 0
// 用于下面函数的第一个 if 判断
previous = options.leading === false ? 0 : _.now();
// 置空一是为了防止内存泄漏,二是为了下面的定时器判断
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
// 获得当前时间戳
var now = _.now();
// 首次进入前者肯定为 true
// 如果需要第一次不执行函数
// 就将上次时间戳设为当前的
// 这样在接下来计算的时候就不会认为当前时间点是可以执行的了
if (!previous && options.leading === false) previous = now;
// 是否可以执行 = 时间间隔 - 两次执行的时间差
var remaining = wait - (now - previous);
// 更新上下文和参数
context = this;
args = arguments;
// 如果当前调用已经大于上次调用时间 + wait
// 或者用户手动调了时间
// 如果设置了 trailing,只会进入这个条件
// 如果没有设置 leading,那么第一次会进入这个条件
// 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
// 其实还是会进入的,因为定时器的延时
// 并不是准确的时间,很可能你设置了2秒
// 但是他需要2.2秒才触发,这时候就会进入这个条件
if (remaining <= 0 || remaining > wait) {
// 如果存在定时器就清理掉否则会调用二次回调
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
// 更新调用时间点
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 判断是否设置了定时器和 trailing
// 没有的话就开启一个定时器
// 并且不能同时设置 leading 和 trailing
timeout = setTimeout(later, remaining);
}
return result;
};
};

大致上来说, 就是前置调用走的是时间戳的逻辑, 后置调用走的是定时器的逻辑

2. 深拷贝与浅拷贝

之所以存在浅拷贝和深拷贝, 主要是因为 JS 中的数据类型主要分类基本数据类型和引用类型, 其中基本数据类型放在栈中, 是直接按值存储的, 引用类型放在推中, 变量实际上一个存放在栈内存的指针, 指向对内存中的地址. 因此在浅拷贝中的简单类型会进行拷贝而引用类型则保持同一个引用. 关于堆和栈的详细记述, 就不在此处展开, 主要来看看如何实现浅拷贝和深拷贝.

2.1 浅拷贝

浅拷贝指的是只拷贝数据的第一层结构, 对于再往下的引用对象, 还是保持同一个引用, 实现的方法很多, 也比较简单:

方案 1: Object.assign

let a = {
age: 1
};
let b = Object.assign({}, a);
a.age = 2;
console.log(b.age); // 1

方案 2: 扩展运算符(...)

let a = {
age: 1
};
let b = { ...a };
a.age = 2;
console.log(b.age); // 1

2.2 深拷贝

对于特定的情况下, 两种有局限性的深拷贝方法, 第一种借助 JSON:

//JSON.parse(JSON.stringify(object))
let a = {
age: 1,
jobs: {
first: 'FE'
}
};
let b = JSON.parse(JSON.stringify(a));
a.jobs.first = 'native';
console.log(b.jobs.first); // FE

局限性在于:

  1. 会忽略 undefined
  2. 会忽略 symbol
  3. 不能序列化函数
  4. 不能解决循环引用的对象

第二种借助MessageChannel:

function structuralClone(obj) {
return new Promise(resolve => {
const { port1, port2 } = new MessageChannel();
port2.onmessage = ev => resolve(ev.data);
port1.postMessage(obj);
});
}

var obj = {
a: 1,
b: {
c: b
}
}(
// 注意该方法是异步的
// 可以处理 undefined 和循环引用对象
async () => {
const clone = await structuralClone(obj);
}
)();

该方法可以处理 undefined 和循环引用的问题, 但是不能解决函数的复制问题.

此外, 就只能按照树的遍历的思路去处理了:

// 如果是对象/数组,返回一个空的对象/数组,
// 都不是的话直接返回原对象
// 判断返回的对象和原有对象是否相同就可以知道是否需要继续深拷贝
// 处理其他的数据类型的话就在这里加判断
function getEmpty(o) {
if (Object.prototype.toString.call(o) === '[object Object]') {
return {};
}
if (Object.prototype.toString.call(o) === '[object Array]') {
return [];
}
return o;
}

//栈
function deepCopyBFS(origin) {
let queue = [];
let map = new Map(); // 记录出现过的对象,用于处理环

let target = getEmpty(origin);
if (target !== origin) {
queue.push([origin, target]);
map.set(origin, target);
}

while (queue.length) {
let [ori, tar] = queue.shift();
for (let key in ori) {
// 处理环状
if (map.get(ori[key])) {
tar[key] = map.get(ori[key]);
continue;
}

tar[key] = getEmpty(ori[key]);
if (tar[key] !== ori[key]) {
queue.push([ori[key], tar[key]]);
map.set(ori[key], tar[key]);
}
}
}

return target;
}

//队列
function deepCopyDFS(origin) {
let stack = [];
let map = new Map(); // 记录出现过的对象,用于处理环

let target = getEmpty(origin);
if (target !== origin) {
stack.push([origin, target]);
map.set(origin, target);
}

while (stack.length) {
let [ori, tar] = stack.pop();
for (let key in ori) {
// 处理环状
if (map.get(ori[key])) {
tar[key] = map.get(ori[key]);
continue;
}

tar[key] = getEmpty(ori[key]);
if (tar[key] !== ori[key]) {
stack.push([ori[key], tar[key]]);
map.set(ori[key], tar[key]);
}
}
}

return target;
}

3. 数组压平

// index: 需要压平的深度
Array.prototype.myflatten = function(index) {
var array = this;
if (!array instanceof Array) {
throw 'target is not a array';
return [];
}

function flat(arr) {
let res = [];
for (var i = 0; i < arr.length; i++) {
if (arr[i] instanceof Array) {
res = res.concat(arr[i]);
} else {
res.push(arr[i]);
}
}
return res;
}

function checkData(arr) {
for (let i = 0; i < arr.length; i++) {
const el = arr[i];
if (el instanceof Array) {
return false;
}
}
return true;
}

var i = 0;
var ret = array;
while (i < index) {
ret = flat(ret);
i++;
if (checkData(ret)) {
return ret;
}
}

return ret;
};

4. js 中的位运算以及应用

首先来回顾一下, es 中的位运算符.

  • NOT(~): 获取二进制反码, 可以看成对数字求负, 然后减一.
let num = 25; //00000000000000000000000000011001
let num2 = ~num; //11111111111111111111111111100110
console.log(num2); //-26

//注意0要占一位, 所以减1
  • AND(&): 对位与操作, 只有两个都为 1 返回 1
  • OR(|): 对位或操作, 只要有一个为 1 返回 1
  • XOR(^): 对位异或操作, 当只有一个 1 的时候, 返回为
  • 左移运算(<<): 将数字向左移动指定数目, 相当于将数组的无符号部分*2指定次
var iOld = 2; //等于二进制 10
var iNew = iOld << 5; //等于二进制 1000000 十进制 64

var iOld2 = -2;
var iNew2 = iOld2 << 5; //-64
  • 有符号右移运算(>>): 将数字向左移动指定数目, 相当于将数组的数位部分/2指定次, 并且保留符号位不动
var iOld = 64; //等于二进制 1000000
var iNew = iOld >> 5; //等于二进制 10 十进制 2
  • 无符号右移运算(>>>): 对于整数, 无符号右移运算的结果和有符号是相同的, 对于负数则不然, 会得到一个横刀的数字
var iOld = 64;      //等于二进制 1000000
var iNew = iOld >>> 5; //等于二进制 10 十进制 2

var iOld2 = -64;
var iNew2 = iOld2 >>? 5; //134217726

最基本的问题使用位运算来实现四则运算:

加法

function add(m, n) {
while (m) {
[m, n] = [(m & n) << 1, m ^ n];
}
return n;
}

解释一下吧,

单位的二进制加法只有四种情况:

1 + 1 = 0
1 + 0 = 1
0 + 1 = 1
0 + 0 = 0

实际上就相当于:

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

多位的二进制则要考虑进位的问题, 而进位的情况是这样的:

0 + 0 = 0//不进位
1 + 0 = 0//不进位
0 + 1 = 0//不进位
1 + 1 = 1//进位

//换个角度看就是这样
0 & 0 = 不进位
1 & 0 = 不进位
0 & 1 = 不进位
1 & 1 = 进位

<<表示进位, 所以:

//进位可以用如下表示:
(x & y) << 1;

多位的加法可以这样来:

//以11+01=110为例

//第一步
11^01=10
(11&01)<<1=10

//第二步, 将第一步得到结果继续运算
10^10=00

(10&10)<<1=100

对于更高的位, 就是不断重复这个过程, 也就是我们一开始写的那个算法了.

减法

加法和减法本质上是一样的

n+m=n+(-m)

由于我们不能使用-, 可以用-m=~m+1来替代. 相当于-m=add(~m, 1), 所以减法的函数如下:

function substract(m, n) {
return add(m, add(~n, 1));
}

乘法

乘法就是累计的加法, 除此之外我们需要考虑正负号的问题. 因此, 应当是先计算绝对值的乘积, 然后再决定符号.

function multiply(m, n) {
let abs_m = m < 0 ? add(~m, 1) : m;
let abs_n = n < 0 ? add(~n, 1) : n;

let prod = 0;
let count = 0;
while (count < abs_n) {
prod = add(prod, abs_m);
count = add(count, 1);
}
if ((m ^ n) < 0) {
prod = add(~prod, 1);
}
return prod;
}

参考竖式计算对累加进行优化:

function multiply_2(m, n) {
let abs_m = m < 0 ? add(~m, 1) : m;
let abs_n = n < 0 ? add(~n, 1) : n;

let prod = 0;
while (abs_n > 0) {
if ((abs_n & 0x1) > 0) {
prod = add(prod, abs_m);
}
abs_m = abs_m << 1;
abs_n = abs_n >> 1;
}

if ((m ^ n) < 0) {
prod = add(~prod, 1);
}
return prod;
}

除法

除法可以换算成减法:

function divide(m, n) {
let dividend = m < 0 ? add(~m, 1) : m; //除数
let divisor = n < 0 ? add(~n, 1) : n; //被除数

let quotient = 0; //商
let remainder = 0; //余数
while (dividend >= divisor) {
quotient = add(quotient, 1);
dividend = substract(dividend, divisor);
}

//确定商符号
if ((m ^ n) < 0) {
prod = add(~prod, 1);
}
//确定余数符号
remainder = b > 0 ? dividend : add(~dividend, 1);
return quotient; // 返回商
}

当然也有优化方案的:

function divide(m, n) {
let dividend = m < 0 ? add(~m, 1) : m; //除数
let divisor = n < 0 ? add(~n, 1) : n; //被除数

let quotient = 0; //商
let remainder = 0; //余数
while (int i = 31; i >= 0; i--) {
if((dividend >> i) >= divisor) {
quotient = add(quotient, 1 << i);
dividend = substract(dividend, divisor << i);
}
}

//确定商符号
if ((m ^ n) < 0) {
prod = add(~prod, 1);
}
//确定余数符号
remainder = b > 0 ? dividend : add(~dividend, 1);
return quotient; // 返回商
}

数字截断

~~2.11 // => 2

-1判断

(~-1) // => 0

模块加载器

/**
* 定义注册器
* @param {*} p 模块的路径
*/
function require(p) {
// 处理模块路径
var path = require.resolve(p);
// 获取模块的具体方法
var mod = require.modules[path];
// 如果不存在, 则抛出一个异常
if (!mod) throw new Error('failed to require "' + p + '"');
// 如果模块没有exports, 则手动注册一个exports
if (!mod.exports) {
mod.exports = {};
// 调用mod方法, 传入三个参数: 模块本身, 模块的exports,
mod.call(mod.exports, mod, mod.exports, require.relative(path));
}
return mod.exports;
}

// 保存了所有注册的模块
require.modules = {};

// 处理模块的路径
require.resolve = function (path) {
var orig = path;
var reg = path + '.js';
var index = path + '/index.js';
return (
// 如果'模块.js'存在, 则返回'模块.js'
(require.modules[reg] && reg) ||
// 如果'模块/index.js'存在, 则返回'模块/index.js'
(require.modules[index] && index) ||
// 直接返回'模块'
orig
);
};

/**
* 注册模块
* @param {*} path 模块的路径
* @param {*} fn 模块的方法
*/
require.register = function (path, fn) {
require.modules[path] = fn;
};

/**
*
* @param {*} parent 当前模块的路径
*/
require.relative = function (parent) {
/**
* 返回一个require函数
* @param {*} p 模块路径
*/
return function (p) {
// 如果不是`.`开头的路径直接调用`require`
if ('.' != p.charAt(0)) return require(p);
// 分割当前模块的路径
var path = parent.split('/');
// 分割现在模块的路径
var segs = p.split('/');
path.pop();

// 合并两个模块的路径
for (var i = 0; i < segs.length; i++) {
var seg = segs[i];
if ('..' == seg) path.pop();
else if ('.' != seg) path.push(seg);
}

// 调用模块
return require(path.join('/'));
};
};

// 使用方式
// 注册模块`moduleId`
require.register('moduleId', function (module, exports, require) {
// Module code goes here
});
// 加载模块
var result = require('moduleId');

6. 尾递归优化

尾递归, 即在函数尾部位置调用自身. 尾递归也是递归的一种特殊形式, 就是在尾部直接调用自身的递归函数.

尾递归在普通尾调用的基础上, 多了两个特性:

  • 在尾部调用的是函数自身
  • 可以通过优化, 使得计算仅占常量栈空间

比如实现一个阶乘, 普通的递归如下:

function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}

factorial(5) // 120

使用尾递归优化:

function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}

factorial(5) // 120

下面是几个常见的尾递归应用:

应用

数组求和:

function sumArray(arr, total) {
if(arr.length === 1) {
return total
}
return sum(arr, total + arr.pop())
}

斐波那契数列:

function factorial2 (n, start = 1, total = 1) {
if(n <= 2){
return total
}
return factorial2 (n -1, total, total + start)
}

参考链接