typical 精简打字机的原理

Created
Feb 27, 2021 03:54 AM
Tags
typical
 
hi 👋

打字机的功能

Demo 如上,页面中的打字机可提供交互性的文本类内容展示、增加页面趣味性,其功能主要有:(以动画形式) 打字、删字、暂停、循环,确实很酷!

typical 的优点

打字功能并非必要功能,无需引入 typed 那样的大库。这时,typical 以其优雅轻巧的步伐像我们走来,其源码,仅仅 50 余行,却五脏俱全。对 Promise 和 Generator 的运用,恰到好处,十分值得模仿学习。此外,typical 有个彩蛋:利用轻微的停顿,模仿人打字的延迟。
beet 写下此文学学它的思路,分析一下原理,记录分析的思路。

原理:从功能切入

项目代码没有注释,所以我认为应该了解 typical 的功能后,再去深入其原理,能更好理解项目作者的意图,下面是 typical 几大功能,我会分别分析其原理:

基本打字功能

用法:type(htmlElement, 'text_you_wanna_type')
首先,type() 的原型:
/* Snippets 1 */
async function type(node, ...args) {
  // (*)
  for (const arg of args) {
    switch (typeof arg) {
      case 'string': // (**)
        await edit(node, arg)
        break
      case 'number':
        await wait(arg)
        break
      case 'function':
        await arg(node, ...args)
        break
      default:
        await arg
    }
  }
}
(*) 处使用扩展运算符,args 变量现在属于一个可遍历的数据结构,每次遍历可以得到传入 type() 函数的下一个参数,所以在此种情况下,for 循环体内是对各种情况的处理:
  • string: edit(node, arg)
  • number: wait(arg)
  • function: arg(node, ...args)
  • default: 主要包含对 Promise 的处理
(**) 处属于当前情形,自然跳转到 edit() 函数:
/* Snippets 2 */
async function edit(node, text) {
  const overlap = getOverlap(node.textContent, text) // (*)
  await perform(node, [
    ...deleter(node.textContent, overlap), // (**)
    ...writer(text, overlap), // (***)
  ])
}
函数只是个黑匣子,当我们暂时不了解其作用时,望文生义也不常是一种机智之举,(**) 和 (***) 处 deleter 和 writer 极可能是打字时删、添功能的实现,再者,他们都使用扩展运算符,一定是可遍历的数组或其他数据结构。(*) 处 overnap 字面是重叠,依据文档的 Smart delete 功能,它可能是计算目标字符段与当前字符的重复字段。最后,根据几个函数之间的依赖关系,肯定要先读懂 overlap,再 deleter \ writer,最后 perform 函数:
/* Snippets 3 */
function getOverlap(start, [...end]) {
  return [...start, NaN].findIndex((char, i) => end[i] !== char)
}
通过对实参和形参的分析,Snippets 2 中的 overlap 表示:拿目标元素原本的字符与目标字符串注意比较,找出不同的那一个索引,赋值给 overlap 变量。此时应该对 deleter \ writer 的功能更加明确了,马上来看看它们的具体实现:
/* Snippets 4 */
function* writer([...text], startIndex = 0, endIndex = text.length) {
  while (startIndex < endIndex) {
    yield text.slice(0, ++startIndex).join('')
  }
}

function* deleter([...text], startIndex = 0, endIndex = text.length) {
  while (endIndex > startIndex) {
    yield text.slice(0, --endIndex).join('')
  }
}
果然,他们都是对字符串的减、增处理,通过 Generator 函数生成可遍历的数据结构。具体来说:deleter 负责生成(相对与下次目标字符串)需要删除的内容,保留可以回收的内容,writer 负责生成(相对与下次目标字符串)还没有打印的序列,比如原本元素文本内容是 'bee',执行 type(htmlElement, 'beetcb') ,deleter 会得到类似 () 的空序列,而 wirter 会得到类似 ('beet', 'beetc', 'beetcb') 的序列,此时通过展开运算符可以得到新的数组:['beet', 'beetc', 'beetcb']。 作为实参传递给 perform() 函数, 现在我们去瞧瞧 perform 函数:
/* Snippets 5 */
async function perform(node, edits, speed = 60) {
  for (const op of editor(edits)) {
    // change the text
    op(node)
    // pause a while
    await wait(speed + speed * (Math.random() - 0.5))
  }
}

function* editor(edits) {
  for (const edit of edits) {
    yield node => requestAnimationFrame(() => (node.textContent = edit))
  }
}
editor 函数类似 deleter \ writer,生成(改变字符)需要请求的动画帧序列,比如延续上面的例子,生成:
(
(node) => requestAnimationFrame(() => (node.textContent = 'beet'),
(node) => requestAnimationFrame(() => (node.textContent = 'beetc'),
(node) => requestAnimationFrame(() => (node.textContent = 'beetcb')
)
所以 perform 里的 op 是每次迭代时用于改变文字的箭头函数, 执行它就相当于改变文字, 然后借用一个 setTimeOut 后 resolve 的 promise 来暂停一会儿(wait 函数,比较简单,不赘述),后继续改变文字。
再次考虑之前举的例子,此时从 'bee' -> 'beetcb' 的过程已经完成,这也结束了 type(htmlElement, 'beetcb') 的执行过程。

暂停

用法:type(ele, 'beetcb', 1000, 'bee')
比较上面的例子,多了两次迭代,再看 Snippets 1 的转换语句,易知第三次迭代调用 wait 函数暂停 1000ms 后迭代到 'bee' 的时候,再次执行处理字符串的所有操作,此时增减则和第一次例子截然相反 deleter 返回 ('beetc', 'beet', 'bee') 序列,而 writer 返回 () 序列,这就完成了 'bee' -> 'beetcb' -> 'bee' 需要的所有操作。

循环

用法:type(ele, 1000, 'beetcb', 1000, 'bee', type)
由转换语句可知,当迭代到函数时,执行此函数,而且其参数与原本的 type 函数相同,也可以理解为递归,这就完成了循环的过程。
hint: 至于为何要在 'beetcb' 前防止一个 1000,是保证出第一次以外的所有递归留有 1000ms 的暂停时间

函数

用法:type(ele, () => console.log('cloud used for anything'))
这其实是为了完成递归的副产品,因为当迭代到函数时,默认执行它,此时不传参也没影响,在迭代 type 的参数时迭代到了这个函数,函数自然就执行了。

Promise

用法: type(ele, new Promise(...))
可以看到,转换语句最后一句,await 传入的 Promise,等它 resolve 之后,再去继续迭代。

优点分析

  • 把函数参数交给 switch case 语句处理,既突破了形参实参顺序必须一致的限制,又巧妙地复用了大部分代码
  • Generator 确实精简,而我还在用 Array.reduce(),菜
  • 大量用到剩余、展开运算符,优雅的写法
  • 异步中含同步,这也是 async await 的简洁之处 ...
最后:放个 repo 地址 去 star 吧!