JavaScript 避免使用 if-else 的方法

本文最后更新于:2020年12月25日 晚上

场景

在日常编写 JavaScript 代码的过程中,或许会遇到一个很常见的问题。根据某个状态,进行判断,并执行不同的操作。吾辈并不是说 if-else 不好,简单的逻辑判断 if-else 毫无疑问是个不错的选择。然而在很多时候似乎我们习惯了使用 if-else,导致代码不断庞大的同时复杂度越来越高,所有的 JavaScript 代码都乱作一团,后期维护时越发困难。

GitHub, 演示地址

例如下面这段代码,点击不同的按钮,显示不同的面板。

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>JavaScript 避免使用 if-else</title>
  </head>
  <body>
    <main>
      <div id="tab">
        <label>
          <input type="radio" data-index="1" name="form-tab-radio" />
          第一个选项卡
        </label>
        <label>
          <input type="radio" data-index="2" name="form-tab-radio" />
          第二个选项卡
        </label>
        <label>
          <input type="radio" data-index="3" name="form-tab-radio" />
          第三个选项卡
        </label>
      </div>
      <form id="extends-form"></form>
    </main>
    <script src="./js/if-else.js"></script>
  </body>
</html>
// js/if-else.js
document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach((el) => {
  el.addEventListener("click", () => {
    const index = el.dataset.index;
    const header = el.parentElement.innerText.trim();
    // 如果为 1 就添加一个文本表单
    if (index === "1") {
      document.querySelector("#extends-form").innerHTML = `
            <header><h2>${header}</h2></header>
            <div>
              <label for="name">姓名</label>
              <input type="text" name="name" id="name" />
            </div>
            <div>
              <label for="age">年龄</label>
              <input type="number" name="age" id="age" />
            </div>
            <div>
              <button type="submit">提交</button> <button type="reset">重置</button>
            </div>
          `;
    } else if (index === "2") {
      document.querySelector("#extends-form").innerHTML = `
        <header><h2>${header}</h2></header>
        <div>
          <label for="avatar">头像</label>
          <input type="file" name="avatar" id="avatar" />
        </div>
        <div><img id="avatar-preview" src="" /></div>
        <div>
          <button type="submit">提交</button> <button type="reset">重置</button>
        </div>
      `;
      function readLocalFile(file) {
        return new Promise((resolve, reject) => {
          const fr = new FileReader();
          fr.onload = (event) => {
            resolve(event.target.result);
          };
          fr.onerror = (error) => {
            reject(error);
          };
          fr.readAsDataURL(file);
        });
      }
      document.querySelector("#avatar").addEventListener("change", (evnet) => {
        const file = evnet.target.files[0];
        if (!file) {
          return;
        }
        if (!file.type.includes("image")) {
          return;
        }
        readLocalFile(file).then((link) => {
          document.querySelector("#avatar-preview").src = link;
        });
      });
    } else if (index === "3") {
      const initData = new Array(100).fill(0).map((v, i) => `${i} 项内容`);
      document.querySelector("#extends-form").innerHTML = `
        <header><h2>${header}</h2></header>
        <div>
          <label for="search-text">搜索文本</label>
          <input type="text" name="search-text" id="search-text" />
          <ul id="search-result"></ul>
        </div>
      `;
      document
        .querySelector("#search-text")
        .addEventListener("input", (evnet) => {
          const searchText = event.target.value;
          document.querySelector("#search-result").innerHTML = initData
            .filter((v) => v.includes(searchText))
            .map((v) => `<li>${v}</li>`)
            .join();
        });
    }
  });
});

那么,我们可以如何优化呢?

抽取函数

稍有些经验的 developer 都知道,如果一个函数过于冗长,那么就应该将之分离成多个单独的函数。

所以,我们的代码变成了下面这样

实现思路

  1. 抽取每个状态对应执行的函数
  2. 根据状态使用 if-else/switch 判断然后调用不同的函数
// 抽取函数

