MUR也懂的交互式文章教程!

Roselia-Blog Hooks II

Jun 19, 2020

见习魔法师

---feature:roselia-dom---

前言

Roselia-Blog Hooks 使得用户可以在文章中使用交互式环境,在曾经,这是一个内嵌脚本,做的是普通的字符串替换。State的更新会导致整体文章全部被重新渲染,这样十分低效。例如上一篇文章,使用了旧版的基于字符串替换的渲染。点击了按钮之后需要60ms左右的时间更新,如果你能打开审查元素,你会发现每次更新导致dom被全部更新,大量无效的更新使得效率低下。但是现在,每次更新只需要8ms,而且这个更新还是异步的。今天,我们就来简单介绍一下流程以及应用,十分简单,MUR大先辈都能理解。

流程

如果要尝试新版的Roselia-Blog文章,你需要在文章的第一行加上:

---feature:roselia-dom---

这样,你的文章就会由新版的处理流程处理。

编译

没错,你的文章会被编译,编译成一系列的函数组件或者原生组建,最终是编译到对createElement的调用,会生成一颗虚拟的文档树,再经过diff算法之后,生成新的fiber,对fiber绑定的Element进行相应的改动。编译的流程是先将文章内容parse成一颗节点树,这里没有什么新奇的地方,因为用的就是js自带的document.innerHTML。之后遍历这颗树,对相应的props进行处理,如果props中有存在r {{}}的属性,则将其解释为一个函数组件,否则就是通常的文档组件。 即:<div>364364</div>被编译为createElement('div', null, ['364364']),而 <div onClick="r {{ () => alert('Soudayo') }}"></div> 被编译成 createElement(() => createElement('div', {onClick: () => alert('Soudayo')}, []), null, [])这里之所以要再包一层,是为了确保当其中的某些状态改变时,能重新渲染。 比如,你写了一篇大作:

# Hello, r{{ raw{post.title} }}

Do not repeat yourself, dear r{{ raw{post.author.nickname} }}.
这篇大作会先被markdown渲染成HTML:

<div>
    <h1>Hello, r{{ raw{post.title} }}</h1>
    <p>Do not repeat yourself, dear r{{ raw{post.author.nickname} }}.</p>
</div>

接下来被编译器编译成js:

createElement('div', {}, [
    createElement('h1', {},
        [
            "Hello, ",
            createElement(function () { return (post.title); }, null, [])
        ]), "\n",
    createElement('p', {},
        [
            "Do not repeat yourself, dear ", 
            createElement(function () { return (post.author.nickname); }, null, []), "."]), "\n"])

生成VNode

createElement会生成一个VNode,这个node其实就是一个object,里面存着tag,prop,prop中有children。

export function createElement(
    tag: keyof HTMLElementTagNameMap | Function | string,
    props: object | null = null,
    children: RoseliaVNode[]): RoseliaVNode {
    return {
        tag: tag, structural equal.
        props: {
            ...(props || {}),
            children
        },
        key: props?.key
    }
}

可以看见createElement第一个参数可以是字符串,也可以是函数,如果是函数,就生成函数组件,如果是字符串,则生成原生组件。这里的行为和React一致。

渲染

通常情况下,我们渲染一个组件,需要递归将VNode所有children都遍历一遍。 我们通常使用深度优先遍历。这样做的缺点是一旦开始就停不下来,必须等到所有的组件都渲染完成才能结束计算。在组件比较大的时候,可能会影响性能。人们做了114种 1 尝试来改进这个,包括但不限于改进diff算法等各种树比较算法。React 16引入了Fiber来解决这个问题,当然,Roselia-Blog也使用了Fiber来进行异步更新。其核心思想是:将dfs的遍历序列拆成一个流,在空闲的时候处理这个流,否则就暂停工作。在处理期间完成了vdom的diff算法,之后直接在dom上做patch。这种创造性的想法增强了React的性能。

Fiber的结构如下:

export interface Fiber {
    dom: Element | null
    parent?: Fiber
    child?: Fiber
    sibling?: Fiber
    vNode: RoseliaVNode
    alternate: Fiber | null
    effectTag: DomChangeType
}

是一颗记录了兄弟,孩子,双亲节点的树。

