Giter VIP home page Giter VIP logo

my-blog's People

Contributors

dependabot[bot] avatar github-actions[bot] avatar hacker0limbo avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

Forkers

ayakamiki

my-blog's Issues

简单实现一个 HashTable

简单实现一个 hashtable

这个玩意有无数个别名: map/dict/object/hashTable

需求:

  • 具有读写功能, 可以使用setItem(key, value)写入键值对(key-value), 当写入的是相同的键但不同的值时, 为更新, 当重复写入同样的键值对, 不做任何操作(即不会存在重复将一个键值对存两遍, 也不会存在同一个键对应多个不同的值)
  • 具有读功能, 可以使用getItem(key)根据键名得到值, 当读取不存在的键名是, 抛出null

思路:
首先需要一个数组来存取所有的 key-value, 取名table

然后最简单的, 写一个函数hashStringToInt(s), 该函数接受字符串为参数, 返回的是经过hash过的数字. 对应到实现 hashTable, 将 key 传入到该函数, 得到的结果即为对应table的索引, 然后在对应索引下存入 value, 如下

// 三个 key-value 以及对应的 hash 值
key    hash    value
name   0       'a' 
age    2       '18'
height 3       169

// table 最后为
['a', undefined, '18', 169, ...]

但是这样做存在两个问题:

  • 第一个问题, 初始化table的时候, 一般会固定一个值, 假设为100(有效的索引最多到 99), hashStringToInt(key)很有可能是超过 99 的. 这个时候可以对最后的结果取table.length的模, 即这样无论如何最后 hash 出来的结果都不会超过table.length(这里是 99)
  • 第二个问题, 很有可能出现冲突(collide), 理想情况下所有的 key 根据 hash 后的结果均匀分布到各个 index 上, 实际情况可能会出现多个 key 同一个 value, 这时候需要改变存取方法

方法为, 将出现冲突的 index 的位置的元素, 改成一个数组, 该数组存取所有 hash 结果和该索引一样的 key-value, 形式也为一个数组, 如下

// 三个 key-value 以及对应的 hash 值
// 其中 name 和 gae hash 冲突, 均为 0
key    hash    value
name   0       'a' 
age    0       '18'
height 3       169

// table 最后为
[[['name', 'a'], ['age', '18']], undefined, undefined, [[['height', 169]]]]

到这里基本上没有什么问题了, 但是性能还是要考虑一下, 运气不好你甚至可能出现你想存的所有 key-value 对所对应的 hash 都是同一个...那么该 index 下就会存在 n(n 可以是 9999999...) 个 key-value 组合, 然后最坏情况就是你遍历 n 次, 然后找到了最后一个才是你想要的

所以比较好的情况还是能够比较均匀的将 key-value 分布到各个 index, 每个 index 下可能只存 1-3 个 key, value, 那么查找的时候最坏也就查 3 次找到, 相比 9999999 实在好很多

要实现这一功能, 其实很简单, 增加一个loadFactor系数, 为存取的 key-value 数量 / table length, 这里可以设定当loadFactor大于 0.8 的时候, 增加table的容量, 将容量扩大到下一个质数

同时最后还要注意一个问题, 当对同一个 key 进行写入的时候, 如果是不同的 value, 则更新 value, 如果还是同一个 value, 不做任何操作, 因此需要做一些判断

完整代码如下:

class HashTable {
  constructor() {
    this.table = new Array(3)
    this.numItems = 0
  }
  
  hashStringToInt(s, tableSize) {
    let hash = 17
    for (let i = 0; i < s.length; i++) {
      hash = (13 * hash * s.charCodeAt(i)) % tableSize
    }
    return hash
  }
  
  resize() {
    // 使用 Mersenne prime 得到下一个 prime, 肮脏实现
    const newTable = new Array(2 ** this.table.length - 1)
    // rehash all
    for (const e of this.table) {
      if (e) {
        // 存在该 index 下的元素是 undefined 的可能性...
        for (const [key, value] of e) {
          const index = this.hashStringToInt(key, newTable.length);
          if (newTable[index]) {
            newTable[index].push([key, value]);
          } else {
            newTable[index] = [[key, value]];
          }
        }
      }
    }
    this.table = newTable
  }

  getItem(key) {
    const index = this.hashStringToInt(key, this.table.length)
    if (!this.table[index]) {
      return null
    }
    return this.table[index].find(e => e[0] === key)[1]
  }

  setItem(key, value) {
    const loadFactor = this.numItems / this.table.length
    if (loadFactor > 0.8) {
      // resize
      this.resize()
    }
    const index = this.hashStringToInt(key, this.table.length)
    if (this.table[index]) {
      // 判断是否在设置同一个 key
      const item = this.table[index].find(e => e[0] === key)
      if (item) {
        // 相同即更新
        item[1] = value
      } else {
        // 否则添加新元素
        this.numItems++
        this.table[index].push([key, value])
      }
    } else {
      this.numItems++
      this.table[index] = [[key, value]]
    }
  }
}

const hashTable = new HashTable()
hashTable.setItem('A', 'a')
hashTable.setItem('B', 'b')
hashTable.setItem('A', 'a1')
hashTable.setItem('C', 'c')
hashTable.setItem('D', 'd')
console.log(hashTable.numItems) // 4
console.log(hashTable.table.length) // 7
console.log(hashTable.getItem('A')) // a1
console.log(hashTable.getItem('B')) // b
console.log(hashTable.getItem('C')) // c
console.log(hashTable.getItem('D')) // d

参考

简单聊一聊浏览器下的 eventloop, 宏任务与微任务

简单聊一聊浏览器下的 event-loop, 微任务与宏任务

函数调用栈

函数都是有调用栈的, 大多数程序可以看成函数调用函数不断调用函数, 比如下面这张图:

func_stack

可以看到, 首先有一个 main(可以看做是入口), 然后依次是fn3()调用fn2(), 再调用fn1(), 最后调用console.log(). 一般出现错误的时候, 就会出现类似栈调用情况, 如下:

callstack_error

event loop

首先推荐观看这个视频What the heck is the event loop anyway?, 以及作者写了一个 event loop 可视化工具: Loupe, 不过目前这个工具支持比较有限, 不支持查看 callback queue 里面具体的 macrotask 和 microtask.

这里还是总结一下基本的 event loop 流程, 首先看这张图:

event_loop_overview

  • 首先 JavaScript 是一门单线程语言, 只有一个主线程, 你可以看成 main, 该线程负责调用函数堆栈, 也就是前面所说的Call Stack
  • 其中有一些 API 浏览器是不提供, 所以无法处理, 主线程会把这些 API, 放到另外一个地方(绿色部分)处理, 这些 API 叫作 Web API(也可以称之为调度者), 如上图中的 setTimeout, setTimeInterval, XMLHttpRequest等等
  • 这里注意, 在处理这些 Web API 的时候, js 引擎的主线程还是在不断工作的(如果有任务的话), 可以认为两边是互相工作, 互不干扰
  • 回到 WebAPI 部分, 假设放到 Web API 部分的这段代码是 setTimeout((_) => console.log(1), 1000), 这里绿色部分所做的是:
    • 等 1000ms
    • (_) => console.log(1)这个回调放到 Event Queue(事件队列, 蓝色部分) 里面, 这里注意: 并非是把setTimeout()整个函数放入到事件队列里面, 仅放入其中的回调函数部分
  • 也就是说, 调度者在上下文代码中还是同步立即执行, 只不过其随后会将自己里面的回调函数放入任务队列中等待执行.
  • 最后对于 Event Queue 部分, 这里会堆积很多 Web API 移动下来的回调函数. 当 js 引擎的 call stack 为空的时候, event queue 会把最先进入到 queue 里面的回调函数推入 call stack 里面执行

给两张张图片描述一下上面的流程, 其中第二张图片的代码为:

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

event_loop

event_loop_2

几个注意点:

  • web api 执行 (setTimeout, 请求等), 一旦时间到, 立马就将里面的回调函数放入到 event queue 任务队列中
  • 一旦主线程空了, 那么不管 web api 里面是否还有代码在运行, 都会开始执行任务队列里面的任务

所以整个执行过程是相当连贯协调, 大家分工合作, 每个函数都有自己的职责和暂时归属地, 属于哪里以及什么时候被执行都是井井有条. 避免了同步情况下, 一个无关紧要的任务被卡死, 其余任务无法被执行的现象

异步任务和同步任务

  • 同步任务: JS 引擎主线程里面立即被推入 call stack 且可以被执行的函数
  • 异步任务: 在 event queue 里面的回调函数, 也就是独立于主线程里面的任务

举一个例子:

下面的这个片段, synCb这个回调函数是不会被推入到 event queue 里面执行的, 会被浏览器当做同步任务执行, 所以打印的结果就是按照从上到下面的顺序, 依次进入堆栈输出的

console.log('start')

const syncFun = function(synCb) {
  const v = 100
  synCb(v)
}
syncFun(v => console.log(v))
console.log('end')

// start
// 100
// end

假如我们加入异步任务, 如下, 那么这里的asynCb是会作为异步任务放入 event queue 里面等主线程清空以后(也就是两个 console.log都执行完毕), 才会被推入 call stack 被调用

console.log('start')

setTimeout(function asynCb() {
    console.log(100)
}, 0)

console.log('end')

// start
// end
// 100

总的来说在这种情况下, js 形成了两个队列: 同步任务队列异步任务队列, 先执行同步任务队列里面的任务, 所有同步任务执行完毕以后, 再执行异步任务队列里面的任务.

异步回调

普通函数回调

例如setTimeout(callback, timer), 里面的 callback 是异步任务, 最后是会被放入到 event queue 里面的, 这里要注意一个setTimeout的点: 即里面的时间并非真正意义上的执行时间, 考虑如下代码:

setTimeout(() => {
  console.log(8)
}, 5000)

setTimeout(() => {
  console.log(9)
}, 5000)

setTimeout(() => {
  console.log(10)
}, 5000)

// 8
// 9
// 10

上述代码最后会依次打印 8, 9, 10. 这是由于根据执行顺序会依次放入到 event queue 里面, 也就是说, 打印 8 的回调会被先放入到异步任务队列里面, 然后是 9, 10.

所以这里的时间并非指的是这个函数的执行时间, 而指代的是, 异步回调函数几秒后会被放入到任务队列

Promise 和 async/await

Promise

promise中, .then()里面的为异步回调

假设有如下代码:

console.log('start')

new Promise((resolve, reject) => {
  resolve(1)
  console.log('middle')
}).then(v => console.log(v))

console.log('end')

// start
// middle
// end
// 1

上面代码中, v => console.log(v)是异步回调, 注意, new Promise在实例化的过程中所执行的代码都是同步进行的, 所以console.log('middle')会同步执行

async/await

async/await 本质上还是基于 Promise 的一些封装, async函数在await 之前的代码都是同步执行的,可以理解为await之前的代码属于new Promise时传入的代码,await 之后的所有代码都是在Promise.then中的回调

console.log('start')

async function main() {
  console.log('test')
  const v = await Promise.resolve(1)
  console.log(v)
}

main()
console.log('end')

// start
// test
// end
// 1

写法总结

使用request, 写一个getUser函数, 有基本以下写法:

回调写法:

const getUser = function(callback) {
  request('http://www.example.com', function(err, res, body){
    callback(body)
  })
}

getUser(v => console.log(v))

Promise 写法:

const getUser = function() {
  return new Promise((resolve, reject) => {
    request('http://www.example.com', function(err, res, body){
      if (err) {
        return reject(err)
      }
      resolve(body)
    })
  })
}

getUser()
  .then(v => console.log(v))
  .catch(err => console.error(err))

async/await 写法:

const getUser = function() {
  return new Promise((resolve, reject) => {
    request('http://www.example.com', function(err, res, body){
      if (err) {
        return reject(err)
      }
      resolve(body)
    })
  })
}

(async function() {
  try {
    const v = await getUser()
    console.log(v)
  } catch(e) {
    console.error(e)
  }
})()

宏任务与微任务

上面提到的 event queue 里的异步任务还可以细分为: macrotask(宏任务), microtask(微任务). 这里注意, 现在标准称呼可以认为是 tasks 和 jobs, 但本文还是以宏任务和微任务来代指

宏任务:

  • script(整体代码, 上下文)
  • setTimeout, setInterval
  • I/O
  • UI rendering

微任务:

  • Promise
  • process.nextTick

当然上面的任务其实均指的是其中的回调函数, 而上面的 api 前面也提过, 充当调度者的作用

有几个概念需要注意一下:

  • promise.then(callback)里面的callback是一个微任务, 且会被推入当前的微任务队列, 当且仅当该promise状态变更为resolved或者rejected, 否则不被推入任务队列
  • setTimeout(callback, t)callback是一个宏任务, 会被推入当前的宏任务队列中, 即使t为 0
  • 整个在 script 中的代码也是一个宏任务

模型

关于宏任务和微任务的模型可以看成如下图的形式:

micro_macrotasks

有几个重要的概念:

  • 始终只有一条宏任务队列
  • 可以有多条微任务队列, 但是每一个宏任务后面仅跟随一条微任务队列, 且只对当前的宏任务"有效"
  • 一次 loop(循环), 都会去执行宏任务里面最前面的一个宏任务, 然后检查是否有微任务队列, 依次执行微任务队列里面的微任务. 执行完毕后开始下一次循环, 和之前一样从宏任务队列里面选取最新的宏任务执行, 不断循环
  • 在执行微任务/宏任务的过程中, 如果发现微任务, 那么添加到当前的微任务队列中等待稍后被执行, 而如果发现宏任务, 该宏任务被添加到宏任务队列中等待下一次 loop 被执行. 也就是说: 发现微任务是可以在当前 loop 下被执行的, 而发现的宏任务只能等到下一次循环的时候被执行
  • 每一次 loop 都包含: 一个(且只有一个)宏任务被执行, 以及对应的微任务队列, 执行完毕后开启下一个 loop

总结:

  1. 运行宏任务队列最先进来的宏任务, 然后移除他(一般第一个宏任务是 script)
  2. 依次运行微任务队列中的微任务, 移除他们
  3. 开始下一次 loop(返回过程 1)

异步任务模型伪代码可以模拟成如下:

// 第一次 loop
[
  ['宏任务 1', '微任务 1.1', ' 微任务 1.2'],
  ['宏任务 2'],
  ['宏任务 3'],

]

// 第二次 loop
[
  // ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], 执行完毕被清空
  ['宏任务 2', '微任务 2.1'],
  ['宏任务 3'],
]

// 第三次 loop
[
  // ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], 执行完毕被清空
  // ['宏任务 2'], 执行完毕被清空
  ['宏任务 3'],
]

示例

示例 1:

console.log('start')

setTimeout(function() {
  console.log('timeout')
}, 0)

new Promise(function(resolve) {
  console.log('promise')
  resolve()
}).then(function() {
  console.log('promise resolved')
})

console.log('end')

// start
// promise
// end
// promise resolved
// timeout

分析:

  1. 建立执行上下文,进入执行栈开始执行代码,打印 start
  2. 往下执行,遇到setTimeout,将回调函数放入宏任务队列,等待执行
  3. 继续往下,有个new Promise,其回调函数并不会被放入其他任务队列,因此会同步地执行,打印promise,但是当resolve后,.then会把其内部的回调函数放入微任务队列
  4. 执行到了最底部的代码,打印出 end, 此时 call stack 被清空, 可以执行异步任务
  5. 开始第一次 event loop:
    1. 由于整个 script 算一个宏任务, 因此该宏任务已经被执行完毕
    2. 检查微任务队列, 发现其中有 3 放入的微任务, 执行打印出 promise resolved,第一次循环结束
  6. 开始第二次 loop:
    1. 从宏任务开始,检查宏任务队列是否有可执行代码,发现有 2 中放入的一个,打印timeout
    2. 检查微任务队列, 微任务, 第二次循环结束

示例 2:

console.log('start')

setTimeout(function () {
  console.log('event loop2, macrotask')
  new Promise(function (resolve) {
    console.log('event loop2, macrotask continue')
    resolve()
  }).then(function () {
    console.log('event loop2, microtask1')
  })
}, 0)

new Promise(function (resolve) {
  console.log('middle')
  resolve()
}).then(function () {
  console.log('event loop1, microtask1')
  setTimeout(function () {
    console.log('event loop3, macrotask')
  })
})

console.log('end')

// start
// middle
// end
// event loop1, microtask1
// event loop2, macrotask
// event loop2, macrotask continue
// event loop2, microtask1
// event loop3, macrotask

分析:

  1. 打印 start, 发现 setTimeout, 回调放入宏任务队列中
  2. 发现在初始化 promise 实例, 初始化过程中打印 middle, 初始化完毕后 promise 状态变为 resolve
  3. resolve 后因为后面的.then()将回调放当前入微任务队列中
  4. 打印 end, script 完成
  5. 第一次 event loop:
    1. 由于 script 完成, 相当于完成了宏任务
    2. 检查微任务队列, 发现有一个在第 3 步放入的微任务, 执行打印 event loop1, microtask1, 继续执行, 发现有setTimeout, 将其中回调放入宏任务队列中
  6. 第二次 event loop:
    1. 检查宏任务队列, 发现排在最前面的是第 1 步放入的宏任务, 执行打印 event loop2, macrotask
    2. 继续执行, 发现在初始化 promise 实例, 打印 event loop2, macrotask continue
    3. promise 在状态被 resolve 之后回调放入当前微任务队列中
    4. 检查微任务队列, 发现有一个在第 6.3 中的微任务, 执行打印 event loop2, microtask1
  7. 第三次 event loop:
    1. 发现在第 5.2 步中放入的最后一个宏任务, 执行并打印 event loop3, macrotask

一个相关问题

前两天在知乎看到的一个问题: JavaScript的DOM事件回调不是宏任务吗,为什么在本次微任务队列触发

console.log('本轮任务');
new Promise((resolve, reject) => {
  resolve(3)
}).then(() => {
  console.log('本轮微任务');
})
document.getElementById('div').addEventListener('click', () => { console.log('click'); })
document.getElementById('div').click()

// 本轮任务
// click
// 本轮微任务

这里注意: click()dispatchEvent()等人工合成事件是同步触发的, 其回调并非会被放入宏任务队列中, 而是直接作为同步任务执行, 具体答案可以参考这个回答补充

参考

简单聊一聊 React, Context 和 Redux 的性能优化

该篇文章主要使用一个 demo, 针对使用传统 props&state, context, redux 做状态管理来进行性能优化.

Demo 效果大致如下:

demo

三种方法均尝试实现同一个效果, 有三个 room, 每个 room 是一个组件, 可以通过点击按钮改变背景颜色, 并且原则上改变其中一个 room 的背景颜色时其他 room 不会被重新渲染, 可以通过 console 里的输出查看.

完整代码如下: https://stackblitz.com/edit/react-tqmejp

一些基本总结

首先放一些有关 React 渲染和 Context 的总结:

  • React 默认递归式的渲染组件, 所以当一个父组件被渲染时, 所有子组件默认也会被渲染
  • Class 组件的 this.setState(), this.forceUpdate(), hooks 组件的 useState 的 setters, useReducer 的 dispatches 都会触发该组件的 re-render
  • React.memo() 可以跳过一些无必要渲染, 如果该组件的 props 和上一次对比没有改变
  • Context providers 会比较提供的 value 的引用, 这个 value 可以是任意类型, 比如一个字符串, 一个对象, 但注意的是如果该 value 的引用发生改变, 所有 consumers 会 re-render, 即使该 consumer 只用到了 value 的一部分, 比如 value 对象的某个属性值.
  • 一个好的建议是, 在 context provider 下的子组件用 React.memo() 包裹, 这样即使 context value 发生了改变导致 re-render, 或者因为 React 本身递归式的渲染, 这些被包裹的组件可能能避免 re-render

更详细的说明和总结可以查看这两篇文章:

Props & State

先看最简单的使用 props 和 state 实现的 demo:

import React, { useState } from 'react';

const Room = ({ isLit, flipLight, index }) => {
  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}
      <br />
      <button onClick={() => flipLight(index)}>Flip</button>
    </div>
  );
};

export default function PropsStateDemo() {
  const [lights, setLights] = useState([true, false, false]);

  const flipLight = (index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  };

  return (
    <div>
      <p className="title">Props and State Demo</p>
      {[0, 1, 2].map((index) => (
        <Room
          key={index}
          isLit={lights[index]}
          flipLight={memoedFlipLight}
          index={index}
        />
      ))}
    </div>
  );
}

虽然功能上实现了, 但是存在性能问题, 每当点击其中一个 room 的 flip 按钮, 其余 room 组件一样会被重新渲染, 具体效果大致如下:

problem

原因也很简单, 每次点击按钮触发 flipLight 方法, 都会触发父组件 PropsStateDemo 里的 setLights, lights 状态改变. 由于默认行为是父组件被渲染时, 子组件也会默认被渲染. 因此所有 Room 组件都被渲染了一次

优化也很简单, Room 组件的 props 分别是 isLit, flipLight, index. 每一个 Room 组件的 isLit 都应该是独立的, 也就是说当某个 Room 改变了 isLit 状态, 虽然这会导致 lights 状态变更, 且确实无法避免, 因为 lights 状态是一个数组, 但单个的 isLit 状态是独立的, 别的 Room 的 isLit 状态是不会影响到其他 Room 的. 同理 index 也是独立的. 而 flipLight 这个函数每次在父组件渲染的时候都会被传一份新的引用, 那尝试保证引用不变就行了.

所以只要用 React.memo() 包裹 Room 组件, 同时使用 useCallback 保证每次 flipLight 引用一样即可

优化后的代码:

import React, { useState, useCallback } from 'react';

const MemoedRoom = React.memo(({ isLit, flipLight, index }) => {
  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}
      <br />
      <button onClick={() => flipLight(index)}>Flip</button>
    </div>
  );
});

export default function PropsStateDemo() {
  const [lights, setLights] = useState([true, false, false]);

  const memoedFlipLight = useCallback((index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  }, []);

  return (
    <div>
      <p className="title">Props and State Demo</p>
      {[0, 1, 2].map((index) => (
        <MemoedRoom
          key={index}
          isLit={lights[index]}
          flipLight={memoedFlipLight}
          index={index}
        />
      ))}
    </div>
  );
}

效果即是开头贴的示例效果, 就不重复放了.

Context

虽然这里完全没有必要使用到 Context, 但为了演示模拟一下.

import React, { useState, useContext } from 'react';

const RoomContext = React.createContext();

const RoomProvider = ({ children }) => {
  const [lights, setLights] = useState([false, true, false]);

  const flipLight = (index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  };

  const value = {
    lights,
    flipLight,
  };

  return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
};

const Room = ({ index }) => {
  const { lights, flipLight } = useContext(RoomContext);
  const isLit = lights[index];

  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}
      <br />
      <button onClick={() => flipLight(index)}>Flip</button>
    </div>
  );
};

export default function ContextDemo() {
  return (
    <div>
      <p className="title">Context Demo</p>
      <RoomProvider>
        {[0, 1, 2].map((index) => (
          <Room key={index} index={index} />
        ))}
      </RoomProvider>
    </div>
  );
}

和之前一样, 当更改其中一个 Room 的背景颜色后, 其余 Room 也会被重新渲染. 原因为, flipLight 会导致 RoomProvider 重新渲染, 导致每次产生一份新的 value, value 引用变化导致所有 Room 作为 consumer 都被重新渲染了

如果按照之前的做法, 尝试用 React.memo, useMemouseCallback 进行性能优化, 代码大致如下:

const RoomProvider = ({ children }) => {
  const [lights, setLights] = useState([false, true, false]);

  const memoedFlipLight = useCallback((index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  }, []);

  const memoedValue = useMemo(
    () => ({
      lights,
      flipLight: memoedFlipLight,
    }),
    [lights, memoedFlipLight]
  );

  return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
}

const Room = React.memo(({ index }) => {
  const { lights, flipLight } = useContext(RoomContext);
  // ...
})

仍旧失败, 原因在于, 虽然使用 React.memo 包裹了 Room 组件, 但由于内部又使用了 useContext, 同时 lights 状态其实每次都是变化的, 因此即使使用了 useMemo, 每次 value 还是不一样, 这样 Room 组件作为消费者又被迫重新被渲染.

要解决这个问题需要将 useContext 抽出来, 也就是不能再 React.memo 里使用, 因为 React.memo 优化只针对 props. 同时因为 lights 一直变化的缘故, 传递的状态最好和之前一样是 isLit 这种单一的状态, 而非整个完整的状态. 修改后的代码如下:

import React, { useState, useContext, useCallback, useMemo } from 'react';

const RoomContext = React.createContext();

const RoomProvider = ({ children }) => {
  const [lights, setLights] = useState([false, true, false]);

  const memoedFlipLight = useCallback((index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  }, []);

  const memoedValue = useMemo(
    () => ({
      lights,
      flipLight: memoedFlipLight,
    }),
    [lights, memoedFlipLight]
  );

  return <RoomContext.Provider value={memoedValue}>{children}</RoomContext.Provider>;
};

const withRoom =
  (Component) =>
  ({ index }) => {
    const { lights, flipLight } = useContext(RoomContext);

    return (
      <Component index={index} isLit={lights[index]} flipLight={flipLight} />
    );
  };

const MemoedRoom = withRoom(
  React.memo(({ index, isLit, flipLight }) => {
    console.log('render room', index);

    return (
      <div className={`room ${isLit ? 'lit' : 'dark'}`}>
        Room {index} is {isLit ? 'lit' : 'dark'}
        <br />
        <button onClick={() => flipLight(index)}>Flip</button>
      </div>
    );
  })
);

export default function ContextDemo() {
  return (
    <div>
      <p className="title">Context Demo</p>
      <RoomProvider>
        {[0, 1, 2].map((index) => (
          <MemoedRoom key={index} index={index} />
        ))}
      </RoomProvider>
    </div>
  );
}

这里新抽出一个高阶函数 withRoom, 在这个高阶组件里进行 context 的消费, 然后返回需要的组件, 传递的 props 均为单一的属性, 而非一直会变化的 lights 状态, 这样 React.memo 就能进行优化了.

可以看到这样的优化代码非常丑陋, 而且可能会需要花费一些时间.

Redux

如果使用 Redux 来编写一般就不需要考虑这些性能优化的问题, 因为 Redux 内部其实都有做好这些脏活. 这里使用 Redux Toolkit 实现:

import React from 'react';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

const {
  actions: { flipLight },
  reducer: roomReducer,
} = createSlice({
  name: 'room',
  initialState: {
    lights: [false, false, true],
  },
  reducers: {
    flipLight: (state, action) => {
      state.lights[action.payload] = !state.lights[action.payload];
    },
  },
});

const store = configureStore({
  reducer: {
    room: roomReducer,
  },
});

function Room({ index }) {
  const isLit = useSelector((state) => state.room.lights[index]);
  const dispatch = useDispatch();

  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}.
      <br />
      <button onClick={() => dispatch(flipLight(index))}>Flip</button>
    </div>
  );
}

function ReduxDemo() {
  return (
    <div>
      <p className="title">Redux Demo</p>
      {[0, 1, 2].map((index) => (
        <Room key={index} index={index} />
      ))}
    </div>
  );
}

export default function Root() {
  return (
    <Provider store={store}>
      <ReduxDemo />
    </Provider>
  );
}

这里使用了 useSelector 进行状态的获取, useSelector 默认行为是在一个 action 被 dispatch 之后, 会对返回的选取状态进行严格比较, 如果相同组件不渲染, 否则重新渲染. isLit 作为 primitive type, 能够进行严格地址比较, 因此不再触发重新渲染.

当然也可以使用 connectmapState, 而且性能方面会比 useSelector 更好, 因为做的是浅比较, 且 connect 返回的组件是用 React.memo 包裹的. 这里不多细究, 细节方面可以查阅官方文档

总结

这篇文章的示例参考的是一个视频: React Context API vs. Redux 有兴趣可以观看视频, 印象可能更深

参考

简单聊一聊 window resizing 和 reduce

水文一篇...

其实这里要讲两个事情, 但是由于这两个东西可以写的东西都不多, 所以就用一篇文章了. 另外这两个东西几乎没有任何关联性

window resizing

业务上碰到的一个需求, 我需要监听 window 在什么时候 resizing 结束, 在结束的那一刻我可以做一个标记, 比如最简单的进行一次 setState

错误思路

一开始没想太多, 直接写. 写到一半发现不对了. 代码可能长这样:

import React, { useState, useEffect } from "react";

export default function WindowResize() {
  const [windowResizing, setWindowResizing] = useState(false);

  const handleWindowResize = e => {
    setWindowResizing(true);
  };

  useEffect(() => {
    window.addEventListener("resize", handleWindowResize);

    return () => window.removeEventListener("resize", handleWindowResize);
  }, []);

  return <div>{JSON.stringify({ windowResizing })}</div>;
}

很明显, 最后的结果就是一旦开始 resize, windowResizing 就会一直保持 true, 即使我已经停止了 resize. 原因也很简单, resize 这个事件只会去监听 change 时候的变化, 至于什么时候 stop, 他没有暴露任何 API, 他只知道变了, 但不知道这个变了啥时候停止

但我恰恰就需要知道啥时候停止

防抖

先考虑另外一种场景, 我需要监听 resize 时候 windowwidth 变化, 但我不需要在 resize 发生每一帧都去监并显示变化, 这是一个高频率触发的事件. 比如 resize 到一半的时候, 停顿了一下, 这个频率是比正常 resize 频率低一些的, 那我在这个时候是可以去获得或者说监听到 width 变化.

实际上说了这么多就是要对 resize 事件做一个防抖(debounce)...

import React, { useState, useEffect } from "react";

