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 吧!