如何写好JavaScript?(二) | 青训营笔记

59 阅读10分钟

这是我参与「第四届青训营 」笔记创作活动的的第7天

7月25日,月影大佬的JavaScript课,本文为课程笔记下文

三.过程抽象

1.什么是过程抽象

所谓过程抽象 其实是一种思维方式,也是一种编程方式,我们可以把函数当成一个控制器,控制这函数的输入和输出,它也是函数式编程思想的基础。

案例

我们想象一个应用场景,就是常见的表单提交,一般我们都应该限制点击提交后不能再次提交,防止多次提交。

为了能够让‘只执行一次’的需求来覆盖不同的事件处理,我们可以将这个需求剥离出来,这个过程也被称为过程抽象。 我们可以创建一个once的高阶函数,让它来处理这个操作。

function once(fn){
  return function(...args){
    if(fn){
      const ret = fn.apply(this,args);
      fn = null;
      return ret;
    }
  }
}

我们这里给这个函数传入function,第一次进来首先会为true,if中的内容会执行,在执行之后立马function被设为null,下次进入这个函数的时候,if就不会执行了,因为null在条件判断中相当于false。

2.高阶函数

上面用到高阶函数,讲一下什么是高阶函数,

高阶函数一般以函数作为参数并且把函数作为返回值。常用于作为函数装饰器。上面的例子我们也可以在函数处理前后添加其他操作,比如在发送数据的时候添加请求头等等。

高阶函数除了上面once还有其他常用的

3.纯函数

纯函数就是返回结果只依赖于它的参数且不会改变上下文环境,不会有任何副作用,一个项目如果纯函数很多的话,说明这个项目可维护性很高。

俩个例子

    // 纯函数
    function add(a,b){
        return a + b;
    };
    // 非纯函数
    let num = 6;
    function add1(){
      return  num++;
    }
    console.log(add(1,2));//3
    console.log(add1());//6
    console.log(add1());//7

4.高阶函数场景分析

想象一个场景,比如我们的代码库要更新,有些代码要废弃或者重新修改,但是目前还有很多人在用,我们要给现在用的人一个提升要更新的操作。

一般情况下我们首先想到的就是找到要更改的函数,里面添加console.warn 但是我们这样操作的话容易出现bug,而且很多的话容易累死。我们可以设计一个纯函数,把需要修改的函数放进去统一进行添加之后再暴露出来。

 import {bar as bar1,foo as foo1} from "jackson";
    function depracation(fn){
        return function(...args){
            console.log('要修改了-Jackson');
            return fn.apply(this,...args);
        }
    }
    const foo = depracation(foo1);
    const bar = depracation(bar1);
    export {foo,bar};

四.函数封装

案例:交通灯

这个例子的具体需求是,模拟交通灯信号,每隔一段时间,显示不同的颜色,循环切换状态

未封装:菜鸟版

这个拿给我们新手菜鸟,可能会这样写:

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);
})();

上面的这段菜鸟版代码虽然实现了我们的需求,但是它在设计上有很大的缺陷

个缺陷:reset函数访问了外部环境traffic,而它在函数内部不具有意义 这么做有两个问题:

  1. 如果我们修改了HTML代码,元素不叫做traffic了,这个函数就不工作了。
  2. 如果我们想把这个函数复用到其他地方,我们还得在那个地方重建这个traffic对象。

个缺陷是 回调地狱,我们要手写那么多次回调套回调,如果要增加或者减少状态,会很麻烦

出现这些问题的原因就是我们没有做到对函数进行封装!!!

所以,我们要封装函数,不能直接将traffic这个对象直接写在函数中,也不能将状态切换的具体数据直接写在函数中

封装数据:数据抽象版

我们先对数据进行抽象,或者可以说将数据从函数中解耦出来~

首先,我们将traffic变量作为函数的参数传入我们的start函数中,这样函数体内部就没有完全来自外部环境的变量了

然后我们将状态(数据)抽象出来,形成一个对象数组,存着状态的名称state和等待时间last。将数组传入我们的start函数中,递归调用applyState函数,来实现状态切换。

这样做对函数进行了封装,将数据抽象出来,我们在修改数据的时候,不用修改函数体中的内容,使得我们函数的封装性得到了很大的提升。

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);

数据抽象就是把数据定义并聚合成能被过程处理的对象,交由特定的过程处理。 简单来说就是数据的结构化。

这里做出了两点改进

  1. 将外部变量变成参数传进函数 traffic
  2. 将状态数据与函数进行解耦,抽象数据 stateList 都提升了函数封装性和可复用

封装行为:过程抽象版

在之前我们说到的三大原则的过程抽象中,我们知道,不仅可以对数据进行抽象,也可以对过程进行抽象,下面我们来通过抽象过程来进行函数封装。

这次我们抽象出过程的两种操作:① 改变类名 ② 等待时间

① 改变类名封装成函数setState

function setState(state){
  traffic.className = state;
}

② 等待时间 就是用wait函数封装setTimeout,我们把定时器的操作抽象出来,进行promise化,提高我们代码的可读性

function wait(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

将setTimeout函数封装成一个返回Promise的wait函数。 然后配合async/await语法,可以用同步代码风格写异步代码

这段代码与之前的代码相比,它的可读性是不是提高了很多?并且我们把他的过程都抽象出来了

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(1000);
    setState('pass');
    await wait(1000);
  }
}

