前端工程师进阶要点三——代码的封装性、可读性和正确性|小册免费学

659 阅读9分钟

函数的封装性

函数的封装性是指把函数相关的数据和行为结合在一起,对调用者隐藏内部处理过程

函数的封装性常常被忽略,并且很容易被破坏。首先看一下如下"交通灯"的效果实现:

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。会有两个问题:

  1. 如果修改了traffic元素,该函数将无法工作
  2. 如果把这个函数复用到其他地方,需要重建这个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, waitstop,及切换的时间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秒 -> 将trafficclassName属性修改为traffic wait -> 等1.5秒 -> 将trafficclassName属性修改为traffic stop -> 等待3.5秒 -> 将trafficclassName属性修改为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函数的用途。

Promsieasync/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;
}

本文正在参与「掘金小册免费学啦!」活动, 点击查看活动详情