递归下降时,按照 child -> sibling ,递归返回时,按照 parent -> parent.child,即只需要按照子节点,双亲节点,叔叔节点的顺序遍历即可。

关于React Fiber的介绍网上应该有114514篇1文章了罢,其中比较推荐这一篇:Build Your Own React

交互文章

如何编写让人眼前一亮的文章呢?答案是专注于优质的内容。那么如果像MUR先辈一样憋不出内容怎么办?就可以使用一些dirty的手段,比如增加文章的交互性,让人有看下去的欲望。

Roselia-Script是披着Vue皮的React,但是用法却类似于静态作用于的LaTEX,即控制语句和渲染语句放在一起。因为你在写文章,肯定是没有地方声明data,methods是啥的。而且其本身可以被视为一个巨大的函数组件,因此只能使用React hooks那一套做状态管理。

其中最像Vue的一点就是状态可以直接通过赋值来改变,当然,你得用defState;第二点是将整体编译成一个函数。

编写交互式文章,仅需关心在当前状态下结果是什么,结果之间的改动由Roselia-Blog帮助处理。

比如我们想展示Office系列app的logo:

r{{ defState('product', 'Outlook') }} r{{ def('products', ['Office', 'Word', 'Excel', 'PowerPoint', 'Outlook', 'Teams', 'SharePoint']) }}

r{{ products.some(p => p.toLowerCase() === product.toLowerCase()) ? createElement('img', {src: 'https://static2.sharepointonline.com/files/fabric/assets/brand-icons/product/svg/'+product.toLowerCase()+'_48x1.svg', height: 48, width: 48}) : btn(product+'不是Office产品', null, 'error') }}

选择Office 产品: r{{ createElement('', null, products.map(p => btn(p, () => product = p, product.toLowerCase()==p.toLowerCase() ? 'primary' : ''))) }}

或者输入产品名称:r{{ createElement('input', {value: product, onInput() { product = this.value} }) }}

如果我们要使用MVC架构的话,可能要累死,但是Roselia-Blog我们只用了5行搞定,第一行定义所有产品;

def('products', ['Office', 'Word', 'Excel', 'PowerPoint', 'Outlook', 'Teams', 'SharePoint'])
第二行定义状态;
defState('product', 'Outlook')

第三行渲染图标,如果找不到该产品,则显示错误。

products.some(p => p.toLowerCase() === product.toLowerCase()) ?
    createElement(
        'img',
        {
            src: 'https://static2.sharepointonline.com/files/fabric/assets/brand-icons/product/svg/' + product.toLowerCase() + '_48x1.svg',
            height: 48,
            width: 48
        }) :
    btn(product + '不是Office产品', null, 'error')

第四行渲染一个list,里面是Office产品的按钮,如果当前产品和该产品名称相同,则该产品高亮,否则不高亮。

createElement(products.map(p => btn(p, () => product = p, product.toLowerCase() == p.toLowerCase() ? 'primary' : '')))
注:这里createElement如果只传入第一个参数,则这个参数必须是list,效果等同于直接将这个list中所有元素挂在到所在位置的dom上。 因为引入了fiber,所以每个node不必返回单一的root。

第五行渲染一个输入框。

createElement('input', { value: product, onInput() { product = this.value } })
这一行代码实现了input和product的双向绑定,设定了input的value为produt,则在product改变的时候,input也会改变,接下来绑定了一个onInput函数, 在用户输入的时候,改变product的值。

因此我们只关心在最终状态下,界面将会如何,以及点击按钮以后,数据要做哪些改动。这就是mvvm的魅力,即使是MUR大先辈这样的池沼,也该领悟到了吧。

TODO List

最后是一个可保存任务的TODO list,可以增加,删除或完成任务。 r{{ def(['window'], useState(''.constructor.constructor('return window;'))) }} r{{ def('tick', useState()[1]) }} r{{ def('storageKey', post.id + '-todo'); }} r{{ def('complishStorageKey', storageKey + '-complish'); }} r{{ def('tryParse', (json, defaultV) => { try { return window.JSON.parse(json) || defaultV } catch (e) { return defaultV } }) }} r{{ defState('tasks', () => { const saved = tryParse(window.localStorage.getItem(storageKey), []); return saved }) }} r{{ defState('complish', () => { const saved = tryParse(window.localStorage.getItem(complishStorageKey), []); if (saved.length != tasks.length) return tasks.map(() => false) return saved }) }}

