logo

《前端十日谈》笔记

Fri Oct 21 2022 Posted 2 years ago

代码的封装性、可读性和正确性 #

封装 #

控制红绿灯循环

const traffic = document.querySelector('.traffic')

// loop函数依赖于外部环境traffic
function loop() {
  traffic.className = 'traffic pass'
  setTimeout(() => {
    traffic.className = 'traffic wait'
    setTimeout(() => {
      traffic.className = 'traffic stop'
      setTimeout(loop, 3500)
    }, 1500)
  }, 5000)
}

loop()

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

const traffic = document.querySelector('.traffic')

// signalLoop产生了副作用,因为它改变了subject的className
function signalLoop(subject, signals = []) {
  const signalCount = signals.length
  function updateState(i) {
    const { signal, duration } = signals[i % signalCount]
    subject.className = signal
    setTimeout(updateState.bind(null, i + 1), duration)
  }
  updateState(0)
}

// 数据抽象
const signals = [
  { signal: 'traffic pass', duration: 5000 },
  { signal: 'traffic wait', duration: 3500 },
  { signal: 'traffic stop', duration: 1500 },
]
signalLoop(traffic, signals)

去除副作用

const traffic = document.querySelector('.traffic')

// 将原本改变外部变量的操作用回调的方法传给signalLoop
function signalLoop(subject, signals = [], onSignal) {
  const signalCount = signals.length
  function updateState(i) {
    const { signal, duration } = signals[i % signalCount]
    onSignal(subject, signal)
    setTimeout(updateState.bind(null, i + 1), duration)
  }
  updateState(0)
}

const signals = [
  { signal: 'pass', duration: 5000 },
  { signal: 'wait', duration: 3500 },
  { signal: 'stop', duration: 1500 },
]
signalLoop(traffic, signals, (subject, signal) => {
  subject.className = `traffic ${signal}`
})

可读性 #

使用Promise封装setTimeout

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}
const traffic = document.querySelector('.traffic');
// 简化第一版的loop方法
(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'
  }
}())
const traffic = document.querySelector('.traffic')

async function signalLoop(subject, signals = [], onSignal) {
  const signalCount = signals.length
  for (let i = 0; ;i++) {
    const { signal, duration } = signals[i % signalCount]
    await onSignal(subject, signal)
    await wait(duration)
  }
}

const signals = [
  { signal: 'pass', duration: 5000 },
  { signal: 'wait', duration: 3500 },
  { signal: 'stop', duration: 1500 },
]
signalLoop(traffic, signals, (subject, signal) => {
  subject.className = `traffic ${signal}`
})

正确性 #

洗牌函数

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)

// [45071, 45016, 49406, 50455, 50727, 50205, 50981, 52346, 55793]
// 每次结果有变化,但总的来说前面的数字小,后面的数字大

改进:每次从数组中随机取一个元素并将它放到新的数组中。

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 的时间复杂度太高,改为每次从数组中随机取一个元素并将其与数组的第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)

改进:如果抽奖次数有限制,那么在抽到奖品后就不需要再进行随机

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]
  }
}

const items = [...new Array(100).keys()]

let n = 0

for (const item of shuffle(items)) {
  console.log(item)
  if (n++ >= 5)
    break
}

过程抽象提升系统的可维护性 #

过程抽象 == 高阶函数抽取 ?

once函数封装 #

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

once 函数可以被称为函数装饰器(高阶函数),即用函数来包装函数,并且不会改变原始函数,只拓展功能。

在原来的基础上可以对 once 进行拓展:当返回的函数被多次执行后调用另一个函数。

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

  }
}

const store = {
  init: once(() => {
    /** initial data **/
  }, () => {
    throw new Error('init should only be called once')
  })
}

throttle函数封装 #

function throttle(fn, delay = 250) {
  let timer = null
  return function (...args) {
    if (!timer) {
      const result = fn.apply(this, args)
      timer = setTimeout(() => {
        timer = null
      }, delay)
      return result
    }
  }
}

节流是让事件处理函数隔一个指定毫秒再触发

使用 throttle 实现 once

function once(fn) {
  return throttle(fn, Infinity)
}

debounce函数封装 #

function debounce(fn, delay = 250) {
  let timer = null
  return function (...args) {
    if (timer)
      clearTimeout(timer)
    timer = setTimeout(() => {
      return fn.apply(this, args)
    }, delay)
  }
}

防抖是忽略中间的操作,只响应用户最后一次操作

函数拦截器封装 #

function deprecate(fn, oldApi, newApi) {
  const message = `The ${oldApi} is deprecated.Please use the ${newApi} instead.`
  const notice = once(console.warn)

  return function (...args) {
    notice(message)
    return fn.apply(this, args)
  }
}

当我们想要修改函数库中的某个API,我们可以选择不修改代码本身,而是对这个API进行修饰,修饰的过程可以抽象为拦截它的输入或输出。

function intercept(fn, { beforeCall = null, afterCall = null }) {
  return function (...args) {
    if (!beforeCall || beforeCall.call(this, args) !== false) {
      // do not excute fn if beforeCall() return false
      const result = fn.apply(this, args)
      if (afterCall)
        afterCall.call(this, result)
      return result
    }
  }
}

/** 校验参数,监测性能 **/
function sum(...list) {
  return list.reduce((a, b) => a + b)
}

