Roselia-Blog Hooks

Jun 11, 2020

见习魔法师

Roselia-Blog Hooks

我们知道,React Hooks是一个令人称道的设计,它可以让使用者在不使用class的前提下使用state以及其他react特性,这个和Roselia-Blog的内嵌脚本十分相似,该脚本类似于tex,是文字与控制命令的结合。但是,该脚本没有类来存储状态,在这种情况下,我们可以将整个文章视为一个渲染函数,作者写出了一篇文章,恰巧,这篇文章里有部分内嵌脚本用于帮助渲染文章的内容。

在3.8.2中,Roselia-Blog支持了hooks API,这些API和React中的同名hook表现一致(虽然Roselia-Blog是用Vue实现的),当然,也有Vue-style的API以供使用。 当然,hooksAPI 在评论中是无法使用的,所有的改动也只针对文章内容。

defState

让我们先从最简单的一个例子来看看hooks是如何实现的:

r{{ raw{ defState('c', 0), btn(c, () => ++c) } }}

渲染出来结果如下:r{{ defState('c', 0), btn(c, () => ++c) }}

这个语句由两个部分组成,第一个是对defState的调用,第二个是对btn的调用,最后返回了btn的结果(逗号表达式,并不是生成元组)。

defState接受两个参数,第一个是变量名,第二个是初始值,可以是一个接受0个参数,返回初始值的函数。之后会在上下文中引入同名的响应式变量,这就是接下来能用c的原因。btn接受三个参数(都是可选的),第一个是按钮显示的文字,第二个是onClick的handler,最后一个是class的名字。这段代码会生成一个按钮,显示当前count,点击一下就将count + 1,最后就是这样的结果。

那么为什么count改变时,button上显示的文字也会改变呢?其实很简单,只要监听变量的改变,然后重新渲染即可。此时所有的script都会被执行,在第二次执行defState的时候,和第一次不同,将会直接引用之前的变量,也就是说,defState会根据传入的名字来检查cache中有没有该变量,若有,则直接返回,若没有,则初始化该值并返回,然后监听该值的变化,如果改变了,就重新渲染。其基本思想是依赖调用的时序和名字,保证每次调用对相同的名字返回相同的引用。

export class RoseliaScriptState {
  private readonly internalState: object
  public readonly state: object
  private updateCallback: (() => void) | undefined;
  constructor(callback?: () => void) {
    this.updateCallback = callback
    this.internalState = {}
    this.state = new Proxy(this.internalState, {
      set(target, key, value) {
        const result = Reflect.set(target, key, value)
        this.triggerCallback()
        return result
      }
    })
  }

  public triggerCallback() {
    this.updateCallback?.()
  }

  public defineState<S>(key: string, value: S | (() => S)) {
    if (key in this.internalState) return this.internalState[key];
    this.internalState[key] = value instanceof Function ? value() : value;
  }
}

可以看见,监听改变是通过Proxy实现的,对外只暴露被监听的state

useState

当然,有诸多React的拥趸怀念useState,不用担心,我特地准备了一份。useState只接受一个参数,即初始值,返回一个列表,第一个元素是值,第二个元素是设置该值的函数,该设置函数可以接受一个值,或者一个根据旧值生成新值的函数。

useState依赖于函数调用的顺序,即该函数在每次渲染时返回的是时序的,相同次序调用的函数返回相同的引用。关于其具体实现,可以看React hooks: not magic, just arrays 。因此,我们可以这样写一个计数器:

r{{ raw{ def(['x', 'setX'], useState(0)), btn(x, () => setX(x + 1)) } }}

渲染结果如下:r{{ def(['x', 'setX'], useState(0)), btn(x, () => setX(x + 1)) }}

注意,在测试时,同一篇文章中变量不要冲突,现在c是r{{c}},x是r{{x}}。 因为所有的脚本是按顺序调用的,所以定义的变量的作用域是从定义到文章末尾,所以变量要先定义再声明,否则你可能会获得上次渲染的变量。

useEffect

useEffect是另一个hook,可以管理渲染中的副作用。该hook和Effect Hook表现一致,可以用于显示通知等其他用处。

这里有一个demo是利用useEffect实现一个可保存状态的todo list,其原理是通过监听某个变量的改动而执行effect,每次渲染时,如果值没有发生改变,则不执行。

r{{ def('selection', (value, onClick, whenChecked) => { const el = createElement('div', null, [ createElement('input', { type: 'checkbox', value, checked: whenChecked }), value ]) return withEventListener(el, 'click', onClick) }) }}