start();

封装行为:宏观版(封装轮询操作)

这里我们将改变类名和等待时间的操作封装在一起到setstate中去,

将设置状态封装成函数,将这些状态作为轮询函数的参数

async function setState(state, ms){
  traffic.className = state;
  await wait(ms);
}

主要是要封装轮询函数

将循环播放抽象成一个轮询函数,用来切换状态

function poll(...fnList){
  let stateIndex = 0;
  
  return async function(...args){
    let fn = fnList[stateIndex++ % fnList.length];
    return await fn.apply(this, args);
  }
}

就可以这样使用

let trafficStatePoll = poll(setState.bind(null, 'wait', 1000),
                            setState.bind(null, 'stop', 3000),
                            setState.bind(null, 'pass', 3000));

最终,完整的代码就是如下所示

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', 1000),
                            setState.bind(null, 'pass', 1000));

(async function() {
  // noprotect
  while(1) {
    await trafficStatePoll();
  }
}());

这样加强了我们函数的灵活性,我们状态切换的具体内容可以更改,而且将状态抽象出来,改变的状态的数量也很方便修改,直接在trafficStatePoll中添加即可

总结

  1. 函数要做好封装,降低函数耦合性
  2. 要确保函数尽量不要直接使用和修改外部的变量,要用到外部变量,应该使其成为参数传入函数中
  3. 函数是一个处理数据的最小单元。它包含数据处理过程
  4. 做好数据抽象,将用到的数据抽象出去形成对象或数组,可以提高函数的复用性
  5. 做好过程抽象,将过程进行抽象形成独立的函数,可以提高函数的复用性、灵活性
  6. 将异步操作进行promise化,可以提高函数的可读性

五.妙用特性

案例1:判断4的幂

菜鸟版

这题拿到太简单了,直接循环除以4最后能除尽就是啦

① 负数直接返回 false

② 对n进行循环除以4的遍历,只要有一次不能被4整除,就返 false

③ 否则返回 true

var isPowerOfFour = function(n) {
    if( n < 1) return false 
    while(n > 1){
        if(n % 4) return false
        n /= 4
    }
    return true
};

学废版

使用位运算来提高效率

一个数x模4就相当于 x 的二进制形式 与 二进制数 11 也就是十进制的 3 按位相,(与运算是两个数都为1结果才为1,否则结果都是0)因为11前面都是0,所以x的二进制形式最后只剩最后两位,最后两位 与 11 做与运算,结果还是自身(x的二进制形式的最后两位),所以 n % 4 和 n & 3 是一样的;

一个数除以4,相当于把他的二进制形式向右移动2位(向右移动一位相当于除以2),所以 n / 4 和 n >>> 2 是一样的

var isPowerOfFour = function(n) {
    if( n < 1) return false 
    while(n > 1){
        if(n & 3) return false
        n >>>= 2
    }
    return true
};

进阶版

4的幂的前提是必须是2的幂,2的幂的前提必须是非负数

我们这里有三个判断,全部符合才是true

function isPowerOfFour(n) { 
    return n > 0 && (n & (n - 1)) === 0 && (n & 0xAAAAAAAA) === 0; 
}

① n > 0 —— > 必须是非负数

② (n & (n - 1)) === 0 ——> 必须是2的幂

n & (n - 1)之前我们说过这样操作可以将 n 二进制表示的最低位 1 移除

我们再来看看2的幂数的二进制表示

1 0000 0001

2 0000 0010

4 0000 0100

8 0000 1000

16 0001 0000

32 0100 0000

64 1000 0000

所以满足 (n & (n - 1)) === 0 的数 都是 2 的幂

【注意】这里位元算外面的括号,由于位运算的优先级比较低,所以这个括号是不可省略的

③ (n & 0xAAAAAAAA) === 0 ——> 在2的幂的基础上必须是4的幂

0xAAAAAAAA也就是0b1010101010101010 ,它的所有偶数二进制位都是 0,所有奇数二进制位都是 1。 这样一来,我们将 n 和 0xAAAAAAAA 进行按位运算,如果结果为 0,说明 n 二进制表示中的 1 出现在偶数的位置,这样就排除了 2, 8, 32 之类的是2的幂不是4的幂的数字。

JS特性技巧版

都做到位运算了,为什么不更深层次的看看4的幂的数的二进制的特点呢? 1 0000 0001

4 0000 0100

16 0001 0000

64 0100 0000

4的幂的二进制都是1开头,后面偶数个零这样的形式

我们将n转换成2进制,然后 用 正则匹配结果作为返回值即可

function isPowerOfFour(n) { 
    n = n.toString(2) 
    return /^1(?:00)*$/.test(n) 
}

案例2:深拷贝

普通版

function deepClone(obj){
	if (typeof obj !== 'object' || obj === null){
		return obj
	}
	let result = Array.isArray(obj) ? []: {}
	for (let key in obj) {
		if(obj.hasOwnProperty(key)) {
			result[key] = deepClone(obj[key])
		}
	}
	return result
}

特性技巧版

我们用JSON这个API就很简单

function deepClone1(target) {
  // 通过数据创建JSON格式的字符串
  let str = JSON.stringify(target);
  // 将 JSON 字符串创建为JS数据
  let data = JSON.parse(str);
  return data;
}
function deepClone1(target) {
  return JSON.parse(JSON.stringify(target));
}