const WindowWidth = () => {
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  useEffect(() => {
    let timerId;

    const handleResize = () => {
      clearTimeout(timerId);
      timerId = setTimeout(() => {
        setWindowWidth(window.innerWidth);
      }, 200);
    };

    window.addEventListener("resize", handleResize);

    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <div>{JSON.stringify({ windowWidth }, null, 2)}</div>;
};

export default WindowWidth;

理解起来也不难, 这里我给了 200ms 的限制, 也就是说低于 200ms 速率的 resize 操作, handleResize 这个回调函数里的 setWindowWidth 操作还没等到 200ms, 就被 clearTimeout 掉了, 然后 timerId 又存了最新的一次操作的 id, 只要 resize 速率是低于 200ms 的, 那么一定是出现上一次操作还没来得及执行就被 clear 掉, timerId 被(下一次)最新一次的操作给覆盖. 所以 windowWidth 永远还是最初的, setState 也就不会被高频触发

直到某次 resize 操作后有大于 200ms 的停顿, 这个时候 setWindowWidth 等到了时间, 可以执行一次 setState, 虽然再开始 resize 的时候 timerId 还是被 clear 掉, 但是 setWindowWidth 已经执行过了, 因此不影响.

总结就是上一次 resize 和 下一次 resize 之间间隔在 200ms 以内, 限制对应操作, 否则允许操作函数执行

解决方案

扯了很多防抖, 其实去监听 window resize 什么时候停止, 也是类似的方案

首先要明确, 什么时候才算 resize 停止, 我可以认为没有 resize 事件后的 10s 算停止(有点长), 我也可以认为是 0.000001ms 之后算停止(太短了), 所以我还是用 200ms 作为界限, 上一次 resize 和下一次 resize 之间间隔在 200ms 以内的, 都算还在 resizing, 超过 200ms 的, 记为 resize 停止了.

代码如下:

import React, { useState, useEffect } from "react";

const WindowResize = () => {
  const [windowResizing, setWindowResizing] = useState(false);

  useEffect(() => {
    let timerId;
    const handleResize = () => {
      clearTimeout(timerId);
      setWindowResizing(true);
      timerId = setTimeout(() => {
        setWindowResizing(false);
      }, 200);
    };

    window.addEventListener("resize", handleResize);

    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <div>{JSON.stringify({ windowResizing }, null, 2)}</div>;
};

export default WindowResize;

从代码上来看, windowResizing 这个状态是一直在 truefalse 之间切换, 但 false 状态是有延迟的. 所以可以模拟出 resize 停止的那个状态

reduce

这个函数也用的很多了, 面试也经常考让手写一个简易的.

这里想记录的是做项目时遇到的一个 immutable 或者 mutable 的写法问题, 由于真实项目的数据还要复杂和繁琐, 这里只做模拟实现

目标

[['abcde', 0.9], ['fghij', 0.8], ...] 

reduce 将上面的二位数组转成下面这种格式

{ abcde: 0.9, fghij: 0.8, ... }

二位数组里的数据至少有 3000 条

两种做法

直接看代码:

import React, { useState } from "react";

const arr = Array.from(Array(3000)).map(v => [
  Math.random()
    .toString(36)
    .substring(7),
  Math.random()
]);

export default function App() {
  const [data1, setData1] = useState("immutable");
  const [data2, setData2] = useState("mutable");

  const handleR1 = () => {
    console.time("immutable in reduce");
    const r1 = arr.reduce(
      (r, v) => ({
        ...r,
        [v[0]]: v[1]
      }),
      {}
    );
    console.timeEnd("immutable in reduce");
    setData1("r1 finish");
  };

  const handleR2 = () => {
    console.time("mutable in reduce");
    const r2 = arr.reduce((r, v) => {
      r[v[0]] = v[1];
      return r;
    }, {});
    console.timeEnd("mutable in reduce");
    setData2("r2 finish");
  };

  return (
    <div>
      <div>
        <button onClick={handleR1}>{data1}</button>
      </div>
      <div>
        <button onClick={handleR2}>{data2}</button>
      </div>
    </div>
  );
}

handleR1handleR2 分别是两种写法, 结果都是一样的, 对应 immutable 和 mutable. 我个人是倾向第一种. 但是第一种有一些性能问题

这里我用 console.timeconsole.timeEnd 分别记录两种操作所需要的时间, 第一种 immutable 写法的时间大致为 2000ms, 第二种 mutable 写法时间大致为 2ms, 2000 倍的差距...

效果图如下:
reduce_pattern

点击 immutable 的按钮花了很久才显示结束, 卡顿非常明显, 而 mutable 的按钮秒结束...

所以 immutable 的写法有时候可能不一定那么好?

简单用 React+Redux+TypeScript 实现一个 TodoApp (二)

前言

上一篇文章讲了讲如何用 TypeScript + Redux 实现 Loading 切片部分的状态, 这篇文章主要想聊一聊关于 TodoFilter 这两个切片状态的具体实现, 以及关于 Redux Thunk 与 TypeScript 的结合使用.

想跳过文章直接看代码的: 完整代码

最后的效果:
todoapp

Todo

首先思考一下 Todo 应该是怎样的状态, 以及可能需要涉及到的 action.

页面上的每一个 todo 实例都对应一个状态, 合起来总的状态就应该是一个数组, 这也应该是 reducer 最后返回的状态形式. 同时, 考虑 action, 应该有以下几种操作:

  • 初始化页面的时候从服务端拿数据设置所有的 todos
  • 增加一个 todo
  • 删除一个 todo
  • 更新一个 todo
  • 完成 / 未完成一个 todo

这里需要注意的是, 所有的操作都需要和服务端交互, 因此我们的 action"不纯的", 涉及到异步操作. 这里会使用 Redux Thunk 这个库来加持一下. Action Creator 写法也会变成对应的 Thunk 形式的 Action Creator

types

每一个 todo 的状态类型应该如下:

// store/todo/types.ts

export type TodoState = {
  id: string;
  text: string;
  done: boolean;
};

id 一般是服务端返回的, 不做过多解释. texttodo 的具体内容, done 属性描述这个 todo 是否被完成

actions

actionTypes

还是和之前一样, 在写 action 之前先写好对应的类型, 包括每一个 actiontype 属性

根据上面的描述, type 有如下几种:

// store/todo/constants.ts

export const SET_TODOS = "SET_TODOS";
export type SET_TODOS = typeof SET_TODOS;

export const ADD_TODO = "ADD_TODO";
export type ADD_TODO = typeof ADD_TODO;

export const REMOVE_TODO = "REMOVE_TODO";
export type REMOVE_TODO = typeof REMOVE_TODO;

export const UPDATE_TODO = "UPDATE_TODO";
export type UPDATE_TODO = typeof UPDATE_TODO;

export const TOGGLE_TODO = "TOGGLE_TODO";
export type TOGGLE_TODO = typeof TOGGLE_TODO;

对应的 actionTypes, 就可以引用写好的常量类型了:

// store/todo/actionTypes.ts

import { TodoState } from "./types";
import {
  SET_TODOS,
  ADD_TODO,
  REMOVE_TODO,
  UPDATE_TODO,
  TOGGLE_TODO
} from "./constants";

export type SetTodosAction = {
  type: SET_TODOS;
  payload: TodoState[];
};

export type AddTodoAction = {
  type: ADD_TODO;
  payload: TodoState;
};

export type RemoveTodoAction = {
  type: REMOVE_TODO;
  payload: {
    id: string;
  };
};

export type UpdateTodoAction = {
  type: UPDATE_TODO;
  payload: {
    id: string;
    text: string;
  };
};

export type ToggleTodoAction = {
  type: TOGGLE_TODO;
  payload: {
    id: string;
  };
};

export type TodoAction =
  | SetTodosAction
  | AddTodoAction
  | RemoveTodoAction
  | UpdateTodoAction
  | ToggleTodoAction;

actionCreators

这里需要注意, todo 部分的 actions 分为同步和异步, 先来看同步的:

// store/todo/actions.ts

import {
  AddTodoAction,
  RemoveTodoAction,
  SetTodosAction,
  ToggleTodoAction,
  UpdateTodoAction
} from "./actionTypes";
import {
  ADD_TODO,
  REMOVE_TODO,
  SET_TODOS,
  TOGGLE_TODO,
  UPDATE_TODO
} from "./constants";
import { TodoState } from "./types";

export const addTodo = (newTodo: TodoState): AddTodoAction => {
  return {
    type: ADD_TODO,
    payload: newTodo
  };
};

export const removeTodo = (id: string): RemoveTodoAction => {
  return {
    type: REMOVE_TODO,
    payload: {
      id
    }
  };
};

export const setTodos = (todos: TodoState[]): SetTodosAction => {
  return {
    type: SET_TODOS,
    payload: todos
  };
};

export const toggleTodo = (id: string): ToggleTodoAction => {
  return {
    type: TOGGLE_TODO,
    payload: {
      id
    }
  };
};

export const updateTodo = (id: string, text: string): UpdateTodoAction => {
  return {
    type: UPDATE_TODO,
    payload: {
      id,
      text
    }
  };
};

同步部分没什么好说的, 核心是异步部分, 我们用 Redux Thunk 这个中间件帮助我们编写 Thunk 类型的 Action. 这种 Action 不再是纯的, 同时这个 Action 是一个函数而不再是一个对象, 因为存在往服务端请求数据的副作用逻辑. 这也是 Redux 和 Flow 的一个小区别(Flow 规定 Action 必须是纯的)

首先我们需要配置一下 thunk, 以及初始化一下 store

// store/index.ts

import { combineReducers, createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { loadingReducer } from "./loading/reducer";
import { todoReducer } from "./todo/reducer";

const rootReducer = combineReducers({
  todos: todoReducer,
  loading: loadingReducer,
  // filter: filterReducer,
});

export type RootState = ReturnType<typeof rootReducer>;

export const store = createStore(rootReducer, applyMiddleware(thunk));

Thunk Action Creator

不考虑类型, 如果纯用 JavaScript 写一个 Thunk ActionCreator, 如下:

export const setTodosRequest = () => {
  return dispatch => {
    dispatch(setLoading("加载中..."));
    return fetch(baseURL)
      .then(res => res.json())
      .then(data => {
        dispatch(setTodos(data));
        dispatch(unsetLoading());
      });
  };
};

这里的 baseURL 在我第一章有说, 用了 mock api 模拟后端的数据, 具体地址可以看文章或者看源码, 同时为了方便, 我直接用浏览器原生的 fetch 做 http 请求了, 当然用 axios 等别的库也是可以的

关于这个函数简单说明一下, 这里的 setTodosRequest 就是一个 Thunk ActionCreator, 返回的 (dispatch) => {} 就是我们需要的 Thunk Action, 可以看到这个 Thunk Action 是一个函数, Redux Thunk 允许我们将 Action 写成这种模式

下面为这个 Thunk ActionCreator 添加类型, Redux Thunk 导出的包里有提供两个很重要的泛型类型:

首先是 ThunkDispatch, 具体定义如下

/**
 * The dispatch method as modified by React-Thunk; overloaded so that you can
 * dispatch:
 *   - standard (object) actions: `dispatch()` returns the action itself
 *   - thunk actions: `dispatch()` returns the thunk's return value
 *
 * @template TState The redux state
 * @template TExtraThunkArg The extra argument passed to the inner function of
 * thunks (if specified when setting up the Thunk middleware)
 * @template TBasicAction The (non-thunk) actions that can be dispatched.
 */
export interface ThunkDispatch<
  TState,
  TExtraThunkArg,
  TBasicAction extends Action
> {
  <TReturnType>(
    thunkAction: ThunkAction<TReturnType, TState, TExtraThunkArg, TBasicAction>,
  ): TReturnType;
  <A extends TBasicAction>(action: A): A;
  // This overload is the union of the two above (see TS issue #14107).
  <TReturnType, TAction extends TBasicAction>(
    action:
      | TAction
      | ThunkAction<TReturnType, TState, TExtraThunkArg, TBasicAction>,
  ): TAction | TReturnType;
}

至于具体怎么实现我不关心, 我关心的是这个东西是啥以及这个泛型接受哪些类型参数, 整理一下如下:

  • 这个 dispatch 类型是由 Redux Thunk 修改过的类型, 你可以用它 dispatch:
    • 标准的 action(一个对象), dispatch() 函数返回这个对象 action 本身
    • thunk action(一个函数), dispatch() 函数返回这个 thunk action 函数的返回值
  • 接受三个参数: TState, TExtraThunkArg, TBasicAction
    • TState: Redux store 的状态(RootState)
    • TExtraThunkArg: 初始化 thunk 中间件时, 传个 thunk 的额外参数(这个项目我们没用到)
    • TBasicAction: 非 Thunk 类型的 action, 即标准的对象 action 类型

再看一下 ThunkAction:

/**
 * A "thunk" action (a callback function that can be dispatched to the Redux
 * store.)
 *
 * Also known as the "thunk inner function", when used with the typical pattern
 * of an action creator function that returns a thunk action.
 *
 * @template TReturnType The return type of the thunk's inner function
 * @template TState The redux state
 * @template TExtraThunkARg Optional extra argument passed to the inner function
 * (if specified when setting up the Thunk middleware)
 * @template TBasicAction The (non-thunk) actions that can be dispatched.
 */
export type ThunkAction<
  TReturnType,
  TState,
  TExtraThunkArg,
  TBasicAction extends Action
> = (
  dispatch: ThunkDispatch<TState, TExtraThunkArg, TBasicAction>,
  getState: () => TState,
  extraArgument: TExtraThunkArg,
) => TReturnType;

整理一下参数类型和代表的意思:

  • ThunkAction 指代的是一个 thunk action, 或者也叫做 thunk inner function
  • 四个类型参数: TReturnType, TState, TExtraThunkArg, TBasicAction
    • TReturnType: 这个 thunk action 函数最后的返回值
    • TState: Redux store 的状态(RootState)
    • TExtraThunkArg: 初始化 thunk 中间件时, 传个 thunk 的额外参数(这个项目我们没用到)
    • TBasicAction: 非 Thunk 类型的 action, 即标准的对象 action 类型

看完发现, 其实 ThunkActionThunkDispatch 真的很像, 对应到具体的参数类型:

  • TState 我们是有的, 即之前写过的 RootState
  • TExtraThunkArg 我们没有用到, 可以直接给 void 或者 unknown
  • TBasicAction 我们还没定义, 我见过有用 ReduxAnyAction 来替代, 但是 AnyAction 这个 any 有点过分...我搜索了一下没找到官方的最佳实践, 就打算用所有的 Redux 的 Action 类型集合

以及, Redux 官网的 Usage with Redux Thunk 其实已经有写怎么配置类型了. 现在需要做的事情其实就很简单:

  • 增加一个 RootAction 类型, 为所有的非 Thunk 类型的 Action 的类型的集合
  • ThunkDispatch 这个泛型传入正确类型
  • ThunkAction 这个泛型传入正确类型

store 部分的代码如下:

// store/index.ts

import { combineReducers, createStore, applyMiddleware } from "redux";
import { todoReducer } from "./todo/reducer";
import { loadingReducer } from "./loading/reducer";
import thunk, { ThunkDispatch, ThunkAction } from "redux-thunk";
import { LoadingAction } from "./loading/actionTypes";
import { TodoAction } from "./todo/actionTypes";

const rootReducer = combineReducers({
  todos: todoReducer,
  loading: loadingReducer,
  // filter: filterReducer,
});

export type RootState = ReturnType<typeof rootReducer>;
export type RootAction = LoadingAction | TodoAction;

export const store = createStore(rootReducer, applyMiddleware(thunk));

export type AppDispatch = ThunkDispatch<RootState, void, RootAction>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  void,
  RootAction
>;

为了方便, 这里给了两个 alias, 也是根据官网来的, 分别为 AppDispatchAppThunk

现在可以完善之前的 Thunk ActionCreator 的类型了:

export const setTodosRequest = (): AppThunk<Promise<void>> => {
  return dispatch => {
    dispatch(setLoading("加载中..."));
    return fetch(baseURL)
      .then(res => res.json())
      .then(data => {
        dispatch(setTodos(data));
        dispatch(unsetLoading());
      });
  };
};

这里注意一下, 由于我们的 thunk action, 是有返回值的, 这里是 return fetch() 返回的是一个 promise, 不过这个 promise 并没有 resolve 任何值, 所以即为 Promise<void>

最后完善一下所有的 actionCreator:

// store/todo/actions.ts

import {
  AddTodoAction,
  RemoveTodoAction,
  SetTodosAction,
  ToggleTodoAction,
  UpdateTodoAction
} from "./actionTypes";
import { setLoading, unsetLoading } from "../loading/actions";
import {
  ADD_TODO,
  REMOVE_TODO,
  SET_TODOS,
  TOGGLE_TODO,
  UPDATE_TODO
} from "./constants";
import { TodoState } from "./types";
import { AppThunk } from "../index";
import { baseURL } from "../../api";

// https://github.com/reduxjs/redux/issues/3455
export const addTodo = (newTodo: TodoState): AddTodoAction => {
  return {
    type: ADD_TODO,
    payload: newTodo
  };
};

export const removeTodo = (id: string): RemoveTodoAction => {
  return {
    type: REMOVE_TODO,
    payload: {
      id
    }
  };
};

export const setTodos = (todos: TodoState[]): SetTodosAction => {
  return {
    type: SET_TODOS,
    payload: todos
  };
};

export const toggleTodo = (id: string): ToggleTodoAction => {
  return {
    type: TOGGLE_TODO,
    payload: {
      id
    }
  };
};

export const updateTodo = (id: string, text: string): UpdateTodoAction => {
  return {
    type: UPDATE_TODO,
    payload: {
      id,
      text
    }
  };
};

export const setTodosRequest = (): AppThunk<Promise<void>> => {
  return dispatch => {
    dispatch(setLoading("加载中..."));
    return fetch(baseURL)
      .then(res => res.json())
      .then(data => {
        dispatch(setTodos(data));
        dispatch(unsetLoading());
      });
  };
};

export const addTodoRequest = (text: string): AppThunk<Promise<void>> => {
  return dispatch => {
    return fetch(baseURL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ text, done: false })
    })
      .then(res => res.json())
      .then((data: TodoState) => {
        dispatch(addTodo(data));
      });
  };
};

export const removeTodoRequest = (todoId: string): AppThunk<Promise<void>> => {
  return dispatch => {
    return fetch(`${baseURL}/${todoId}`, {
      method: "DELETE"
    })
      .then(res => res.json())
      .then(({ id }: TodoState) => {
        dispatch(removeTodo(id));
      });
  };
};

export const updateTodoRequest = (
  todoId: string,
  text: string
): AppThunk<Promise<void>> => {
  return dispatch => {
    return fetch(`${baseURL}/${todoId}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ text })
    })
      .then(res => res.json())
      .then(({ id, text }: TodoState) => {
        dispatch(updateTodo(id, text));
      });
  };
};

export const toogleTodoRequest = (
  todoId: string,
  done: boolean
): AppThunk<Promise<void>> => {
  return dispatch => {
    return fetch(`${baseURL}/${todoId}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ done })
    })
      .then(res => res.json())
      .then(({ id }: TodoState) => {
        dispatch(toggleTodo(id));
      });
  };
};

这里说一点题外话, 其实 Redux 不用 Thunk 这种 middleware 来做异步请求也是可以的, 但是为啥还会有 Redux Thunk 这些库存在呢. 具体细节我之前写过一个回答, 有兴趣可以看一看: redux中间件对于异步action的意义是什么?

reducer

编写完复杂的 ActionCreator, reducer 相比就简单很多了, 这里直接贴代码了:

// store/todo/reducer.ts

import { Reducer } from "redux";
import { TodoAction } from "./actionTypes";
import {
  ADD_TODO,
  REMOVE_TODO,
  SET_TODOS,
  TOGGLE_TODO,
  UPDATE_TODO
} from "./constants";
import { TodoState } from "./types";

const initialState = [];

export const todoReducer: Reducer<Readonly<TodoState>[], TodoAction> = (
  state = initialState,
  action
) => {
  switch (action.type) {
    case SET_TODOS:
      return action.payload;
    case ADD_TODO:
      return [...state, action.payload];
    case REMOVE_TODO:
      return state.filter(todo => todo.id !== action.payload.id);
    case UPDATE_TODO:
      return state.map(todo => {
        if (todo.id === action.payload.id) {
          return { ...todo, text: action.payload.text };
        }
        return todo;
      });
    case TOGGLE_TODO:
      return state.map(todo => {
        if (todo.id === action.payload.id) {
          return { ...todo, done: !todo.done };
        }
        return todo;
      });
    default:
      return state;
  }
};

写完 reducer 记得在 store 中写入 combineReducer()

selectors

最后是 selectors, 由于这部分是需要和 filter 切片进行协作, filter 部分下面会讲, 这里先贴代码, 最后可以再回顾

// store/todo/selectors.ts

import { RootState } from "../index";

export const selectFilteredTodos = (state: RootState) => {
  switch (state.filter.status) {
    case "all":
      return state.todos;
    case "active":
      return state.todos.filter(todo => todo.done === false);
    case "done":
      return state.todos.filter(todo => todo.done === true);
    default:
      return state.todos;
  }
};

export const selectUncompletedTodos = (state: RootState) => {
  return state.todos.filter(todo => todo.done === false);
};

todo 部分基本完成了, 最后有一个点, Redux 文档中其实一直有提到, 不过之前我一直忽略, 这次看了 redux 文档到底说了什么(上) 文章才有注意到, 就是 Normalizing State Shape. 这部分是关于性能优化的, 我自己的项目包括实习的公司项目其实从来都没有做过这一部分, 因此实战经验为 0. 有兴趣的可以去看看

Filter

最后一个状态切片 filter, 这部分主要是为了帮助选择展示的 todo 部分. 由于这部分较为简单, 和 loading 部分类似, 居多为代码的罗列

types

回顾之前想要实现的效果, TodoApp 底部是一个类似 tab 的组件, 点击展示不同状态的 todos. 总共是三部分:

  • 全部(默认)
  • 未完成
  • 已完成

编写一下具体的类型:

// store/filter/types.ts

export type FilterStatus = "all" | "active" | "done";

export type FilterState = {
  status: FilterStatus;
};

actions

actionTypes

// store/filter/constants.ts

export const SET_FILTER = "SET_FILTER";
export type SET_FILTER = typeof SET_FILTER;

export const RESET_FILTER = "RESET_FILTER";
export type RESET_FILTER = typeof RESET_FILTER;
// store/filter/actionTypes.ts

import { SET_FILTER, RESET_FILTER } from "./constants";
import { FilterStatus } from "./types";

export type SetFilterAction = {
  type: SET_FILTER;
  payload: FilterStatus;
};

export type ResetFilterAction = {
  type: RESET_FILTER;
};

export type FilterAction = SetFilterAction | ResetFilterAction;

actions

// store/filter/actions.ts

import { SetFilterAction, ResetFilterAction } from "./actionTypes";
import { SET_FILTER, RESET_FILTER } from "./constants";
import { FilterStatus } from "./types";

export const setFilter = (filterStatus: FilterStatus): SetFilterAction => {
  return {
    type: SET_FILTER,
    payload: filterStatus
  };
};

export const resetFilter = (): ResetFilterAction => {
  return {
    type: RESET_FILTER
  };
};

reducer

import { Reducer } from "redux";
import { FilterAction } from "./actionTypes";
import { SET_FILTER, RESET_FILTER } from "./constants";
import { FilterState } from "./types";

const initialState: FilterState = {
  status: "all"
};

export const filterReducer: Reducer<Readonly<FilterState>, FilterAction> = (
  state = initialState,
  action
) => {
  switch (action.type) {
    case SET_FILTER:
      return {
        status: action.payload
      };
    case RESET_FILTER:
      return {
        status: "done"
      };
    default:
      return state;
  }
};

Store

最后将所有 store 底下的 actions, reducers 集成一下, store 文件如下:

import { combineReducers, createStore, applyMiddleware } from "redux";
import { todoReducer } from "./todo/reducer";
import { filterReducer } from "./filter/reducer";
import { loadingReducer } from "./loading/reducer";
import thunk, { ThunkDispatch, ThunkAction } from "redux-thunk";
import { FilterAction } from "./filter/actionTypes";
import { LoadingAction } from "./loading/actionTypes";
import { TodoAction } from "./todo/actionTypes";

const rootReducer = combineReducers({
  todos: todoReducer,
  filter: filterReducer,
  loading: loadingReducer
});

export type RootState = ReturnType<typeof rootReducer>;
export type RootAction = FilterAction | LoadingAction | TodoAction;

export const store = createStore(rootReducer, applyMiddleware(thunk));

export type AppDispatch = ThunkDispatch<RootState, void, RootAction>;

export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  void,
  RootAction
>;

总结

至此所有关于 store 部分的代码已经全部完成了. 下一篇文章也就是最后一篇文章会完成 UI 部分, 讲一讲关于 React, HooksTypeScript 以及 React Redux 里相关 Hooks 的使用

参考

简单聊一聊 typescript 中的 noUncheckedIndexedAccess

noUncheckedIndexedAccess

其实没啥聊的, 这个配置属于 4.1 版本的一个新 feature, 该属性的作用在于使用索引访问某类型属性时, 该类型属性会被加上 undefined 类型:

Turning on noUncheckedIndexedAccess will add undefined to any un-declared field in the type.

举个例子: 在没有该配置的情况下, 访问一个普通的 array 的类型如下:

const arr = [1, 2, 3] // `arr` type is `number[]`

const x = arr[0] // `x` type is `number`
const y = arr[1000] // `y` type is `number`

实际上这样的类型是不特别准确的, 由于 arr 这个数组长度没有限制, ts 没有足够的信息去判断其元素到底有多少个, 因此即使索引超过初始化定义的长度, ts 还是认为该元素"存在"

noUncheckedIndexedAccess 这一配置能够在使用索引访问时, 添加 undefined 类型:

const arr = [1, 2, 3] // `arr` type is `number[]`

const x = arr[0] // `x` type is `number | undefined`
const y = arr[1000] // `y` type is `number | undefined`

可以看到开启配置之后, 任何索引的访问都会带上 undefined 类型, 毕竟 ts 此时也不确定 arr 具体的形状是啥样了, 甚至是一个空 array 都是有可能的.

Tuple & const assertions

那如何定义固定长度的 array 类型呢? 一种方法是定义为 Tuple 类型:

const x: [number, number, number] = [1, 2, 3]

const x = arr[0] // `x` type is `number`
const y = arr[1000] // error: Tuple type '[number, number, number]' of length '3' has no element at index '9'.

当索引溢出时, 直接编译报错

第二种方法是使用 const assertions, 即断言成字面量类型:

const arr = [1, 2, 3] as const // `arr` type is `readonly [1, 2, 3]`

const x = arr[0] // `x` type is `number`
const y = arr[1000] // error: Tuple type '[number, number, number]' of length '3' has no element at index '9'.

使用这种类型断言之后, 类型会自动加上 readonly 关键字, ts 编译器就知道这种数据类型是 immutable 的, 自然长度也不会变了

以上两种方法不管 noUncheckedIndexedAccess 有没有配置, 都能正确识别索引溢出的错误

自定义类型

然而有一种情况下识别出的元素类型还是存在 undefined 类型的.

这里保持 noUncheckedIndexedAccess 配置开启, 有以下代码:

type A = [1, 2, 3]

const a: A = [1, 2, 3]

declare const i: number

const b = a[i] // `b` type is `1 | 2 | 3 | undefined`

我们仍旧使用 Tuple 类型声明数组, 并且严格规定其元素. 但访问的索引给定一个比较宽泛的类型 number, 这时候去拿数组里的元素时发现他的类型又给出了 undefined 类型

真实的场景会有, 当使用 map(), forEach() 这些方法时, 可能会有(其实并没有)如下代码:

a.forEach((v, i) => {
  const b = a[i] // `b` type is `1 | 2 | 3 | undefined`
})

这时候 v 类型是 1 | 2 | 3, 但是我自己用索引拿到的元素 b 类型却是 1 | 2 | 3 | undefined, 有点困惑...

我想 ts 编译器应该也困惑吧, 毕竟索引类型是 number 了, 它也不知道具体是哪个索引, 所以我只能又给一个 undefined, 虽然我数组的类型是严格的 Tuple

所以这里的索引 i 需要更加准确, 如果手动给的话, 即为 0 | 1 | 2. 但是该如何根据已知的数组 a 和它的类型 A 自动推断出对应的索引类型呢?

比较通用并且容易想到的一种做法是用 keyof:

declare const i: Exclude<keyof A, keyof typeof Array.prototype> // "0" | "1" | "2"

虽然最后的结果类型是 string, 但是对于索引已经够用了

IndexOf

另一种做法就是自定义一个类型, 这里叫 IndexOf, 该类型接受一个类型参数, 即数组的类型, 返回值是该数组的索引类型, 这里先看实现:

type IndexOf<
  T extends any[],
  S extends number[] = []
> = T["length"] extends S["length"]
  ? S[number]
  : IndexOf<T, [...S, S["length"]]>;

使用就非常简单了

type A = [1, 2, 3]

const a: A = [1, 2, 3]

declare const i: IndexOf<A> // 0 | 1 | 2

const b = a[i] // `b` type is `3 | 1 | 2`

最后得到的类型顺序不重要(我觉得...)

简单讲一下过程:

  • TS 是两个泛型参数, T 的形状是一个 array, S 类型为数字数组, 默认为一个空数组类型. 这个 S 存放了每次递归的状态, 这里后面会讲
  • 第一个条件, 也就是 ? 前的 extends 语句, 这个语句是递归的终止条件, 满足条件为当 T 的长度和 S 的长度一样
  • 然后是 ? 后的第一条语句, 这条语句为返回结果, 返回的是 S[number], 也就是对 S 取里面所有元素的 union 的值
  • 最后是递归触发, 每次递归发生时让 S 自增, 从一开始的 [], 一直到每次递增添加一个数字, 所以最后的 S 值为 [0, 1, 2]

最后发现了一个可以做类型体操的地方: type-challenges

参考

简单用 React+Redux+TypeScript 实现一个 TodoApp (三)

前言

上一篇文章讲了讲如何结合 Redux Thunk 完成 store 中核心 Todo 切片的状态编写. 由于关于 store 部分已经全部完成了, 这篇主要谈一谈如何使用 React-Redux 结合 React Hooks 来完成 UI 部分

该篇也是本系列最后一篇文章

想跳过文章直接看代码的: 完整代码

最后的效果:
todoapp

思路

这里我简单就分为三个组件:

  • App
  • TodoApp
  • TodoItem

组件分的多细其实完全看个人偏好, 比如这个项目, 完全可以抽成粒度更细致的, 比如添加 Todo 的输入框可以是单独一个组件, Todo 列表也可以是一个组件, 底下的 Footer 也可以成为一个独立的. 这里为了方便就不抽成很细的了

所有的组件都是用 hooks 编写, 包括 react-redux 部分. 所以关于 class 组件以及相关 react-redux 使用(比如 conntect) 可能需要自行谷歌了

App

先从最基本的开始, 这个组件需要配置一下 Store, 以及引入一下样式:

// components/App.tsx

import React from "react";
import TodoApp from "./TodoApp";
import { Provider } from "react-redux";
import { store } from "../store";
import "../style.css";
import "antd/dist/antd.css";

export default function App() {
  return (
    <Provider store={store}>
      <TodoApp />
    </Provider>
  );
}

这里提一下 css, 主要会用 antd 的一些组件, 同时有自定义一些样式, 都在 style.css 文件下, 有兴趣可以自己去查看, 不做深究

至此这个组件就写完了. 唯一的作用就是提供一个 store, 所有在该 provider 下的子组件都可以拿到里面的状态, 同时有别于原生的 context, 组件可以根据自己拿到的状态按需重新渲染, 不会出现有部分状态更新之后, 所有组件都重新渲染而造成性能问题.

TodoItem

一个 TodoItem 应该具有对应 store 上的如下操作:

  • 左边有一个 checkbox 能够进行勾选 toogleTodo
  • 右边有一个图标点击可以删除该 todo
  • 正常情况下中间显示 todo 的内容, 但是点击可以进行修改更新内容

而一个 TodoItem 里面的数据是无法单独在这个这个组件里连接 Redux 获取的(你咋知道你要的 todo 是哪个 todo). 所以正确做法应该是在父组件(也就是 TodoApp) 里面获取数据, 通过 props 传给 TodoItem, 包括对 redux 里面 action 操作也是如此

代码如下:

// components/TodoItem.tsx

import React, { useState } from "react";
import { TodoState } from "../store/todo/types";
import { Checkbox, Input, List } from "antd";
import CloseOutlined from "@ant-design/icons/CloseOutlined";

export type TodoItemProps = {
  todo: TodoState;
  handleToogle: (todoId: string, done: boolean) => void;
  handleUpdate: (todoId: string, text: string) => Promise<void>;
  handleRemove: (todoId: string) => void;
};

const TodoItem: React.FC<TodoItemProps> = props => {
  const { todo, handleToogle, handleUpdate, handleRemove } = props;
  const [updating, setUpdating] = useState(false);
  const [text, setText] = useState(todo.text);

  const handlePressEnter = () => {
    handleUpdate(todo.id, text).then(() => setUpdating(false));
  };

  return (
    <List.Item className="todo-item" onDoubleClick={() => setUpdating(true)}>
      <span className="todo-left">
        <Checkbox
          className="todo-check"
          checked={todo.done}
          onChange={() => handleToogle(todo.id, !todo.done)}
        />
        {updating ? (
          <Input
            value={text}
            onChange={e => setText(e.target.value)}
            autoFocus
            onPressEnter={handlePressEnter}
            onBlur={() => setUpdating(false)}
          />
        ) : (
          <span className={`todo-text ${todo.done ? "done" : ""}`}>
            {todo.text}
          </span>
        )}
      </span>
      <span className="todo-right" onClick={() => handleRemove(todo.id)}>
        <CloseOutlined />
      </span>
    </List.Item>
  );
};

export default TodoItem;

TodoApp

核心组件, 需要去 Redux 里面取数据以及对应的 action, 同时初始化的时候要向服务端请求数据, 所以结构可能是这样的:

// components/TodoApp.tsx

const TodoApp: React.FC = () => {
  const dispatch = useDispatch()
  const todos = useSelector(selectFilteredTodos);

  useEffect(() => {
    dispatch(setTodosRequest());
  }, [dispatch]);

  return (
    // ...
  )
}

然而很可惜, 这样很有可能 ts 编译器会报错...直接谷歌了一下发现一个类似的问题: type-safe useDispatch with redux-thunk. 其实原因很简单, 我们现在 Dispatch 的方法不是一个标准的 Action, 这个 Action 是被 Thunk 包装过的. 包括我们直接去看一下源码:

/**
 * A hook to access the redux `dispatch` function.
 *
 * Note for `redux-thunk` users: the return type of the returned `dispatch` functions for thunks is incorrect.
 * However, it is possible to get a correctly typed `dispatch` function by creating your own custom hook typed
 * from the store's dispatch function like this: `const useThunkDispatch = () => useDispatch<typeof store.dispatch>();`
 *
 * @returns redux store's `dispatch` function
 *
 */
export function useDispatch<TDispatch = Dispatch<any>>(): TDispatch;
export function useDispatch<A extends Action = AnyAction>(): Dispatch<A>;

可以看到源码的注释也非常清晰的解释了如果用到了 Thunk 那么需要自己传入泛型类型

当然包括 React Redux 官网也有写使用套路.

所以我们只需改一下:

// components/TodoApp.tsx

import { AppDispatch } from "../store";

const TodoApp: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>()
  const todos = useSelector(selectFilteredTodos);

  useEffect(() => {
    dispatch(setTodosRequest());
  }, [dispatch]);

  return (
    // ...
  )
}