r{{ def('selection', (value, onClick, whenChecked) => { return createElement('span', {onClick}, [ createElement('input', { type: 'checkbox', value, checked: whenChecked }), whenChecked ? createElement('del', {className: 'grey--text'}, [createElement('i', null, [value])]) : value ]) }) }}

TODO:完成了 r{{ complish.filter(x => x).length }}个,总共 r{{ tasks.length }} 个。 r{{ (storageKey in window.localStorage) && btn('清除', () => { complish = []; window.localStorage.removeItem(storageKey); window.localStorage.removeItem(complishStorageKey); tasks = []; }) }} r{{ complish.some(x => x) && btn('清除已完成', () => { tasks = tasks.filter((x, i) => !complish[i]) complish = complish.filter(x => !x) }, 'success') }}

r{{ createElement('div', null, tasks.map((task, i) => createElement('div', null, [ selection(task, () => { complish[i] = !complish[i]; tick(); }, complish[i]), btn(icon('delete'), () => { tasks = tasks.filter((_, idx) => idx !== i) complish = complish.filter((_, idx) => idx !== i) }, 'v-btn--flat v-btn--icon v-btn--round v-btn--text theme--light v-size--small error--text ma-2') ]) ).concat(currentTask ? [selection(currentTask)] : [])) }} r{{ defState('currentTask', '') }}

新建任务: r{{ createElement('input', {value: currentTask, onInput() { currentTask = this.value }, onKeyUp(k) { if(currentTask && k.code === 'Enter') { tasks = [...tasks, currentTask]; complish = [...complish, false]; currentTask = ''; } }, style: {border: '1px solid #ccc'}, placeholder: '输入名称...' }, []) }}

r{{ useEffect(() => { window.localStorage.setItem(complishStorageKey, window.JSON.stringify(complish)) }, [...complish]) }}

r{{ useEffect(() => { window.localStorage.setItem(storageKey, window.JSON.stringify(tasks)) }, [...tasks]) }}