function switchFirst(header) {
  document.querySelector("#extends-form").innerHTML = `
          ${header}
          <div>
            <label for="name">姓名</label>
            <input type="text" name="name" id="name" />
          </div>
          <div>
            <label for="age">年龄</label>
            <input type="number" name="age" id="age" />
          </div>
          <div>
            <button type="submit">提交</button> <button type="reset">重置</button>
          </div>
        `;
}

function switchSecond(header) {
  document.querySelector("#extends-form").innerHTML = `
      ${header}
      <div>
        <label for="avatar">头像</label>
        <input type="file" name="avatar" id="avatar" />
      </div>
      <div><img id="avatar-preview" src="" /></div>
      <div>
        <button type="submit">提交</button> <button type="reset">重置</button>
      </div>
    `;
  function readLocalFile(file) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = (event) => {
        resolve(event.target.result);
      };
      fr.onerror = (error) => {
        reject(error);
      };
      fr.readAsDataURL(file);
    });
  }
  document.querySelector("#avatar").addEventListener("change", (evnet) => {
    const file = evnet.target.files[0];
    if (!file) {
      return;
    }
    if (!file.type.includes("image")) {
      return;
    }
    readLocalFile(file).then((link) => {
      document.querySelector("#avatar-preview").src = link;
    });
  });
}

function switchThree(header) {
  const initData = new Array(100).fill(0).map((v, i) => `${i} 项内容`);
  document.querySelector("#extends-form").innerHTML = `
      ${header}
      <div>
        <label for="search-text">搜索文本</label>
        <input type="text" name="search-text" id="search-text" />
        <ul id="search-result"></ul>
      </div>
    `;
  document.querySelector("#search-text").addEventListener("input", (evnet) => {
    const searchText = event.target.value;
    document.querySelector("#search-result").innerHTML = initData
      .filter((v) => v.includes(searchText))
      .map((v) => `<li>${v}</li>`)
      .join();
  });
}

function switchTab(el) {
  const index = el.dataset.index;
  const header = `<header><h2>${el.parentElement.innerText.trim()}</h2></header>`;
  // 如果为 1 就添加一个文本表单
  if (index === "1") {
    switchFirst(header);
  } else if (index === "2") {
    switchSecond(header);
  } else if (index === "3") {
    switchThree(header);
  }
}

document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach((el) => {
  el.addEventListener("click", () => switchTab(el));
});

ES6 class:有限状态机

如果你知道 ES6 的 class 的话,应该也了解到目前 js 可以使用 class 模拟面向对象的继承,以及多态。

实现思路

  1. 创建一个基类,并在其中声明一个需要被子类重写的方法
  2. 根据不同的状态创建不同的子类,并分别实现基类的方法
  3. 添加一个 Builder 类,用于根据不同的状态判断来创建不同的子类
  4. 调用者使用 Builder 类构造出来的对象调用父类中声明的方法

具体实现

// 有限状态机

class Tab {
  // 基类里面的初始化方法放一些通用的操作
  init(header) {
    const html = `
      <header><h2>${header}</h2></header>
      ${this.initHTML()}
    `;
    document.querySelector("#extends-form").innerHTML = html;
  }

  // 给出一个方法让子类实现,以获得不同的 HTML 内容
  initHTML() {}
}

class Tab1 extends Tab {
  // 实现 initHTML,获得选项卡对应的 HTML
  initHTML() {
    return `
      <div>
        <label for="name">姓名</label>
        <input type="text" name="name" id="name" />
      </div>
      <div>
        <label for="age">年龄</label>
        <input type="number" name="age" id="age" />
      </div>
      <div>
        <button type="submit">提交</button> <button type="reset">重置</button>
      </div>
    `;
  }
}