后面就没什么好说的了, 要拿数据只需要 useSelector(), dispatch 一个 action 不管是不是 Thunk Action 现在类型都不会有问题了. Reac Redux 和 TypeScript 的结合相比原生的 Redux 还是好很多的

最后贴一下代码:

import React, { useEffect, useState, useCallback } from "react";
import { Input, List, Radio, Spin } from "antd";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "../store";
import {
  addTodoRequest,
  removeTodoRequest,
  setTodosRequest,
  toogleTodoRequest,
  updateTodoRequest
} from "../store/todo/actions";
import { setFilter } from "../store/filter/actions";
import { FilterStatus } from "../store/filter/types";
import {
  selectFilteredTodos,
  selectUncompletedTodos
} from "../store/todo/selectors";
import { selectLoading } from "../store/loading/selectors";
import TodoItem from "./TodoItem";

const TodoApp: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>();
  const todos = useSelector(selectFilteredTodos);
  const uncompletedTodos = useSelector(selectUncompletedTodos);
  const loading = useSelector(selectLoading);
  const [task, setTask] = useState("");

  useEffect(() => {
    dispatch(setTodosRequest());
  }, [dispatch]);

  const handleAddTodo = () => {
    dispatch(addTodoRequest(task)).then(() => setTask(""));
  };

  const handleToogleTodo = useCallback(
    (id: string, done: boolean) => {
      dispatch(toogleTodoRequest(id, done));
    },
    [dispatch]
  );

  const handleRemoveTodo = useCallback(
    (id: string) => {
      dispatch(removeTodoRequest(id));
    },
    [dispatch]
  );

  const handleUpdateTodo = useCallback(
    (id: string, text: string) => {
      return dispatch(updateTodoRequest(id, text));
    },
    [dispatch]
  );

  const handleFilter = (filterStatus: FilterStatus) => {
    dispatch(setFilter(filterStatus));
  };

  return (
    <div className="todo-app">
      <h1>Todo App</h1>
      <Input
        size="large"
        placeholder="新任务"
        value={task}
        onChange={e => setTask(e.target.value)}
        onPressEnter={handleAddTodo}
      />
      <Spin spinning={loading.status} tip={loading.tip}>
        <List
          className="todo-list"
          footer={
            <div className="footer">
              {uncompletedTodos.length > 0 && (
                <span className="todo-needed">
                  还剩 {uncompletedTodos.length}<span role="img" aria-label="Clap">
                    🎉
                  </span>
                </span>
              )}
              <Radio.Group
                onChange={e => handleFilter(e.target.value)}
                size="small"
                defaultValue="all"
                buttonStyle="solid"
              >
                <Radio.Button className="filter-item" value="all">
                  全部
                </Radio.Button>
                <Radio.Button className="filter-item" value="done">
                  已完成
                </Radio.Button>
                <Radio.Button className="filter-item" value="active">
                  待完成
                </Radio.Button>
              </Radio.Group>
            </div>
          }
          bordered
          dataSource={todos}
          renderItem={todo => (
            <TodoItem
              handleRemove={handleRemoveTodo}
              handleToogle={handleToogleTodo}
              handleUpdate={handleUpdateTodo}
              todo={todo}
            />
          )}
        />
      </Spin>
    </div>
  );
};

export default TodoApp;

总结

最后一篇文章想来想去发现其实没啥好写的, 当然可能是因为我懒了只想罗列代码.

其实我甚至根本没在真实项目里用过 Redux + TypeScript. 这篇文章可以算是我一时兴起的 Demo 文章. 所以完全有可能存在很多错误. 因为很简单, 我连 TypeScript 和 React 都没写过啥项目...而且一个 TodoApp 状态来用 Redux 来管理实在有点大材小用.

讲实话, Redux 和 TypeScript 写起来是真的挺啰嗦的, 而且坑也有一些. 起码我觉得对新手不是特别友好. 有些时候为了一个非常小的类型问题需要大动周折去翻源码搜 issue 实在是有点不值得. 虽然我觉得 Redux 的文档真的已经写的很详细了. 但是有时候过分详细又会让开发者很迷茫手足无措. 写的太多, 反而找不到我想要的东西了的那种感觉

有机会我再去啾啾 Redux Toolkit 这个库吧

参考

第一篇博客: 写博客的经历

博客搭建的经历

算起来从 18 年正式接触编程到现在, 已经两年多了. 自己在学习编程的途中也做了很多笔记, 从最早使用OneNote, 到学会markdown开始折腾各种编辑器, 再到自己买域名建站部署, 直到今天打算回归朴素用issue做笔记. 算是一个过程吧, 有时候下定决心做一件事情可能就是一瞬间的事情

issue来写博客的原因是有原因的, issue有以下几个优势:

  • 简约. 博客的核心是博文的内容, 过于注重外观反而本末倒置. 之前自己建站的时候花了很多心思去弄页面, 博客反而没有写几篇, 也是惭愧
  • 管理. 我对知识的整理没有很高的要求. 自己本地的笔记整理也只是按照分类建立了文件夹, 每个文件夹下面有对应的笔记文件. issueProjectsLabels可以很方便的进行笔记的管理和分类
  • 备份. 这点还是比较重要. 笔记一直放在本地如果出了点问题远程有个备份还是很好的
  • 回顾. 之前很多时候笔记做了一次就扔在那里, 过了很久不看也忘记了. 放到issue可能有助于经常回顾

当然, issue也存在自己的问题, 比如 SEO 等. 但是不影响.

之前也考虑过用issue写博客以后, 拿Github API取到issue内容然后同步到自己的网站博客上. 将来可能会尝试实现这个功能

关于之前建站的记录

上半年的时候心血来潮买了域名, 按照教程搭建了自己的个人网站, 本来想着就用来做博客了, 但是主题字体对中文不是很友好, 就放弃了. 不过总的来说还是挺有意义的一次经历.

总的思路其实很简单, 本地博客是用markdown编写, 但是网页展示需要使用html文件, 那么找个工具转换就行, 最好还是可以带命令行的. markdown-to-html 支持命令行和自定义模板. 一定程度上解决了这个问题. 自己又写了一些功能, 例如文章的导航, 最新的文章等等. 但是需要时间去折腾, 虽然造轮子是好的, 当然最后发现其实有些违背初心. 所以这也是最后转投Github Issue的一个比较重要的原因

当然, 有兴趣也可以去看看我的博客的源码

为什么要写博客

其实细讲原因有很多, 个人原因主要有以下几点:

  • 自己的笔记整理. 很多时候看一个知识点可能懂了, 但是用通俗易懂的语言能给别人听懂其实又是另外一回事. 有点类似像高中的时候做数学题, 编程有时候也是类似的
  • 自己在编程这条道路上的一个记录.
  • 提高文学素养. 写出来的东西还是可能会被别人看的, 虽然高中语文一直是倒数, 但是你在这里写的差不影响你成绩, 写的好还可能有人给你点赞
  • 放松. 由于自身的原因目前一个人在外面读书, 说实话很多时候会很崩溃. 我发现打字写文章对我来说是一个比较好的解压的方式, 比浪费时间看Youtube强很多. 虽然很多时候打了很多又删掉. 自己说实话挺享受打字写文章的这个过程的, 虽然我知道自己语文真的很烂.

自己一直是个很难做到有始有终的人, 希望博客可以一直有动力写下去吧

Github Issue 写博客的资料整理

JavaScript中高阶函数原生实现

reduce

说明

引用自 MDN:

reduce() 方法对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其结果汇总为单个返回值

语法为:
arr.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])

callback 接受 4 个参数

  • Accumulator(acc): 累加器, 每次迭代结果均存在此, 当最后一次迭代结束时, 返回该累加器的结果
  • Current Value(cur): 当前(下一个)被用于计算的值, 用于累计到 acc 累加器中
  • Current Index (idx): 当前被计算元素的索引, 由于第一个数已被作为累加器的初始值, 所以默认从第二个数开始计算, 所以初始索引为 1. 如果提供了初始值(initialValue), 从第一个数开始计算, 初始索引为 1
  • array: 调用 reduce() 的数组

initialValue: 作为第一次调用 callback函数时的第一个参数的值, 如果没有提供初始值, 则将使用数组中的第一个元素.

例子:

const add = (a, b) => a + b

[1, 2, 3, 4].reduce(add) // 10
[1, 2, 3].reduce((acc, next) => {
  return {
    ...acc,
    [next]: next * 5
  }
}, {}) // { '1': 5, '2': 10, '3': 15 }

实现

几个注意点:

  • 第二个参数 initialValue 是否提供进行判断
  • 如果提供了 initialValue, 第一个参数回调函数中 index 需要从 0 开始计数, 否则为 1
Array.prototype.myReduce = function(callback, initialValue) {
  let acc = initialValue
  let i = 0
  if (typeof initialValue === 'undefined') {
    acc = this[0]
    i = 1
  }

  for (i; i < this.length; i++) {
    acc = callback(acc, this[i], i, this)
  }
  return acc
}

参考

session 和 cookie 整理

session 和 cookie 总结

直接使用cookie也是可以追踪用户的, 但是不安全, 所以比较好的做法为, 在第一次登录以后, 往客户端发送一个 cookie, cookie 里面的 value 设置为session_id, 发送请求到服务器的时候, 服务器有一个 session 使用[session_id: username]来验证发送过来的cookie

这样的好处是:

  • 服务端可以使用 session 来跟踪用户
  • 客户端用户登录一次以后由于被设置了 cookie, 下次可以免去登录

具体流程为:

1, 第一访问登录页面, 提交登录表单信息
2, 服务端得到用户提交的登录数据, 设置: 
  session, session[session_id] = user.username
3, 服务端返回 response 带上 cookie 信息:
  headers['Set-Cookie'] = f'user={session_id}'
4, 一个 current_user 函数, 可以用户从用户的 cookie 里得到 session_id, 与服务端匹配, 验证用户:
  session_id = request.cookies.get('user', '')
  username = session.get(session_id, '游客')
5, 如果对应的 session_id 匹配成功, 说明用户处于登录状态, 可以被追踪, 否则用户没有登录
6, 用户如果关闭浏览器, cookie 不受影响一直保存在浏览器里(除非过期), 下次打开页面可以直接免登录
7, 只要服务器不关闭, 那么 session 里面的用户信息是永久保存的, 只要客户端设置了 cookie, 验证成功那么该用户就可以被追踪, 即处于登录成功的状态

示例代码可以参见: cookie&session

模拟代码

from bottle import route, run, response, request, redirect

login_user = {} # 就是 session

@route('/login')
def login():
    key = hash("test password") 
    login_user[key] = "test password"
    response.set_cookie('session_id', str(key)) # 设置Cookie值, 下次可以免登录
    return 'login successfuly!'

@route('/logout')
def logout():
    key = request.get_cookie('session_id')
    login_user.pop(int(key), None) # 删除 session 里面的用户, 这样无法在服务端验证
    return 'logout successfuly!'

@route('/logintest')
def logintest():
    key = request.get_cookie('session_id') # 获取Cookie值
    if key is not None and int(key) in login_user: # 看看 session 字典里存的有没有用户
        return 'login test successfuly!'
    else :
        return redirect('/beforelogin')

@route('/beforelogin')
def beforelogin():
    return 'please login!'

run(host='localhost', port=8080, debug=True)

flash message 原理

需求

客户端提交登录登录表单, 如果成功重定向到首页, 否则停留(重定向)在登录页面, 并显示"登录失败"

原理如下

使用 session

对于 login controller, 需要知道客户端显示登录错误, 还是显示登录成功?

也就是 lgoin:post controller 需要给 redirect 后的 login:get controller 发送一个消息告诉他上次登录失败, 两个 controller 直接无法直接发送消息

由于可以在服务端内部共享消息, 可以将用户的动作存储在 session 里, 具体为:

login:post 发送登录失败就在对应 session 里写入一个标记, redirect 到的 login:get 渲染页面的时候检查是否有登录失败标记, 如果有就在渲染的页面里面取出标记对应的信息, 然后在这个将这个标记从这个 session 里面清楚, 伪代码可以为:

session['操作'] = 'msg'
session.get('操作', '') # 获取该操作对应提示信息
session.pop('操作') # 删除该操作以及信息

使用 cookie

有时候用户可能没有正确的 redirect 到 login:get, 那么这个 flash message 还是存在 session 里面, 也就是说用户下次访问 login:get 会提示登录错误, 这样是不合理的

可以使用 cookie, 做法为:

login:post 检测发现错误了就给客户端加一个 cookie 并且发送 redirect 到 login:get, 也就是说这个 cookie 只有 redirect 成功以后才会被客户端带上, 那么重定向以后只要检查是否带有这个 cookie, 有就渲染出对应的错误消息. 即使 redirect 没有成功, 那么附带的 cookie 也无法正确被发送过去, 因而也不会显示错误信息

当然匹配了 cookie 以后需要手动设置 cookie 过期时间清除 cookie, 防止下次访问该页面出现同样的 flash message

参考

简单聊一聊 React, Redux 和 Context 的行为

这篇文章主要参考了 Blogged Answers: React, Redux, and Context Behavior

关于 React Redux 和 Context 网上存在一些误解:

  • React-Redux 只是"对 React Context" 的包装
  • 你可以通过解构 context 里的属性来避免组件的重复渲染

上面说法说法均错误

其实这是一个 context 老生常谈的问题, 如果给 context 传入的是一个非原始类型, 比如数组或者对象, 那么当你的组件只订阅了部分对象属性, 即使该属性没有发生变化, 但如果其他属性发生变化你的组件仍旧会被迫重新渲染. 可以简单理解为没办法进行局部订阅, 除非你自己去做好性能优化.

举个例子:

function ProviderComponent() {
  const [contextValue, setContextValue] = useState({ a: 1, b: 2 });

  return (
    <MyContext.Provider value={contextValue}>
      <SomeLargeComponentTree />
    </MyContext.Provider>
  );
}

function ChildComponent() {
  const { a } = useContext(MyContext);
  return <div>{a}</div>;
}

如果 ProviderComponent 调用了 setContextValue({ a: 1, b: 3 }), ChildComponent 仍旧会被重新渲染, 即使他解构了对象并且只用到了 a 属性. 原因很简单, 一个新的对象引用被传递给了 provider, 所有的 consumer 都需要重新渲染. 事实上, 如果显示的强制调用一遍 <MyContext.Provider value={{a: 1, b: 2}}>, ChildComponent 仍旧会被重新渲染, 因为这是一个新的对象引用. 可以看做两个用 === 在进行严格比较. 所以理论上尽量不要给 Context 传递对象类型...

而对于 React-Redux, 虽然内部确实用到 context, 但他传递给 provider 的是 store 实例本身, 而非 store 内部的状态. 其基本实现可以看做如下:

function useSelector(selector) {
  const [, forceRender] = useReducer((counter) => counter + 1, 0);
  const { store } = useContext(ReactReduxContext);

  const selectedValueRef = useRef(selector(store.getState()));

  useLayoutEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const storeState = store.getState();
      const latestSelectedValue = selector(storeState);

      if (latestSelectedValue !== selectedValueRef.current) {
        selectedValueRef.current = latestSelectedValue;
        forceRender();
      }
    });

    return unsubscribe;
  }, [store]);

  return selectedValueRef.current;
}

其基本原理也是相当清晰了. 通过订阅 Redux 的 store, 来获知是否有 action 被 dispatch 了, 然后通过 refuseLayoutEffect 来获取 store 里新旧值并进行比对来判断组件是否需要重新渲染. 注意这里还是进行的严格比较. 这也是 useSelectormapStateToProps 的区别. 虽然 react-redux 帮忙做了部分性能优化. 但是更加具体的还是需要自己来. 这里不展开.

注意由于通过 context 传递的是 store 的实例, 所以本质上 useLayoutEffect 不会触发多次渲染, 监听也只会监听一次.

关于 context 的行为可以参考: React issue #14110: Provide more ways to bail out of hooks.. 该 issue 很长, 这里只截取 Dan 在开头提到的两个点:

而对于什么时候使用 context, 在该 issue 下有人总结了一下:

My personal summary is that new context is ready to be used for low frequency unlikely updates (like locale/theme). It's also good to use it in the same way as old context was used. I.e. for static values and then propagate updates through subscriptions. It's not ready to be used as a replacement for all Flux-like state propagation.

中文翻译过来就是传给 context 的值一般是比较少会触发更新的, 比如 locale 或者 theme. context 更被常用的方式应该为注入一个服务, 而不是注入一个状态.

而对于 React-Redux v6, 作者曾尝试把 store state 作为 value 传递给 context. 但是最后证明这种方式存在很大问题. 具体细节可以参考: which is why we had to rewrite the internal implementation to use direct subscriptions again in React-Redux v7. 以及如果想要了解更多关于 React-Redux 工作原理的, 可以看另一篇博客: The History and Implementation of React-Redux

题外话

原文章的作者是 Mark Erikson, 是目前 Redux 和 Redux Toolkit 的维护者, 虽然相比于 Dan Abramov 可能没有那么出名. 但是他的博客质量还是非常高的, 他写了挺多关于 React 和 Redux 的文章, 文章都很长, 而且相当硬核. 不过可能由于过于硬核或者其他原因我是没找到国内的翻译版.

他的博客: https://blog.isquaredsoftware.com/

参考

简单聊一聊一个 Dialog 组件的重构

引子

实习第一周遇到一个 task 是重构一个 Dialog 组件, 看了一下项目代码发现有点东西, 原始代码我抽象了一下大致如下:

const NavBar = () => {
  const handleOpen = () => {
    const Dialog = (
      <Dialog>
        ...
      </Dialog>
    )
    dispatch(openDialog(Dialog))
  }

  return (
    <Button onClick={handleOpen}>Open Dialog</Button>
  )
}

const App = () => {
  const { component } = useSelector(state => state.dialog)

  return (
    <div>
      {component && ...component}
    <div>
  )
}
// actions
const openDialog = component => {
  return {
    type: 'OPEN_DIALOG',
    payload: {
      component
    }
  }
}

const closeDialog = () => {
  return {
    type: 'CLOSE_DIALOG',
  }
}

// reducer
const dialog = (state={ component: null }, action) => {
  switch(action.type) {
    case 'OPEN_DIALOG':
      return {
        component: action.payload.component
      }
    case 'CLOSE_DIALOG':
      return {
        component: null
      }
    default: 
      return state
  }
}

先提一下, 公司技术栈为 React + Redux + Material UI. 简单讲一下原始代码的思路:

  • 用 Redux 存取 Dialog 组件数据, 即 Reducer 里面存的不是状态, 是一个 React 组件的虚拟 DOM
  • open 和 close 行为均通过 Redux 触发, 其中 open 携带的数据即为对应的 Dialog 组件, 可以看到代码中 Dialog 组件是写在 handleOpen 方法里的
  • App(实际项目里面可能是某个级别比较高的组件) 里面, 判断 Reducer 存放的 Dialog 组件是否存在, 存在直接渲染

我第一次遇到原来 Redux 还能这么玩... 毕竟正常 Reducer 里面应该存放可序列化的状态. 我搜了下, 发现还真有人提过这么一个类似问题: Storing React component in a Redux reducer?

很显然这么做肯定不好, 于是就让我重构了. Material UI 本身就有封装 Dialog 组件. 照着官方文档先改了一下:

重构 1

const TopicDialog = props => {
  const { open, onClose } = props

  return (
    <Dialog open={open} onClose={onClose}>
      <DialogTitle>Title</DialogTitle>
      <DialogContent>
        Content
      </DialogContent>
      <DialogActions>
        DialogActions
      </DialogActions>
    </Dialog>
  )
}

const NavBar = props => {
  const [open, setOpen] = useState(false)

  const handleOpen = () => {
    setOpen(true)
  }

  const handleClose = () => {
    setOpen(false)
  }

  return (
    <Button onClick={handleOpen}>Open Dialog<Button>
    <TopicDialog open={open} onClose={handleClose} />
  )
}

思路其实很简单:

  • 通过一个状态 open 来控制 Dialog 组件的开关
  • Dialog 组件不在作为一个抽象概念, 而是直接放在相关组件下边, 这里是放在 NavBar 里, 可以看到这个 TopicDialog 组件是比较定制化的
  • 结合 1, 2 两点来看, 状态与相关方法一般是通过父级组件来维护, 通过 props 传递给子组件

本来想着这样重构就结束了, 但是测试时候发现样式不对. 具体问题为: 由于 TopicDialog 组件放置在 NavBar 组件下, 其主题(Theme) 会直接沿用上级组件, 比如这里的 NavBar 主题是暗色主题, 那么 TopciDialog 颜色什么的都是暗色, 但我想要的主题可能是亮色的

未重构前的代码没出现这样的问题, 其实可以看到, {...componet} 渲染 Dialog 组件的时候, 该 Dialog 组件是放在级别比较高的 App 里面的, 不受 Navbar 控制

于是问了我的 mentor, 提供了两个思路:

  • TopicDialog 用自己的亮色的主题, 覆盖掉父级组件的主题
  • Redux

第一个方法很简单, 代码基本就是这样:

const TopicDialog = props => {
  const { open, onClose } = props

  return (
    <ThemeProvider theme={theme}>
      <Dialog open={open} onClose={onClose}>
        ...
      </Dialog>
    </ThemeProvider>
  )
}

直接用 ThemeProvider 包裹一下, 我本身不熟悉 Material UI, 不过最后还是从项目里找到了亮色主题的 theme, 导入了进来

重构 2

第二种方法 mentor 没有讲具体的细节, 我按照自己的思路试了一下, 先看一下抽象组件 CustomDialog, 大致如下:

CustomDialog 部分

const CustomDialog = props => {
  const { dialogType } = useSelector(state => {
    const openedDialog = Object.entries(state.dialog)
      .filter(([dialogName, dialogState]) => dialogState.open === true)[0]
    return {
      dialogType: dialogType[1]['dialogType']
    }
  })

  switch(dialogType) {
    case 'topicDialog':
      return <TopicDialog />
    case 'userDialog':
      return <UserDialog />
    default:
      return null
  }
}

思路:

  • CustomDialog 是一个抽象组件, 也是按条件渲染.
  • Redux 连接, 根据 open 属性拿到目前需要显示的 dialogType, 渲染对应的 Dialog 组件

redux 部分:

action 部分

// action
const openDialog = dialogType => {
  return {
    type: 'OPEN',
    payload: {
      dialogType
    }
  }
}

const closeDialog = dialogType => {
  return {
    type: 'CLOSE',
    payload: {
      dialogType
    }
  }
}

// high order action creator
const withSuffixAction = (action, suffix) => {
  return dialogType => {
    const state = action(dialogType)
    return {
      ...state,
      type: `${state.type}_${suffix}`
    }   
  }
}

export const openTopicDialog = withSuffixAction(openDialog, 'TOPIC_DIALOG')
export const closeTopicDialog = withSuffixAction(closeDialog, 'TOPIC_DIALOG')

reducer 部分

// reducer
const topicDialog = (state, action) => {
  return state
}


const withSuffixReducer = (reducer, suffix) => {
  return (state={ open: false }, action) => {
    switch(action.type) {
      case `OPEN_${suffix}`:
        return {
          ...state,
          open: true
          dialogType: action.payload.dialogType
        }
      case `CLOSE_${suffix}`:
        return {
          ...state,
          open: false,
          dialogType: action.payload.dialogType
        }
      default:
        return reducer(state, action)
    }
  }
}

export const rootReducer = combineReducer({
  //... 其他 reducer
  dialog: combineReducer({
    topic: withSuffixReducer(topicDialog, 'TOPIC_DIALOG')
  })
})

这里逻辑和代码有些复杂, 当然也可能是我写复杂了, 具体来说有以下几个点:

  • 由于不同 Dialog 组件其实都有一些相同点, 比如都存在 open 属性来控制显示. 不同的地方在于可能我叫 topicDialog, 你叫 userDialog, 然后每个 dialog 还可能存在一些自己的状态, 所以我选择分别用在 actionreducer 基础上封装一层, 提供一个 suffix 来区分不同的 dialog
  • 使用嵌套的combineReducer, 所以最后的状态可能长这样:
const state = {
  // 其他 state
  dialog: {
    topic: {
      dialogType: 'topicDialog',
      open: true,
    },
    user: {
      dialogType: 'userDialog',
      open: false,
    }
  }
}

总结来讲, 通过 dialogType 来判断是哪种 dialog 类型, open 来控制每种类型的 Dialog 的显示隐藏

最后是每一个特定的 Dialog:

TopicDialog 部分

const TopicDialog = props => {
  const { open } = useSelector(state => state.dialog.topic.open)
  const dispatch = useDispatch()

  const handleClose = () => {
    dispatch(closeTopicDialog('topicDialog'))
  }

  return (
    <Dialog open={open} onClose={handleClose}>
      ...
    </Dialog>
  )
}

const NavBar = props => {
  const dispatch = useDispatch()

  const handleTopicDialogOpen = () => {
    dispatch(openTopicDialog('topicDialog'))
  }

  return (
    <Button onClick={handleTopicDialogOpen}>Open Dialog</Button>
  )
}

const App = props => {
  return (
    ...
    <CustomDialog />
  )
}

思路:

  • 在 UI 上和最开始项目的结构基本一致, CustomDialog 作为一个比较基础的公共组件, 根据 reducer 里面的 opendialogType 属性选择性渲染. 这样保证了各种 Dialog 组件均在 App 的 context 下, 因此 Theme 也就跟随 App 了
  • openclose 方法全部使用 redux 里的 action, 保证状态的一致

总结

最后还是老老实实选了官方那种(覆盖 theme 的), 因为我不想写这么多代码, 以及我觉得用 Redux 来保存 Dialog 的 state 有点大材小用了...

当然我这种封装可能也不对...

参考

简单实现 React Router v4

React Router v6 其实已经计划在开发中了, 这篇文章只是通过实现一个简单的 v4 大致了解一下路由的基本概念.

需求与实现效果

需要实现三个基本组件:

  • Route
  • Link
  • Redirect

最后的效果如下: demo

需要实现的有三个基本页面:

  • Home: 对应路由 /, 单纯渲染主页面
  • Aboout: 对应路由 /about, 该页面 1.5s 后重定向到主页面
  • Topic: 对应路由: /topics, 该页面包含三个子路由, 对应三个子页面, 分别为: /topics/react, topics/vue, topics/angular

最后, App 组件的列表导航栏能直接定位到各自路由渲染对应内容, 当然在浏览器内直接输入路径也是可以的. 通过点击浏览器的回退/前进按钮进行路由导航也是可行的

源码在此: https://stackblitz.com/edit/react-router-implement

v4 的基本理念

与 v3 不同, v4 不在对路由进行集中式管理(虽然理论上还是可以做到). 整个路由系统更强调一切都是组件. 比较核心的两个组件 RouteLink, 定义可以理解为如下:

  • Route: 根据给定的 path 属性和浏览器当前的路径(url)是否匹配决定渲染内容, 即根据路由渲染 UI
  • Link: 通过该组件改变当前浏览器路径(url)

关于 React Router v4 和 v5 的设计哲学和理念, 可参考这两篇文章:

实现

测试页面

页面组件与路由配置如下:

export function Home() {
  return <h1>Home</h1>;
}

export class About extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      loading: true
    };
    this.timer = null;
  }

  componentDidMount() {
    this.timer = setTimeout(() => {
      this.setState({
        loading: false
      });
    }, 1500);
  }

  componentWillUnmount() {
    clearTimeout(this.timer);
  }

  render() {
    return this.state.loading ? (
      <div>
        <h1>About</h1>
        Redirecting to Home Page...
      </div>
    ) : (
      <Redirect to="/" />
    );
  }
}

export function Topic({ topicName }) {
  return <h2>Hello {topicName}!</h2>;
}

export const Topics = ({ match }) => {
  const items = [
    { name: 'React', slug: 'react' },
    { name: 'Vue', slug: 'vue' },
    { name: 'Angular', slug: 'angular' }
  ];

  return (
    <div>
      <h2>Topics</h2>
      <ul>
        {items.map(({ name, slug }) => (
          <li key={name}>
            <Link to={`${match.url}/${slug}`}>{name}</Link>
          </li>
        ))}
      </ul>
      {items.map(({ name, slug }) => (
        <Route
          key={name}
          path={`${match.path}/${slug}`}
          render={() => <Topic topicName={name} />}
        />
      ))}
      <Route
        exact
        path={match.url}
        render={() => <h3>Please select a topic.</h3>}
      />
    </div>
  );
};

export default function App() {
  return (
    <div>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/about">About</Link>
        </li>
        <li>
          <Link to="/topics">Topics</Link>
        </li>
      </ul>

      <hr />

      <Route exact path="/" component={Home} />
      <Route path="/about" component={About} />
      <Route path="/topics" component={Topics} />
    </div>
  );
}

Route

根据之前的定义, Route 组件是根据 pathurl 的匹配情况来决定是否渲染对应内容, 即如果匹配我们渲染 UI, 不匹配, 我们返回 null. 同时在 v4 中, Route 组件通过接受 component 或者 render 回调函数来渲染需要的 UI. 两者的区别仅在于若需要传入其他 props 时用 render 回调函数比较好, 否则直接传入一个组件即可. 用法大致如下:

const Settings = ({ match }) => {
  return (
    // ...
  )
}

// 直接传入一个组件, 组件参数包括 match 等路由参数
<Route
  path="/settings"
  exact
  component={Settings}
/>

// 传入一个回调函数, 可自定义渲染内容, 回调函数参数包括 match 等路由参数
<Route
  path="/settings"
  exact
  render={(props) => {
    return <Settings authed={isAuthed} {...props} />;
  }}
/>

最初的实现如下:

class Route extends React.Component {
  constructor(props) {
    super(props)
  }

  render() {
    const {
      path,
      exact,
      component,
      render,
    } = this.props

    const match = matchPath(
      window.location.pathname,
      { path, exact }
    )

    if (match) {
      if (component) {
        return React.createElement(component, { match });
      }

      if (render) {
        return render({ match });
      }
    }

    return null;
  }
}

Route.propTypes = {
  path: PropTypes.string,
  exact: PropTypes.bool,
  component: PropTypes.func,
  render: PropTypes.func,
}

几个点需要注意:

  • path 属性不是必须的, 根据官网定义: Routes without a path always match. 即当不指定 path 时, 默认直接匹配当前浏览器 url, 那么给定的组件一定会被渲染
  • matchPath 是一个外部函数, 下面会实现, 用于检查 Route 组件的 path 属性和当前浏览器的 url 是否匹配以及具体匹配情况

至此已经完成了基本框架, 即对于当前的 url 和给定的 path 是否匹配, 匹配渲染 UI, 否则不做任何事情

前端路由与事件

对于前端路由, 一般有 4 种方式可以改变浏览器地址, 不包括 hash

  • 直接手动输入地址
  • 点击浏览器上的前进后退按钮
  • 点击 <a> 标签进行地址跳转
  • 手动在 JS 代码里触发 history.push(replace)State 函数

对于目前的 Route 组件来说, 是需要感知到 url 的变化来进行比较然后渲染 UI. 第一种情况其实不用考虑, 每次用户输入一遍 url 之后组件都被重新 mount, 所有逻辑都会走一遍意味着匹配包括渲染等都会被执行. 现在考虑第二种情况. 浏览器提供了 onpopstate 监听浏览器前进与后退. 不过需要注意的是, 调用 history.push(replace)State 并不会触发 onpopstate 事件.

修改一下 Route 代码:

class Route extends React.Component {
  constructor(props) {
    super(props)
  }
  componentDidMount() {
    window.addEventListener("popstate", this.handlePop);
  }

  componentWillUnmount() {
    window.removeEventListener("popstate", this.handlePop);
  }

  handlePop = () => {
    this.forceUpdate();
  };

  render() {
    const {
      path,
      exact,
      component,
      render,
    } = this.props

    const match = matchPath(
      window.location.pathname,
      { path, exact }
    )

    if (match) {
      if (component) {
        return React.createElement(component, { match });
      }

      if (render) {
        return render({ match });
      }
    }

    return null;
  }
}

Route.propTypes = {
  path: PropTypes.string,
  exact: PropTypes.bool,
  component: PropTypes.func,
  render: PropTypes.func,
}

这里在 mount 的时候监听 onpopstate 事件, 回调函数作用是让组件重新渲染一次. 在 unmount 的时候移除监听器. 这样就实现了点击浏览器前进后退按钮后, 能够重新根据变化的 url 渲染 UI.

路由的匹配

实现 matchPath 之前, 先考虑 exact 属性. v4 中的匹配可以存在"模糊匹配"和"精确匹配". 声明 exact 属性表示需要精确匹配, 即 path 属性和 window.location.pathname 完全一样. 例如:

path window.location.pathname exact matches?
/one /one/two true no
/one /one/two false yes

注意当 exact 未被声明即是 false 的时候, 即使当前的 url/one/two, path 声明的是 /one, 也算是匹配成功. 因此对于匹配规则, 可以单纯的认为只要从开头包含部分即可.

matchPath 最后返回一个 match 对象, 包含 3 个属性:

  • isExact: 是否是精确匹配
  • path: Route 组件给定的路径
  • url: 和 window.location.pathname 匹配后匹配的部分

以该 demo 为例, 假设当前浏览器的路径为 /topics/react, url 即匹配的部分为:

path window.location.pathname matched url
/ /topics/react /
/about /topics/react null
/topics /topics/react /topics
/topics/vue /topics/react null
/topics/react /topics/react /topics/react

matchPath 的完整实现如下:

const matchPath = (pathname, options) => {
  const { exact = false, path } = options;

  if (!path) {
    // if a Route isn’t given a path, it will automatically be rendered
    return {
      path: null,
      url: pathname,
      isExact: true
    };
  }

  const match = new RegExp(`^${path}`).exec(pathname);

  if (!match) {
    return null;
  }

  const url = match[0];
  const isExact = pathname === url;

  if (exact && !isExact) {
    // There was a match, but it wasn't
    // an exact match as specified by
    // the exact prop.
    return null;
  }

  return {
    path,
    url,
    isExact
  };
};

这里注意两个点:

  • 当未给定 path, 默认为完全匹配, 也就是匹配成功. url 就是当前的浏览器地址
  • 如果给定了 exact 属性, 但是得到的结果并不是完全匹配, 也就是 isExactfalse. 说明还是匹配不成功. 直接返回 nunll

Link

之前提到的改变浏览器 url 可以有 4 种方法. 前两种已经描述用于 Route 组件, Link 组件则适用于后两种方法. 本质上 Link 组件可以看成是 <a> 标签的扩展, 只不过关于路由的跳转不再使用默认浏览器行为, 而是使用 history.push(replace)State 方法

Link 组件使用大致如下:

<Link to="/some-path" replace={false} />

两个属性:

  • to 属性表示将要跳转的路径
  • replace 属性表明当前的跳转是要当做 history 的添加, 也就是可以前进后退. 还是表示当前路由记录被取代. 默认为 false

完整实现如下:

class Link extends React.Component {
  constructor(props) {
    super(props);
  }

  handleClick = e => {
    e.preventDefault();
    const { replace, to } = this.props;

    if (replace) {
      historyReplace(to);
    } else {
      historyPush(to);
    }
  };

  render() {
    const { to, children } = this.props;
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    );
  }
}

Link.propTypes = {
  to: PropTypes.string.isRequired,
  replace: PropTypes.bool
};

这里需要实现两个方法 historyPushhistoryReplace, 是我们根据 history.pushStatehistory.replaceState 自定义的工具函数.

const historyPush = (path) => {
  history.pushState({}, null, path);
};

const historyReplace = (path) => {
  history.replaceState({}, null, path);
};

至此会发现其实存在一个问题. 测试页面上点击 Link 组件虽然更新了浏览器地址, 但是组件却没有对应的进行更新. 这是因为调用 history.push(replace)State 并不会触发 onpopstate 事件. 为了解决这一问题需要手动在触发 history.push(replace)State 时候对 Route 进行渲染. 具体做法如下:

维护一个数组, 该数组存放已经渲染过的 Route 组件. 当 Route 组件 mount 之后就将其注册进数组中, 对应提供注册和取消注册两个函数:

const instances = [];

const register = (comp) => instances.push(comp);
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1);

随后在 Route 组件里, 当 mount 之后就将本身注册进数组

class Route extends React.Component {
  componentDidMount() {
    window.addEventListener("popstate", this.handlePop)
    register(this)
  }

  componentWillUnmount() {
    unregister(this)
    window.removeEventListener("popstate", this.handlePop)
  }

  // ...
}

最后更新 historyPushhistoryReplace 函数, 当触发时手动更新所有注册过的 Route, 让 Route 里的逻辑重新跑一遍也因此能重新渲染更新 UI

const historyPush = (path) => {
  history.pushState({}, null, path);
  instances.forEach((instance) => instance.forceUpdate());
};

const historyReplace = (path) => {
  history.replaceState({}, null, path);
  instances.forEach((instance) => instance.forceUpdate());
};

至此, 当 Link 改变浏览器路径之后, Route 组件能够识别到路径的变化然后进行重新匹配并渲染对应的组件

Redirect

Redirect 组件和 Link 组件很相似, 唯一不同的是 Redirect 不渲染任何 UI, 纯粹用于改变浏览器地址. 而 Link 类似于 <a> 会简单渲染一段文本, 实现如下:

export class Redirect extends React.Component {
  constructor(props) {
    super(props);
  }

  componentDidMount() {
    const { to, push = false } = this.props;

    if (push) {
      historyPush(to);
    } else {
      historyReplace(to);
    }
  }

  render() {
    return null;
  }
}

Redirect.propTypes = {
  to: PropTypes.string.isRequired,
  push: PropTypes.bool
};

Hash

以上均是针对 history 路由, 相比而言 hash 路由基本原理也是类似, 而且更简单的在于 Link 组件不需要过多的处理只需要添加 #, Route 组件能直接通过 onhashchange 识别到路由的变化

前端路由

不管是基于 hash 还是 history 的路由, 均是由前端来控制. 这样的好处是对于单页面应用不再需要刷新. 但是弊端也有. 比如用户进行多次跳转之后一不小心刷新了页面, 那么又会回到最开始的状态, 用户体验较差

参考

简单聊一聊柯里化和 TypeScript 泛型

这篇文章主要参考了Learn Advanced TypeshScript, 加上自己查阅的一些资料和个人理解. 由于后半部分讲的是Ramda里的 placeholder, 个人不太常用 Ramda 就暂时跳过了.

以及, 这篇文章写的比较虚, 很多地方可能没有完全理解, 见谅.

从柯里化讲起

柯里化定义简单来讲, 就是一个接受 n 个参数的函数, 转换成每次只接受一个参数的函数, 最多需要 n 次调用完. 实现如下

function curry(callback) {
  return (...args) => {
    if (args.length < callback.length) {
      return curry(callback.bind(null, ...args))
    } else {
      return callback(...args)
    }
  }
}

两个点:

  • Function.prototype.bind方法在每次绑定参数的时候会减少被绑定的函数(这里即callback)的length, bind几个参数, Function.length相应减少对应的数量. 传入的绑定参数依次以队列形式从左往右添加到callback. 另这里 mdn 我其实没查到相关说明, 去ecma 手册才找到的...

    const foo = (arg1, arg2, arg3) => arg1 + arg2 - arg3
    const bar = foo.bind(null, 1)
    bar.bind(null, ...[2, 3])() // 0
  • 递归调用. 递归调用需要一个终止条件, 这里终止条件为当args.length >= callback.length, 停止接受实参绑定, 调用该回调. 即由于bind, 每次callback都在不断接受需要的参数, 其length属性也不断减少, 并且最终会减少到 0, 那么当最后一次传递参数(比如现在callback.length=1), 传了 2 个(args.length=2), 那么第一个参数会被加入到回调函数中作为实参, 递归终止, 柯里化结束

测试一下:

const test = (a, b, c, ...d) => true

curry(test)(1)(2)(3) // 严格按照定义, 无 rest 参数
curry(test)(1)(2)(3, 4, 5) // 严格按照定义, 怎么任意数量的 rest 参数
curry(test)(1)(2, 3, 4) // 非严格调用, 有 rest 参数
curry(test)(1, 2, 3) //  非严格调用, 无 rest 参数

可以看到, 我们可以遵循严格的柯里化, 一次传入一个参数进行调用, 也可以不遵守, 传入多个参数调用.

这里需要注意一点: 允许...d在最后一次调用的时候传入无限个参数还能调用成功的原因是: Function.length并不把 rest 参数计算在内: This number excludes the rest parameter and only includes parameters before the first one with a default value

添加类型

overload 重载

根据声明函数时的不同一般有两种方法进行函数重载:

  • 普通函数声明
  • const 声明(使用typeinterface声明重载类型)

第一种:

function foo(a: number): number
function foo(a: string): string
function foo(a: any) {
  return a
}

第二种:

// 或者使用 type Foo = {...}
interface Foo {
  (a: number): number
  (a: string): string
}

const foo: Foo = (a: any) => {
  return a
}

重载定义类型

观察上面的curry代码可以发现, 由于存在递归调用, 返回值可能是一个函数(本身), 或者是最后的结果, 因此可以尝试使用重载, 如下:

interface Curry<T extends any[], R> {
  (...args: T): Curry<T, R> 
  (...args: T): R 
}

function curry<T extends any[], R>(callback: Curry<T, R>): Curry<T, R> {
  return (...args: T) => {
    if (args.length < callback.length) {
      return curry(callback.bind(null, ...args))
    } else {
      return callback(...args)
    }
  }
}

很遗憾会出现如下类似错误:

Type '(...args: T) => Curry<T, R> | Curry<any[], unknown>' is not assignable to type 'Curry<T, R>'...

查阅了一下, 发现一个类似的问题: Generic curry function with TypeScript 3.

个人理解是, 这个柯里化函数如果使用上述定义的话, 每次返回的函数的类型都是F<T, R>, 然而实际上随着每次调用传入参数之后其参数类型应该是不同的. ts 目前还不能把剩余泛型参数(Generic rest parameters)分割成更小的元祖类型, 也就是concatenate tuples目前还不完善

解决方法一个是使用heaps of overloads, 也就是硬编码类型, 规定所有可能的情况, 也就是上述问题下的第二个答案做法, 我自己尝试了一下, 大致如下:

interface Curry1<T1, R> {
  (): Curry1<T1, R>
  (t1: T1): R
}

interface Curry2<T1, T2, R> {
  (): Curry2<T1, T2, R>
  (t1: T1): Curry1<T2, R>
  (t1: T1, t2: T2): R
}

interface Curry3<T1, T2, T3, R> {
  (): Curry3<T1, T2, T3, R>
  (t1: T1): Curry2<T2, T3, R>
  (t1: T1, t2: T2): Curry1<T3, R>
  (t1: T1, t2: T2, t3: T3): R
}

然后可以定义到curry函数上:

function curry<T1, R>(fn: (t1: T1) => R): Curry1<T1, R>;
function curry<T1, T2, R>(fn: (t1: T1, t2: T2) => R): Curry2<T1, T2, R>;
function curry<T1, T2, T3, R>(fn: (t1: T1, t2: T2, t3: T3) => R): Curry3<T1, T2, T3, R>;
function curry(callback: any) {
  return (...args: any) => {
    if (args.length < callback.length) {
      return curry(callback.bind(null, ...args))
    } else {
      return callback(...args)
    }
  }
}

当然把上述重载抽成一个类型也是可以的, 如下:

interface Curry {
  <T1, R>(fn: (t1: T1) => R): Curry1<T1, R>;
  <T1, T2, R>(fn: (t1: T1, t2: T2) => R): Curry2<T1, T2, R>;
  <T1, T2, T3, R>(fn: (t1: T1, t2: T2, t3: T3) => R): Curry3<T1, T2, T3, R>;
}

const curry: Curry = (callback: any) => {
  return (...args: any) => {
    if (args.length < callback.length) {
      return curry(callback.bind(null, ...args))
    } else {
      return callback(...args)
    }
  }
}

翻阅了一下lodash里面关于curry里的定义, 思路也是类似的, 唯一区别是里面用了一些占位符(placeholder), 这里不做深究:

curry_lodash

泛型

ts2.8引入了一些新概念, 比如 conditional types(T extends U ? X : Y), infer, 一些工具类型例如(Parameters<T>, ReturnType<T>), 其本质都是为了方便做泛型类型推导

泛型一个很明显的好处是可以在 run time 检查类型, 并且确定类型是一个具体的唯一的值, 比如拿官网的一个泛型例子:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

const x = getProperty({ a: 1, b: 'b' }, 'a') // number

假设不使用泛型:

function getProperty<T, K extends keyof T>(obj: T, key: keyof T) {
  return obj[key];
}

const x = getProperty({ a: 1, b: 'b' }, 'a') // string | number

可以看到推断出的类型是一个 union 类型, 并不精准

需求

假设不考虑具体代码实现, 仅仅想要实现这样一个Curry类型, 满足如下的类型推断:

  • 能够满足一次只传入一个参数, 且有类型提示(推导)
  • 能够满足一次传入多个参数, 且有类型提示(推导)
  • 最后一次调用允许传入剩余参数(rest parameters), 且有类型提示(推导)

具体示例如下

// 仅适用 declare 声明 curry 函数的类型, Curry 是需要最后实现的类型
declare function curry<P extends any[], R>(f: (...args: P) => R): Curry<P, R>
// 需要被柯里化的函数
const toCurry = (a: string, b: number, c: boolean, d: ...args: string[]) => true
const curried = curry(toCurry)

curried('a')(1)(true, 'd', 'dd') // true
curried('a')(1)(2) // error
curried('a', 1)(true, 'd', 'dd') // true
curried('a')(1, true, 'd', 'dd') // true
curried('a')(1, true, 'd', 2) // error

基本概念

三个基本关键词: type, extends, infer

  • type: 自定义类型, 可以想象成一个 函数, 等号左边可以接受输入(也就是泛型), 可以想象成这里泛型充当这个"函数"的参数, 等号右边是输出, 也就是最后会返回的类型, 比如类似这样

    // 无泛型参数, 返回 string 和 number 的联合类型
    type Foo = string | number
    
    // 接受一个泛型参数 T, 返回类型为这个参数泛型本身
    type Self<T> = T
    
    const foo: Self<Foo> = 'foo'
  • extends: 可以看成js中的===, 用于比较. 比如type T0<T> = T extends U ? X : Y 的意思是, 泛型T是否和U类型"相等", 如果相等的话type T0返回X类型, 否则返回Y类型

  • infer: 可以理解成一个变量, 用来代表一些泛型, 后面有例子会讲到

泛型类型和泛型函数(generic type & generic function)

假设有以下两个类型:

type Identical1<T> = (x: T) => T
type Identical2 = <T>(x: T) => T

第一个可以看做就是一个泛型类型(generic type), 接受一个T作为参数. 第二个可以看做是一个普通的泛型类型(generic function), 不接受任何泛型输入, 返回的就是一个泛型函数. 测试一下:

const id1: Identical1<string> = (x) => x
const id2: Identical2 = (x) => x

id1('id') // string
id1(1) // error
id2('id') // string
id2(2) // number

具体想要更详细的参考可以去看这个问题下的回答

元祖类型

直接引用官网的定义:

Tuple types allow you to express an array with a fixed number of elements whose types are known, but need not be the same

type tuple = ['a', number, string[]]

const t1: tuple = ['a', 1, ['b', 'c']]
const t2 = (...args: tuple) => true
t2('a', 42, []) // true

泛型基本类型推导

Params

定义: 以元组形式返回一个函数的参数:

type Params<F extends (...args: any[]) => any> = 
  F extends ((...args: infer A) => any) ? A : never

理解: 接受一个参数F类型, 该类型满足(...args: any[]) => any的函数类型, 判断条件为F是否满足((...args: infer A) => any), 满足返回A类型, 否则返回never类型

伪代码如下:

const Params = (F) => {
  if (F === ((...args: infer A) => any)) {
    return A
  } else {
    return never
  }
}

测试一下:

const fn = (name: string, age: number, single: boolean) => true
Params<typeof fn> // [string, number, boolean]

官方也有一样的实现: Parameters

不过注意, 这里虽然给extends后面的条件加上括号不是必须的, 但是建议加上, 遇到更加复杂的类型推导的时候也更容易看清, 后面会有具体例子说明这一点

Head

定义: 给定一个元祖类型, 返回该元祖里面的第一个类型

type Head<T extends any[]> = 
  T extends [any, ...any[]] ? T[0] : never

测试一下:

Head<[string, number, boolean]> // string

Tail

定义: 给定一个元祖类型, 返回该元祖类型里第一个类型之后的所有类型

type Tail<T extends any[]> = 
  ((...args: T) => any) extends ((arg1: any, ...tail: infer A) => any) ? A : []

测试一下:

Tail<[string, number, boolean]> => [number, boolean]

HasTail

定义: 给定一个元祖类型, 返回这个元祖类型是否能满足 Tail 类型(其类型数量是否 > 1)

type HasTail<T extends any[]> = 
  T extends ([] | [any]) ? false : true

测试一下:

HasTail<[1, 2, string]> // true
HasTail<Tail<Tail<[1, 2, string]>>> // false => [string]

上面三个类型和我们实现一个柯里化类型其实是有一定的关系的:

  • Head: 比较经典的柯里化里面, 一个柯里化过后的函数每一次只接受一个参数, 因此Head类型可以帮助检查每次需要接受的一个参数是哪个
  • Tail: 每次调用一次柯里化的函数, 正常讲一个参数就已经被消耗(consumed)掉了, 因此需要移入下一个参数, Tail类型可以帮助判断下一个所需要的参数类型是哪个
  • HasTail: 回顾上面的柯里化函数实现, 无限递归是需要有一个终止条件的, 也就是所有需要给定的参数都被消耗完毕, HasTail正好用来判断这点

ObjectInferValue

定义: 给定一个对象和键, 返回该键下的值类型

type ObjectInferValue<O, K> = 
  K extends keyof O ? O[K]: never

测试一下:

ObjectInferValue<{ a: 1, b: '22' }, 'a'> // 1

ObjectInferKey

定义: 给定一个对象和值, 根据值得到所对应的键类型

type ObjectInferKey<O, V> = 
  { [K in keyof O]: O[K] extends V ? K : never }[keyof O]

这里的推断思路是这样的: 先算出{}[], 也就是对应的 O[K], 而 O[k] extends V 形成一个条件, 也就对这个结果进行再一步判断, 看是否和 V 相等, 相等返回推导结果是 K

伪代码大致为:

const ObjectInferKey = (O, V) => {
  const K = keyof O
  if (O[K] === V) {
    return K
  }
  return never
}

测试一下:

ObjectInferKey<{ a: 1, b: '22' }, '22'> // 'b'

FunctionInfer

定义: 给定一个函数, 返回该函数的参数和返回值

type FunctionInfer<F> = 
  F extends (...args: infer A) => infer R ? [A, R] : never

测试一下:

FunctionInfer<(a: number, b: string) => true> // [[number, string], true]

PromiseInfer

定义: 返回 promise 的类型

type PromiseInfer<P> = 
  P extends Promise<infer T> ? T : never

测试一下:

const p = new Promise<string>()
PromiseInfer<typeof p> // string

ArrayInfer

定义: 返回一个数组里的类型

type ArrayInfer<T extends any[]> = 
  T extends (infer U)[] ? U : never

测试一下:

ArrayInfer<typeof arr> => string | number
ArrayInfer<(string | number)[]> => string | number

TupleInfer

定义: 给定一个元祖类型, 返回第一个类型以及后面元素的联合类型

type TupleInfer<T> = 
  T extends [infer A, ...(infer B)[]] ? [A, B] : never

测试一下

TupleInfer<[string, number, boolean]> => [string, number | boolean]

测试

可以自定义简单的类型测试函数/类型帮助我们进行测试

Equals

定义: 判断两个类型是否相等, 相等返回true, 否则返回false

type Equals<X, Y> = 
  (<T>() => T extends X ? 1 : 2) extends 
  (<T>() => T extends Y ? 1 : 2) ? true : false;

使用方法如下:

Equals<[true, false], [true, false]> // true
Equals<[true, false], [true, 1]> // false

assertType / assertNotType

两个函数分别用于做类型检测

const assertType = <T extends true>() => {}
const assertNotType = <T extends false>() => {}

举个例子, 需要测试之前实现的Head类型:

type test = Head<[string, number, boolean]>

assertType<Equals<test, string>>()

编译器不报错就说明没类型是正确的

柯里化类型 1

至此根据上面定义的一些工具类型已经可以写出比较基本的柯里化类型了

CurryV0

实现如下:

type CurryV0<P extends any[], R> =
  (arg: Head<P>) => HasTail<P> extends true ? CurryV0<Tail<P>, R> : R

解析: 这里接受两个参数, P(所需要接收的所有参数)和R(返回结果), 判断条件为HasTail<P>是否和true相等, 相等返回输出(arg: Head<P>) => CurryV0<Tail<P>, R>这样的一个函数类型, 否则返回(arg: Head<P>) => R的函数类型(也就是最后一次接受参数的形态)

伪代码解析一下:

const CurryV0 = (P, R) => {
  if (HasTail<P> === true) {
    return (arg: Head<P>) => CurryV0<Tail<P>, R>
  }
  return (arg: Head<P>) => R
}

分析一下实现思路: 根据传统的柯里化定义, 每次只接受一个参数, 直到最后所有参数接收完毕返回结果. 那么Head<P>即返回每次所需要的那个参数, 每次调用(传入参数)之后, Tail<P>做切片处理, 帮助去除之前已经传入的参数, 返回还未被传入的参数列表.HasTail帮助判断是否还有参数等待被传入

测试一下:

这里定义一下两种需要被柯里化的函数, 下面所有的测试均使用这两个函数:

// 无 ...rest 参数的
const curryCb1 = (a: string, b: number, c: boolean) => true
// 有 ...rest 参数的
const curryCb2 = (a: string, b: number, ...c: string[]) => true

定义curry函数, 这里使用declare仅指定类型, 不做具体实现, 该类型会在 run-time 被删除

declare function curryV0<P extends any[], R>(f: (...args: P) => R): CurryV0<P, R>
const curriedCb = curryV0(curryCb1)
const curriedCb2 = curryV0(curryCb2)

const r1 = curriedCb('name')
const r2 = curriedCb('name')(1)
const r3 = curriedCb('name')(1)(true)

type test = typeof curriedCb
assertType<Equals<test, (arg: string) => CurryV0<[number, boolean], boolean>>>()
// 传统的只接受一个参数调用的都很完美

可以看到进行传统的一个参数的柯里化调用的时候很完美, 包括完整的类型推断

// 无法进行剩余多参数调用
const r4 = curriedCb2('name')(1)('a', 'b') // error
// 无法进行合并参数调用
const r5 = curriedCb2('name', 1) // error

然而似乎并不接受非柯里化类型的多个参数调用, ...rest也不支持

CurryV1

改进一下, 如下:

type CurryV1<P extends any[], R> = 
  (arg: Head<P>, ...rest: Tail<Partial<P>>) => HasTail<P> extends true 
    ? CurryV1<Tail<P>, R> : R

这样一看, 似乎确实是支持非柯里化的多参数调用了, 测试一下:

// 定义一下柯里函数
declare function curryV1<P extends any[], R>(f: (...args: P) => R): CurryV1<P, R>

const curriedCb = curryV1(curryCb1)
const curriedCb2 = curryV1(curryCb2)

const r1 = curriedCb('name') // ok
const r2 = curriedCb('name', 1, true) // ok
const r3 = curriedCb('name', 1, true)(1, false) // should be error

可以发现, 尽管改进之后允许传入多个参数, 但是对于第三个测试用例在已经传入了numberboolean类型之后本应该返回R的, 似乎返回的类型还是一个函数类型, 且实质和仅传入了一个参数的效果是一样的

仔细看实现发现, 实际上还是使用了HeadTail进行参数校验, 也就是说不管传入了多少参数, 由于我们使用Head强制要求传入了第一个参数, Tail永远只会抹除第一个传入的参数, 返回剩余的参数数组.

Somehow, we are going to need to keep track of the arguments that are consumed at a time

CurryV2

继续改进, 如下:

type CurryV2<P extends any[], R> = 
  <T extends any[]>(...args: T) => HasTail<P> extends true
    ? CurryV2<Tail<T>, R> : R

这里不考虑使用Head了, 直接使用T来跟踪所有被传入的参数, Tail<T>确实是可以根据数量"正确"返回剩余所需要的参数的

但是这里有个问题, 使用了any[]实际上失去了所有的类型检查, 比如下面的测试:

declare function curryV2<P extends any[], R>(f: (...args: P) => R): CurryV2<P, R>

const curriedCb = curryV2(curryCb1)

const r1 = curriedCb(1) // should be error

显而易见, curryCb1要求的第一个参数应该是string类型, 但是传入number类型好像也不报错, 可以看一下具体的类型

type test = typeof curriedCb
type test1 = typeof r1

assertType<Equals<test, <T extends any[]>(...args: T) => CurryV2<Tail<T>, boolean>>>()
assertType<Equals<test1, <T extends any[]>(...args: T) => boolean>>()

由于any[]的存在, 失去了类型检测. 因此目前来看还需要做更多

递归类型与其他泛型推导

根据之前的实践发现, 现在需要一些工具类型来帮助追踪已经传入的参数, 也就是说, 没错调用之后需要明确知道有哪些参数已经被传入进去(消耗掉的), 还有哪些参数等待被传入. 接下来的工具函数都是为了帮助做到这点

Last

定义: 返回元祖类型的最后一个元素

type Last<T extends any[]> = { 0: Last<Tail<T>>, 1: Head<T> }[HasTail<T> extends true ? 0 : 1]

简单讲一讲推断思路: 最后返回的是 {}[], 也就是该对象的结果, 其中该对象的 key 需要判断, 判断条件是 T 中元素是否> 2, 是继续递归调用, 同时 T 使用 Tail<T> 进行数量缩减, 否则返回最后的元素

伪代码为:

const Last = (T) => {
  const o = {
    0: Last<Tail<T>>,
    1: Head<T>
  }
  const k = HasTail<T> === true ? 0 : 1
  return o[k]
}

测试一下:

Last<[1, 2, 3, 4]> // 4

Length

定义: 返回一个元祖类型的长度

type Length<T extends any[]> = T['length']

测试一下:

Length<[]> // 0
Length<any, any> // 2

Prepend

定义: 给定一个类型和一个元祖类型, 在该元祖类型的头部插入这个类型, 输出该新元祖类型

type Prepend<E, T extends any[]> = 
  ((arg: E, ...args: T) => any) extends ((...args: infer U) => any) ? U : T

测试一下

Prepend<string, []> // [string]
Prepend<number, [1, 2]> // [number, 1, 2]

Drop

定义: 给定一个数字 N, 一个元祖类型, 返回从索引从数字之后(包括)的所有类型

type Drop<N extends number, T extends any[], I extends any[]=[]> = {
  0: Drop<N, Tail<T>, Prepend<any, I>>,
  1: T
}[Length<I> extends N ? 1 : 0]

简单讲一讲推导思路: 给定一个 I 为空元祖, 每次 Drop 一次就往里面添加一个元素, 不断比较 NI 的长度, 如果 I 的长度 < N 继续递归调用, 否则返回最终结果

测试一下:

Drop<2, [0, 1, 2, 3]> // [2, 3]

Cast

定义: 检查 X 是否是 Y 类型, 如果满足保持 X 类型, 否则返回 Y 类型

type Cast<X, Y> = X extends Y ? X : Y

测试一下:

Cast<string, any> // string
Cast<string, string | number> // string
Cast<string, number> // number

柯里化类型 2

讲一讲如何追踪已经传入的参数的思路, 假设有如下两个类型parametersconsumed, 分别代表总的(一开始)需要的参数元祖类型和已经传入(consumed)的参数元祖类型:

type parameters = [string, number, boolean, string[]]
type consumed = [string, number]

配合之前的工具类型可以轻松得到剩余需要的类型列表

type toConsume = Drop<Length<consumed>, parameters> // [boolean, string[]]

CurryV3

根据上面的思路可以实现第三版:

type CurryV3<P extends any[], R> =
  <T extends any[]>(...args: T) =>
    Length<Drop<Length<T>, P>> extends 0
      ? R : CurryV3<Drop<Length<T>, P>, R>

简单讲一下推导思路: 核心在于Drop<Length<T>, P>, 这个类型代表的就是剩余需要被传入的参数, 利用Length进行判断是否还存在参数等待被传入

然而实现的时候会存在错误:

Type instantiation is excessively deep and possibly infinite.

这时需要使用之前提到的Cast帮助修复

CurryV4

type CurryV4<P extends any[], R> =
  <T extends any[]>(...args: Cast<T, Partial<P>>) =>
    Length<Drop<Length<T>, P> extends infer DT ? Cast<DT, any[]> : never> extends 0
      ? R
      : CurryV4<Drop<Length<T>, P> extends infer DT ? Cast<DT, any[]> : never, R>      

测试一下:

declare function curryV4<P extends any[], R>(f: (...args: P) => R): CurryV4<P, R>

const curriedCb = curryV4(curryCb1)

const r1 = curriedCb('name') // ok
const r2 = curriedCb('name', 1, true) // ok

似乎对于严格的单个参数柯里化调用和非严格的函数调用都没有问题. 然而, 假如尝试加入...rest参数

const curriedCb2 = curryV4(curryCb2)
const r3 = curriedCb2('name', 1)('f')('a', 'aaa') // error

出现了两个问题:

  • curriedCb2('name', 1)('f')('a', 'aaa')这样的调用方式其实是错误的, 正确方式应该是curriedCb2('name', 1, 'f', 'a', 'aaa')也就是...rest参数应该是随着最后一个参数一起传入完毕, 不应该返回出一个新的函数类型来接受...rest参数
  • 永远也拿不到最后的boolean结果了

原因其实是这样, Length类型是无法推导出...rest的参数个数的, 毕竟他无法确切知道你需要传入的参数到底是有几个

type restargs = [string, number, boolean, ...string[]]
type restargsLength = Length<restargs> // number

推导结果是number....其实是一个挺不错的结果了

CurryV5

type CurryV5<P extends any[], R> =
  <T extends any[]>(...args: Cast<T, Partial<P>>) =>
    Drop<Length<T>, P> extends [any, ...any[]]
      ? CurryV5<Drop<Length<T>, P> extends infer DT ? Cast<DT, any[]> : never, R>
      : R

这里使用[any, ...any[]]来进行判断是否是最后一次非...rest参数的传入, 同时配合Cast保证返回正确类型

测试一下:

const curriedCb = curryV5(curryCb1)
const curriedCb2 = curryV5(curryCb2)

// 单个参数调用
const r1 = curriedCb('name')
// 多个参数调用
const r2 = curriedCb('name', 1, true)
// ...rest 参数调用
const r3 = curriedCb2('name', 1, 'aaa')

算是基本实现了功能

占位符(Placeholders)

个人不太熟悉Ramda, 不做深究, 不过lodash源码里面也提到了一些, 这里仅做一个简单了解

下面几种调用是等价的:

f(1, 2, 3)
f(_, 2, 3)(1)
f(_, _, 3)(1)(2)
f(_, 2, _)(1, 3)
f(_, 2)(1)(3)
f(_, 2)(1, 3)
f(_, 2)(_, 3)(1)

这里的_就是作为一个占位符, 即 placeholder, 或叫作 gap

这块有兴趣还是建议去看作者原文

关于原文和源码

作者最后提到, 这篇文章其实是ts-toolbelt这个 repo 的入门教程, 这个 repo 涵盖了很多高级实用的 typescript 类型定义, 欢迎 star

以及, 作者将源码也放到了github上, 增加了一些新的内容, 比如pipe等新的类型定义, 可以自己 clone 下来试一试

再次感谢作者@pirix-gh

参考

redux 中 dispatch 一个 action 的几种写法

以下代码均以计数器为例.

方法一

connect方法不加第二个参数, 默认会将dispatch传入至组件的props

// drecement 是一个 action creator
const decrement = (id) => {
  return {
    type: "DECREMENT",
    id
  }
}

const App = props => {
  const { counter, dispatch } = props

  return (
    <div>
      <span></span>
      <button onClick={() => dispatch({ type: "INCREMENT" })}></button>
      <button onClick={() => dispatch(decrement(1))}></button>
    </div>
  )
}

const mapStateToProps = state => {
  return {
    counter: state.counter
  }
}

export default connect(mapStateToProps)(App)

方法二

使用mapDispatchToProps, 此时dispatch方法不会再被传入到组件的props中, 取而代之的传入的是一个对象(类似mapStateToProps), 对象里面包含所有可以 dispatch一个action的函数

// drecement 是一个 action creator
const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}

const App = props => {
  const { counter, increment, decrement } = props

  return (
    <div>
      <span></span>
      <button onClick={() => increment()}></button>
      <button onClick={() => decrement(1)}></button>
    </div>
  )
}

const mapStateToProps = state => {
  return {
    counter: state.counter
  }
}