r{{ useEffect((function() { with(window) { const _ = { isString(value) { const type = typeof value return type === 'string' }, isArray(value) { return Array.isArray(value) }, escapeStringLiteral(string) { return string.replace(/["'\\\n\r\u2028\u2029]/g, character => { switch (character) { case '"': case "'": case '\\': return '\\' + character case '\n': return '\\n' case '\r': return '\\r' case '\u2028': return '\\u2028' case '\u2029': return '\\u2029' } return character; }) } } const isStringContentFragment = (value) => (typeof value === 'object') && _.isString(value.value) && _.isString(value.type); const stringToLiteral = (string) => `"${_.escapeStringLiteral(string)}"`; const fragmentToLiteral = (fragment) => { if (fragment.type === 'string') return stringToLiteral(fragment.value); return fragment.value; }; const splitStringContentToFragment = (fragments, delim) => { delim = delim || ['(?:r|R|roselia|Roselia){{', '}'+'}']; const result = fragments.split(new RegExp(delim.join('\\s*?([\\s\\S]+?)\\s*?'), 'gm')).map((value, index) => { if (index & 1) { let code = value.trim(); const singleArityLike = /^([a-zA-Z_$]+[a-zA-Z_0-9]*){([\s\S]+)}/.exec(code); if (singleArityLike) { const [_input, fn, ctx] = singleArityLike; code = `${fn}(${stringToLiteral(ctx)})`; } if (code.startsWith('//')) code = ''; else if (code.endsWith(';')) code = `((${code.substring(0, code.length - 1)}), '')`; return { type: 'roselia', value: code }; } return { type: 'string', value }; }).filter(x => !!x.value); if (result.length) return result; return [ { type: 'string', value: '' } ]; }; const isTextNode = (node) => node.nodeType === Node.TEXT_NODE const isElementNode = (node) => node.nodeType === Node.ELEMENT_NODE const compileTemplateBody = (template) => { const fragment = document.createElement('div'); fragment.innerHTML = template; return emitFragmentToBody(elementToTemplateFragment(fragment)) // const children = [...fragment.childNodes] // if (!children.length) { // return '' // } // else if (children.length === 1) return emitFragmentToBody(elementToTemplateFragment(fragment.firstChild)); // return `h([${children.map(elementToTemplateFragment).map(emitFragmentToBody).join(', ')}])` }; function elementToTemplateFragment(element) { if (isTextNode(element)) { return { tag: 'text', props: { textContent: splitStringContentToFragment(element.textContent || '') }, children: [] }; } if (isElementNode(element)) { const propEntries = [...element.attributes].map(attribute => [attribute.name, attribute.value]).filter(([key, value]) => { if (!value) return false; if (key.startsWith('on')) return true; return _.isString(value); }).map(([key, value]) => { if (_.isString(value)) { const processedValue = splitStringContentToFragment(decodeURIComponent(value)); if (key === 'class') return ['className', processedValue]; else return [key, processedValue]; } return [key, value]; }); const props = Object.fromEntries(propEntries); const children = [...element.childNodes].flatMap(child => { if (isTextNode(child)) { return splitStringContentToFragment(child.textContent || ''); } else { return [elementToTemplateFragment(child)]; } }); return { tag: element.tagName.toLowerCase(), props, children }; } return { tag: 'text', props: { textContent: element.textContent || '' }, children: [] }; } const emitFragmentList = (fragments) => { return `(${fragments.map(fragmentToLiteral).join('+')})`; }; const emitPropsObject = (props) => { const entries = Object.entries(props).map(([key, value]) => { if (_.isString(value)) return [key, value]; if (_.isArray(value)) return [key, emitFragmentList(value)]; return [key, emitPropsObject(value)]; }); return `{ ${entries.map(x => x.join(': ')).join(', ')} }`; }; function emitFragmentToBody(fragment) { if (fragment.tag === 'text') { const content = fragment.props.textContent; if (_.isString(content)) return stringToLiteral(content); else if (_.isArray(content)) { if (content.length === 1) return stringToLiteral(content[0].value); else return `${content.map(fragmentToLiteral).join('+')}`; } else return ''; } const props = emitPropsObject(fragment.props); const children = `${fragment.children.map(value => { if (isStringContentFragment(value)) { if (value.type === 'string') { const literal = value.value.trim(); return literal ? stringToLiteral(literal) : ''; } return `h(() => ${value.value})`; } return emitFragmentToBody(value); }).filter(s => s.length).join(', ')}`; const shouldBeFunction = Object.values(fragment.props).some(x => _.isArray(x) && x.some(y => y.type !== 'string')); const hasProps = !!Object.keys(fragment.props).length const elementCreater = fragment.tag in hyperScript ? `h.${fragment.tag}(${hasProps ? `${props}, ` : ''}${children})` : `h(${fragment.tag}, ${hasProps ? `${props}, ` : ''}${children})`; if (shouldBeFunction) return `h(() => ${elementCreater})`; return elementCreater; } def('compileTemplateBody', compileTemplateBody) } }), []) }} r{{ defState('template', '') }} r{{ def('KeyList', obj => { return hyperScript.ul( window.Object.getOwnPropertyNames(obj).map(name => hyperScript.li(name)) ) }) }}

RoseliaScript Playground

是不是想知道具体的编译流程呢?输入你的内容,看看编译出来的结果吧 现在已经有的状态,你可以直接使用

定义好的变量:r{{ KeyList(functions) }}

状态:r{{ KeyList(stateManager.rawState.state) }}

源文章: r{{ hyperScript.textarea({ value: template, oninput() { template = this.value }, style: { width: '100%', border: '1px solid' } }) }} r{{ def('compiledCode', useMemo(() => "def('h', hyperScript), \n" + compileTemplateBody(template), [template])); }}

编译结果: r{{ hyperScript.textarea({ value: compiledCode, readOnly: true, style: { width: '100%', border: '1px solid' }, onclick() { this.select() window.document.execCommand('copy') toast('Code copied', 'success') } }) }}

渲染结果: r{{ useMemo(() => { return ''.constructor.constructor('with(this) { with(functions) { with(states) {return (' + compiledCode + ')} } }').call(this) }, [compiledCode]) }}

r{{ changeExtraDisplaySettings({blurMainImage: true}) }}


  1. 虚数词,表许多