class Tab2 extends Tab {
  initHTML() {
    return `
    <div>
      <label for="avatar">头像</label>
      <input type="file" name="avatar" id="avatar" />
    </div>
    <div><img id="avatar-preview" src="" /></div>
    <div>
      <button type="submit">提交</button> <button type="reset">重置</button>
    </div>
    `;
  }
  // 重写 init 初始化方法,并首先调用基类通用初始化的方法
  init(header) {
    super.init(header);
    document.querySelector("#avatar").addEventListener("change", (evnet) => {
      const file = evnet.target.files[0];
      if (!file) {
        return;
      }
      if (!file.type.includes("image")) {
        return;
      }
      this.readLocalFile(file).then((link) => {
        document.querySelector("#avatar-preview").src = link;
      });
    });
  }
  // 子类独有方法
  readLocalFile(file) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = (event) => {
        resolve(event.target.result);
      };
      fr.onerror = (error) => {
        reject(error);
      };
      fr.readAsDataURL(file);
    });
  }
}

class Tab3 extends Tab {
  initHTML() {
    return `
      <div>
        <label for="search-text">搜索文本</label>
        <input type="text" name="search-text" id="search-text" />
        <ul id="search-result" />
      </div>
    `;
  }
  init(header) {
    super.init(header);
    const initData = new Array(100).fill(0).map((v, i) => `${i} 项内容`);
    document
      .querySelector("#search-text")
      .addEventListener("input", (evnet) => {
        const searchText = event.target.value;
        document.querySelector("#search-result").innerHTML = initData
          .filter((v) => v.includes(searchText))
          .map((v) => `<li>${v}</li>`)
          .join();
      });
  }
}

class TabBuilder {
  /**
   * 获取一个标签子类对象
   * @param {Number} index 索引
   * @returns {Tab} 子类对象
   */
  static getInstance(index) {
    // Tab 构造类,用于根据不同的状态 index 构造不同的 Tab 对象
    const tabMap = new Map(
      Object.entries({
        1: () => new Tab1(),
        2: () => new Tab2(),
        3: () => new Tab3(),
      })
    );
    return tabMap.get(index)();
  }
}

document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach((el) => {
  el.addEventListener("click", () =>
    // 首先通过 Builder 构造类获取 Tab 子类实例,然后调用初始化方法 init
    TabBuilder.getInstance(el.dataset.index).init(
      el.parentElement.innerText.trim()
    )
  );
});

主要优势

  • 分离了状态与执行函数之间的关联,具体执行由具体的子类决定
  • 子类允许包含独有的属性/方法
  • 可扩展性更好,随时可以扩展任意多个子类

ES6 class:无限状态机

上面使用 class 继承多态实现的状态机虽然很好,但却并不能应对 不确定 具体有多少种状态的情况。因为每个子类都与父类有着强关联,直接在 Builder 类中进行了声明。那么,有没有一种方式,可以在添加/删除后不影响基类或者构造类呢?

  1. 创建一个基类,并在其中声明一个需要被子类重写的方法
  2. 添加一个 Builder 类,具体子类对应的状态由子类的某个属性决定
  3. 根据不同的状态创建不同的子类,并分别实现基类的方法,调用 Builder 类的方法注册自身

    此处因为 js 无法通过反射拿到所有子类,所以子类需要在 Builder 类注册自己

  4. 使用 Builder 构造子类对象,并调用基类声明的方法

具体实现

// 无限状态机

class Tab {
  // 基类里面的初始化方法放一些通用的操作
  init(header) {
    const html = `
      <header><h2>${header}</h2></header>
      ${this.initHTML()}
    `;
    document.querySelector("#extends-form").innerHTML = html;
  }

  // 给出一个方法让子类实现,以获得不同的 HTML 内容
  initHTML() {}
}

/**
 * 状态机
 * 用于避免使用 if-else 的一种方式
 */