const mapDispatchToProps = dispatch => {
  return {
    increment: () => dispatch({ type: "INCREMENT" }),
    decrement: (id) => dispatch(decrement(id))
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(App)

方法三

使用bindAcionCreator, 相比方法二, 无需编写一系列函数.

注意, 需要将action写成action creator形式, 即函数形式

import { bindAcionCreator } from 'redux'

const increment = () => {
  return {
    type: "INCREMENT",
  }
}

// drecement 是一个 action creator
const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}

const App = props => {
  const { counter, increment, decrement } = props

  return (
    <div>
      <span></span>
      <button onClick={() => increment()}></button>
      <button onClick={() => decrement(1)}></button>
    </div>
  )
}

const mapStateToProps = state => {
  return {
    counter: state.counter
  }
}

const mapDispatchToProps = dispatch => {
  return bindActionCreator({ increment, decrement }, dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(App)

方法四(推荐)

mapDispatchToProps定义为一个对象

const increment = () => {
  return {
    type: "INCREMENT",
    id
  }
}

// drecement 是一个 action creator
const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}

const App = props => {
  const { counter, increment, decrement } = props

  return (
    <div>
      <span></span>
      <button onClick={() => increment()}></button>
      <button onClick={() => decrement(1)}></button>
    </div>
  )
}

const mapStateToProps = state => {
  return {
    counter: state.counter
  }
}

export default connect(mapStateToProps, { increment, decrement })(App)

方法五(推荐)

方法四存在的问题是, 如果有很多action creator, 那么写起来不会很方便, 此时结合方法三里的bindAcionCreator可以一次性导入

稍微提一下bindAcionCreator, 接受两个参数:

  1. 一个函数(一个 action creator), 或者一个对象, 每个元素对应一个action creator
  2. dispatch
// actions.js

export const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}

// drecement 是一个 action creator
export const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}
import { bindAcionCreator } from 'redux'
import * as types from './actions'

const App = props => {
  const { counter, increment, decrement } = props

  return (
    <div>
      <span></span>
      <button onClick={() => increment()}></button>
      <button onClick={() => decrement(1)}></button>
    </div>
  )
}

const mapStateToProps = state => {
  return {
    counter: state.counter
  }
}

const mapDispatchToProps = dispatch => {
  return bindActionCreator(types, dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(App)

方法六

在方法五的基础上, 使用装饰器@connect, 此方法需要开启babel

// actions.js

export const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}

// drecement 是一个 action creator
export const decrement = id => {
  return {
    type: "DECREMENT",
    id
  }
}
import { bindAcionCreator } from 'redux'
import * as types from './actions'

const mapStateToProps = state => {
  return {
    counter: state.counter
  }
}

const mapDispatchToProps = dispatch => {
  return bindAcionCreator(types, dispatch)
}

@connect(mapStateToProps, mapDispatchToProps)
class App extends Component {
  render() {
    const { counter, increment, decrement } = this.props

    return (
      <div>
        <span></span>
        <button onClick={() => increment()}></button>
        <button onClick={() => decrement(1)}></button>
      </div>
    )
  }
}

总结

自己常用的主要是 4 和 5. 当然肯定是存在别的更加优雅的写法, 也欢迎在评论里和我交流

参考

简单聊一聊 React 和 VSCode Webview (一)

实习快走之前一直在写一个插件, 当时为了赶进度, Webview 部分用的是 vue + Ant Design, 通过 cdn 的方式直接引入. 写的我痛不欲生, 后打算用 React 重写, 于是就有了这篇文章记录, 也算是一个学习和总结吧.

这篇文章更多的是倾向于配置, 我本身对于 Webpack 和 TypeScript 等的配置也不是很熟, 写的时候也基本都是随手谷歌抄过来并没有进行深究. 有错误也请谅解, 另如有更好的方案或有错误也及时指出

代码: https://github.com/hacker0limbo/vscode-webview-react-boilerplate

项目结构

├── app # React 部分
│   ├── App.tsx
│   ├── index.tsx
│   └── tsconfig.json
├── package-lock.json
├── package.json
├── src
│   ├── extension.ts
│   └── view
│       └── ViewLoader.ts
├── test
├── tsconfig.json
└── webpack.config.js

初始化项目

根据官网的 tutorial 初始化项目

npm install -g yo generator-code

yo code

这里需要修改一下目录结构. 默认 test 目录是在 src 下面的, 我个人习惯抽出来和 src 平级. 搜索了一下发现微软很多自己的库 test 文件都是不放在 src 下的, 比如 vscode-postgresql 这个库. 自己给的脚手架却又是另一种方案, 也是很无语...

测试不是本篇文章的重点, 具体信息参考官网的 Testing Extensions 这一章

初始化的项目编译后的代码在 out 目录下呈现的结构是:

out
├── extension.js
└── test

我们希望的目录结构为:

out
├── src
│   ├── extension.js
└── test

因此需要改以下几个文件:

tsconfig.json:

官网关于 rootDir 这章已经说的很清晰了, 如果编译想保留当前目录名, rootDir 需要设置为 "."

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "outDir": "out",
    "lib": [
      "es6"
    ],
    "sourceMap": true,
+   "rootDir": ".",
    "strict": true   /* enable all strict type-checking options */
    /* Additional Checks */
    // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
    // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
    // "noUnusedParameters": true,  /* Report errors on unused parameters. */
  },
  "exclude": [
    "node_modules",
    ".vscode-test",
  ]
}

package.json:

入口文件需要改一下:

{
- "main": "./out/extension.js",
+ "main": "./out/src/extension.js",
}

.vscode/launch.json:

我们希望编译后有对应的 source map 方便调试代码, 在 .vscode 目录下的 launch.json 文件修改一下配置:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "outFiles": [
-       "${workspaceFolder}/out/**/*.js"
+       "${workspaceFolder}/out/src/**/*.js"
      ],
+     "sourceMaps": true,
      "preLaunchTask": "${defaultBuildTask}"
    },
    {
      "name": "Extension Tests",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}",
        "--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
      ],
      "outFiles": [
        "${workspaceFolder}/out/test/**/*.js"
      ],
+     "sourceMaps": true,
      "preLaunchTask": "${defaultBuildTask}"
    }
  ]
}

思路

Webview 可以看成是一个独立的 iframe, 有自己独立的运行环境, 同时也可以和 extension 本身发送和监听消息. 官网的给的文档已经很全了, 这里不做深究

用 React 来写 Webview 其实也很简单, 本质就是给定好一个 html, 利用 Webpack 打包好编译 jsx 等文件到一个 script 中, 然后链接一下即可.

如之前的目录结构所示, app 目录为我们编写 React 代码的部分, 编译后的代码会打包到 out/app 目录下, 在 ViewLoader.ts 里引用这个编译后的文件即可

ViewLoader

原则上来讲, Webview 有一个即可, 这里用单例模式来实现 ViewLoader:

// src/view/ViewLoader.ts

import * as vscode from 'vscode';
import * as path from 'path';

export class ViewLoader {
  public static currentPanel?: vscode.WebviewPanel;

  private panel: vscode.WebviewPanel;
  private context: vscode.ExtensionContext;
  private disposables: vscode.Disposable[];

  constructor(context: vscode.ExtensionContext) {
    this.context = context;
    this.disposables = [];

    this.panel = vscode.window.createWebviewPanel('reactApp', 'React App', vscode.ViewColumn.One, {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app'))],
    });

    // render webview
    this.renderWebview();

    // listen messages from webview
    this.panel.webview.onDidReceiveMessage(
      (message) => {
        console.log('msg', message);
      },
      null,
      this.disposables
    );

    this.panel.onDidDispose(
      () => {
        this.dispose();
      },
      null,
      this.disposables
    );
  }

  private renderWebview() {
    const html = this.render();
    this.panel.webview.html = html;
  }

  static showWebview(context: vscode.ExtensionContext) {
    const cls = this;
    const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined;
    if (cls.currentPanel) {
      cls.currentPanel.reveal(column);
    } else {
      cls.currentPanel = new cls(context).panel;
    }
  }

  static postMessageToWebview(message: any) {
    // post message from extension to webview
    const cls = this;
    cls.currentPanel?.webview.postMessage(message);
  }

  public dispose() {
    ViewLoader.currentPanel = undefined;

    // Clean up our resources
    this.panel.dispose();

    while (this.disposables.length) {
      const x = this.disposables.pop();
      if (x) {
        x.dispose();
      }
    }
  }

