“这是我参与「第五届青训营」伴学笔记创作活动的第 4 天”
主要内容
- 过程抽象概念
- 高阶函数使用模式
- JavaScript 编程范式
- 代码写作关注事项
- left-pad事件背后的代码规范
- 如何对代码进行优化
1 JavaScript编码原则 ——过程抽象
graph LR
id1["INPUT x"] --> id2[("FUNCTION f")]
id2 --> id3["OUTPUT f(x)"]
- 用来处理局部细节控制的一些方法
- 例如,React Hooks就是很常见的一种过程抽象的应用
- 函数式编程思想的基础应用
- 函数本身可以理解为一个封装好的过程,我们关注它输入输出的结果
- 纯函数不改变外界的环境,当输入值固定时,它的输出值也是固定的,也就是说它的行为是可预期的,很容易就可以测试它的正确性。
function add(a, b) { return a + b; } add(2, 3); // 5
- 非纯函数的结果会受到调用次数和顺序等的影响,依赖于外部环境。下面的代码是一个非纯函数的例子,要想测试这一
count
函数的正确与否,我们需要构建一个上下文环境,它是有副作用的。let idx = 0 function count() { ++idx } count() // 1 count() // 2 const result = count() // 3 console.assert(result === 3, 'failed ${result}')
1.1 操作次数限制
- 一些异步交互
- 一次性的HTTP请求
1.1.1 Once —— 只执行一次
为了能够让“只执行一次”的需求覆盖不同的事件处理,我们可以将整个需求剥离出来。这个过程我们称为过程抽象。
以实现一个学习任务列表为例,如何限制操作次数?
原始版本
<ul>
<li><button></button>任务一:学习 html</li>
<li><button></button>任务二:学习 css</li>
<li><button></button>任务三:学习 JavaScript</li>
</ul>
function once(fn) {
return function(...args) {
if(fn) {
const ret = fn.apply(this, args);
fn = null;
return ret;
}
};
}
const list = document.querySelector('ul')
const buttons = list.querySelectorAll('button')
buttons.forEach((button) => {
button.addEventListener('click', (evt) => {
const target = evt.target;
target.parentNode.className = 'completed';
setTimeout(() => {
list.removeChild(target.parentNode);
console.log('remove')
}, 2000);
});
});
在点击按钮的2秒后,按钮所对应的列表项将被删除。但在被删除前的这两秒内,如果多次点击同一按钮,将会出现如下图所示的报错。报错原因也很明显,在这多次点击按钮触发once函数时,函数所对应的目标在第一次点击时已经被删除了,当然也无法执行删除操作。
代码改进
在上面给出的代码版本中增加一组参数:
buttons.forEach((button) => {
button.addEventListener('click', (evt) => {
...
}, {once: true});
});
就可以解决掉这一报错问题了。或者如下面的代码所示进行修改,也是一样的效果。这里起到关键作用的就是once
函数。
buttons.forEach((button) => {
button.addEventListener('click', once((evt) => {
...
}));
});
通过对“只执行一次”的过程抽象,可以让这一需求单独剥离处理并覆盖到不同的事件处理。
为了更好的理解,举例来说,如果要打开一间房子的门,我们可以把门甚至房子这样的数据抽象成一个结构。类似的,对于“开门”open的动作这一过程,我们也可以把它抽象出来。在JavaScript里,这一过程抽象是通过高阶函数来实现的。
1.1.2 高阶函数(Higher-Order Function, HOF)
1.1中的once
函数就是高阶函数的一个具体应用,它具有如下的三个特征:
- 以函数作为参数
- 以函数作为返回值
- 常用于作为函数装饰器
function HOF0(fn) { // 一个默认的等价高阶函数
return function(args) {
return fn.apply(this, args);
}
}
常用的高阶函数
- Once —— 保证函数只执行一次
- Throttle —— 节流
- 例如,像
mousemove
这样的事件触发频率很高,为避免带来过多的性能开销,我们可以设定让它每500ms触发一次。 - 下面的代码效果是,每隔500ms可允许用户点击按钮,使得
circle
元素内的数字累加并产生fade
效果。
function throttle(fn, time = 500) { let timer; return function(...args) { if (timer==null) { fn.apply(this, args) timer = setTimeout(() => { timer = null }, time) } } } btn.onClick = throttle(function(e) { circle.innerHTML = parseInt(circle.innerHTML) + 1 circle.className = 'fade' setTimeout(() => circle.className = '', 250) })
- 例如,像
- Debounce —— 防抖
- 例如,我们要实现一个自动保存的功能。如果每次修改都触发保存的话,对服务器会产生很大的负担。我们可以设定当结束编辑(键盘不输入或者文本内容不变化超过500ms等)时进行保存。
- 在下面的代码中,当鼠标超过一段时间不移动后,
bird
才会向光标所在的方向移动。
var i = 0; setInterval(function(){ bird.className = "sprite " + 'bird' + ((i++) % 3); }, 1000/10); function debounce(fn, dur){ dur = dur || 100; var timer; return function(){ clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, arguments); }, dur); } } document.addEventListener('mousemove', debounce(function(evt){ var x = evt.clientX, y = evt.clientY, x0 = bird.offsetLeft, y0 = bird.offsetTop; console.log(x, y); var a1 = new Animator(1000, function(ep){ bird.style.top = y0 + ep * (y - y0) + 'px'; bird.style.left = x0 + ep * (x - x0) + 'px'; }, p => p * p); a1.animate(); }, 100));
- Consumer —— 消费者问题
- 在学习线程相关知识时常见的一个概念,这里就不多解释了。
- 在下面的例子里,连续多次点击
hit
按钮,每隔一段固定的时间才会调用一次consumer
函数,实现同步变为异步的延时调用效果。
function consumer(fn, time){ let tasks = [], timer; return function(...args){ tasks.push(fn.bind(this, ...args)); if(timer == null){ timer = setInterval(() => { tasks.shift().call(this) if(tasks.length <= 0){ clearInterval(timer); timer = null; } }, time) } } } btn.onclick = consumer((evt)=>{ let t = parseInt(count.innerHTML.slice(1)) + 1; count.innerHTML = `+${t}`; count.className = 'hit'; let r = t * 7 % 256, g = t * 17 % 128, b = t * 31 % 128; count.style.color = `rgb(${r},${g},${b})`.trim(); setTimeout(()=>{ count.className = 'hide'; }, 500); }, 800)
- Iterative —— 可迭代方法
- 例如,在jQuery中有一个设计原则叫做批量操作,即调用API同时操作多个元素。
- 在下面的代码中,如果传入的
obj
是可迭代的,那么就会对子元素一次进行这一操作,否则对该函数只调用一次。这里对传入的可迭代els
中包含的奇数行列表项元素,均进行了颜色的修改。
const isIterable = obj => obj != null && typeof obj[Symbol.iterator] === 'function'; function iterative(fn) { return function(subject, ...rest) { if(isIterable(subject)) { const ret = []; for(let obj of subject) { ret.push(fn.apply(this, [obj, ...rest])); } return ret; } return fn.apply(this, [subject, ...rest]); } } const setColor = iterative((el, color) => { el.style.color = color; }); const els = document.querySelectorAll('li:nth-child(2n+1)'); setColor(els, 'red');
1.1.3 编程范式 —— 命令式与声明式
graph LR
id1[programming] --> id2[imperative]
id2 --> id3["procedural e.g. FORTRAN, C"]
id2 --> id4["object oriented e.g. C++, Java"]
id5[declarative] --> id6["logic e.g. Prolog"]
id5 --> id7["functional e.g. Haskell, Erlang"]
id1 --> id5
JavaScript既有命令式的特点,也有声明式的特点,也就是说JS可以用两种不同的语言风格来写。
- 命令式风格“怎么做” —— 更强调具体的做法
let list = [1, 2, 3, 4] let mapl = [] for (let i=0; i<list.length; i++) { mapl.push(list[i] * 2) }
- 声明式风格“做什么” —— 更加简洁,强调目标而非流程
let list = [1, 2, 3, 4] const double = x => x * 2 list.map(double)
案例 —— Toggle
案例描述:实现点击按钮切换其ON/OFF状态。
-
命令式做法:
代码不算复杂,也就是当按钮状态为OFF时切换到ON,当状态为ON时就切换到OFF。这种做法的优势就是实现简单,便于理解。
switcher.onclick = function(evt) { if(evt.target.className === 'on') { evt.target.className = 'off'; } else { evt.target.className = 'on'; } }
-
声明式做法:
这里定义了一个名为
toggle
的高阶函数。这种做法的优点就是具有较高的拓展性。在此基础上可以更方便地实现多态切换,例如加入一个新的warn
状态,只需在传递参数时加入新的一项即可。提高拓展性也是过程抽象非常重要的意义所在。function toggle(...actions) { return function(...args) { let action = actions.shift(); actions.push(action); return action.apply(this, args); } } switcher.onclick = toggle( // evt => evt.target.className = 'warn', evt => evt.target.className = 'off', evt => evt.target.className = 'on' );
2 代码优化
- 写代码最应该关注什么?
- 风格?
- 效率?
- 约定?
- 使用场景?
- 设计?
举个例子,下面的代码好不好?为什么?
// 判断一个mat2d矩阵是否是单位矩阵
function isUnitMatrix2d(m) {
return m[0]===1 && m[1]===0 && m[2]===0 && m[3]===1 &&& m[4]===0 && m[5]===0;
}
上面这段代码比起使用for循环来说,具有更快的渲染性能。因此,评价一段代码的好坏不能单纯地看它的风格,而是要结合它的使用场景。
2.1 引入 —— left-pad事件
left-pad的功能是对字符串进行左侧补齐。因为它的使用场景很多,所以这一功能被很多的库所依赖。因此,当这一功能被作者下架时,依赖它的库就不可用了。
- 事件本身的槽点:
- NPM模块粒度 —— 为什么一个小小的left-pad功能也要被封装成一个模块?
- 代码风格
- 代码质量/效率
版本一 —— left-pad原版代码
function leftpad(str, len, ch) {
str = String(str)
var i = -1
if (!ch && ch !==0) ch = ' '
len = len - str.length
while (++i < len) {
str = ch + str
}
return str
}
版本二
- 代码更简洁
- 效率提升
- 删除了时间复杂度为O(n)的
while
循环 repeat
是JS的内置函数,主要使用的快速幂算法,时间复杂度为O(logn)
- 删除了时间复杂度为O(n)的
function leftpad(str, len, ch='') {
str = "" + str;
const padLen = len - str.length;
if (padLen<=0) {
return str;
} else {
return ("" + ch).repeat(padLen) + str;
}
}
Reference: MDN - String.prototype.repeat()
但是,由于在left-pad的使用场景中,并不需要拼接太长的字符串,其实使用while
循环遍历的性能也没有太大差别,反而还具有更高的代码可读性。当然,这里主要是说明,上述代码在性能方面还具有一定的改进空间。
版本三
这里预先算出了需要循环的次数,也是一种可以参考的补齐方式。
var maxCount = str.length * count;
count = Math.floor(Math.log(count / Math.log(2));
while (count) {
str += str;
count--;
}
str += str.substring(0, maxCount - str.length);
2.2 案例 —— 交通灯状态切换
案例描述:实现一个切换多个交通灯状态切换的功能。
版本一 —— setTimeOut多层嵌套
很容易想到的是使用setTimeOut
函数来实现这一功能。但由于它是异步的,需要嵌套五层。虽然代码是可以运行的,但它的可维护性和美观程度都比较差。
const traffic = document.getElementById('traffic');
(function reset(){
traffic.className = 's1';
setTimeout(function(){
traffic.className = 's2';
setTimeout(function(){
traffic.className = 's3';
setTimeout(function(){
traffic.className = 's4';
setTimeout(function(){
traffic.className = 's5';
setTimeout(reset, 1000)
}, 1000)
}, 1000)
}, 1000)
}, 1000);
})();
版本二 —— 数据抽象
另一个版本是定义一个start
方法,使用递归的方式更加优雅地实现了这一功能,交通灯的不同状态抽象成了列表stateList
方便状态的增删与修改。
const traffic = document.getElementById('traffic');
const stateList = [
{state: 'wait', last: 1000},
{state: 'stop', last: 3000},
{state: 'pass', last: 3000},
];
function start(traffic, stateList){
function applyState(stateIdx) {
const {state, last} = stateList[stateIdx];
traffic.className = state;
setTimeout(() => {
applyState((stateIdx + 1) % stateList.length);
}, last)
}
applyState(0);
}
start(traffic, stateList);
版本三 —— 过程抽象
将轮巡poll
与等待wait
的过程定义成了单独的方法,使用到了过程抽象的概念。虽然过程抽象后得到的代码明显变多,但得到了通用的过程方法,可以更方便地扩展到其他领域,更加灵活。
const traffic = document.getElementById('traffic');
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function poll(...fnList){
let stateIndex = 0;
return async function(...args){
let fn = fnList[stateIndex++ % fnList.length];
return await fn.apply(this, args);
}
}
async function setState(state, ms){
traffic.className = state;
await wait(ms);
}
let trafficStatePoll = poll(setState.bind(null, 'wait', 1000),
setState.bind(null, 'stop', 3000),
setState.bind(null, 'pass', 3000));
(async function() {
// noprotect
while(1) {
await trafficStatePoll();
}
}());
版本四 —— 异步+函数式
版本三中的代码相对来说不太容易理解。这一版本地代码就提高了代码的可读性,更贴近自然的理解模式。对于轮巡操作,主要包括状态的设置和等待若干时间后再设置下一个状态,前者可以通过setState
实现,后者可以通过wait
方法实现。
const traffic = document.getElementById('traffic');
function wait(time) {
return new Promise(resolve => setTimeout(resolve, time));
}
function setState(state) {
traffic.className = state;
}
async function start() {
//noprotect
while(1){
setState('wait');
await wait(1000);
setState('stop');
await wait(3000);
setState('pass');
await wait(3000);
}
}
start();
2.3 案例 —— 判断是否是4的幂
版本一 —— while循环 + 按数取模
使用while
循环。如果这个数num
不断地除以4后最终等于1,则说明这个数是4的幂。
function isPowerOfFour(num) {
num = parseInt(num);
while(num > 1) {
if(num % 4) return false;
num /= 4;
}
return num === 1;
}
版本二 —— 位操作
依旧是借助while
循环,但使用位操作提高了性能。通过按位与0b11,判断二进制数最后两位是否为0。若为0,右移两位;否则说明该数不是4的幂。
function isPowerOfFour(num) {
num = parseInt(num);
while(num > 1) {
if(num & 0b11) return false;
num >>>=2;
}
return num === 1;
}
版本三 —— 位运算
另一种改进方案是时间复杂度为O(1)的算法。要理解这个算法,首先来观察一下2的幂:
0b 00000000 00000000 00000000 00010000(是2的幂)
0b 00000000 00001000 00000000 00000000(是2的幂)
0b 00001000 00000000 00000000 00000000(是2的幂)
0b 10000000 00000000 00000000 00000000(不是2的幂,因为变成负数了)
在32位的二进制中,只要有一个位置是1(不能是符号位),其他位置都是0,那么这个数就是2的幂。在此基础上,我们可以看到在int类型中,是2的幂的只有31个,那么再继续观察一组数据:
0b 00000000 00000000 00000000 00000001(是2的幂也是4的幂)
0b 00000000 00000000 00000000 00000010(只是2的幂但不是4的幂)
0b 00000000 00000000 00000000 00000100(是2的幂也是4的幂)
0b 00000000 00000000 00000000 00001000(只是2的幂但不是4的幂)
0b 00000000 00000000 00000000 00010000(是2的幂也是4的幂)
0b 00000000 00000000 00000000 00100000(只是2的幂但不是4的幂)
0b 00000000 00000000 00000000 01000000(是2的幂也是4的幂)
0b 00000000 00000000 00000000 10000000(只是2的幂但不是4的幂)
……
0b 00100000 00000000 00000000 00000000(只是2的幂但不是4的幂)
可以看出,如果一个数是2的幂,并且二进制从右边数奇数位是1的一定是4的幂。综上我们可以得到判断一个数num
是4的幂需要满足的条件:
num
为正整数num
二进制数中只有一个数位为1- x & (x-1) 得到的结果中1的位数会比x中包含的1的位数少1位,所以当
x & (x-1) === 0
时,x & (x-1)中没有数位为1,即x中存在一个数位为1。 - 例如,0010000 - 1 = 0001111,0010000 & 0001111 = 0000000 = 0
- 001110 - 1 = 000111,001110 & 000111 = 000110 != 0
- x & (x-1) 得到的结果中1的位数会比x中包含的1的位数少1位,所以当
num
二进制数中的1位于从右数的奇数位- 0xaaaaaaaa = 10101010 10101010 10101010 10101010(偶数位为1,奇数位为0)
- 当
num
按位与0xaaaaaaaa,若num & 0xaaaaaaaa === 0
,说明num
里这个唯一的1肯定在奇数位上,即可最终证明num
是4的幂。
function isPowerOfFour(num) {
num = parseInt(num);
return num > 0 &&
(num & (num - 1)) === 0 &&
(num & 0xAAAAAAAA) === 0;
}
num.addEventListener('input', function(){
num.className = '';
});
checkBtn.addEventListener('click', function(){
let value = num.value;
num.className = isPowerOfFour(value) ? 'yes' : 'no';
});
版本四 —— 正则表达式
基于JS语言的特性,我们还可以考虑使用正则表达式进行判断,即判断num
的二进制数是否是一个数位1之后跟了若干个0的结构,原理在版本三中有展开说明。
function isPowerOfFour(num) {
num = parseInt(num).toString(2);
return /^1(?:00)*$/.test(num);
}
2.4 案例 —— 洗牌
版本一 - 错误写法
使用sort
方法排序会导致洗牌次数不均匀的问题,这个方法的公平性是很差的。
const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
function shuffle(cards) {
return [...cards].sort(() => Math.random() > 0.5 ? -1 : 1);
}
console.log(shuffle(cards));
const result = Array(10).fill(0);
for(let i = 0; i < 1000000; i++) {
const c = shuffle(cards);
for(let j = 0; j < 10; j++) {
result[j] += c[j];
}
}
console.table(result);
版本二 - 正确写法
- 遍历数组
- 随机取出一位数并放到数组末尾
- 剩下的数循环上述步骤
const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
function shuffle(cards) {
const c = [...cards];
for(let i = c.length; i > 0; i--) {
const pIdx = Math.floor(Math.random() * i);
[c[pIdx], c[i - 1]] = [c[i - 1], c[pIdx]];
}
return c;
}
console.log(shuffle(cards));
const result = Array(10).fill(0);
for(let i = 0; i < 10000; i++) {
const c = shuffle(cards);
for(let j = 0; j < 10; j++) {
result[j] += c[j];
}
}
console.table(result);
版本三 - 使用生成器
在版本二的基础上,这一版代码使用yield
方法,无需跑完整个for
循环。上一版中我们把整副牌洗完了,当我们只想抽出1张或者前10张牌时,这种方法能够更加高效。
const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
function * draw(cards){
const c = [...cards];
for(let i = c.length; i > 0; i--) {
const pIdx = Math.floor(Math.random() * i);
[c[pIdx], c[i - 1]] = [c[i - 1], c[pIdx]];
yield c[i - 1];
}
}
const result = draw(cards);
console.log([...result]);
2.5 案例 —— 分红包
切西瓜法
- 将红包总额随机分成两份
- 对数值较大的一份重复第一步
- 直到红包数量足够
这个算法当中,由于总是优先对数额较大的一份进行切分,最后的红包数额会趋向均匀化。
function generate(amount, count){
let ret = [amount];
while(count > 1){
// 挑选出最大一块进行切分
let cake = Math.max(...ret),
idx = ret.indexOf(cake),
part = 1 + Math.floor((cake / 2) * Math.random()),
rest = cake - part;
ret.splice(idx, 1, part, rest);
count--;
}
return ret;
}
const amountEl = document.getElementById('amount');
const countEl = document.getElementById('count');
const generateBtn = document.getElementById('generateBtn');
const resultEl = document.getElementById('result');
generateBtn.onclick = function(){
let amount = Math.round(parseFloat(amountEl.value) * 100);
let count = parseInt(countEl.value);
let output = [];
if(isNaN(amount) || isNaN(count)
|| amount <= 0 || count <= 0){
output.push('输入格式不正确!');
}else if(amount < count){
output.push('钱不够分')
}else{
output.push(...generate(amount, count));
output = output.map(m => (m / 100).toFixed(2));
}
resultEl.innerHTML = '<li>' +
output.join('</li><li>') +
'</li>';
}
抽牌法
- 随机抽出n-1个数,作为数组分隔符
这个算法的问题在于空间复杂度较高。
function * draw(cards){
const c = [...cards];
for(let i = c.length; i > 0; i--) {
const pIdx = Math.floor(Math.random() * i);
[c[pIdx], c[i - 1]] = [c[i - 1], c[pIdx]];
yield c[i - 1];
}
}
function generate(amount, count){
if(count <= 1) return [amount];
const cards = Array(amount - 1).fill(0).map((_, i) => i + 1);
const pick = draw(cards);
const result = [];
for(let i = 0; i < count; i++) {
result.push(pick.next().value);
}
result.sort((a, b) => a - b);
for(let i = count - 1; i > 0; i--) {
result[i] = result[i] - result[i - 1];
}
return result;
}