class StateMachine {
  static getBuilder() {
    const clazzMap = new Map();
    /**
     * 状态注册器
     * 更好的有限状态机,分离子类与构建的关系,无论子类如何增删该都不影响基类及工厂类
     */
    return new (class Builder {
      // noinspection JSMethodCanBeStatic
      /**
       * 注册一个 class,创建子类时调用,用于记录每一个 [状态 => 子类] 对应
       * @param state 作为键的状态
       * @param clazz 对应的子类型
       * @returns {*} 返回 clazz 本身
       */
      register(state, clazz) {
        clazzMap.set(state, clazz);
        return clazz;
      }

      // noinspection JSMethodCanBeStatic
      /**
       * 获取一个标签子类对象
       * @param {Number} state 状态索引
       * @returns {QuestionType} 子类对象
       */
      getInstance(state) {
        const clazz = clazzMap.get(state);
        if (!clazz) {
          return null;
        }
        //构造函数的参数
        return new clazz(...Array.from(arguments).slice(1));
      }
    })();
  }
}
/**
 * 状态注册器
 * 更好的有限状态机,分离子类与构建的关系,无论子类如何增删该都不影响基类及工厂类
 */
const builder = StateMachine.getBuilder();

const Tab1 = builder.register(
  1,
  class Tab1 extends Tab {
    // 实现 initHTML,获得选项卡对应的 HTML
    initHTML() {
      return `
        <div>
          <label for="name">姓名</label>
          <input type="text" name="name" id="name" />
        </div>
        <div>
          <label for="age">年龄</label>
          <input type="number" name="age" id="age" />
        </div>
        <div>
          <button type="submit">提交</button> <button type="reset">重置</button>
        </div>
      `;
    }
  }
);

const Tab2 = builder.register(
  2,
  class Tab2 extends Tab {
    initHTML() {
      return `
      <div>
        <label for="avatar">头像</label>
        <input type="file" name="avatar" id="avatar" />
      </div>
      <div><img id="avatar-preview" src="" /></div>
      <div>
        <button type="submit">提交</button> <button type="reset">重置</button>
      </div>
      `;
    }
    // 重写 init 初始化方法,并首先调用基类通用初始化的方法
    init(header) {
      super.init(header);
      document.querySelector("#avatar").addEventListener("change", (evnet) => {
        const file = evnet.target.files[0];
        if (!file) {
          return;
        }
        if (!file.type.includes("image")) {
          return;
        }
        this.readLocalFile(file).then((link) => {
          document.querySelector("#avatar-preview").src = link;
        });
      });
    }
    // 子类独有方法
    readLocalFile(file) {
      return new Promise((resolve, reject) => {
        const fr = new FileReader();
        fr.onload = (event) => {
          resolve(event.target.result);
        };
        fr.onerror = (error) => {
          reject(error);
        };
        fr.readAsDataURL(file);
      });
    }
  }
);

const Tab3 = builder.register(
  3,
  class Tab3 extends Tab {
    initHTML() {
      return `
      <div>
        <label for="search-text">搜索文本</label>
        <input type="text" name="search-text" id="search-text" />
        <ul id="search-result" />
      </div>
    `;
    }
    init(header) {
      super.init(header);
      const initData = new Array(100).fill(0).map((v, i) => `${i} 项内容`);
      document
        .querySelector("#search-text")
        .addEventListener("input", (evnet) => {
          const searchText = event.target.value;
          document.querySelector("#search-result").innerHTML = initData
            .filter((v) => v.includes(searchText))
            .map((v) => `<li>${v}</li>`)
            .join();
        });
    }
  }
);

document.querySelectorAll('#tab input[name="form-tab-radio"]').forEach((el) => {
  el.addEventListener("click", () =>
    // 调用方式不变
    builder
      .getInstance(Number.parseInt(el.dataset.index))
      .init(el.parentElement.innerText.trim())
  );
});

主要优势

  • 可扩展性最好,添加/修改/删除子类不影响父类及构造类

那么,关于 JavaScript 中如何避免使用 if-else 到这里就结束啦


JavaScript 避免使用 if-else 的方法
https://blog.rxliuli.com/p/6586ffbb50ac49ceb31397ce58b49f16/
作者
rxliuli
发布于
2020年2月2日
许可协议