函数的封装性
函数的封装性是指把函数相关的数据和行为结合在一起,对调用者隐藏内部处理过程。
函数的封装性常常被忽略,并且很容易被破坏。首先看一下如下"交通灯"的效果实现:
html和css如下:
html, body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
/*设置html和body元素的布局为弹性布局*/
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
header {
line-height: 2rem;
font-size: 1.2rem;
margin-bottom: 20px;
}
.traffic { /*将class=traffic元素设置为弹性布局,它的子元素按照从上面到下排列*/
padding: 10px;
display: flex;
flex-direction: column;
}
.traffic .light {
width: 100px;
height: 100px;
background-color: #999;
border-radius: 50%;
}
/*将class=traffic & class=pass元素下的第一个class=light的元素的背景色设置为绿色*/
.traffic.pass .light:nth-child(1) {
background-color: #0a6; /*绿灯*/
}
.traffic.wait .light:nth-child(2) {
background-color: #cc0; /*黄灯*/
}
.traffic.stop .light:nth-child(3) {
background-color: #c00; /*红灯*/
}
<header>模拟交通灯</header>
<main>
<div class="traffic pass">
<div class="light"></div>
<div class="light"></div>
<div class="light"></div>
</div>
</main>
具体需求是:模拟交通灯信号,分别以5秒、1.5秒、3.5秒来循环切换绿灯(pass状态)、黄灯(wait状态)和红灯(stop状态)。
如下,是一个简单的实现:
const traffic = document.querySelector('.traffic');
function loop() {
traffic.className = 'traffic pass';
setTimeout(() => {
traffic.className = 'traffic wait';
setTimeout(() => {
traffic.className = 'traffic stop';
setTimeout(loop, 3500);
}, 1500);
}, 5000);
}
loop();
代码执行如下:获取class=traffic
元素,然后在loop函数中,首先将traffic
元素的class设置为traffic pass
,即为绿灯;然后setTimeout
方法嵌套,第一层在5秒后执行,会将traffic
的class变为tranffic wait
,即为黄灯;然后setTimeout
方法在1.5秒后执行,变为红灯;然后3.5秒后变为绿灯。重复运行。
上面的代码,设计上有很大缺陷:loop
函数访问了外部环境traffic
。会有两个问题:
- 如果修改了
traffic
元素,该函数将无法工作 - 如果把这个函数复用到其他地方,需要重建这个
traffic
对象。
这两个问题都是因为函数的封装性完全被破坏。
因此,不能直接将traffic
这个对象直接写在loop
函数中。
如下,通过参数传入:
const traffic = document.querySelector('.traffic');
function loop(subject) {
subject.className = 'traffic pass';
setTimeout(() => {
subject.className = 'traffic wait';
setTimeout(() => {
subject.className = 'traffic stop';
setTimeout(loop.bind(null, subject), 3500);
}, 1500);
}, 5000);
}
loop(traffic);
函数体内部不应该有完全来自外部环境的变量,除非这个函数不打算复用。
目前loop中,还有其他写“死”在代码里面的数据,如果不提取出来,代码的可复用性依然很差。
bind
函数改变函数执行的作用域,后面的参数为列表形式,结果返回一个新函数。不会直接执行原函数
实现异步状态切换函数的封装
如何封装一个函数可以根据某个数据的状态切换完成相应功能呢?
函数简单来说,是一个处理数据的最小单元。包含两个部分:数据和处理过程。要让函数具有通用性,则可以抽象数据,也可以抽象过程。
第一步:数据抽象
数据抽象就是把数据定义并聚合成能被过程处理的对象,交由特定的过程处理。简单来说就是数据的结构化。
抽象数据,就是将数据重新定义并组合成特定结构的对象,用来交给某个过程处理,完成需要的任务。
对于上面的异步状态切换,首先,找到要处理的数据:状态pass
, wait
和stop
,及切换的时间5
秒、1.5
秒和3.5
秒。
将数据从loop
函数中剥离出来:
const traffic = document.querySelector('.traffic');
function signalLoop(subject, signals = []) {
const signalCount = signals.length;
function updateState(i) {
let mod=i % signalCount; // 防止i+1持续增加
const {signal, duration} = signals[mod];
subject.className = signal;
setTimeout(updateState.bind(null, mod + 1), duration); // 执行i+1下一个状态
}
updateState(0);
}
// 数据抽象
const signals = [
{signal: 'traffic pass', duration: 5000},
{ signal: 'traffic wait', duration: 1500 },
{ signal: 'traffic stop', duration: 3500 },
];
signalLoop(traffic, signals);
将状态和时间抽象成一个包含3个对象的数组,并将这个结构化的数据传递到signalLoop
方法中。利用updateState
方法的递归调用实现了状态的切换。
经过数据抽象的代码可以适应不同状态和时间的业务需求,只需要修改数据抽象即可,而不需要修改signalLoop
方法。
但是,目前数据抽象重构后,signalLoop
方法还未达到完全封装。因为signalLoop
函数中存在一部分改变外部状态的代码。
把改变外部状态的部分叫做代码的副作用(side-effect)。通常,可以把函数体内部有副作用的代码剥离出来,提升函数的通用性、稳定性和可测试性。
第二步:去副作用化
signalLoop
方法中,subject.className = signal;
改变了外部的状态,因为subject
是外部变量,该句改变了这个变量的className状态。因此需要将其从函数中剥离出来:
const traffic = document.querySelector('.traffic');
function signalLoop(subject, signals = [], onSignal) {
const signalCount = signals.length;
function updateState(i) {
let mod=i % signalCount; // 防止i+1持续增加
const {signal, duration} = signals[mod];
if(typeof onSignal === "function"){
onSignal(subject, signal);
}
setTimeout(updateState.bind(null, mod + 1), duration);
}
updateState(0);
}
// 数据抽象
const signals = [
{signal: 'pass', duration: 5000},
{ signal: 'wait', duration: 1500 },
{ signal: 'stop', duration: 3500 },
];
signalLoop(traffic, signals, (subject, signal) => {
subject.className = `traffic ${signal}`;
});
如上,将改变外部变量的操作用回调的方法传给singalLoop
。这样修改提升了signalLoop
函数的通用性,使得这个函数也可以用于操作其他的DOM元素的状态切换。
这样的封装,提高了signalLoop
函数的"纯度"。
代码的“语义”与可读性
上面几个版本的函数,代码的可读性不是很高。
为了提高异步状态切换代码的可读性,可以采用ES6异步规范 —— Promise
,重构代码:
function wait(ms) {
return new Promise((resolve) => {
setTimetout(resolve, ms);
});
}
这段代码将setTimeout
方法封装成wait
函数。该函数将setTimeout
方法用Promise
包裹起来,并返回这个Promise
对象。
这样,可以将有些晦涩的setTimeout
嵌套改为一个async
函数中的await
循环:
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const traffic = document.querySelector('.traffic');
(async function () {
while(1) {
await wait(5000);
traffic.className = 'traffic wait';
await wait(1500);
traffic.className = 'traffic stop';
await wait(3500);
traffic.className = 'traffic pass';
}
}());
将原来的loop
方法改为立即调用函数的方式,并将3个setTimeout
部分修改为while
死循环。循环体中的部分很容易理解:等待5秒 -> 将traffic
的className
属性修改为traffic wait
-> 等1.5秒 -> 将traffic
的className
属性修改为traffic stop
-> 等待3.5秒 -> 将traffic
的className
属性修改为traffic pass
。
与之前比较,可读性有了很大提高。
同样,用Promise
修改signalLoop
的版本
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const traffic = document.querySelector('.traffic');
async function signalLoop(subject, signals = [], onSignal) {
const signalCount = signals.length;
for(let i = 0; ;i++) {
let mod=i % signalCount;
const {signal, duration} = signals[mod];
if(typeof onSignal === "function"){
await onSignal(subject, signal);
}
await wait(duration);
i=mod;
}
}
const signals = [
{signal: 'pass', duration: 5000},
{ signal: 'wait', duration: 1500 },
{ signal: 'stop', duration: 3500 },
];
signalLoop(traffic, signals, (subject, signal) => {
subject.className = `traffic ${signal}`;
});
这次主要重构代码内部。使用async/await
能够把异步的递归简化为更容易让人阅读和理解的循环,而且,还允许onSignal
回调也是一个异步过程,进一步增加了signalLoop
函数的用途。
Promsie
和async/await
创造的不仅仅是语法,而是一种新的语义。代码是人阅读的,只是偶尔让计算机执行一下。
函数的正确性和效率
相比封装性和可读性,代码的正确性更为重要。
实际开发中,我们可能会写出错误的代码而不自知。比如:洗牌算法的陷阱。
一个抽奖场景:给定一组生成好的抽奖号码,然后需要实现一个模块。模块的功能是将这组号码打散(即,洗牌)然后输出一个中奖的号码。
打散号码的JS片段如下:
function shuffle(items) {
return [...items].sort((a, b) => Math.random() > 0.5 ? -1 : 1);
}
真实项目会很复杂,且抽奖代码不是在客户端运行,而是服务器端完成。
这段代码的问题在于,随机方法根本就不够随机。
比如,通过下面测试:
function shuffle(items) {
return items.sort((a, b) => Math.random() > 0.5 ? -1 : 1);
}
const weights = Array(9).fill(0);
for(let i = 0; i < 10000; i++) {
const testItems = [1, 2, 3, 4, 5, 6, 7, 8, 9];
shuffle(testItems);
testItems.forEach((item, idx) => weights[idx] += item);
}
console.log(weights);
// [44645, 45082, 49934, 50371, 50903, 50344, 50677, 52427, 55617]
// 每次结果有变化,但总的来说前面的数字小,后面的数字大
把1到9数字经过shuffle函数随机10000次,然后把每一位出现的数字相加,得到总和。
多次运行,检验发现总和数组,基本都是前面的数字较小,后面的数字较大。
这意味着,越大的数字出现在数组后面的概率要大一些。
造成这个结果的原因是,数组的
sort
方法内部是一个排序算法,我们不知道它的具体实现, 但一般来说,排序算法用某种规则依次选取两个元素比较它们的大小,然后根据比较结果交换位置。这个算法给排序过程一个随机的比较算子
(a, b) => Math.random() > 0.5 ? -1 : 1
,从而让数组元素的交换过程代码随机性,但是交换过程的随机性,并不能保证数学上让每个元素出现在每个位置都具有相同的几率,因为排序算法对每个位置的元素和其他元素交换的次序、次数都是有区别的。
要实现比较公平的随机算法,需要每次从数组中随机取出一个元素,将其放到新的队列中,直至全部取完,这样就得到了随机排列。(不考虑JavaScript引擎内置的Math.random函数本身的随机性问题的前提下)可以保证获取数组中元素的每个位置的几率是相同的。
如下:
function shuffle(items) {
items = [...items];
const ret = [];
while(items.length) {
const idx = Math.floor(Math.random() * items.length); // 随机获取数组内的元素位置
const item = items.splice(idx, 1)[0];
ret.push(item);
}
return ret;
}
let items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
items = shuffle(items);
console.log(items);
每次从数组中随机挑选元素,将这个元素从原数组的副本中删除(为了不影响原素组,此处创建了副本),放入新的数组,这样就可以保证每一个数在每个位置的概率是相同的。
这个算法的处理没有问题,但是效率上,因为使用splice
方法,该算法的时间复杂度为O(n^2)。
为了更快些,不必用splice
将元素从原数组副本中一一抽取,可以在原数组副本中执行元素的交换,只要在每次抽取的时候,直接将随机到的位置的元素与数组的第“i”个(当前)元素交换。
如下:
function shuffle(items) {
items = [...items];
for(let i = items.length; i > 0; i--) {
const idx = Math.floor(Math.random() * i);
[items[idx], items[i - 1]] = [items[i - 1], items[idx]];
}
return items;
}
let items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
items = shuffle(items);
console.log(items);
每次从数组的前i个元素(第0~i-1个元素)中随机挑选一个,将它和第i个元素(下标为i-1)进行交换,然后把i的值减1,直到i的值小于1。
这个算法的时间复杂度是O(n)。性能上会更好。
我们还可以更进一步改进,因为根据需求,用户抽奖的次数是有限制的,而且如果在次数允许的情况下,已经抽到了幸运数字,就不必再抽取下去,所以其实不必对整个数组进行完全的随机排列。
此时,可以改用生成器:
function* shuffle(items) {
items = [...items];
for(let i = items.length; i > 0; i--) {
const idx = Math.floor(Math.random() * i);
[items[idx], items[i - 1]] = [items[i - 1], items[idx]];
yield items[i - 1];
}
}
let items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
items = shuffle(items);
console.log(...items);
将return
改成yield
,就可以将函数改成生成器,实现部分洗牌或用于抽奖。
如下,100个号码中随机抽取5个:
function *shuffle(items) {
items = [...items];
for(let i = items.length; i > 0; i--) {
const idx = Math.floor(Math.random() * i);
[items[idx], items[i - 1]] = [items[i - 1], items[idx]];
yield items[i - 1];
}
}
let items = [...new Array(100).keys()];
let n = 0;
// 100个号随机抽取5个
for(let item of shuffle(items)) {
console.log(item);
if(n++ >= 5) break;
}
本文正在参与「掘金小册免费学啦!」活动, 点击查看活动详情