  render() {
    const bundleScriptPath = this.panel.webview.asWebviewUri(
      vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app', 'bundle.js'))
    );

    return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>React App</title>
        </head>
    
        <body>
          <div id="root"></div>
          <script src="${bundleScriptPath}"></script>
        </body>
      </html>
    `;
  }
}

对应的在 extension.ts 里面注册好打开 Webview 的命令后, 只需要用静态方法 showWebview() 即可初始化或显示之前被隐藏的 Webview panel.

// src/extension.ts

export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand('webview.open', () => {
    ViewLoader.showWebview(context);
  });

  context.subscriptions.push(disposable);
}

同时规定, 所有关于 React 的文件均放在 app 目录下, 编译后的文件名为 bundle.js 也在 out/app 目录下, 保持一致

安装依赖

npm install react react-dom react-router-dom
npm install --save-dev @types/react @types/react-dom @types/react @types/react-router-dom webpack webpack-cli ts-loader css-loader style-loader npm-run-all

这里提一下, 为了让编译 Webview 的任务和编译插件本身的任务同时进行, 安装了 npm-run-all 这个库.

配置 Webpack

本人对 Webpack 不熟, 这些配置基本都是从官网或者谷歌抄过来的, 如果你有更好的自定义方案求轻喷:

// webpack.config.js

const path = require('path');

module.exports = {
  entry: path.join(__dirname, 'app', 'index.tsx'),
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.css'],
  },
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: '/node_modules/',
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'out', 'app'),
  },
};

这里需要更改一下根目录下的 tsconfig.json 文件, 如下:

{
  "exclude": [
    "node_modules",
    ".vscode-test",
+    "app"
  ]
}

app 下的所有文件均交给 Webpack 来处理, 该 tsconfig.json 文件仅负责对 extension 代码编译

app

app 目录下新建一个 tsconfig.json 文件, 这个文件是用于编译 app 部分的 ts 和 tsx 代码, 注意和根目录下的 tsconfig.json 区别:

// app/tsconfig.json

{
  "compilerOptions": {
    "module": "ESNext",
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "target": "ES5",
    "jsx": "react",
    "sourceMap": true,
    "experimentalDecorators": true,
    "lib": ["dom", "ES2015"],
    "strict": true
  },
  "exclude": ["node_modules"]
}

同样的, 我对 ts 配置也不熟, 这里只是给出从网上拔过来的最基本方案, 求轻喷

React & TSX

于是可以欢快的写 React 和 TSX 了...

// app/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<div>Hello World</div>, document.getElementById('root'));

编译运行

修改一下 package.json 文件里的命令:

"scripts": {
  "compile": "npm-run-all compile:*",
  "compile:extension": "tsc -p ./",
  "compile:view": "webpack --mode development",
  "watch": "npm-run-all -p watch:*",
  "watch:extension": "tsc -watch -p ./",
  "watch:view": "webpack --watch --mode development",
  "pretest": "npm run compile && npm run lint",
  "lint": "eslint src --ext ts",
  "test": "node ./out/test/runTest.js"
},

运行插件后, VSCode 会开两个进程, 一个负责编译 Extension 部分代码, 一个负责编译 Webview React 部分代码, cmd + shift + p 输入 open webview 命令就能看到最后的 web view 效果了. 至此基本的框架完成

后续

该篇文章只是很简单的说明了如何用 React 来写 VSCode 插件中的 Webview. 但真实的场景下会有很多其他的需求, 比如该如何模拟路由, 如何在插件和 Webview 之间传递消息进行沟通, 如何动态传递插件本身的变量

有时间会写篇文章讲讲上面的思路, 没时间就算了直接看源码吧

参考

简单用 React+Redux+TypeScript 实现一个 TodoApp (一)

前言

最近看到了一篇非常好的文章: redux 文档到底说了什么(上), 加之最近项目可能需要用到 Redux + Typescript. 所以仔细拜读了这篇博客, 受益匪浅. 看完后自己尝试又重新根据自己的理解实现了一遍, 部分代码和思路参考了原文, 所以首先还是非常感谢作者的这篇好文.

由于这篇文章比较注重于讲原生 Redux + TypeScript 的写法, 且不涉及到使用 Redux Toolkit 这个库, 如果对这个库使用有兴趣的可以同样可以参考作者的另一篇文章redux 文档到底说了什么(下)

另有些不重要的细节例如 css 等就不做深究了

在写的过程中其实发现还是有部分的写法以及问题文档里已经提到过了, 所以一般我会选择按照文档的套路来, 具体关于 React + Redux + TypeScript 的链接如下:

想跳过文章直接看代码的: 完整代码

最后的效果:
todoapp

配置与实现思路

后端

使用了 mockapi 这个在线工具, 非常方便来模拟增删改查接口并且是免费的. 返回的响应格式如下:

Method Url Code Default Response
GET /todos 200 Array<Todo>
GET /todoss/:id 200 Todo
POST /todos 201 Todo
PUT /todos/:id 200 Todo
DELETE /todos/:id 200 Todo

我自己的 API 端点为: https://5d2d9b4343c343001498d272.mockapi.io/api/v1/todos

前端

用到的库有 React + Redux + Redux Thunk + TypeScript + Antd

目录结构为:

├── package.json
├── public
│   └── index.html
└── src
    ├── README.md
    ├── api
    │   └── index.ts
    ├── components
    │   ├── App.tsx
    │   ├── TodoApp.tsx
    │   └── TodoItem.tsx
    ├── index.tsx
    ├── store
    │   ├── filter
    │   │   ├── actionTypes.ts
    │   │   ├── actions.ts
    │   │   ├── constants.ts
    │   │   ├── reducer.ts
    │   │   └── types.ts
    │   ├── index.ts # store 的入口
    │   ├── loading
    │   │   ├── actionTypes.ts
    │   │   ├── actions.ts
    │   │   ├── constants.ts
    │   │   ├── reducer.ts
    │   │   ├── selectors.ts
    │   │   └── types.ts
    │   └── todo
    │       ├── actionTypes.ts # action 的类型
    │       ├── actions.ts # 所有的 action, 包括标准的 actionCreator 和 thunk 类型的 action
    │       ├── constants.ts # 常量, 主要存放 action type 的类型和值
    │       ├── reducer.ts # todo 的 reducer
    │       ├── selectors.ts # 选择器
    │       └── types.ts # 基本公用的类型
    └── style.css

可以看到其实组件部分没有分的很细, store 也就是 redux 部分分的比较细, 一共是有三个 slice, 每个 slice 都有大致相同的结构, 真实大项目可能不同, 这里只是为了演示.

思路

可以看到要实现的 TodoApp 有如下的操作(action):

  • 初始化拿到所有的 Todo
  • 增加/删除/修改/完成 一个 Todo
  • 底部可以选择展示 所有/完成/未完成 的 Todo

Loading

先从最简单的 loading 这个 slice 开始. 由于这个项目是需要和后端交互的, 交互过程需要时间, 因此可以在发送请求等待响应的过程中显示 loading 组件, 即这个时候的 loading 的状态应该是 true, 在得到响应后, loading 状态为 false, 相关的 loading 组件也不再展示

types

编写一下 loading 对应的状态, 这里的 status 表示当前 loading 的状态, tip 为可选, 在 loadingtrue 的时候显示加载的具体提示信息

// store/loading/types.ts

export type LoadingState = {
  status: boolean;
  tip?: string;
};

actions

actionTypes

action 部分也比较简单, 正常就是 setLoadingunsetLoading 两个状态. 如果是在写 JavaScript 直接写就行了, 但是在写 TypeScript 的时候需要先编写 action 的类型(actionTypes), 由于发出的 action 往往只有一种. 在这个 action 进入 reducer 的时候, reducer 其实是需要知道这个 action 是哪种类型的, 有具体的类型也有助于后面编写 reducer 有更好的类型提示

// store/loading/actionTypes.ts

export type SetLoading = {
  type: 'SET_LOADING';
  payload: string;
};

export type UnsetLoading = {
  type: 'UNSET_LOADING';
};

export type LoadingAction = SetLoading | UnsetLoading;

为了更好的组织代码, 这里将 type 字符串单独放到一个文件里, 导出具体的类型和值.

// store/loading/constants.ts

export const SET_LOADING = "SET_LOADING";
export type SET_LOADING = typeof SET_LOADING;

export const UNSET_LOADING = "UNSET_LOADING";
export type UNSET_LOADING = typeof UNSET_LOADING;

这里有一个很有意思的地方, TypeScript 中其实有些类型既可以表示值也可以表示变量, 具体是有一个术语的(但我忘了这个术语叫啥...). 仔细想想, 如果声明一个类, 这个类既不就是既可以当类型又可以当一个类使用么?

class Person {

}

const p: Person = new Person()

现在 actionTypes.ts 里需要改一下:

// store/loading/constants.ts

import { SET_LOADING, UNSET_LOADING } from "./constants";

export type SetLoading = {
  type: SET_LOADING;
  payload: string;
};

export type UnsetLoading = {
  type: UNSET_LOADING;
};

export type LoadingAction = SetLoading | UnsetLoading;

actionCreators

redux 有导出一个 ActionCreator<A> 的类型, 所以一开始我写出来是这样的:

// store/loading/actions.ts

import { SetLoading, UnsetLoading } from "./actionTypes";
import { SET_LOADING, UNSET_LOADING } from "./constants";
import { ActionCreator } from "redux";

export const setLoading: ActionCreator<SetLoading> = (tip: string) => {
  return {
    type: SET_LOADING,
    payload: tip
  };
};

export const unsetLoading: ActionCreator<UnsetLoading> = () => {
  return {
    type: UNSET_LOADING
  };
};

看上去好像没问题, 实际上是有很大问题的. 问题的根本在于 ActionCreator<A> 这个类型实际上非常不精确, 它是没有办法正确推断返回的 actionkey 类型的, 举个例子, 假设我改成这样写:

// store/loading/actions.ts

export const setLoading: ActionCreator<SetLoading> = (tip: string, a: number) => {
  return {
    type: SET_LOADING,
    payload: tip,
    a: a
  };
};

// ...

编译是通过的, 但是语义上完全是错误的. 这里给一下我当时搜到的相关 issue: [bug: typescript] ActionCreator lose it's arguments type, 这个 issue 到今天为止还是开着的...

我们可以看一下源码(虽然最新的源码和 issue 里引用的不同有更新, 但是最新的 redux 包里的貌似还是使用的旧的, 这里两种定义方式都列出)

// github 源代码 master 分支上最新的, 但在 4.0.5 发布的包里并未包含这种写法
export interface ActionCreator<A, P extends any[] = any[]> {
  (...args: P): A
}

// "旧的", 也为当前最新的 4.0.5 包里的类型定义
export interface ActionCreator<A> { 
  (...args: any[]): A 
} 

解决办法也是有的, 而且非常简单, 官网包括该 issue 下的一个评论 都给出了写法, 简单讲就是定义好 actionCreator 返回的类型, 其余包括参数等让 TypeScript 自行推断, 具体写法如下:

// store/loading/actions.ts

import { SetLoading, UnsetLoading } from "./actionTypes";
import { SET_LOADING, UNSET_LOADING } from "./constants";

export const setLoading = (tip: string): SetLoading => {
  return {
    type: SET_LOADING,
    payload: tip
  };
};

export const unsetLoading = (): UnsetLoading => {
  return {
    type: UNSET_LOADING
  };
};

reducer

这里 redux 有提供一个工具类型 Reducer<S, A>, 我用下来没有什么坑, 不过用官网的写法也是没问题的

// store/loading/reducer.ts

import { Reducer } from "redux";
import { LoadingAction } from "./actionTypes";
import { SET_LOADING, UNSET_LOADING } from "./constants";
import { LoadingState } from "./types";

const intialState: LoadingState = {
  status: false
};

export const loadingReducer: Reducer<Readonly<LoadingState>, LoadingAction> = (
  state = intialState,
  action
) => {
  switch (action.type) {
    case SET_LOADING:
      return {
        status: true,
        tip: action.payload
      };
    case UNSET_LOADING:
      return {
        status: false
      };
    default:
      return state;
  }
};

这里稍微提一下 reducer, reducer 返回的就是当前 store 里的状态, 这个状态怎么涉及其实挺有讲究. 原文章 包括 Redux 官网都提到一个点就是可以对状态进行Normalize. 这个对于性能优化是挺有帮助的. 有兴趣可以了解

另一个点是, reducer 最后的 default: return state 在没有特殊情况下都应该这样写. 我之前就有昏了头写成 default: return initialState, 然后就因为这个 bug 我找了一下午...实际上每一个被 dispatchaction 都会在 reducer 里走一遍, 当所有 type 都匹配不到就会走最后一个 default 分支, 其状态也就不变维持之前的状态

selectors

最后部分是 selectors 部分, 如果用 hooks 的话会需要用到 useSelector() 来从 store 中拿对应的数据, 这部分我个人理解放在组件里写或者抽成一个单独文件其实都可以, 这里就放在一个单独文件里了

// store/loading/selectors.ts

import { RootState } from "../index";

export const selectLoading = (state: RootState) => {
  return state.loading;
};

这里其实有点跳跃了, RootState 其实就是整个 redux store 里的所有状态的合集. 具体看下面的章节的实现.

另外, 关于 selector 或者说衍生数据, redux 官网专门开了一章: Computing Derived Data来写怎么计算获取衍生状态数据, 涉及到性能优化等方面非常多, 这里不做深究.

Store

前面有提到需要定义 RootState 状态, 但之前需要先定义一下总的 reducer, 这里我们用 combineReducer() 来集成所有细分的 reducer. 而 RootState 其实就是 combineReducer() 最后返回的 rootReducer() 的返回值(有兴趣可以去看一下 combineReducer() 的源码, 很有意思, 面试可能会让你手写一个!)

// store/index.ts

const rootReducer = combineReducers({
  todos: todoReducer,
  // filter: filterReducer,
  // loading: loadingReducer
});

export type RootState = ReturnType<typeof rootReducer>;

总结

至此关于 Loadingstore 部分就写完了, 后续还会写两篇文章完成剩余的 TodoApp 部分:

  • todofilter 部分, 涉及到 Redux ThunkTypeScript 的结合使用
  • 组件的编写, React, HooksTypeScript 以及 React Redux 里相关 Hooks 的使用

参考

简单实现一个数组

本文参考了 Creating your own implementation of Array, 加入了自己的补充和修改. 是一个精简版, 如果希望看原文可以点击原链接, 原文还有配套视频

先看一下最后实现的结果:

const friends = List("Jordyn", "Mikenzi");

friends.push("Joshy"); // 3
friends.push("Jake"); // 4

friends.pop(); // Jake

friends.filter(friend => friend.charAt(0) !== "J"); // ['Mikenzi']

friends.map(friend => `Hello ${friend}`); // ["Hello Jordyn", "Hello Mikenzi", "Hello Joshy"]

friends.reduce((result, friend) => result + `${friend}, `, "friends: "); // friends: Jordyn, Mikenzi, Joshy,
console.log(friends);
/*
  {
    0: 'Jordyn',
    1: 'Mikenzi',
    2: 'Joshy',
    length: 3,
    push: fn,
    pop: fn,
    filter: fn,
    map: fn,
    reduce: fn
  }
*/

首先要明确二点:

  • 为了防止命名冲突, 我将构造器命名为 List (列表), 实际上用任何名字都行...
  • 实现的过程中不应该使用任何 Array.prototype 上的任何方法, 但是可以使用基于对象上的方法

思路

首先思考一下, 在 JavaScript 中 Array 到底是啥. 我们可以用 typeof 来测试一下:

const arr = []
typeof arr // "object"

所以数组其实也是一个对象. 他可以看成是一个键为类似数字类型的(numerical), 且有一个 length 属性的对象. 同时这个对象还配有一些方法, 比如 .push(), .pop(), 具体类比如下:

const friendsArray = ['Jake', 'Jordyn', 'Mikenzi']
const friendsObj = { 0: 'Jake', 1: 'Jordyn', 2: 'Mikenzi' }

friendsArray[1] // Jordyn
friendsObj[1] // Jordyn

第一步

实现第一步目标:

  • 创建一个函数, 返回一个拥有 .length 属性的对象
  • 返回的对象其原型链委托在该函数上(可以想象成该函数就是一个类)

我们可以使用 Object.create() 来实现需求:

function List() {
  const array = Object.create(List.prototype)
  array.length = 0

  return array
}

第二步

List() 函数接收任意数量的参数作为 array 的元素, 我们可以用 ...args 来获取并实现, 但是现在假设的情况是没有数组及配套方法...所以用 arguments 以及 for...in 方法遍历传入的所有参数元素

function List() {
  let array = Object.create(List.prototype)
  array.length = 0

  for (key in arguments) {
    array[key] = arguments[key]
    array.length += 1
  }

  return array
}

const friends = List('Jake', 'Mikenzi', 'Jordyn')
friends[0] // Jake
friends[2] // Jordyn
friends.length // 3

第三步

还需要实现对应的方法, 包括 .push(), .pop(), .map(), filter().reduce(), 这些方法分别在 Listprototype 上实现:

List.prototype.push = function() {

}

List.prototype.pop = function() {

}

List.prototype.filter = function() {

}

// ...

.push()

先来实现 .push(), 注意两点:

  • 这里的 this 指向的是实例对象(也就是 List() 之后 return 出来的东西)
  • .push() 最后返回的是当前数组的长度
List.prototype.push = function(element) {
  this[this.length] = element;
  this.length++;
  return this.length;
};

.pop()

.push() 很像

List.prototype.pop = function() {
  this.length--;
  const elementToRemove = this[this.length];
  delete this[this.length];
  return elementToRemove;
};

.filter()

List.prototype.filter = function(callback) {
  const result = List();

  for (const index in this) {
    // 由于 for ...in 会遍历包括原型链上的键, 使用 .hasOwnProperty() 阻止遍历 prototype 上的方法
    if (this.hasOwnProperty(index)) {
      const element = this[index];
      if (callback(element, index, this)) {
        result.push(element);
      }
    }
  }
  return result;
};

实际上到目前为止, 上面的代码都是存在一点问题的, 只不过在实现 .filter() 的时候, 当使用 for...in 会暴露出这个问题

测试一下:

const friends = List('Jake', 'Jordyn', 'Mikenzi')

friends.filter((friend) => friend.charAt(0) !== 'J')

/* Breakdown of Iterations*/

1) friend is "Jake". The callback returns false
2) friend is "Jordyn". The callback returns false
3) friend is "Mikenzi". The callback returns true
4) friend is "length". The callback throws an error

问题在于, 使用 for...in 的时候, 由于 length 也为一个可遍历属性(enumerable), 所以也会被遍历到. (回顾之前的实现, 我们只是简单的设置了 array.length = 0). 因此我们需要加一些限制使得 .length 属性无法被遍历. 这里可以用 Reflect.defineProperty() 方法, 进行元编程修改属性的属性.

function List() {
  const array = Object.create(List.prototype);

  // 阻止遍历 length 属性
  Reflect.defineProperty(array, "length", {
    value: 0,
    enumerable: false,
    writable: true
  });

  for (const key in arguments) {
    array[key] = arguments[key];
    array.length += 1;
  }

  return array;
}

.map() 和 .reduce()

后续还有 .map().reduce(), 实现思路都是类似的. 这里我简单写了一下自己的实现方法, 仅供参考. 完整代码如下:

function List() {
  const array = Object.create(List.prototype);

  // 阻止遍历 length 属性
  Reflect.defineProperty(array, "length", {
    value: 0,
    enumerable: false,
    writable: true
  });

  for (const key in arguments) {
    array[key] = arguments[key];
    array.length += 1;
  }

  return array;
}

List.prototype.push = function(element) {
  this[this.length] = element;
  this.length++;
  return this.length;
};

List.prototype.pop = function() {
  this.length--;
  const elementToRemove = this[this.length];
  delete this[this.length];
  return elementToRemove;
};

List.prototype.filter = function(callback) {
  const result = List();

  for (const index in this) {
    // 阻止遍历 prototype 上的 方法
    if (this.hasOwnProperty(index)) {
      const element = this[index];
      if (callback(element, index, this)) {
        result.push(element);
      }
    }
  }
  return result;
};

List.prototype.map = function(callback) {
  const result = List();

  for (const index in this) {
    if (this.hasOwnProperty(index)) {
      const element = this[index];
      result.push(callback(element, index, this));
    }
  }
  return result;
};

List.prototype.reduce = function(callback, initialValue) {
  let startPoint = typeof initialValue !== "undefined" ? 0 : 1;
  let result = typeof initialValue !== "undefined" ? initialValue : this[0];
  for (const i in this) {
    const index = parseInt(i) + startPoint;
    if (this.hasOwnProperty(index) && index < this.length) {
      const element = this[index];
      result = callback(result, element, index, this);
    }
  }

  return result;
};

参考

简单实现 useReducer 与 middleware 以及 compose

这篇文章主要讲两部分内容, 两个内容完全无关, 第一个是如何在 useReducer 中增加简单的 middleware 机制, 第二个是如何实现 compose 函数

useReducer 与 middleware

完整代码: https://stackblitz.com/edit/react-gjsy9j

实现的效果如下:
demo 9 55 35 pm

useReducer 本身是不支持 middleware 的, 不过可以实现一个自定义 hook, 在 reducer 计算的过程中(状态发生变更之前和之后), 增加可插入式的 middleware.

还是以最基本的 todolist 为例子, 首先定义基本状态:

const initialTodos = [
  {
    id: 'a',
    task: 'Learn React',
    complete: false,
  },
  {
    id: 'b',
    task: 'Learn Firebase',
    complete: false,
  },
];

定义对应的 reducer:

const todoReducer = (state, action) => {
  switch (action.type) {
    case 'DO_TODO':
      return state.map((todo) => {
        return {
          ...todo,
          complete: todo.id === action.id ? true : todo.complete,
        };
      });
    case 'UNDO_TODO':
      return state.map((todo) => {
        return {
          ...todo,
          complete: todo.id === action.id ? false : todo.complete,
        };
      });
    default:
      return state;
  }
};

App 定义如下:

const App = () => {
  const [todos, dispatch] = React.useReducer(todoReducer, initialTodos);

  const handleChange = (todo) => {
    dispatch({
      type: todo.complete ? 'UNDO_TODO' : 'DO_TODO',
      id: todo.id,
    });
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <label>
            <input type="checkbox" checked={todo.complete} onChange={() => handleChange(todo)} />
            {todo.task}
          </label>
        </li>
      ))}
    </ul>
  );
};

使用的 middleware 也定义一下, 这里就简单定一个一个 logger 函数, 用于打印即将(之前之后)会触发的 action, 以及当前所对应的状态:

const loggerBefore = (action, state) => {
  console.log('logger before dispatch action:', { action, state });
};

const loggerAfter = (action, state) => {
  console.log('logger after dispatch action:', { action, state });
};

由于 useReducer 并不支持第三个 middleware 参数, 因此需要自己实现一个 useReducerWithMiddleware 的 custom hook, 需要注意的有以下几点:

  • 有两种 middleware, 一种发生在 dispatch 一个 action 之前, 一个发生在之后
  • middleware 可以有多个

先考虑最基础的在 dispatch 一个 action 之前的 middleware

const useReducerWithMiddleware = (reducer, initialState, precedingMiddleware) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const dispatchWithMiddleware = (action) => {
    precedingMiddleware.forEach((pm) => pm(action, state));

    dispatch(action);
  };

  return [state, dispatchWithMiddleware];
};

本质的实现其实就是用一个函数多包装一层, 接受相同的 action 参数, 里面在调用 dispatch(action) 时, 先调用一遍 middleware

而基于上面定义的 logger 函数效果其实类似在定义的 reducer 之前增加一个 console.log 语句:

// 类似如下效果
const todoReducer = (state, action) => {
  console.log(state, action);
  switch (
    action.type
    // ...
  ) {
  }
};

具体使用该 hook 的时候如下:

const App = () => {
  const [todos, dispatch] = useReducerWithMiddleware(todoReducer, initialTodos, [logger, logger]);
  // ...
};

接下来考虑 dispatch 一个 action 之后的 middleware

假设做如下实现:

const useReducerWithMiddleware = (
  reducer,
  initialState,
  precedingMiddleware,
  succeedingMiddleware
) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const dispatchWithMiddleware = (action) => {
    precedingMiddleware.forEach((pm) => pm(action, state));

    dispatch(action);

    succeedingMiddleware.forEach((sm) => sm(action, state));
  };

  return [state, dispatchWithMiddleware];
};

很可惜该实现存在问题, 由于更新是异步的, 这种情况下拿到的 state 仍旧是之前的, 因此需要使用 useEffect 做状态变更的更新监听:

const useReducerWithMiddleware = (
  reducer,
  initialState,
  precedingMiddleware,
  succeedingMiddleware
) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const dispatchWithMiddleware = (action) => {
    precedingMiddleware.forEach((pm) => pm(action, state));

    dispatch(action);
  };

  React.useEffect(() => {
    succeedingMiddleware.forEach((sm) => sm(state));
  }, [succeedingMiddleware, state]);

  return [state, dispatchWithMiddleware];
};

仍旧存在一个问题在于, 无法获取到 action, 解决办法也很简单, 通过 ref 或者临时变量在 dispatch 对应的 action 的时候进行赋值, 即可拿到对应的 action, 代码如下:

const useReducerWithMiddleware = (
  reducer,
  initialState,
  precedingMiddleware,
  succeedingMiddleware
) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const actionRef = React.useRef();

  const dispatchWithMiddleware = (action) => {
    precedingMiddleware.forEach((pm) => pm(action, state));

    actionRef.current = action;

    dispatch(action);
  };

  React.useEffect(() => {
    if (!actionRef.current) return;

    succeedingMiddleware.forEach((sm) => sm(actionRef.current, state));

    actionRef.current = null;
  }, [succeedingMiddleware, state]);

  return [state, dispatchWithMiddleware];
};

需要注意的是需要对 actionRef 做判断, 以及最后要将 actionRef 清空, 因为严格意义上来讲可能存在其他 action 也能触发状态的更新, 而我们需要的是仅针对一个 action 做一组之前/之后的 middleware 的调用.

compose

redux 里有一个 compose 函数, 用来将多个函数组合调用, 比如我要调用 compose(f2, f2, f1)(10) 其实就等同于 f3(f2(f1(10))), 注意最先计算的是从最右边开始的, 举个例子:

const f1 = (x) => x + 1;
const f2 = (x) => x * 10;
const f3 = (x) => x - 1;

compose(f3, f2, f1)(10); // (10 + 1) * 10 - 1 = 109

redux 官网对这个实现也是非常精致, 去掉 ts 类型如下:

function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce(
    (a, b) =>
      (...args) =>
        a(b(...args))
  );
}

通过 reduce 每次提取两个函数, 返回 (...args) => a(b(...args)) 这样包装后的函数体

那如果不用 reduce, 使用普通的 for 循环实现呢? 其实本质是一样的, 我当时实现的版本如下:

function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  let result;
  for (let i = funcs.length - 1; i > -1; i--) {
    result = result ? (...args) => funcs[i](result(...args)) : (...args) => funcs[i](...args);
  }

  return result;
}

区别仅在于需要做一次判断, 因为 reduce 一次拿两个函数, 普通的 for 循环一次只拿一个

然而测试的时候发现报错:

RangeError: Maximum call stack size

显示错误原因貌似出现了无限递归, 当时无法理解后去 stackoverflow 提问了一下, 其实造成错误的缘由很简单, 先看下面的简单的例子:

let a = () => 2;
a = () => 3 * a();

console.log(a); // () => 3 * a()
console.log(a()); // 报错, 无限递归

原因在于 a = () => 2 这个函数从来就没被调用过, 他只是被声明了一次, 而后面这个引用又重新被篡改了, 过程如下:

  • 先声明一次 a = () => 2 这个函数
  • 重新声明 a, 此时 a 就变成了 () => 3 * a(), 而 a() 从没被执行过
  • 最后调用 a() 造成了无限递归

函数在被声明和调用的过程中内部变量等可能是不一样的, 要做好区分

回到之前的实现, 其实也是类似的原因, result 在每次循环中引用都被不断被改变, result 本身并没有在我想象的那样被"执行"拿到结果, 他仍旧是一个声明的函数体, 这就导致了最后出现自己调用自己造成无限递归

改起来也简单, 用个变量接一下就好了:

function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  let result;
  for (let i = funcs.length - 1; i > -1; i--) {
    const r = result;
    result = r ? (...args) => funcs[i](r(...args)) : (...args) => funcs[i](...args);
  }

  return result;
}

这里的 r 每次都保留了当前循环环境下的 result 引用, 保证引用不再被篡改. 有点类似 stale closure, 不过这次有点反过来...

当然了, 写法可以有很多种, 比如后面有回答里给了这种写法:

function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  return function (...args) {
    let result = funcs.at(-1)(...args);
    for (let i = funcs.length - 2; i > -1; i--) {
      result = funcs[i](result);
    }
    return result;
  };
}

这里就不再细究了

参考

简单聊一聊如何处理 React props 是 null 的情况

水文一篇...

场景

两个组件: AppHello

// Hello.js

import React from "react";

export default function Hello(props) {
  const { name = "mike" } = props;
  return <div>Hello {name}</div>;
}
// App.js

import React from "react";
import Hello from "./Hello";

export default function App() {
  return (
    <div>
      <Hello name={null} />
    </div>
  );
}

// renders Hello

我当时天真的以为这里会渲染 Hello mike. 实际上最后渲染的是 Hello...

另这里用 es6 的对象解构默认参数还是用 react 提供的 defaultProps 效果都是一样的

原因

去查了一下 MDN 的 Default parameters 以及 Destructuring assignment
, 果然是我疏忽了:

Default function parameters allow named parameters to be initialized with default values if no value or undefined is passed.

A variable can be assigned a default, in the case that the value unpacked from the object is undefined.

MDN 下的例子非常生动形象的解释了:

In the second call in this example, even if the first argument is set explicitly to undefined (though not null or other falsy values), the value of the num argument is still the default.

function test(num = 1) {
  console.log(typeof num)
}

test()           // 'number' (num is set to 1)
test(undefined)  // 'number' (num is set to 1 too)

// test with other falsy values:
test('')         // 'string' (num is set to '')
test(null)       // 'object' (num is set to null)

以及, React 官方有一个 issue 专门对于这个现象的讨论: null props considered differently in getDefaultProps vs. isRequired #2166

总结来讲就是(具体细节不细纠结了...网上应该很多文章大讲特讲这两个的区别):

  • null 是一个定义过的的值, 这个值是存在的, 虽然这个值代表了啥都没有
  • undefined 这个值根本不存在

所以上面的组件想要触发默认 props 需要这么做:

<Hello name={undefined} />
<Hello />

// renders Hello mike

解决办法?

其实我没找到最佳实践, 因为有好多种写法可以规避 null 的出现. 但是我在 stackoverflow 上搜到了一个类似的问题: React.js - default prop is not used with null is passed, 最高票答案是这样的(56 个赞), 以我的例子举例:

You can change the null value to undefined to use the default value.

<Hello name={getName() || undefined} />

好的...

简单写一个打字测速 app

关于 Online IDE

每次想做有关 React + TS 的小项目或者 demo, 都需要用 npx create-react-app --template typescript 开一个项目到本地, 既耗费时间又占用资源. 能直接写 React + TS 的 Online IDE 目前只找到 StackBlitzCodeSandbox. 前者关于 ts 的类型提示还是很有问题, 但是速度倒是挺快的. 而且最近新出了一个 feature 能直接运行 Node.js 程序. 后者我电脑带不动...Hot Reload 啥的延迟很高, 经常写着写着就报错, 过一会又自己好了.

目前没有别的好办法, 要想比较好的测试开发体验还是只能老老实实本地开个脚手架然后用 vscode. 有考虑用 code-server 啥的部署一个, 但是又要花钱买服务器啥的就算了...

在不换电脑的前提下有比较靠谱的 Online IDE 可以推荐一下

效果

demo

源码: https://stackblitz.com/edit/typing-speed-app

需求与分析

结合 Demo 可以看到, 当开始打字的时候上面的示例文字会实时显示所打的每个字母出否正确, 下面有三个数据显示. 第一个为总时间, 可以认为是一个时钟, 当开始打字的时候触发. 直到打字结束即字数和示例文字一样的时候时钟停止. 此时也无法再继续往输入框内输入文字. WPM 即 word per minutes, 每分钟多少个字. 这里的 word 定位为 1 word = 5 characters. 最后显示的是正确的字母数, 外加一个按钮可以重新开始.

需求明确了可以思考需要哪些基本状态, 以及对应的衍生状态, 这里直接列出来了, 一共可以需要 4 个基本状态:

const initialState = {
  text: '',
  input: '',
  seconds: 0,
  timerId: undefined,
}

解释一下, 由于上述的需求, 我们需要一个 text 规定示例文字, 其实这个不作为状态也可, 因为示例文字原则上是不会变的, 这里为了方便就归在状态里了, input 代表用户输入的文字, 是实时改变的. seconds 是定时器状态, 当计时器开始的时候每一秒会自动增加 1. timerId 是定时器 id, 因为我们虚监控定时器. 比如当用户开始打字的时候我们设置一个定时器. 此时 timerId 是存在的. 当打字结束或者用户点击了 reset 之后 timerId 需要被重设为 undefined

衍生状态就有很多, 比如 correctCharacters 就可以由 textinput 得出. WPM 又可以由 correctCharactersseconds 得出. 规定好了基本状态, 衍生状态都可以直接按需计算得出, 而无需放在初始状态里.

实现

关于状态管理部分打算使用 useReducer + useContext, 会和 redux 有点像. 不过类型部分应该不会写的非常严谨.

types

该文件存放所有类型定义, 主要有 action, state, reducer. 如下

// ./store/types.ts

export type TypingState = {
  text: string;
  input: string;
  seconds: number;
  timerId?: number;
};

export enum TypingActionTypes {
  CHANGE_INPUT,
  SET_TIMER,
  TICK,
  RESET_TICK
}

export type TypingAction<T> = {
  type: TypingActionTypes;
  payload?: T;
};

export type TypingReducer = (
  state: TypingState,
  action: TypingAction<any>
) => TypingState;

关于 actionpayload 类型这里简略的就用 any 替代了, 严格上所有定义的 action 都应该有关于其 payload 的精确的类型, 然后通过 union 合并成一个总的类型, 例如这样:

type TypingInputAction = {
  type: 'TYPING_INPUT',
  payload: string
}

type TypingSetTimerAction = {
  type: 'TYPING_SET_TIMER',
  payload?: number
}

// other action types

export type TypingAction = TypingInputAction | TypingSetTimerAction

类型部分会有点像 redux, 更多可以直接参考 redux 源代码是怎么定义相关工具类型的. 或者参考我之前写过的文章: 简单用 React+Redux+TypeScript 实现一个 TodoApp

这里用枚举一共定义了 4 种 action 类型, 具体为:

  • CHANGE_INPUT: 当用户开始输入会不断触发 onChange 事件, 该 action 也会不断被触发, 需要实时获取文本框即用户的输入
  • SET_TIMER: 设置定时器的动作, 当开始输入时设置定时器的 id, 结束时设回 undefined
  • TICK: 时钟动作, 初始为 0, 定时器开始后每一秒触发一次, 每次加一, 代表定时器的时间
  • RESET_TICK: 重设时钟, 重设为 0

reducer

有了类型和 action, 就可以完善 reducer, 即状态是如何根据 action 变化的:

// ./store/reducers.ts
import { TypingReducer, TypingActionTypes } from './types';

export const typingReducer: TypingReducer = (state, action) => {
  switch (action.type) {
    case TypingActionTypes.CHANGE_INPUT:
      return {
        ...state,
        input: action.payload
      };
    case TypingActionTypes.SET_TIMER:
      return {
        ...state,
        timerId: action.payload
      };
    case TypingActionTypes.TICK:
      return {
        ...state,
        seconds: state.seconds + 1
      };
    case TypingActionTypes.RESET_TICK:
      return {
        ...state,
        seconds: 0
      };
    default:
      return state;
  }
};

注意这里的 reducer 是结合 useReducer 这个 hook 一起使用的, 不像 redux 里可以直接给参数赋值声明初始状态. 即 useReducer(reducer, initialState). reducer 只需要负责状态的改变的逻辑部分即可.

context

关于 context 部分, 需要明确我们需要把什么作为全局数据传入到组件中. 由于是结合 useReducer, 直接将 useReducer 的返回值即 [state, dispatch] 传入即可. 当然类型需要明确一下. 同时自定义一个 Provider 作为容器存放全局数据. 整体架构大致如下:

// ./store/context.tsx

const initialState: TypingState = {
  text: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
  input: '',
  seconds: 0,
  timerId: undefined
};

export const typingContext = createContext<
  [TypingState, Dispatch<TypingAction<any>>]
>([{} as TypingState, () => {}]);

export const TypingProvider: React.FC = ({ children }) => {
  const value = useReducer(typingReducer, initialState);
  return (
    <typingContext.Provider value={value}>{children}</typingContext.Provider>
  );
};

然后在根组件声明 TypingProvider:

// ./components/App.tsx

export function App() {
  return (
    <div>
      <TypingProvider>
        {/* components */}
      </TypingProvider>
    </div>
  )
}

TypingProvider 下的任何组件, 都可以通过 useContext(typingContext) 获得全局数据 [typingState, dispatch], 前者为当前的状态, 后者可用于发送 action 修改状态.

这里深入一点, 业务逻辑比如对应的方法可以放到组件里写, 也可以在选择自定义一个 hook 暴露出需要的方法, 组件只需要用这个 hook 即可.

回顾 demo 需要整个流程大致是这样的:

  • 当文本输入框第一次有输入时候, 设置一个定时器, 即 SET_TIMER action. 同时在定时器, 也就是 setInterval 的回调里面不断触发 TICK action. 保证每秒都记录下时间. 这里注意如何去辨别第一次输入, 正常情况下只能 onChange 事件的监听只存在与输入是否有变化, 判断是否为第一次需要加上两个条件:
    • 当前状态里是否有 timerId
    • 当前用户输入的文字长度是否小于示例文字, 即打字是否完成
  • 定时器开始后用户不断开始打字, 此时 onChange 事件继续不断被监听, 回调函数需要不断触发 CHANGE_INPUT action
  • 当用户打字结束(这里简单定义为用户输入的字数长度和示例文字长度相同), 定时器销毁, 同时状态中的 timerId 设回 undefined
  • reset 按钮需要将所有状态初始化, 包括 timerId, input, seconds

context 部分完整代码如下:

// ./store/context.tsx

import React, { createContext, useReducer, useContext, Dispatch } from 'react';
import { typingReducer } from './reducers';
import { TypingState, TypingAction, TypingActionTypes } from './types';

const initialState: TypingState = {
  text: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
  input: '',
  seconds: 0,
  timerId: undefined
};

export const typingContext = createContext<
  [TypingState, Dispatch<TypingAction<any>>]
>([{} as TypingState, () => {}]);

export const useTypingContext = () => {
  const [state, dispatch] = useContext(typingContext);

  const onInput = (value: string) => {
    if (value.length < state.text.length && !state.timerId) {
      startTimer();
    }

    if (value.length >= state.text.length && state.timerId) {
      stopTimer();
    }

    dispatch({
      type: TypingActionTypes.CHANGE_INPUT,
      payload: value
    });
  };

  const startTimer = () => {
    const timerId = setInterval(
      () => dispatch({ type: TypingActionTypes.TICK }),
      1000
    );
    dispatch({ type: TypingActionTypes.SET_TIMER, payload: timerId });
  };

  const stopTimer = () => {
    clearInterval(state.timerId);
    dispatch({ type: TypingActionTypes.SET_TIMER });
  };

  const onReset = () => {
    stopTimer();
    dispatch({ type: TypingActionTypes.CHANGE_INPUT, payload: '' });
    dispatch({ type: TypingActionTypes.RESET_TICK });
  };

  return { state, onInput, onReset };
};

export const TypingProvider: React.FC = ({ children }) => {
  const value = useReducer(typingReducer, initialState);
  return (
    <typingContext.Provider value={value}>{children}</typingContext.Provider>
  );
};

组件

一共有三个组件

  • Preview 组件用户展示示例文字, 包括用户输入和示例文字的差异也会用颜色在示例文字上标注
  • UserInput 组件渲染文本框, 供用户输入
  • SpeedInfo 组件展示用户打字的各种数据

Preview

textinput 状态均为两个字符串, 不同的是 text 是静态的, 而 input 会随着用户的输入而动态变化. 对于 text 上的每一个字母, 其索引位置如果有对应的 input 的字母, 则进行比较并进行 class 的标注, 否则保持不变. 具体代码如下

// ./components/Preview.tsx

import React from 'react';
import { useTypingContext } from '../store/context';

export const Preview: React.FC = () => {
  const {
    state: { text, input }
  } = useTypingContext();

  return (
    <div>
      {text.split('').map((c, i) => (
        <span
          key={`${c}-${i}`}
          className={i < input.length ? (c === input[i] ? 'green' : 'red') : ''}
        >
          {c}
        </span>
      ))}
    </div>
  );
};

UserInfo

这个组件比较简单, 唯一需要注意的是当用户打字完成后需要将输入框变成 readonly 状态, 判断条件则是之前所说的当 input 的长度和 text 的长度一样, 具体代码如下:

// ./components/UserInfo.tsx

import React from 'react';
import { useTypingContext } from '../store/context';

export const UserInput: React.FC = () => {
  const {
    state: { input, text },
    onInput
  } = useTypingContext();

  return (
    <textarea
      cols="60"
      rows="3"
      readOnly={input.length >= text.length}
      value={input}
      onChange={e => onInput(e.target.value)}
    />
  );
};

SpeedInfo

该组件需要渲染当前用户打字速度的状态. 比如 WPM, 正确的字母数. 关于这些数据的计算方法不多描述, 均写在了 utils.ts

// ./utils.ts

export const words = (c: number) => c / 5;

export const minutes = (s: number) => s / 60;

export const wpm = (c: number, s: number) =>
  words(c) === 0 || minutes(s) === 0 ? 0 : Math.round(words(c) / minutes(s));

export const countCorrectCharacters = (text: string, input: string) => {
  const tc = text.replace(' ', '');
  const ic = input.replace(' ', '');
  return ic.split('').filter((c, i) => c === tc[i]).length;
};

组件也只需按需拿状态和方法即可

// ./components/SpeedInfo.tsx

import React from 'react';
import { useTypingContext } from '../store/context';
import { countCorrectCharacters, wpm } from '../utils';

export const SpeedInfo = () => {
  const {
    state: { input, seconds, text },
    onReset
  } = useTypingContext();

  const correctCharacters = countCorrectCharacters(text, input);

  return (
    <div>
      <div>Total time: {seconds} s</div>
      <div>WPM: {wpm(correctCharacters, seconds)}</div>
      <div>Correct characters: {correctCharacters}</div>
      <button onClick={onReset}>Reset</button>
    </div>
  );
};

至此该 Demo 算是完成了

参考

简单聊一聊闭包

最近根据一些教程尝试实现一个简易的 react-hooks. 里面用到了大量的闭包, 翻阅了一些文章加上一些自己的思考, 整理一下

这篇文章更多的是想谈一谈为什么要用闭包, 以及我们可以用闭包来做些什么. 很多网上的教程只是讲一下什么是闭包(很多甚至都没有讲明白), 而对于闭包的实际应用往往是一笔带过. 知乎上有这么一个问题: Python 所谓的“闭包”是不是本着故意把人搞晕的态度发明出来的?

本人也是初学者, 可能很多地方理解的也不是很准确, 也欢迎和我交流

从函数的生命周期讲起

函数也是可以看做有生命周期的, 如下图:

函数的生命周期

具体可以参考这篇文章, 这里就不多描述了

什么是闭包

网上看到一个比较精简的解释:

一个函数在调用的时候, 内部的自由变量, 要到这个函数被定义的地方去找, 而不是在这个函数当前被调用的地方去找 这个函数连同它被定义时的环境一起, 构成了一个数据结构, 就是闭包

真正想要去了解闭包的整个执行流程以及基本原理, 我推荐看这篇教程. 讲的非常深入浅出, 从词法环境, 讲到作用域链和活动对象, 是非常清晰的.

不过这里还是总结一下, 有这么几点:

  1. 每个函数创建的时候, 就已经会创建一个词法环境. 当在运行的时候, 创建一个新的词法环境, 这两个词法环境很有可能是不同的. 所以分析的时候一定是要看最新的词法环境, 比如如下的例子:

    let phrase = 'Hello'
    
    function say(name) {
      alert(`${phrase}, ${name}`)
    }
    
    say('John') // Hello, John

    f2

    上图是函数say创建时的词法环境

    f1

    上图是函数say()调用时的词法环境

    所以可以看出来, 创建时和运行时的词法环境是截然不同的, 同时由于引用到了全局环境下的phrase变量, 如果在say()调用前修改该变量, 那么调用say()的时候, 其词法环境又会发生变化, 比如将代码改成如下形式:

    let phrase = 'Hello'
    
    function say(name) {
      alert(`${phrase}, ${name}`)
    }
    
    phrase = 'World'
    say('John') // World, John

    很明显的, 在调用say('John')这个函数的时候, 其词法环境中引用的phrase变量已经被修改, 因此最后结果为 "World, John"

    当然如果将phrase的修改放到函数执行结束后, 那么词法环境并不会改变, 毕竟变量的修改是在函数结束之后发生的, 在执行say()函数的时候, 其词法环境中的phrase并没有被修改掉, 代码如下:

    let phrase = 'Hello'
    
    function say(name) {
      alert(`${phrase}, ${name}`)
    }
    
    say('John') // Hello, John
    phrase = 'World'

    所以总结来说, 一定要去看函数被调用时的词法环境, 由于词法环境里面往往引用到了外部的变量, 环境等, 很有可能在被调用时已经和创建的时候发生了很大的变化(例如上面的say函数例子)

  2. 词法环境包括自己内部的环境, 和引用的外部的环境. 当存在嵌套函数的时候, 外部环境可能还引用更加外部的环境, 就形成了一条作用域链. 最最里层的函数在执行的时候, 会根据这条链子, 由内而外寻找需要的变量. 然后可以对找到的变量进行修改, 修改是在该变量所在的作用域内修改, 也就是说对于闭包来讲, 修改的地方在该函数的外部环境修改, 而非克隆一份放低自己的内部环境内修改. 比如下面代码:

    function makeCounter() {
      let count = 0
    
      return function() {
        return count++
      }
    }
    
    let counter = makeCounter()
    alert(counter())

    context1
    执行闭包, 创建对外部环境的引用

    context2
    修改外部环境变量

  3. 每次调用一个函数, 如果这个函数存在闭包, 那么都会创建一个单独的闭包环境, 里面有该闭包的状态. 多次调用这个函数, 会创建多个闭包, 这些闭包内部的环境状态都是独立的. 类似于有一个类, 你可以进行多次实例化, 每次实例化出来的都是不同的对象. 例如下面的例子:

    function makeCounter() {
      let count = 0
      return function() {
        return count++
      }
    }
    
    let counter1 = makeCounter();
    let counter2 = makeCounter();
    
    alert(counter1()) // 0
    alert(counter1()) // 1
    
    alert(counter2()) // 0 (独立的)

如果想要深入了解整个的流程还是需要去看上面推荐的那篇文章, 也有英文版的. 另提一句, 这个教程的其他内容质量也是不错的, 也有对应的中文翻译, 可以参考

闭包的几个小练习

两道闭包的小练习题:

第一题

function foo() {
  let x = 100

  function add(_x) {
    x += _x
    console.log('change closure x', x)
  }

  return [x, add]
}

const [x1, add1] = foo()
console.log('x1 before', x1) // x1 before, 100
add1(5) // change closure x, 105
console.log('x1 after', x1) // x1 after, 100

const [x2, add2] = foo() 
console.log('x2 before', x2) // x2 before, 100
add2(10) // change closure x, 110
console.log('x2 after', x2) // x2 after, 100

这里有两个点需要注意:

  • 每次执行函数以后创建的闭包其环境都是独立的

  • 引用问题, 再调用完add1()函数以后, x已经改变了地址, 不再指向 100, 而是指向了 105, 然而x1还是指向原始的 100, 同理add2(). 不理解的可以看下面的示例代码:

    let a = 0
    let b = a // a 和 b 指向同一地址
    a = 100 // a 的指向改变, b 不变
    console.log({ a, b }) // { a: 100, b: 0 }

第二题

function foo() {
  let x = 100

  function render() {
    const _x = x

    function inner() {
      x += 5
      console.log('innerrender', { _x, x })
    }
    console.log('render', { _x, x })
    return inner
  }

  function clear() {
    x = 0
  }

  return [add, render, clear]
}

const [add, render, clear] = foo()

inner = render() // render { _x: 100, x: 100 }
clear()
inner() // innerrender { _x: 100, x: 5 }

这里存在多个嵌套的闭包, 在调用最里层的函数inner()的时候需要分析清楚此时它所在的环境里引用的x_x的值是怎样的

闭包的应用

上面花了很大的篇幅将闭包是什么, 这里谈一谈闭包的实际应用

在之前, 先总结一下闭包的几个特点:

  • 使得函数有状态, 且状态可以改变
  • 使得函数有记忆
  • 状态是私有的, 可以对外暴露方法读取/修改它
  • 每个闭包里面的环境都是独立, 多次调用同一个函数创造出来的多个闭包的环境都是相互隔离的

可以想到, 普通(纯)函数是没有状态的, 闭包可以使得函数有状态, 有状态就意味着有记忆, 毕竟状态可以发生改变. 当然, 这里的状态, 往往是在外部环境所提供的, 不过内部函数可以读取并操作这个状态.

所以...如果你想进行一些状态的管理与保存, 你实际上可以有两个选择:

  1. 用类
  2. 用闭包

闭包和类都是保存状态用的

如果熟悉React, 类和闭包好像有点 class 组件React hooks 的味道!

不过这里先不讲React, 先考虑如下例子, 想要实现一个计数器, 有这么两个功能:

  • 可以得到当前的计数
  • 可以增加当前的计数

假设我们想要用普通纯函数实现, 可能会这么写:

const Counter = function() {
  let count = 0
  count++
  return count
}

Counter() // 1
Counter() // 1

这里的函数是没有状态的, 调用多少次永远都是 1. 同时内部变量count在函数调用完毕之后也被垃圾回收了. 所以这个计数器的实现是失败的

那如果用类实现, 可能代码是这样:

class Counter {
  constructor() {
    this.count = 0
  }

  add() {
    this.count += 1
  }

  getCount() {
    return this.count
  }
}

c1 = new Counter()
c1.add()
c1.add()
c1.getCount() // 2

c2 = new Counter()
c2.add()
c2.getCount() // 1

使用类模拟后, 每一次实例化都新生成了一个新的对象, 每个对象里面的count属性都是独立的, 我们可以对获取它, 也可以操作它

如果用闭包来实现:

const Counter = function() {
  let count = 0

  const add = function() {
    count += 1
  }

  const getCount = function() {
    return count
  }

  // return { getCount, add }
  return [getCount, add] // 返回一个数组或者对象都可以
}

const c1 = Counter()
c1[1]()
c1[1]()
c1[0]() // 2

const c2 = Counter()
c2[1]()
c2[0]() // 1

使用闭包模拟, 每次运行Counter()产生的闭包环境都是独立的, 不受其他闭包影响. 注意这里的闭包指的是getCountadd两个闭包, Counter只是提供这两个闭包的一部分环境(count), count作为状态, 被这两个函数读取操作

通过以上例子, 我们用闭包, 实现了类才能实现的功能, 这是一件非常神奇的事情.

闭包和 React Hooks

当然, 闭包的实际应用还有很多, 比如React Hooks, 写这篇文章的原因很大程度上也是因为Hooks的使用还是有很多心智负担的, 想要更好的使用还是需要去了解一些稍微底层的原理.

如果你对 React-hooks 的实现比较感兴趣, 可以参考这篇文章, 这篇文章使用原生JavaScript实现了最最简单的Hooks. 不过还是有很多地方说的比较简略. 后续可能会再写一篇博客将里面代码做更加详细的分析

如果对 React 或者 hooks 不了解的, 我非常推荐去看一看 Redux 作者 Dan Abramov 在 2018 年 React Conf 上关于 hooks 介绍的一篇演讲: React Today and Tomorrow and 90% Cleaner React With Hooks. 相信你会非常震撼的.

参考

简单聊一聊 useEffect

翻到几篇文章, 发现之前对于 useEffect 这个 hook 的认识是有问题的, 这里稍微重新整理一下

基本点

官网其实对 useEffect 的讲解已经很清晰了:

Sometimes, we want to run some additional code after React has updated the DOM.

对于这么一个函数:

useEffect(() => {
  // effect
  return () => {
    // cleanup
  };
}, [deps]);

有这么几个点:

  • 传递给 useEffect 的回调函数的调用在 render phase 之后, 并且是在渲染至屏幕之后, 这里与 useLayoutEffect 不同, 不展开
  • dependent array 即依赖数组决定该回调函数触发的时机
  • 返回的 cleanup 函数不仅仅只是在组件 unmount 的时候触发, 如果该组件存在多次 render 的情况, 也会触发, 同时触发时机在"下次" useEffect 的回调函数触发之前

虽然 hooks 官方并没有给出生命周期图, 但是网上有人制作了一份还是很清晰的:

react-hooks-lifecycle

示例

以最基本的计数器为例, 如下:

import React, { useEffect, useState } from 'react';

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('effect');

    return () => {
      console.log('cleanup');
    };
  }, [count]);

  console.log('render');

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

export default App;

过程简化如下:

  1. 初始 mount 的时候, 先 render 一次
  2. 执行 useEffect 里的回调
  3. 用户点击按钮, 状态更新, 重新 render 一次
  4. 由于依赖数组的状态也是 count 状态, 执行 useEffect 里的返回函数
  5. 执行 useEffect 里的回调

因此控制台的输出结果依次如下

// mount 之后
render
effect
// 用户点击按钮触发更新之后
render
cleanup
effect

效果图如下:
overall

每次都运行

当没有依赖数组的时候, useEffect 里的回调函数在每次 render 的时候都会执行, 即初始 mount 的时候以及后续组件状态改变重新 re-render 的时候

useEffect(() => {
  console.log('I run on every render: mount + update.');
})

只有 mount 才运行

当依赖数组是空时, useEffect 里的回调函数只在第一次 render 即 mount 之后执行

useEffect(() => {
  console.log('I run only on the first render: mount.');
})

mount 和特定 update 运行

当依赖数组里存在依赖项, 例如组件的 state 或者 props, 当其中任一一个依赖项发生变动时, useEffect 执行其中的回调函数

useEffect(() => {
  console.log('I run if count change (and on mount).');
}, [count])

只有特定 update 运行

这个需求是我需要 console.log() 这条语句只在组件更新的时候触发, 组件第一次渲染 mount 的时候不触发, 实现也很简单, 通过 ref 或者全局变量的状态控制

import React, { useEffect, useState, useRef } from 'react';

function App() {
  const [count, setCount] = useState(0);
  const didMountRef = useRef(false);

  useEffect(() => {
    if (didMountRef.current) {
      console.log('I run only if count changes, mount is not included');
    } else {
      didMountRef.current = true;
    }
  }, [count]);

  console.log('render');

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

export default App;

当然这里 useEffect 依旧会在 mount 之后执行, 只是里面的判断使得有条件的执行

更具体点的可以抽成一个自定义 hook, 如下:

const useEffectOnlyOnUpdate = (callback, dependencies) => {
  const didMountRef = React.useRef(false);

  React.useEffect(() => {
    if (didMountRef.current) {
      callback(dependencies);
    } else {
      didMountRef.current = true;
    }
  }, [callback, ...dependencies]);
};

组里里使用:

function App() {
  const [count, setCount] = useState(0);

  useEffectOnlyOnUpdate(
    (dependencies) => {
      console.log('I run only if count changes, mount is not included');
    },
    [count]
  );

  console.log('render');

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

export default App;

usePrevious

由于 useEffect 里的回调函数是在 render 之后进行的, 可以根据这一个机制获取到之前的状态的值.

const usePrevious = value => {
  const previousRef = useRef()

  useEffect(() => {
    previousRef.current = value
  }, [value])

  return previousRef.current
}

使用:

function App() {
  const [count, setCount] = useState(0);

  const previousCount = usePrevious(count);

  console.log(
    `render, previousCount: ${previousCount}, currentCount: ${count}`
  );

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

export default App;

打印结果为:

// mount 之后
render, previousCount: undefined, currentCount: 0
// 用户点击按钮触发一次更新之后
render, previousCount: 0, currentCount: 1

效果如下:
usePrevious

需要注意的是, 这里的实现不一定需要强制使用 ref, 用一个全局变量也是一样的, 只是 ref 提供了一样的功能, 即 useRef 提供的引用在整个组件的声明周期都是持久并且是最新的. ref 一个比较好的作用就是能够解决 hooks 的陈旧闭包问题, 之前的博客里有写, 这里不多描述, 有兴趣可以看这个问题以及我之前的文章

cleanup

如上述提到的, cleanup 函数不仅仅是在 unmount 才执行, 每次组件更新的时候也会执行, 用于消除上次存在的不用资源

以实现自增器为例, 其实可以有很多种写法, 第一种如下:

import React from 'react';

const App = () => {
  const [timer, setTimer] = React.useState(0);

  React.useEffect(() => {
    const interval = setInterval(() => setTimer(timer + 1), 1000);

    return () => {
      clearInterval(interval);
    };
  }, [timer]);

  return <div>{timer}</div>;
};

export default App;

这里每次更新导致 useEffect 执行都会创建一个新的 interval, 而每次的 cleanup 函数都用于清除上次运行时创建的定时器. 这么做虽然没问题但是他的机制在于, 每次都会创建新的定时器, 而我们所需要的往往只是一个定时器实例, 并且每次定时器只跑了一秒就被清除

更好的做法是稍微修改一下依赖数组:

import React from 'react';

const App = () => {
  const [timer, setTimer] = React.useState(0);

  React.useEffect(() => {
    const interval = setInterval(
      () => setTimer((currentTimer) => currentTimer + 1),
      1000
    );

    return () => clearInterval(interval);
  }, []);

  return <div>{timer}</div>;
};

export default App;

保证在 mount 之后只存在一个定时器实例, 同时该定时器在组件 unmount 时也能被销毁.

当然上述需求用 setTimeout 实现也是可以的:

import React from 'react';

const App = () => {
  const [timer, setTimer] = React.useState(0);

  React.useEffect(() => {
    setTimeout(() => {
      setTimer((t) => t + 1);
    }, 1000);
  }, [timer]);

  return <div>{timer}</div>;
};

export default App;

参考

简单聊一聊 github action

简单聊一聊 github action

Github Action 是 Github 官方出的持续集成服务, 挺早之前就推出了, 这次正好遇到一点需求, 看了一下文档自己写了一个 workflowaction 脚本

文档还是很全的, 但是细节有点多, 写的时候不注意的话很容易踩坑, 而且这个东西无法在本地进行调试, 我只能每次更新了代码后手动 run 一次 workflow, 虽然有一个叫 act 的库貌似支持本地跑的, 但是又要用到 docker 啥的, 我电脑比较旧带不动, 就算了

基本概念

阮一峰老师写过一篇还挺清晰的教程介绍基本概念: GitHub Actions 入门教程. 最基本的几个概念为:

  • workflow
  • event
  • job
  • step
  • action

以官方给的一个 workflow 文件为例, 所有的 workflow 都存放在 .github/workflows 目录下:

# .github/workflows/learn-github-actions.yml
name: learn-github-actions
on: [push]
jobs:
  check-bats-version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'

该 workflow 的含义为:

  • push 事件触发时激活该 workflow
  • 一共有一个 job, 名字为 check-bats-version, 该 job 跑在最新的 ubuntu 上
  • 该 job 一共有 2 个 step, 每个 step, 对应一个 action
  • 其中第二个 actions/setup-node@v2 action 运行时需要传递参数, 参数名 node-version, 参数值 14

触发 workflow 的事件还有很多, 可以是多个事件, 也可以手动触发, 也可以细分具体到一个事件的不同类型, 具体需要参考官方文档: Events that trigger workflows

关于 workflow 的语法具体也参考官方文档: Workflow syntax for GitHub Actions

需求

虽然 github marketplace 有很多已经写好的 action, 但是还是免不了自己有特质需求需要自定义 action. 比如我的需求是, 我的博客全部写在 issue 里, README.md 通常作为目录按照时间分类放置每篇文章的标题和对应到 issue 的链接, 大致格式是这样:

# 个人博客

## 2019
- [第一篇博客](https://github.com/owner/repo/issues/1)
- [第二篇博客](https://github.com/owner/repo/issues/2)

## 2020
...

但是每次更新 issue 之后就需要手动去更新 README, 我所希望的时候能有 workflow 能帮我自动处理这些事情, 在博客更新之后通过 github 提供的 API 手动提交一次 commit 更新我的 README 目录

实现

action.yml 和 workflow.yml

官方有关于自定义 action 的文档: About custom actions. 以及如何自定义 JavaScript action: Creating a JavaScript action 还算详细. 不过有一些细节需要注意:

  • action 命名一定是 action.yml 或者是 action.yaml
  • 自定义 action 的文件位置一般放在根目录下, 当然放在 .github 文件夹下也行, 不过在 workflow 的时候还需要 checkout 定位到 repo 上. 而且只有在根目录下的 action 才能发布到 github marketplace.

action.yml 的大致语法可以参考: Metadata syntax for GitHub Actions. 这里简略罗列一下我的:

name: 'Action Name'
description: 'Description for custom action'
inputs:
  repoToken:
    description: 'github access token'
    required: true
runs:
  using: 'node12'
  main: 'dist/index.js'

说明一下:

  • namedescript 分别是自定义 action 的名字和描述
  • inputs 是该 action 的输入源, 可以有多个. 由于我的需求需要用到 github 的 API, 使用过程需要 token 来 authentication. 这个 token 每次 workflow 运行时会自动生成, 不需要手动输入. 因此在编写 workflow 的时候要注明. 这里我给的输入源名字为 repoToken
  • runs 注明了使用 node12, 同时对应的 JavaSctipt action 文件为根目录下的 dist/index.js

关于 token, 官方有提到: Automatic token authentication. 如文中所述, 在编写 workflow 时, 需要使用 with 语法提供对应的参数, 这个参数就是自动生成的 token.

对应我的 workflow 文件内容如下:

name: My Workflow
on:
  workflow_dispatch:
  issues:
    types: [opened, edited, deleted]
jobs:
  sync-readme:
    runs-on: ubuntu-latest
    name: My job name
    steps:
      - name: use my custom action
        uses: owner/repo@master
        with:
          repoToken: ${{ secrets.GITHUB_TOKEN }}

这里 on 监听了两个事件, 一个是 workflow_dispatch, 一个是 issues 具体的类型. workflow_dispatch 是为了允许手动跑 workflow, 方便线上调试. 具体可以看: Manually running a workflow. issues 我提供了三个类型分别在新增一条 issue, 编辑一条 issue 和删除一条 issue 时触发该 workflow.

最后是绑定自定义的 action, 注意这里 with 下的 repoToken 和上面 action.yml 中的 inputs 里对应

编写脚本

需要用到两个库:

第一个库主要用于获取输入输出, 比如上文提到的 token. 打日志. 第二个库提供了 Github API 的接口, 返回的是一个已经 auth 过的 Octokit REST 客户端

由于我的需求是根据在我更新完 issue 之后根据最新的 issue 信息更新我的 README. 所有思路也很简单, 发一次请求获取所有的 issue 信息, 然后再根据 issue 信息做一次 commit 更新 readme. 需要用到 3 个 API:

这里注意一下, 由于在使用第三个 api 即 createOrUpdateFileContents 时候, 如果是更新文件内容需要提供该文件的 sha 值, 所以只能先通过 getContent 获取到 sha 值之后再更新, 同时更新的内容必须使用 Base64 encoding 加密.

最后的文件大体思路为:

const core = require('@actions/core');
const github = require('@actions/github');
const { Buffer } = require('buffer');

function run() {
  const repoToken = core.getInput('repoToken');
  const octokit = github.getOctokit(repoToken);

  octokit.rest.issues
    .listForRepo({
      owner: 'xxx',
      repo: 'yyy',
    })
    .then(({ data: issues }) => {
      const content = contentFromIssues(issues)

      octokit.rest.repos
        .getContent({
          owner: 'xxx',
          repo: 'yyy',
          filePath: 'README.md'
        })
        .then(({ data }) => {
          const { sha } = data

          octokit.rest.repos
            .createOrUpdateFileContents({
              ...config,
              path: 'README.md',
              message: 'feat: xxx',
              content: Buffer.from(content).toString('base64'),
              sha,
            })
            .then((res) => core.info('success to update'))
            .catch(error => core.setFailed(error.response.data.message))
        })
        .catch(error => core.setFailed(error.response.data.message))
    })
    .catch(error => core.setFailed(error.response.data.message))
}

run()

最后, 官方文档建议使用 @vercel/nccaction 脚本进行打包, 这么做也是为了避免上传 node_modules. 或者另一种做法是在 workflow 跑的时候手动安装需要的依赖. 这里我选择前一种方法. 这里 npm i -g @vercel/ncc 后使用 ncc build action.js 即可打包文件到 dist/index.js 中. 遗憾的是貌似无法指定对应的文件名.

参考

简单聊一聊 React 和 VSCode Webview (二)

上一篇文章 主要讲了如何配置 Webview 的 React 开发环境. 这篇文章主要想谈谈开发 Webview 时候可能遇到的场景和问题, 一些细节就不展开了.

代码地址: https://github.com/hacker0limbo/vscode-webview-react-boilerplate

最后的效果:
screenshot

需求

需求很简单, 主要实现三个页面, 一个导航栏以及一个刷新按钮:

  • 导航栏: 点击能够跳转到三个页面
  • 刷新按钮: 点击能够刷新整个 Webview, 类似于浏览器的 Reload 功能
  • 页面:
    • Home: 主页面
    • About: 详情页面, 会发送请求, 得到之后渲染数据
    • Message: 有两个子页面, 一个用于接收从 Extension 发送的消息并实时渲染, 一个类似表单可以往 Extension 发送消息

由于我不会 CSS, 最后效果看上去可能有点丑, 就不要在意这些细节了

导航栏与路由

Webview 默认应该是不支持 URL 的, 所以如果用 BrowserRouter 可能会失效. 为了支持路由, 这里改用 MemoryRouter, 用法也是非常简单, 以我的场景为例, 只需配置一下需要的路由端点即可:

import { MemoryRouter as Router, Link } from 'react-router-dom';

<Router initialEntries={['/', '/about', '/message', '/message/received', '/message/send']}>
  <ul className="navbar">
    <li>
      <Link to="/">Home</Link>
    </li>
    <li>
      <Link to="/about">About</Link>
    </li>
    <li>
      <Link to="/message">Message</Link>
    </li>
  </ul>
</Router>;

更多的用法还是参考官网的 API, 这里不展开

消息

消息的传递

ExtensionWebview 本身都支持接收和发送消息, 消息的类型没有限制, 官方给的都是 any. 这里简单介绍各自一下具体的 API

Extension 里接收和发送消息:

// 初始化
const panel = vscode.window.createWebviewPanel({ ... })

// 接收从 Webview 发送过来的消息
panel.webview.onDidReceiveMessage(
  (message: any) => {
    console.log('message from webview: ': message)
  },
  undefined,
  context.subscriptions
);

// 发送消息给 Webview
panel.webview.postMessage(...);

Webview 里接收和发送消息:

// 接收从 Extension 发送过来的消息
window.addEventListener('message', (event: MessageEvent<any>) => {
  const message = event.data;
  console.log('message from extension: ', message)
});

// 发送消息给 Extension
const vscode = acquireVsCodeApi();
vscode.postMessage(...);

消息的类型

由于默认所以消息类型都是 any, 在开发的时候会有一些不方便. 因此最好规定一下消息的格式. 这里只简单讲一下我的规定.

src/view/message 里新建一个 messageTypes.ts 专门存放消息类型

export type MessageType = 'RELOAD' | 'COMMON';

export interface Message {
  type: MessageType;
  payload?: any;
}

export interface CommonMessage extends Message {
  type: 'COMMON';
  payload: string;
}

export interface ReloadMessage extends Message {
  type: 'RELOAD';
}

Message 为最基本的消息类型, 有两个属性, type 表示当前消息属于哪种类型, payload 为消息的数据, 可选. 有点像 ReduxAction 了...

至于是否需要定义消息是属于发送还是接收, 我个人觉得没有太大意义, 由于只存在 ExtensionWebview 两个载体, 也就是一对一关系, 在任何一方做接收或者发送的时候其实就已经能很清楚的知道这个消息的起始点或者重点对应是哪一方. 而对于定义消息的类型 type 反而很有必要, 目的是为了在一方接收的时候做区分, 不同的消息类型所携带的 payload 也是不同的. 有了 type 之后开发也可以做类型守护或者类型断言来更加严格定义消息的类型.

注入 vscode

关于在 webview 里接收和发送消息, 虽然官方提到可以很简单的使用 const vscode = acquireVsCodeApi(); 来获取 vscode 变量进而发送消息. 但由于我们 webview 使用的是 ts. 这种注入的变量是没有任何类型声明, 编译器不知道它哪来的会直接报错. 这里其实有很多方法来解决, 为了方便我的做法是在 app 文件(也就是 Webview React 目录)下新建一个 global.d.ts, 手动声明 vscode 类型, 同时在之前的 ViewLoader.ts 文件的 render() 方法里提前注入 vscode 变量:

// src/view/ViewLoader.ts

export class ViewLoader {
  // ...
  render() {
    const bundleScriptPath = this.panel.webview.asWebviewUri(
      vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app', 'bundle.js'))
    );

    return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>React App</title>
        </head>
    
        <body>
          <div id="root"></div>
          <script>
            const vscode = acquireVsCodeApi();
          </script>
          <script src="${bundleScriptPath}"></script>
        </body>
      </html>
    `;
  }
}
// app/global.d.ts