r{{ def(['window'], useState(''.constructor.constructor('return window;'))) }} r{{ def('tick', useState()[1]) }} r{{ def('storageKey', post.id + '-todo'); }} r{{ defState('tasks', ['Roselia-Hooks', 'defState', 'useState', 'useEffect']) }} r{{ defState('complish', () => { const saved = window.JSON.parse(window.localStorage.getItem(storageKey) || '[]'); if (saved.length != tasks.length) return tasks.map(() => false) return saved }) }}

Roselia-Hooks 了解情况:完成了 r{{ complish.filter(x => x).length }}个,总共 r{{ tasks.length }} 个。r{{ (storageKey in window.localStorage) && btn('清除', () => { complish = tasks.map(x => false); window.localStorage.removeItem(storageKey); tick() }) }} r{{ useEffect(() => { (complish.every(x => x)) && sendNotification({ message: '你太强了!', color: 'success'}) }, complish.slice())}}

r{{ createElement('div', null, tasks.map((task, i) => selection(task, () => {complish[i] = !complish[i]; tick(); }, complish[i]))) }}

r{{ useEffect(() => { window.localStorage.setItem(storageKey, window.JSON.stringify(complish)) }, complish.slice()) }}

代码如下:

r{{ raw{ def('selection', (value, onClick, whenChecked) => {
    const el = createElement('div', null, [
        createElement('input', {
            type: 'checkbox',
            value,
            checked: whenChecked
        }),
        value
    ])
    return withEventListener(el, 'click', onClick)
})} }}

r{{ raw{ def('tick', useState()[1])} }}
r{{ raw{ def('storageKey', post.id + '-todo');} }}
r{{ raw{ defState('tasks', ['Roselia-Hooks', 'defState', 'useState', 'useEffect'])} }}
r{{ raw{ defState('complish', () => {
    const saved = window.JSON.parse(window.localStorage.getItem(storageKey) || '[]');
    if (saved.length != tasks.length) return tasks.map(() => false)
    return saved
})} }}

Roselia-Hooks 了解情况:完成了 r{{ raw{ complish.filter(x => x).length } }}个,总共 r{{ raw{ tasks.length } }} 个。

r{{ raw{ createElement('div', null, tasks.map((task, i) => selection(task, () => {complish[i] = !complish[i]; tick(); }, complish[i])))} }}


r{{ raw{ useEffect(() => {
    window.localStorage.setItem(storageKey, window.JSON.stringify(complish))
}, complish.slice())} }}

例如,Roselia-Blog的额外显示设置,现在可以使用反应式状态来跟踪,从而实现动态更新的效果,目前Roselia-Blog支持的开关有: r{{ defState('noSideNav', false) }} r{{ defState('blurImage', true) }} r{{ defState('metaUnderImage', false) }} r{{ selection('disableSideNavigation: 关闭侧边导航', () => {noSideNav = !noSideNav}, noSideNav) }} r{{ selection('blurMainImage:模糊主图片', () => blurImage = !blurImage, blurImage) }} r{{ selection('metaBelowImage:文章信息显示在图片底下', () => metaUnderImage = !metaUnderImage, metaUnderImage) }}

r{{ changeExtraDisplaySettings({disableSideNavigation: noSideNav, blurMainImage: blurImage, metaBelowImage: metaUnderImage}) }}

在引入了hooksAPI之后,我们就不需要使用class-style component了,虽然我也从来没有过这个plan。hooks 的好处是可以在想要使用reactive状态的时候直接声明,将相同任务的代码放在一起,但是可能会有性能损失,因为所有的闭包都会重复创建,比如所有的帮助函数,都会重新创建一份,但是鉴于Roselia-Blog的文章都相对简短,现代计算机足以应对这种程度的重渲染,因此不需要再暴露dom API去手动管理dom了,也算是告别了刀耕火种的时代了。

做一个游戏

手速大挑战! r{{ defState('now', 0), defState('high', 0), defState('timer', 10) }} r{{ defState('interval', null) }} r{{ useInterval(() => {if (!(--timer)) {interval = null; high = Math.max(high, now); if (now >= 100) {sendNotification({message: '不愧是你!', color: 'success'});} else if(now < high) sendNotification({message: '你怎么变弱了?', color: 'error'}); now = 0; timer = 10;} }, interval) }}

最高分: r{{ high }}

当前分数:r{{ now }} 剩余时间:r{{ timer }}

r{{ btn('点我!', () => {if(!(now++)) interval = 1000;}, 'primary') }}

如果你迫不及待想要尝试hooks API,可以参见上篇文章中提到的使用编辑页面的的方法。