sum = intercept(sum, {
  beforeCall(args) {
    if (args.some(a => typeof a !== 'number')) {
      console.log('sum() only recive Number')
      return false
    }
    console.log(`The argument is ${args}`)
    console.time('sum')
  },
  afterCall(ret) {
    console.log(`The resulte is ${ret}`)
    console.timeEnd('sum')
  }
})

sum(1, 2, 3, 4, 5)

/** 调整参数顺序 **/
const mySetTimeout = intercept(setTimeout, {
  beforeCall(args) {
    [args[0], args[1]] = [args[1], args[0]]
  }
})

mySetTimeout(1000, () => {
  console.log('done')
})

纯函数 #

export function setStyle(el, key, value) {
  el.style[key] = value
}

export function setStyles(els, key, value) {
  els.forEach(el => setStyle(el, key, value))
}

上面两个函数的缺点:依赖外部环境(el元素),并且还会改变这个环境。

带来的问题:提高了测试成本。因为为了测试以上两个函数,需要构建不同的DOM元素结构,然后再去获取元素或元素列表,最后再根据执行函数后的DOM元素所呈现的效果来判断函数是否符合预期。

什么纯函数?:具有确定性无副作用幂等的特点的函数。也就是说,纯函数不依赖外部环境,也不改变外部环境,不管调用几次,不管什么时候调用,只要参数确定,返回值就确定。

function batch(fn) {
  return function (subject, ...args) {
    if (Array.isArray(subject)) {
      return subject.map((s) => {
        return fn.call(this, s, ...args)
      })
    }
    else {
      return fn.call(this, subject, ...args)
    }
  }
}

export const setStyle = batch((el, key, value) => {
  el.style[key] = value
})

高阶函数的范式 #

// High Order Function
function HOF0(fn) {
  return function (...args) {
    return fn.apply(this, args)
  }
}

HOF0是高阶函数的等价范式,或者说,HOF0修饰的函数功能和原函数fn的功能完全相同。因为被修饰后的函数就只是采用调用的this上下文和参数来调用fn,并将结果返回。也就是说,执行它和直接执行fn完全没区别。其他的函数装饰器就是在HOF0的基础上,要么对参数进行修改,如batch,要么对返回结果进行修改,如oncethrottledebouncebatch

在范式的基础上可以设计出其他的高阶函数:

function continous(reducer) {
  return function (...args) {
    return args.reduce((a, b) => reducer(a, b))
  }
}

const add = continous((a, b) => a + b)
console.log(add(1, 2, 3, 4)) // 1 + 2 + 3 + 4 = 10

batch类似,continous也可以用来创建批量操作元素的方法,只不过参数和用法需要调整一下。如下代码所示:

const setStyle = continous(([key, value], el) => {
  el.style[key] = value
  return [key, value]
})

const list = document.querySelectorAll('li:nth-child(2n+1)')
setStyle(['color', 'red'], ...list)

注意到因为continous是递归迭代执行,我们要把list展开传入setStyle

如果我们想要直接使用list作为参数而不是传...list,我们可以再实现一个高阶函数来处理它:

function fold(fn) {
  return function (...args) {
    const lastArg = args[args.length - 1]
    if (lastArg.length)
      return fn.call(this, ...args.slice(0, -1), ...lastArg)

    return fn.call(this, ...args)
  }
}

const setStyle = fold(continous(([key, value], el) => {
  el.style[key] = value
  return [key, value]
}))

const list = document.querySelectorAll('li:nth-child(2n+1)')

setStyle(['color', 'red'], list)

fold函数判断最后一个参数是一个数组或类数组(如NodeList),那么将它展开传给原函数fn(相对于被修饰的原函数而言是折叠了参数,所以用fold命名这个高阶函数)。

接下来,我们可以调整一下参数顺序,让setStyle更接近batch那一版:

function reverse(fn) {
  return function (...args) {
    return fn.apply(this, args.reverse)
  }
}
const setStyle = reverse(fold(continous(([key, value], el) => {
  el.style[key] = value
  return [key, value]
})))

const list = document.querySelectorAll('li:nth-child(2n+1)')

setStyle(list, ['color', 'red'])

然后,我们可以把参数['color', 'red']展开,所有我们需要实现一个与fold相反的spread高阶函数:

function spread(fn) {
  return function (first, ...rest) {
    return fn.call(this, first, rest)
  }
}

const setStyle = spread(reverse(fold(continous(([key, value], el) => {
  el.style[key] = value
  return [key, value]
}))))

const list = document.querySelectorAll('li:nth-child(2n+1)')

setStyle(list, 'color', 'red')

所以最终我们得到了和上一个故事一样的效果的setStyle方法:

function batch(fn) {
  return spread(reverse(fold(continous(fn))))
}
const setStyle = batch(setStyle)

另外,像这样spread(reverse(fold(continous...)))嵌套的写法,我们也可以用高阶函数改变成更加友好的形式:

function pipe(...fns) {
  return function (input) {
    return fns.reduce((a, b) => {
      return b.call(this, a)
    }, input)
  }
}

const double = x => x * 2
const pow2 = x => x ** 2
const half = x => x / 2

const cacl = pipe(double, pow2, half)
const result = cacl(10) // (10 * 2) ** 2 / 2 = 200

pipe本身也可以用高阶函数continous重新定义为:

const pipe = continous((prev, next) => {
  return function (input) {
    return next.call(this, prev.call(this, input))
  }
})