type Message = import('../src/view/messages/messageTypes').Message;

type VSCode = {
  postMessage<T extends Message = Message>(message: T): void;
  getState(): any;
  setState(state: any): void;
};

declare const vscode: VSCode;

这里 postMessage() 简单做一下泛型...

Reload

Webview 本身不提供类似浏览器的刷新按钮, 万幸的是 vscode 提供了一个命令用于刷新所有的 Webview: 'workbench.action.webview.reloadWebviewAction', 对于单个的 Webview 不支持. 具体讨论可以看这个 ISSUE

实现思路很简单, 由于这个命令是需要从 Extension 层面触发, Webview 发送一条消息通知 Extension, Extension 接收消息触发 reload 命令, 简易代码如下:

// app/components/App.tsx

import React from 'react';

export const App = () => {
  const handleReloadWebview = () => {
    vscode.postMessage<ReloadMessage>({
      type: 'RELOAD',
    });
  };

  return <button onClick={handleReloadWebview}>Reload Webview</button>;
};
// src/view/ViewLoader.ts

export class ViewLoader {
  public static currentPanel?: vscode.WebviewPanel;

  private panel: vscode.WebviewPanel;
  private context: vscode.ExtensionContext;
  private disposables: vscode.Disposable[];

  constructor(context: vscode.ExtensionContext) {
    this.context = context;
    this.disposables = [];

    this.panel = vscode.window.createWebviewPanel('reactApp', 'React App', vscode.ViewColumn.One, {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app'))],
    });

    // render webview
    this.renderWebview();

    // listen messages from webview
    this.panel.webview.onDidReceiveMessage(
      (message: Message) => {
        if (message.type === 'RELOAD') {
          vscode.commands.executeCommand('workbench.action.webview.reloadWebviewAction');
        }
      },
      null,
      this.disposables
    );

    this.panel.onDidDispose(
      () => {
        this.dispose();
      },
      null,
      this.disposables
    );
  }

  // ...
}

Home 页面

没啥说的...

About 页面

这个页面会往服务端发送 Http 请求, API 我直接用的网上给的 Random User API, 请求端点: https://randomuser.me/api/. 该 API 会返回随机伪造的用户数据, 页面会以列表形式渲染用户的姓名, 性别和邮箱地址

同时, 该 API 支持参数, 比如允许请求的用户只为男性, 那么 URL 变为: https://randomuser.me/api?gender=male. 这个参数我们可以选择让用户在 VSCodesettings 里自行配置, 然后 Webview 从中读取配置.

配置

官方文档有详细的说明如何做配置, 这里我做的配置如下:

{
  "contributes": {
    "configuration": {
      "title": "Webview React",
      "properties": {
        "webviewReact.userApiGender": {
          "type": "string",
          "default": "male",
          "enum": ["male", "female"],
          "enumDescriptions": [
            "Fetching user information with gender of male",
            "Fetching user information with gender of female"
          ]
        }
      }
    }
  }
}

最后在 VSCode 的配置 UI 展示为一个下拉框, 默认值为 male.

读取配置也很简单:

// src/config/index.ts

import * as vscode from 'vscode';

export const getAPIUserGender = () => {
  const gender = vscode.workspace.getConfiguration('webviewReact').get('userApiGender', 'male');
  return gender;
};

注入配置到 Webview

和之前注入 vscode 变量一样, 在 render() 方法里注入 gender, 同时在 global.d.ts 文件里声明好类型

// src/view/ViewLoader.ts

export class ViewLoader {
  // ...
  render() {
    const bundleScriptPath = this.panel.webview.asWebviewUri(
      vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app', 'bundle.js'))
    );

    const gender = getAPIUserGender();

    return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>React App</title>
        </head>
    
        <body>
          <div id="root"></div>
          <script>
            const vscode = acquireVsCodeApi();
            const apiUserGender = "${gender}"
          </script>
          <script>
            console.log('apiUserGender', apiUserGender)
          </script>
          <script src="${bundleScriptPath}"></script>
        </body>
      </html>
    `;
  }
}
// global.d.ts

type Message = import('../src/view/messages/messageTypes').Message;

type VSCode = {
  postMessage<T extends Message = Message>(message: T): void;
  getState(): any;
  setState(state: any): void;
};

declare const vscode: VSCode;

declare const apiUserGender: string;

画页面

没啥说的, 这部分反而是最简单的

import React, { useState, useCallback, useEffect } from 'react';
import { apiUrl } from '../api';

type UserInfo = {
  name: string;
  gender: string;
  email: string;
};

export const About = () => {
  const [userInfo, setUserInfo] = useState<UserInfo>({
    name: '',
    gender: '',
    email: '',
  });
  const [loading, setLoading] = useState(false);

  const fetchUser = useCallback(() => {
    setLoading(true);
    fetch(apiUrl)
      .then((res) => res.json())
      .then(({ results }) => {
        const user = results[0];
        setLoading(false);
        setUserInfo({
          name: `${user.name.first} ${user.name.last}`,
          gender: user.gender,
          email: user.email,
        });
      })
      .catch((err) => {
        setLoading(false);
      });
  }, []);

  useEffect(() => {
    fetchUser();
  }, [fetchUser]);

  return (
    <div>
      <h1>About</h1>
      <h3>User Info</h3>
      {loading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          <li>Name: {userInfo.name}</li>
          <li>Gender: {userInfo.gender}</li>
          <li>Email: {userInfo.email}</li>
        </ul>
      )}
      <button onClick={fetchUser}>Fetch</button>
    </div>
  );
};

数据请求我就用了原生的 Fetch, 因为我不想再装库了...

这里有一个小 BUG, 如果用户在打开 Webview 之后再更新了配置, 点击按钮之后请求的数据还是更新前的. 原因在于 render() 方法中的 html 并不会根据配置的更新而重新渲染, 要改其实也不难, VSCode 提供了一个 onDidChangeConfiguration 的方法用于监听配置更改, 只要在这个方法中重新渲染 html 即可. 但因为本人比较懒, 就没实现这个需求...

Message

该页面有两个子页面, 一个为 ReceivedMessages.tsx, 一个为 SendMessage.tsx. 前者用于监听 Extension 发送过来的消息, 后者可以发送消息给 Extension.

ReceivedMessages

Extension 端, VSCode 提供一个 InputBox API showInputBox, 可供输入简单的单行文本. 当用户输入文本之后按下 Enter 键, 消息即被发送到 Webview. 如果选择 ESC, 不做任何操作

// src/extension.ts

const disposable = vscode.commands.registerCommand('extension.sendMessage', () => {
  vscode.window
    .showInputBox({
      prompt: 'Send message to Webview',
    })
    .then((result) => {
      result &&
        ViewLoader.postMessageToWebview<CommonMessage>({
          type: 'COMMON',
          payload: result,
        });
    });
});

Webview 端需要做监听, 如果直接放在 ReceivedMessages 这个比较深的组件里, 虽然是可行的. 但切换路由的时候组件就 umount 了, 没有了监听即使 Extension 发送了任何消息过来也不会有任何响应. 所以我选择放在 App.tsx 这个比较顶层的组件, 该组件一直存在. 监听到的消息通过 context 传递给 children 组件. 任何组件有需要消息的, 只要订阅 context 即可.

当然了, 怎么写放在哪还是看具体的业务需求. 这里只是提供思路

// app/context/MessageContext.tsx

import React from 'react';

export const MessagesContext = React.createContext<string[]>([]);
// app/components/App.tsx

export const App = () => {
  const [messagesFromExtension, setMessagesFromExtension] = useState<string[]>([]);

  const handleMessagesFromExtension = useCallback(
    (event: MessageEvent<Message>) => {
      if (event.data.type === 'COMMON') {
        const message = event.data as CommonMessage;
        setMessagesFromExtension([...messagesFromExtension, message.payload]);
      }
    },
    [messagesFromExtension]
  );

  useEffect(() => {
    window.addEventListener('message', (event: MessageEvent<Message>) => {
      handleMessagesFromExtension(event);
    });

    return () => {
      window.removeEventListener('message', handleMessagesFromExtension);
    };
  }, [handleMessagesFromExtension]);

  // ...

  return (
    <MessagesContext.Provider value={messagesFromExtension}>
      <Switch>
        <Route exact path="/">
          <Home />
        </Route>
        <Route path="/about">
          <About />
        </Route>
        <Route path="/message">
          <Message />
        </Route>
      </Switch>
    </MessagesContext.Provider>
  );
};

具体渲染消息的页面就不多说了

SendMessage

Webview 作为发送端, 渲染一个 input 框和一个 button, 不多描述, 代码如下:

import React, { useState } from 'react';
import { CommonMessage } from '../../src/view/messages/messageTypes';

export const SendMessage = () => {
  const [message, setMessage] = useState('');

  const handleMessageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(e.target.value);
  };

  const sendMessage = () => {
    vscode.postMessage<CommonMessage>({
      type: 'COMMON',
      payload: message,
    });
  };

  return (
    <div>
      <p>Send Message to Extension:</p>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
};

Extension 作为接收端, 在 ViewLoader.ts 里接受消息. 这里选择将接受的消息以 InformationMessage Dialog 的形式展示出来:

this.panel.webview.onDidReceiveMessage(
  (message: Message) => {
    if (message.type === 'RELOAD') {
      vscode.commands.executeCommand('workbench.action.webview.reloadWebviewAction');
    } else if (message.type === 'COMMON') {
      const text = (message as CommonMessage).payload;
      vscode.window.showInformationMessage(`Received message from Webview: ${text}`);
    }
  },
  null,
  this.disposables
);

后记

至此整个项目就算完善了, 可能有更加复杂的业务场景没有考虑到, 后续遇到了也会及时更新. 本人水平也不高, 开发的时候可能也有很多地方有错误, 看代码的话也请及时指出.

Webview 只算开发插件的很小一部分. VSCode 整个生态系统是非常庞大的, 同时也暴露了非常好的接口供开发者自行开发插件, 虽然有些时候文档不一定全, 但大多数问题还是可以通过谷歌, Stackoverflow, 或者搜 Github Issue 来解决的.

社区也有对应的中文文档, 地址: https://liiked.github.io/VS-Code-Extension-Doc-ZH/#/

新年快乐!

简单聊一聊一个 BackTop 组件

给了一个很简单的需求, 某个页面需要做一个 BackTop 组件, 在页面滑动向下滑动到一定程度的时候(比如直接滑到底部)显示这个组件, 点击可以直接回到顶部, 该组件同时也消失

基础实现例子

本来想手写的, 但是公司用的 material-ui 这个组件库里直接搜到了例子, 这里简化一下代码:

function ScrollTop(props) {
  const { children, window } = props;
  const trigger = useScrollTrigger({
    target: window ? window() : undefined,
    disableHysteresis: true,
    threshold: 100,
  });

  const handleClick = (event) => {
    const anchor = (event.target.ownerDocument || document).querySelector('#back-to-top-anchor');

    if (anchor) {
      anchor.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
  };

  return (
    <Zoom in={trigger}>
      <div onClick={handleClick} role="presentation" className={classes.root}>
        {children}
      </div>
    </Zoom>
  );
}

function App(props) {
  return (
    <React.Fragment>
      <div id="back-to-top-anchor" />
      <Container>
        ... // content
      </Container>
      <ScrollTop>
        <KeyboardArrowUpIcon />
      </ScrollTop>
    </React.Fragment>
  )
}

ReactDOM.render(<App />, document.querySelector('#root'));

思路

梳理一下思路:

  • 首先有一个 useScrollTrigger()hook 返回一个 trigger 状态, 这个状态用来判断是否显示 BackTop 组件, 如果自己写的话可以用这个状态来控制 css 里的 display: none 属性, 当然这里直接用了 material ui 里内置的 <Zoom> 组件了
  • 需要实现 handleClick(), 具体细节为:
    • 有一个锚点(anchor)用于定位滚动到的地方, 例子里为 <div id="back-to-top-anchor" />
    • 点击的时候利用 scrollIntoView() 原生 api 滚动到锚点的位置
  • 传入 children, 可以自定义, 用于显示 BackTop 组件的 UI, 这里简单使用 <KeyboardArrowUpIcon />

思路可以说非常清晰了

useScrollTrigger()

这个 hooks 令我有些困惑, 他提供的参数 options 有三个属性: disableHysteresis, target, threshold, 主要聊一聊前两个参数:

disableHysteresis

options.disableHysteresis (Boolean [optional]): Defaults to false. Disable the hysteresis. Ignore the scroll direction when determining the trigger value.

hysteresis 这个词我根本不认识...于是直接谷歌, 发现了这么一个问题: What does hysteresis mean and how does it apply to computer science or programming?

该问题里的第一个回答重点如下:

Hysteresis characterizes a system whose behavior (output) does not only depend on its input at time t, but also on its past behavior, on the path it has followed.

也就是说他的值, 可能是根据上一次值来决定的, 这里联系一下源码来看

import * as React from 'react';

function defaultTrigger(store, options) {
  const { disableHysteresis = false, threshold = 100, target } = options;
  const previous = store.current;

  if (target) {
    // Get vertical scroll
    store.current = target.pageYOffset !== undefined ? target.pageYOffset : target.scrollTop;
  }

  if (!disableHysteresis && previous !== undefined) {
    if (store.current < previous) {
      return false;
    }
  }

  return store.current > threshold;
}

const defaultTarget = typeof window !== 'undefined' ? window : null;

export default function useScrollTrigger(options = {}) {
  const { getTrigger = defaultTrigger, target = defaultTarget, ...other } = options;
  const store = React.useRef();
  const [trigger, setTrigger] = React.useState(() => getTrigger(store, other));

  React.useEffect(() => {
    const handleScroll = () => {
      setTrigger(getTrigger(store, { target, ...other }));
    };

    handleScroll(); // Re-evaluate trigger when dependencies change
    target.addEventListener('scroll', handleScroll);
    return () => {
      target.removeEventListener('scroll', handleScroll);
    };
    // See Option 3. https://github.com/facebook/react/issues/14476#issuecomment-471199055
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target, getTrigger, JSON.stringify(other)]);

  return trigger;
}

先提一点, 其中使用 useRef() 做数据存储, 存储当前(之前)所滑动的长度. 为啥不用 useState 是因为 useState 在你 state 发生改变的时候会重新渲染组件, 而 ref 被重新设置是不会触发重新渲染的. 他就是一个存取器用来存一些值, 组件重新渲染啥的和他无关

useRef() = useState({ current: initialValue })[0]

所以如果涉及到 UI 的需要依据某些变化的值来重新渲染的, 考虑用 useState, 否则用 useRef

回到源码, 关于 disableHysteresis 的逻辑就在于那段 if:

if (!disableHysteresis && previous !== undefined) {
  if (store.current < previous) {
    return false;
  }
}

解读一下:

  • 如果 disableHysteresis 为 false(说明 Hysteresis 是被允许的) 并且, previous(之前的滑动长度也是有的)
  • 并且如果当前的滑动长度小于之前保存的滑动长度, 那么直接返回 false

也就是说, 只要在某个阶段, 滚动条向上移动, 那么当前的滑动条长度(pageYOffset 或者是 scrollTop) 一定是小于之前的, 同时 disableHysteresisfalse, 那么 trigger 直接就是 false 了, 根本不会再走 store.current > threshold 这个逻辑来判断...

这就造成了两种 UI 效果

  • disableHysteresisfalse 的时候, 即使 BackTop 组件在最底部, 用户只要往上滚动一点点, BackTop 组件立马消失, 因为他根本不会再比较你当前的滑动条长度和给定的 threshold 了(即使你是超过的...)
  • disableHysteresistrue 的时候, 老老实实比较当前滑动条长度和指定的 threshold. 所以往上滑的时候, 没超过 threshold 是不会显示 BackTop 组件的

最后来看下效果:

首先是 disableHysteresis = true, 也就是不存在 Hysteresis 现象, 可以看到自始至终只有一个 Hysteresis not triggered 这条 log:

disableHysteresis_true

接着来看 disableHysteresis = false, 也就是存在 Hysteresis 现象, 可以发现会有两条 log, 同时只要往上滑, 一定会是出现 Hysteresis not triggered 这条 log:

disableHysteresis_false

另: 我翻了一下 Ant Design 的 BackTop 组件, 貌似是没有提供这个功能, 当然用起来也是更加简单, 有兴趣可以去看看源码, 这里不做深究

target

options.target (Node [optional]): Defaults to window.

如果 target 是 window, 那没啥好纠结的, 但情况往往需要传入的是一个特定的元素, 比如可能是左侧侧边栏

回顾一下之前的源码:

target.addEventListener('scroll', handleScroll);
return () => {
  target.removeEventListener('scroll', handleScroll);
};

对于给定的 target 做事件监听, 也就是说, 你必须非常精准的传入触发 scroll 事件的元素...

我为什么说是精准, 因为在当时项目里我为了传这个 dom 节点花了很大的力气, 如果没有正确传入这个 eventHandler 是根本不会触发的...

所以我当时想, 能不能传入一个不必太精准的 dom 节点, 也就是说做一下事件委托? 于是我尝试改了一下源码:

React.useEffect(() => {
  const handleScroll = (e) => {
    setTrigger(getTrigger(store, { target: e ? e.target : window, ...other }));
  };

  handleScroll(); // Re-evaluate trigger when dependencies change
  target.addEventListener('scroll', handleScroll);
  return () => {
    target.removeEventListener('scroll', handleScroll);
  };
}, [target, getTrigger, JSON.stringify(other)]);

然后尝试传入一个略微父级的元素, 发现根本不触发 handleScroll ...

遇到了一个之前一直很忽略的知识点: scroll 事件的目标元素是一个元素的话, 比如说是一个 div, 那么此时事件只有从 documentdiv 的捕获阶段以及 div 的冒泡阶段.

也就是说, 尝试在父级监视 scroll 的冒泡阶段监视这一事件是无效的..., 而 addEventListener 里第三个参数默认是 false 指定在了冒泡阶段. 在这里做事件委托是失败的.

给一张 scroll 事件的事件流吧(红色以上不执行...):

scroll-event-flow

当时因为这个问题卡了很久, 直到搜到了这个相关问题才得以解决: Listening to all scroll events on a page

就像回答里说的: 如果要做事件委托, 请将 addEventListener 的第三个参数改为 true, 以方便在捕获阶段捕获事件

document.addEventListener('scroll', function(e){ }, true);

关于事件流, 事件委托和 scroll 事件的, 我推荐看这一篇文章: 你所不知道的scroll事件:为什么scroll事件会失效?

最后的最后, 我还是没改源码, 老老实实找到了对应的 dom 节点传过去, 最后项目里的代码大概可能长这样:

<MyScrollTop target={someRef.current ? someRef.current.parentNode : window} />

参考

简单聊一聊 JavaScript 中的引用

老生常谈的问题了, 之前看到一篇还挺有趣的文章, 记录一下

什么是引用

考虑如下代码:

let word = 'hello';

我们可以说变量 word 指向(pints to) 一个盒子, 该盒子里装着 "hello". 注意 word 变量不是盒子, 而是指向盒子.

现在给 word 重新赋值

word = 'world';

这段代码的解释可以为, 现在有一个新的盒子被创建了, 盒子里装着 "world", word 变量现在指向这个新的装有 "world" 盒子, 而之前装有 "hello" 的盒子被 garbage collector 清理掉了, 因为没有地方用到他.

用图表示的话如下:

reassign-a-variable

引申一下, 如果尝试过给函数参数重新进行赋值, 你会发现他并不能改变函数外的任何值.

简单来讲, 即如果给函数形式参数重新赋值, 那对外部传递的实际参数没有任何影响. 比如以下代码:

function reassignFail(word) {
  // this assignment does not leak out
  word = 'world';
}

let test = 'hello';
reassignFail(test);
console.log(test); // prints "hello"

上面代码的解释为:

  • 一开始只有 test 变量指向 "hello"
  • 当执行 reassignFail 函数开始传递实参的时候, testword 均指向相同的盒子, 即 "hello"
  • 函数执行, 进行变量的重新赋值, 此时形式参数 word 指向了新的盒子 "world", 但该操作对外面的 test 变量没有任何影响, test 变量仍旧指向 "hello" 盒子

在 JavaScript 中变量的赋值可以看做是这么解释的. 当重新给一个变量赋值的时候, 他不会改变别的也指向之前同样"盒子"的变量, 他只改变自己对"盒子"的指向, 且不管这个盒子里装的是什么, boolean, number, object, array, function, 该操作均是这样进行的

两种类型

JavaScript 有两种广泛的类型分类, 他们对于赋值(assignment) 和 referential equality(引用) 有不同的规则

Primitive Type 原始类型

原始类型包括 string, number, boolean, symbol, undefined, null, 这些类型的数据是 immutable 的, 即只可读, 不可被改变(read-only, can’t be changed)

当一个变量持有原始类型的数据时, 你不能改变数据本身, 换句话说, 如果盒子里装的是原始类型, 你不能改变盒子里的东西, 你能做的只能创建一个新的盒子, 然后将变量指向这个新的盒子, 就如同之前的图所展示的:

reassign-a-variable

而不是以下所示图这样:

Flawed_Mental_Model_Of_Reassignment

同样的, 举个例子, 所有 string 的方法总是返回一个新的 string 而不是改变 string 本身, 如果你想要获取新的 string, 你就必须用一个变量指向他把他保存起来, 因为旧的值永远不会发生改变, 例如以下代码片段:

let name = "Dave"
name.toLowerCase();
console.log(name) // still capital-D "Dave"

name = name.toLowerCase()
console.log(name) // now it's "dave"

Object Type

第二种类型是对象类型, 包括常见的 Object, Array, Function, MapSet. 和原始类型不同的是这些类型是 mutable 的, 形象点说你可以改变盒子里的东西

Immutable is Predictable

Immutable 是可控的. 就像之前所说, 如果将一个原始类型作为参数传递给一个函数, 那么可以保证指向这个原始类型的变量是"安全的". 因为调用这个函数永远不会改变到这个变量所对应的值. 即盒子还是原来的盒子, 盒子里的东西永远没变.

但是对于对象类型, 这就存在隐患. 如果将一个对象传递给一个函数, 那么调用这个函数后该对象的值可能会发生改变. 比如如果这个对象是一个数组, 函数内部可以对这个数组进行增加元素或者删除元素操作. 虽然说对于外部一开始指向对象的变量引用没变, 但是由于对象是 mutable 的, 盒子里的值还是变了. 即盒子还是原来的盒子, 但是盒子里的东西最后有可能发生变化...

一个不会改变参数, 或者任何外部的东西的函数可以认为他是一个纯函数. 如果需要改变参数某个值, 那么会选择返回一个新的变量存着新的值

简单回顾: 变量指向盒子, 原始类型是 immutable 的

不管是原始类型还是对象类型, 在进行赋值操作的时候, 我们都认为创建了一个新的盒子, 然后将变量指向那个盒子, 这种操作永远都是成立的, 不管是发生在第一次赋值(assignment)还是后面的重新赋值(reassignment)

let num = 42
let name = "Dave"
let yes = true
let no = false
let person = {
  firstName: "Dave",
  lastName: "Ceddia"
}
let numbers = [4, 8, 12, 37]

原始类型是不可变的, 你没有办法改变盒子里的东西, 你只能重新创建一个盒子有着新的值, 然后让变量指向这个新的盒子.

对象类型: 改变盒子里的内容

假设有这样一个对象 book:

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

如果对这个对象的某个属性进行修改操作:

book.isCheckedOut = true

虽然 book 这个变量指向没变, 一直指向的盒子是同一个盒子(同一个对象). 但是盒子里的内容还是变了, 因为对象的属性变了.

如图所示:

change-object

注意这种赋值和之前的规则是一样的, 不同于我们直接使用变量 isCheckedOut, 这次使用 book.isCheckedOut 来代表一个变量, 重新赋值的时候指向新的盒子

需要注意的是, book 这个对象的引用一直没变, 即盒子还是原来的盒子. 如果我们尝试用另一个变量指向这个盒子, 会发现当 book 内的属性改变时, 另一个变量也会跟随变化, 因为都是指向同一个对象. 例如:

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

let backup = book

book.isCheckedOut = true

console.log(backup === book)  // true!

这种操作不是拷贝, 因为没有制造一个新的对象, 只是单纯的让两个变量指向同一个对象地址, 所以最后 console.log 返回的是 true

需要注意的是, 变量永远只会指向盒子, 不存在变量指向变量的情况. 比如这个例子里, 当进行 backup = book 操作的时候, JS 会查看 book 指向的盒子, 然后让 backup 也指向相同的盒子, 不存在 backup 指向 book 这种说法. 这就很赞, 因为就不会被谁指向谁再指向谁一层一层绕晕了...

在函数中 Mutate(修改) 一个对象

之前提到在函数中直接完全修改形式参数是不会影响到实际参数的(换盒子地址). 但是如果改变的是盒子内部的属性, 那么会影响到函数外边对该盒子的变量. 因为形式参数和实际参数都指向同一个盒子. 比如这个例子:

function checkoutBook(book) {
  // this change will leak out!
  book.isCheckedOut = true
}

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

checkoutBook(book); // isCheckedOut: true

用图来表示和之前的一样:

change-object

如果想要阻止这种情况发生, 比较好的做法是对传来的对象做一份浅拷贝, 创造一个新盒子, 新盒子里的属性和旧盒子完全一样. 这样改变新盒子的内容就不会影响到旧盒子了

function pureCheckoutBook(book) {
  let copy = { ...book }

  // this change will only affect the copy
  copy.isCheckedOut = true

  // gotta return it, otherwise the change will be lost
  return copy
}

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

// This function returns a new book,
// instead of modifying the existing one,
// so replace `book` with the new checked-out one
book = pureCheckoutBook(book);

关于引用的一些小例子

第一个例子:

document.addEventListener('click', () => console.log('clicked'));
document.removeEventListener('click', () => console.log('clicked'));

// won't work

addEventListenerremoveEventListener 第二个参数必须是一样的引用才能保证该回调函数被成功添加/移除到 click 事件上, 上面的例子相当于创建了两个匿名的盒子, 引用当然完全不一样

比如可以试一下:

let a = () => {}
let b = () => {}
console.log(a === b) // false

最后结果一定是 false. 所有的对象(array, function, set, map, etc.) 创建的时候都存在于自己的盒子里

第二个例子:

function minimum(array) {
  array.sort();
  return array[0]
}

const items = [7, 1, 9, 4];
const min = minimum(items);
console.log(min) // 1
console.log(items) // [1, 4, 7, 9]

sort() 会改变 array 本身. 不注意的话有时候就会造成意想不到的结果...

Strict Equal 和 Shallow Equal

两个概念其实经常出现在 React 以及一些衍生库里, 比如一些 APi 会提到默认使用 Strict Equal 进行比较, 如果想要自定义比较过程需要手动写 equal function.

Strict Equal

先说 strict equal. 中文翻译过来就是严格比较. 官方也有相关文档. 简单来说如果是原始类型, 那直接比较值就行, 比如 true === true, 1 === 1, null === null. 如果比喻成盒子的话, 比较的时候会具体到盒子里存的内容, 比如这样:

let a = 1
let b = 1
let c = a

console.log(a === b) // true
console.log(a === c) // true
console.log(b === c) // true

如果是两个对象进行严格比较, 那么比较的是他们两个地址一不一样, 即两个对象是不是同一个对象, 或者说他们指向的是不是同一个盒子.

比如最简单的:

let a = {}
let b = {}
console.log(a === b) // false

Shallow Equal

然后是 shallow equal, 浅比较. React 官方有关于这个概念简单的定义:

shallowCompare performs a shallow equality check on the current props and nextProps objects as well as the current state and nextState objects.
It does this by iterating on the keys of the objects being compared and returning true when the values of a key in each object are not strictly equal.

简单来讲, 就是对一个对象的所有属性进行严格比较, 注意是只有第一层的属性. 所以如果第一层属性对应的值是一个对象的话, 比较结果可能是 false, 因为两个对象的引用根本不一样. 举一些例子:

shallowEqual([1, 2, 3], [1, 2, 3]); // => true
shallowEqual([{ a: 5 }], [{ a: 5 }]); // => false

shallowEqual({ a: 5, b: "abc" }, { a: 5, b: "abc" }); // => true
shallowEqual({ a: 5, b: {} }, { a: 5, b: {} }); // => false

一些应用

React

React 就提供一些 API 基于 shallowEuqal 的特性进行性能优化, 比如比较常见的一个 React.memo. 官网的介绍也是非常清晰了, 这里直接贴一下:

If your component renders the same result given the same props, you can wrap it in a call to React.memo for a performance boost in some cases by memoizing the result. This means that React will skip rendering the component, and reuse the last rendered result.

React.memo only checks for prop changes. If your function component wrapped in React.memo has a useState, useReducer or useContext Hook in its implementation, it will still rerender when state or context change.

By default it will only shallowly compare complex objects in the props object. If you want control over the comparison, you can also provide a custom comparison function as the second argument.

翻译过来就这么几个点:

  • 如果组件接受相同的 props 且 render 的结果也一样, 那么可以用 React.memo 进行优化, 优化方式是这种情况下会跳过重复渲染, 直接采用上次渲染的结果(毕竟 render 结果都是一样的)
  • React.memo 只考虑 props 变化, 如果 state 有变化即使 props 一样组件依旧会被渲染.
  • 使用 shallowEqual 对 props 比较, 也就是对 props 这个对象进行浅比较. 如果 props 这个对象是一个很复杂的对象, 比如包含很多深层次的属性, 那可以自定义比较函数

说了这么多, 直接看一个例子: https://stackblitz.com/edit/react-wvppng-tfoayr

先看效果:

react-memo

代码如下:

import React from 'react';

function Child({ count }) {
  console.log('child render');
  return (
    <div>
      Child: <span>{count}</span>
    </div>
  );
}

const MemorizedChild = React.memo(({ count }) => {
  console.log('memorized child render');
  return (
    <div>
      MemorizedChild: <span>{count}</span>
    </div>
  );
});

export default function App() {
  const [value, setValue] = React.useState('');

  return (
    <div>
      <h1>React memo 使用</h1>
      <input
        type="text"
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
      <Child count={0} />
      <MemorizedChild count={0} />
    </div>
  );
}

App 组件里由于有一个输入框, 输入框有内容输入的时候会不断触发 setState 继而 App 会不断被渲染. 而由于 React 的默认行为是当父组件被渲染时, 子组件也会被渲染(不考虑 bail out 情况). 所以 ChildMemorizedChild 也理应要被渲染. 这里可以注意, 两个子组件的 props 一直都是一样的都是 { count: 0 }. 为了避免这种无必要重复渲染, 使用 React.memo 包裹后的 MemorizedChild 就不会被重复渲染了.

Redux

Redux 提供的 useSelector 默认的比较行为是严格比较(===), 如果想要通过返回一个对象或者数组拿多个状态的话很有可能造成没必要的重复渲染, 因为可能只是某个子状态发生了改变, 但由于进行的是严格比较, 整个对象的引用都变了, 会导致组件也会被渲染. 一般没有特殊需求的话使用 shallowEqual 就行. 如下:

import { shallowEqual, useSelector } from 'react-redux'

// later
const selectedData = useSelector(selectorReturningObject, shallowEqual)

或者直接自定义为一个 hook:

import { useSelector, shallowEqual } from 'react-redux'

export function useShallowEqualSelector(selector) {
  return useSelector(selector, shallowEqual)
}

另一个类似的库 zustand 也是提倡类似的概念, 官方文档也更加简洁清晰, 如下:

// If you want to construct a single object with multiple state-picks inside, similar to redux's mapStateToProps, you can tell zustand that you want the object to be diffed shallowly by passing the shallow equality function.

import shallow from 'zustand/shallow'

// Object pick, re-renders the component when either state.nuts or state.honey change
const { nuts, honey } = useStore(state => ({ nuts: state.nuts, honey: state.honey }), shallow)

// Array pick, re-renders the component when either state.nuts or state.honey change
const [nuts, honey] = useStore(state => [state.nuts, state.honey], shallow)

// Mapped picks, re-renders the component when state.treats changes in order, count or keys
const treats = useStore(state => Object.keys(state.treats), shallow)

// For more control over re-rendering, you may provide any custom equality function.
const treats = useStore(
  state => state.treats,
  (oldTreats, newTreats) => compare(oldTreats, newTreats)
)

用这类库的时候有时候多写一行也不是什么坏事, 保证每次获取的状态都是独立, 这样也不用去考虑如何写 compareFn, 理论上也不会导致不必要的渲染

参考

简单聊一聊一个前端编辑器的性能优化

最近项目一直在使用 Monaco Editor 这个库. 在我加了一个新功能之后, 整个编辑器开始变的非常卡, 我试图解决这个性能问题. 但是发现有一些棘手...

评论以及文末有更新

场景

其实我加的新功能很简单, 以 vscode 为例, 他底部有一个 status bar, 用于显示当前编辑器的一些信息. 我当时加的功能是模仿 vscode, 在底部增加一个能显示当前光标位置/选中位置, 以及选中的单词的长度. 由于是公司代码的原因我这里只是简单放一下伪代码, 实际代码会比这个这个伪代码复杂和抽象很多

文件结构:

<App />
  // ... other components
  <div>
    <Explorer />
    <Editor />
  </div> 
  <StatusBar />

简单说明一下, 有一个组件叫 App, 由于历史遗留原因这二组件是个 class 组件, 而且代码很多, 这个组件下有非常多的子组件, EditorStatusBar 就是其中的两个子组件

而我现在的需求是: 用户在 Editor 里做任何操作, StatusBar 组件都需要显示出对应用户光标的位置

我思路也很简单:

  • cursor(光标) 部分的状态在 Editor 中是可以拿到的
  • 由于 StatusBar 这个组件需要根据 cursor 状态来渲染, 因此做状态提升, 到 App
  • App 中初始化 cursor 状态, 传入相关 setState() 回调函数到 Editor 中, Editor 组件调用更新 cursor 状态, 最后 StatusBar 获取 cursor 最新的状态进行渲染.

实现

所以代码可能长这样

// App.js

import Editor from './Editor'
import StatusBar from './StatusBar'

export default class App {
  constructor(props) {
    super(props) {
      this.state = {
        // ...other state
        cursorPosition: {
          lineNumber: 1,
          column: 1
        }
      }
      this.editorRef = React.createRef()
    }
  }

  setCursorPosition = (cursorPosition) => {
    this.setState({
      cursorPosition,
    })
  }

  render() {
    return (
      // ... other components
      <div>
        <Explorer />
        <Editor 
          setCursorPosition={this.setCursorPosition} 
          editorRef={this.editorRef}
        />
      </div> 
      <StatusBar cursorPosition={this.cursorPosition} />
    )
  }
}
// Editor.js

import MonacoEditor from 'some-thirdparty-react-monaco-package'

export default function Editor(props) {
  const { setCursorPosition, editorRef } = props

  const handleEditorDidMount = () => {
    editorRef.current.onDidChangeCursorPosition(ev => {
      setCursorPosition(ev.position)
    })
  }

  return (
    <MonacoEditor editorDidMount={handleEditorDidMount} />
  )
}
// StatusBar.js

import Button from '@material-ui/core/Button';

export default function StatusBar(props) {
  const { cursorPosition } = props

  return (
    <Button size="small">
      {cursorPosition}
    </Button>
  )
}

看上去好像没啥问题, 我当时这么写也没考虑太多

问题

实际上最后写完我测试发现了很大的性能问题

其实很简单, 我每次在 Editor 里面调用父组件(App) 的 setState(), 都会导致父组件重新渲染. 而 App 这个组件是一个很大的类组件, 里面还渲染很多别的组件, 而用户在编辑器里面只要敲一点东西, 光标几乎都会改变(或者直接点, 用户闲着没事在编辑器里直接乱点一通, 也能达到相同的效果). 这直接导致重新渲染 App 的频率相当频繁...

然后我页面就卡爆了...

我当时有点懵, 因为说实话这种渲染确实好像没法避免, 我每次的 cursor 状态确实不一样, 我没法直接通过 shouldComponentUpdate 来避免不必要的重复渲染

当然有时候可以避免, 就是当用户的每次都点击同一个地方, cursorPosition 就一样了...

方法

我一开始想到的一个办法是, 将 Editor组件和 StatusBar 重新写在一个新的组件, 这样所有的状态就只在这个新组件(可以看成这是一个中间组件)里面管理, 不会触发 App 这个大组件的重新渲染了.

但我还是很快放弃了这个想法, 因为把 StatusBarEditor 放一起其实不简单.... 从 dom 上来看, 他们其实不是严格的兄弟组件, 中间还有着别的组件, 如果要抽出来还必须连带着别的组件一起重写:

export default function MyNewComponent(props) {
  const { 
    editorRef,
    // ...还有很多别组件的 props...
  } = props

  // ...
  return (
    <div>
      <CompA />
      <CompB />
      // ...other component
      <div>
        <Explorer />
        <Editor editorRef={editorRef} />
      </div>
      <StatusBar />
    <div>
  )
}

总之, 我这么重构 effort 其实挺大...

可能的解决方案?

后来和另一个组里的实习生交流的时候, 他提出尝试把 cursor 部分的状态放到 redux 里面管理, 在 Editor 里面 dispatch 相应改变 cursorPositionaction, 在 StatusBar 里面连接 redux 拿到最新的 cursorPosition 状态. 这样直接绕过 App 这一层, 避免了重复渲染

其实这个思路和之前的想法类似 都是要避开 App 这个大组件, 只不过用 redux 这种状态管理库似乎代码写起来简单一些

代码最后就变成这样了:

// Editor.js

import { useDispatch } from 'react-redux'
import { setCursorPosition } from './editorActions.js'
import MonacoEditor from 'some-thirdparty-react-monaco-package'

export default function Editor(props) {
  const { setCursorPosition, editorRef } = props
  const dispatch = useDispatch()

  const handleEditorDidMount = () => {
    editorRef.current.onDidChangeCursorPosition(ev => {
      dispatch(setCursorPosition(ev.position))
    })
  }

  return (
    <MonacoEditor editorDidMount={handleEditorDidMount} />
  )
}
// StatusBar.js

import Button from '@material-ui/core/Button';
import { useSelector, shallowEqual } from 'react-redux'

export default function StatusBar(props) {
  const { cursorPosition } = useSelector(state => state.editor, shallowEqual)

  return (
    <Button size="small">
      {cursorPosition}
    </Button>
  )
}

不过这么讲也存在别的一些问题:

  • cursorPosition 这个状态只在一个组件里被用到, Redux 本意还是为了做状态的管理, 多个组件可能都会共享到这个状态, 但现在只有一个, 似乎有点大材小用了
  • 我们项目里 Redux 已经放了很多的状态了, mentor 不希望我再放别的进去了...
  • 我没见过像我一样用 Redux 来做性能优化的...

可能有更好的解法?

如果你恰好能明白我在讲什么, 并且有更好的办法, 请务必告诉我...


更新

文章发布之后有几位前辈评论了一下, 都非常好. 我自己总结然后实践了一下, 以下是我的解决思路

不管是我之前用的 Redux, 还是评论里面提到的 Context, 其实都是 Pub/Sub (发布订阅)这个**的实践. 不过由于目前使用 Redux 有点太重, 所以其实用 Context 会更好

不过在使用 Context 的时候存在一个问题: 就是如果 contextvalue 是一个对象这种复杂结构, 然后存在多个消费者, 每个消费者可能只是订阅一部分 value. 但是由于 context 的设计, 只要 value 部分变了, 那么所有的消费者都会被通知, 那么有很大的可能所有的消费者组件都被重新渲染了.

基于这个问题 Dan Abramov 也是有给出一些解法. 其实最直白的做法就是将多个 context 分离成几个更小的. 不过我在搜索的过程中发现了另外一个似乎更精巧的解法, 虽然可能有点简陋. 但是用在我们项目里我觉得应该够了(其实我不确定, 等过两天 mentor 给我 review 代码的时候再问问)?

context

先看看最基本的使用 context 的做法, 也是评论里 @李引证 提到的做法, 不过这里我略有修改.

// editorContext.js

import React from 'react'

// actions
export const setCursorPosition = () => {
  // ...
}

export const setSelections = () => {
  // ...
}

// context
export const EditorStateContext = React.createContext()
export const EditorDispatchContext = React.createContext()

const initialState = {
  cursorPosition: {
    lineNumber: 1,
    column: 1
  },
  selections: ['']
}

function editorReducer(state, action) {
  // ... reducer logic
}

export function EditorProvider({ children }) {
  const [state, dispatch] = React.useReducer(editorReducer, initialState)
  return (
    <EditorStateContext.Provider value={state}>
      <EditorDispatchContext.Provider value={dispatch}>
        {children}
      </EditorDispatchContext.Provider>
    </EditorStateContext.Provider>
  )
}

export function useEditorState() {
  const context = React.useContext(EditorStateContext)
  return context
}

export function useEditorDispatch() {
  const context = React.useContext(EditorDispatchContext)
  return context
}

这里我选择用 useReducer() 也是因为其实我的 editor 状态有点复杂, 而且 cursorPositionselections 是对应两个不同的消费者组件. 如果单纯这么用, 其实是有一点我之前提到的性能问题的

mapStateToProps

参考了 React Context API and avoiding re-renders 这个问题下的一个回答. 其实核心就在于用 React.memo 以及将相关对应的 context 上的 value map 到对应的消费者组件的 props 上, 相关 props 变了, 组件才重新渲染. 虽然感觉兜兜转转绕了半天又绕到了 Redux 上....

// editorContext.js
// ... 

export const useEditorCursorState = () => {
  const { cursorPosition } = useEditorState()
  return {
    cursorPosition
  }
}

export const useEditorSelectionState = () => {
  const { selections } = useEditorState()
  return {
    selections
  }
}

export function connectToContext(WrappedComponent, select) {
  return props => {
    const selectors = select()
    return <WrappedComponent {...selectors} {...props} />
  }
}

结合我之前的 EditorStatusBar 一起用:

// App.js

import Editor from './Editor'
import StatusBar from './StatusBar'

export default class App {
  constructor(props) {
    super(props) {
      this.editorRef = React.createRef()
    }
  }

  render() {
    return (
      // ... other components
      <EditorProvider>
        <div>
          <Explorer />
          <Editor 
            editorRef={this.editorRef}
          />
        </div> 
        <StatusBar />
      </EditorProvider>
    )
  }
}
// Editor.js

import React from 'react'
import { 
  useEditorDispatch, 
  setCursorPosition, 
  setSelections 
} from './editorContext'
import MonacoEditor from 'some-thirdparty-react-monaco-package'

const Editor = React.memo((props) => {
  const { setCursorPosition, editorRef } = props
  const editorDispatch = useEditorDispatch()

  const handleEditorDidMount = () => {
    editorRef.current.onDidChangeCursorPosition(ev => {
      useEditorDispatch(setCursorPosition(ev.position))
    })

    editorRef.current.onDidChangeCursorSelection(ev => {
      useEditorDispatch(setSelections(ev.selections))
    })
  }

  return (
    <MonacoEditor editorDidMount={handleEditorDidMount} />
  )
})

export default Editor
// StatusBar

import React from 'react'
import Button from '@material-ui/core/Button';
import { connectToContext, useEditorCursorState } from './editorContext'

const StatusBar = React.memo((props) => {
  const { cursorPosition } = props

  return (
    <Button size="small">
      {cursorPosition}
    </Button>
  )
})

export default connectToContext(StatusBar, useEditorCursorState)

如果你有更优雅的解法, 请一定告诉我...

简单聊一聊 hooks 与闭包

简单聊一聊 hooks 与闭包

变量引用

关于这方面问题不做深究, 可以看做是指针

let x = 0
let y = x
let z = `hello ${x}`

x = 1
console.log(y) // 0
console.log(z) // hello 0

闭包

  • 函数在创建的时候就会生成一个词法环境, 在运行的时候同样会创建另一个新的词法环境, 两个词法环境可能不同. 分析时需要理清楚
  • 闭包中的变量引用. 词法环境中如存在引用, 需要分析该引用在后面的函数(闭包)调用中是否被修改.

这里重点谈谈第二点, 关于闭包里的变量引用问题.

例子 1:

function outer() {
  let x = 0
  function inner() {
    let _x = x
    function log() {
      console.log({ x, _x })
    }

    return log
  }

  function change() {
    x += 1
  }

  return [inner, change]
}

let [inner, change] = outer()

let log = inner()
log() // { x: 0, _x: 0 }
change()

log() // { x: 1, _x: 0 } 

分析:

  • 首先调用outer()函数创建了一个闭包, 闭包中的变量为x = 0, inner函数和change在被创建的过程中形成词法作用域, 可以访问到该变量
  • 调用inner()函数, 此时在当前的词法作用域下创建一个新的闭包, 该闭包中创建了一个新的变量_x, 当然还存在之前引用的x变量. log函数在被创建时形成词法作用域, 可以访问到_xx, 当然这两个变量目前相等
  • 调用change()函数, 该函数在第一次声明的词法作用域下修改了x变量, 使其为1
  • 调用log()函数, 注意, 由于闭包的特性, log()函数可以访问到x_x, 但由于change()修改了最顶层的词法作用域里的x, 这里读取的x也为1. 不过, 由于这里的_x只在inner()函数调用(也就是声明log函数)的时候声明一次, 且指向 x = 1, 即使change()修改了x对其并无任何影响. 因此这时的log()函数调用的词法作用域为x = 1, _x = 0

例子 2:
该例子取自于 react-hooks-stale-closures 这篇文章. 虽然我个人觉得这篇文章的作者其实没写到点子上...

有如下两段代码

function createIncrement(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    const message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  
  return increment;
}

const inc = createIncrement(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3

log();             // "Current value is 1"
function createIncrementFixed(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    return function logValue() {
      const message = `Current value is ${value}`;
      console.log(message);
    };
  }
  
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3

log();             // "Current value is 3"

这里就不细展开了, 具体的分析可以看 这个问题下的答案, 主要需要注意这几点:

  • value 在最顶层的词法作用域, 确实是不断在变化的
  • log() 函数在被调用的时候, 确实拿到的value值是最新的值, 但是第一段代码与第二段代码的区别在于message变量, 第一段代码中的messagevalue修改值之后其保存的value仍旧是原始的值(也就是 1)(此 value 非彼 value), 而第二段代的message并不是作为log函数的定义时的闭包变量而存在, 而是作为自己的作用域内的变量. 因此在调用log()函数的时候, 才会声明message, 这时候再去查找value, 得到的当然是最新值

对于第二段代码, 这么改效果也是一样的:

function createIncrementFixed(i) {
  let message;
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3
log();             // "Current value is 3"

分析: 每次调用inc()的时候不仅修改顶层词法作用域里的value, 也在不断重写message, 使其内部的valuevalue变量始终保持一致

hooks

从计时器开始

上一篇已经讲到, 可以使用闭包来模拟类的行为, 还是以计数器为例, 假定有如下代码:

const Counter = () => {
  let value = 0

  const render = () => {
    setTimeout(() => {
      console.log(value)
    }, 2000)
  }

  const inc = () => {
    value += 1 
    render()
  }

  return { render, inc }
}

const c = Counter()

c.inc() // 3
c.inc() // 3
c.inc() // 3

可以看到结果, 2 秒以后输出值全为 3. 然而往往需求可能是, 每次打印出来的值应该是顺序的, 比如在这个例子里面希望 2 秒后依次打印 1, 2, 3

可以这么做:

const Counter = () => {
  let value = 0

  const render = () => {
    let v = value
    setTimeout(() => {
      console.log(v)
    }, 2000)
  }

  const inc = () => {
    value += 1 
    render()
  }

  return { render, inc }
}

const c = Counter()

c.inc() // 1
c.inc() // 2
c.inc() // 3

这里我们使用v这个临时变量存取当前(上一次)的value的值, 这样使得setTimeout这个闭包内部可以在 2 秒后, "正确"读取到当前属于自己的值. 而对于第一段代码, 由于inc()不断调用修改了顶层词法作用域里的value变量, setTimeout读取到的value的值永远都是最新的(因为 value 不断被更新...)

react

引子

先来看一个场景:
Dan 在他的 博客 里面有这样一个场景实例: 有一个类似 twitter 的页面, 你想要 follow 某个用户, 但是点击 follow 这个动作是一个异步请求可能需要时间, 在点击了 follow 这个操作之后, 立马切换到另一个用户的页面, 几秒钟之后客户端收到响应, 显示你 follow 了"这个"用户

具体的 live demo 点击这里查看

然而, 使用函数式组件写法, 和 class 组件写法带来的效果是很不同的, 两种写法的组件和效果分别如下:

class 组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

bug

函数式组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

fix

可以看到, 使用函数式组件可以得到正确的 follow, 延时请求所请求的 user 是之前点击的 Follow 按钮的 user, 而非像 class 组件一样发送了错误的 user 请求

下面会进行分析, 当然也推荐直接看 Dan 的 博客

分析

Dan 在另外一篇 文章 中总结的相当好:

  • 函数式组件在每一次渲染都有它自己的…所有, 你可以想象成每次 render 的时候都形成了一次快照, 保存了所有下面的东西, 每一份快照都是不同且独立的. 即
    • 每一次渲染都有自己的 props 和 state
    • 每一次渲染都有自己的事件处理函数
    • 每一次渲染都有自己的 useEffect()
  • class 组件之所以有时候"不太对"的原因是, React 修改了 class 中的 this.state 使其指向永远最新状态

例子

有如下代码: live demo

function App() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

一开始先点击Show alert按钮, 然后立马点击 3 次 Click me按钮, 3 秒过后浏览器打印出来的结果为打印出"You clicked on: 0"

更多次实验以后会发现, Show alert 只会显示在点击触发前那一刻所对应的 count 的值, 比如目前 count 为 5, 点击 Show alert之后立马再点击几次Click me, 3 秒过后浏览器打印的结果为 5

这是由于闭包的原因, 每次setTimeout()读取到的 count 的是当前 render状态下的值, 即使后面对count进行了改变, setTimeout()中的 count不受影响, 永远是当前 render 下的 count 的值, 而非最新的 count 的值

那如果想要得到最新的值呢?

最简单, 可以使用useRef()来存取最新的值, 那么代码可以改成如下:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0)

  countRef.current = count

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + countRef.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

这时候重复上述过程, 可以发现 3 秒之后能得到当前的最新值.

关于 useEffect
实际上 useEffect 也是一个函数, 和 handleAlertClick类似, 也可以实现类似需求: 即在组件 mount 的时候根据初始 state 只发送一个异步请求, 用户在等待请求的过程中对该 state 重新进行了设置 那么该请求中所涉及到的 state 应该是在 mount 时候的初始值, 也就是 initial state, 代码如下:

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }, [])

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

在页面初次渲染之后便开始发送请求, 此时点击几次Click me按钮, 3 秒之后会显示You clicked on: 0

当然, 如果想要实现在 mount 时发送请求携带的 state 是最新的用户操作过后的数据, 那么还是一样可以使用useRef()来存取最新的 state, 代码改成如下:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);

  countRef.current = count;

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + countRef.current);
    }, 3000);
  }, [])

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

这样再点击Clik me之后, state 发生了修改, 那么之前发送的请求中的 state 也就是修改过后的最新的 state.

这里需要注意, 第一段代码中useEffect()虽然为空, 但是 eslint 会提示需要加上 count, 但是加上了并不是我们想要的效果, 代码会变成这样:

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }, [count])

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

那么最后的结果为, 每次点击Click me, 触发 count 的更新, 随之也触发useEffect()这个函数的调用, 那么请求也会每次被发送, 当然由于闭包, 3 秒之后会依次显示所对应的 count 的值, 比如点击了 3 次, 那么 3 秒过后依次打印 1, 2, 3

总结: 需要分析清楚你想要的是什么, 是需要最新的 state/props(可以用 ref), 还是想要每次 render 下所对应的自己的 state/props

另外, 非常推荐 Dan 的 A Complete Guide to useEffect, 虽然很长, 但是讲的是非常细致, 当然啃下来还是不容易的...

参考

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.