作者:Serge Zaitsev
翻译:New Frontend
德国最近有一个很长的银行假期,空出了不少时间,于是我浮想联翩。我对 React 一直是十动然拒。通常我最终会用一些轻量级的替代品,比如 Preact、superfine、hyperapp、Mithril。我可以浏览它们的源代码,理解各种机制是如何实现的,这种满足感是我选择这些轻量级替代品的原因。另外,提前声明,我不是前端开发者,所以请以批判的眼光阅读这篇文章。
不知怎的,今天早上我产生了这样一个念头,实现一个愚蠢的 React 克隆需要做什么?这会是一个特别缓慢,错漏百出,基本不可用的克隆,不过这也会是一个我亲手实现的克隆。
准备好了吗?
Hyperscript
React 类框架使用 JSX 描述布局。可是 JSX 不过是 JavaScript 的语法扩展,生产环境的代码中可没有 JSX(会被转译为普通的 JavaScript 代码)。许多读者都知道,React 框架内部,JSX 是用许多嵌套的 createElement() 调用表示的。每个函数调用声明一个 DOM 节点或组件,包括具体的标签名称,属性集合,子节点列表,这些都以类似的函数调用表示。下面这两段布局代码是等价的:
// 使用 JSX
<div onClick={handleClick}>
<h1 className="header">Hello</h1>
</div>
// 使用 createElement()
createElement('div', {onClick: handleClick},
createElement('h1', {className: 'header'}, 'Hello'));
事实上,后面一种语法在 React 出名前就有了,称为 hyperscript。它和 createElement 一模一样,只是使用较短的函数名而已(h(tag, props, ...children))。
我们丑陋的 React 克隆里也有 h() 函数,这个函数把参数封装成对象,留待渲染阶段处理:
// 我们用的微小的 Hyperscript 函数。
// `el` 是元素名(标签或组件)
// `props` 是属性表
// `children` 是子元素数组
const h = (el, props, ...children) => ({el, props, children});
现在我们看下如何渲染 hyperscript 布局。
渲染
一般来说,我们需要一个 render(virtNode, domNode) 函数将一组虚拟节点渲染为现有的真实 DOM 节点的子元素。
我们常常只需要传递一个虚拟节点,不过有时候也需要传递一组虚拟节点。所以我们使用 [].concat() 这个小技巧来处理这两种情况(将单个元素转换为数组,将数组扁平化)。
接着遍历每个虚拟节点。在我们简陋的 React 克隆中,节点可能是对象(hyperscript 调用的结果),字符串(DOM 节点间的纯文本),函数(返回 hyperscript 结构的函数组件)。
我们调用函数时会传入相应的属性表,子元素列表,以及特殊的 forceUpdate 函数,调用 forceUpdate 函数会重新渲染整个组件。之后我们给有状态的组件加上动态行为时会用到这个函数。
接下来我们创建一个构建函数,这个函数会根据虚拟节点的类型,创建一个新的 DOM 元素或文本节点。等我们检查完虚拟节点和真实 DOM 元素的差别后才会调用这个构建函数。
如果不存在真实 DOM 元素,或者标签不一样——我们调用构建函数插入新创建的 DOM 元素。
然后我们将所有虚拟节点的属性保存到真实节点。它们将用于下一个渲染周期的虚拟节点和真实节点比较。如果真实节点储存的属性不同,那就重新赋值。
此时 DOM 节点和虚拟节点是一致的,我们在节点子元素上递归调用渲染函数。
最后,所有虚拟节点处理完毕,并复制到真实 DOM 后,我们移除真实 DOM 树上的遗留 DOM。
const h = (el, props, ...children) => ({el, props, children});
const render = (vnodes, dom) => {
vnodes = [].concat(vnodes);
const forceUpdate = () => render(vnodes, dom);
vnodes.forEach((v, i) => {
while (typeof v.el === 'function') {
v = v.el(v.props, v.children, forceUpdate);
}
const newNode = () => v.el ? document.createElement(v.el) : document.createTextNode(v);
let node = dom.childNodes[i];
if (!node || (node.el !== v.el && node.data !== v)) {
node = dom.insertBefore(newNode(), node);
}
if (v.el) {
node.el = v.el;
for (let propName in v.props) {
if (node[propName] !== v.props[propName]) {
node[propName] = v.props[propName];
}
}
render(v.children, node);
} else {
node.data = v;
}
});
for (let c; (c = dom.childNodes[vnodes.length]); ) {
dom.removeChild(c);
}
};
// Example
const Header = (props, children) => (
h('h1', {style: "color: red"}, ...children)
);
render(h(Header, {}, 'Hello', 'World'), document.body);
上面的代码会渲染出红色的「Hello World」文本。
有状态的组件
正经的 React 克隆会使用键来智能地给 DOM 树打补丁,也会使用键联系在渲染过程中移动了的组件的状态,还会使用 hook(hook 与组件相连,可以用来智能地管理组件状态)。
我决定暂时不在这上面花太多时间,直接给每个组件配上一个 forceUpdate 回调。任何事件监听器都可以调用这个回调函数,强制重新渲染整个组件。不妨想象下末日即将来临,放纵一下,把状态保存在全局变量中。
let n = 0;
const Counter = (props, children, forceUpdate) => {
const handleClick = () => {
n++;
forceUpdate();
};
return x`
<div>
<div className="count">Count: ${n}</div>
<button onclick=${handleClick}>Add</button>
</div>
`;
};
让我兴味盎然的是,不用那些无意义的转译,就可以模拟 JSX。
标签模板字面量
你多半熟悉 ES6 的模板字面量(用反引号包起来的字符串)。然而,所有现代的浏览器都支持标签字面量,也就是带有前缀的字符串,这个前缀是一个处理模板字符串的函数。这个函数接受一个字符串数组(数组的每个成员是被占位符分隔开来的字符串)和占位符作为参数:
const x = (strings, ...fields) => {...};
x`Hello, ${user}!`
// strings: ['Hello ', '!'];
// fields: [user]
现在我们来动手实现一个微型解析器,解析一种类似 HTML 的语言,根据给定的字符串返回 hyperscript 节点。
我准备支持这样的语法:常规标签,比如 <{tagName} attr={value} ...>,以 /> 结尾的自动闭合标签,以 </ 开头的闭合标签,以及标签中间的纯文本。除了占位符,属性必须加引号。就这些。没有 HTML 注释、空格挤压之类的东西。
考虑解析这样一个语言需要的状态机,只需要 3 个状态:
「文本」,查找 < 或 </。
「开」,在开始标签之内,查找到标签结束为止的属性。
「闭」,在闭合标签之内,查找 >。
初始状态是「文本」。占位符可能是标签名、属性值、纯文本。也就是说,如果这些位置上的字符串字面量为空,那我们将使用占位符,否则我们继续读取字符串字面量。
最终的解析器大概是这样的:
export const x = (strings, ...fields) => {
const stack = [{children: []}];
const find = (s, re, arg) => {
if (!s) {
return [s, arg];
}
let m = s.match(re);
return [s.substring(m[0].length), m[1]];
};
const MODE_TEXT = 0;
const MODE_OPEN = 1;
const MODE_CLOSE = 2;
let mode = MODE_TEXT;
strings.forEach((s, i) => {
while (s) {
let val;
s = s.trimLeft();
switch (mode) {
case MODE_TEXT:
if (s[0] === '<') {
if (s[1] === '/') {
[s, val] = find(s.substring(2), /^([a-zA-Z]+)/, fields[i]);
mode = MODE_CLOSE;
} else {
[s, val] = find(s.substring(1), /^([a-zA-Z]+)/, fields[i]);
mode = MODE_OPEN;
stack.push(h(val, {}, []));
}
} else {
[s, val] = find(s, /^([^<]+)/, '');
stack[stack.length - 1].children.push(val);
}
break;
case MODE_OPEN:
if (s[0] === '/' && s[1] === '>') {
s = s.substring(2);
stack[stack.length - 2].children.push(stack.pop());
mode = MODE_TEXT;
} else if (s[0] === '>') {
s = s.substring(1);
mode = MODE_TEXT;
} else {
let m = s.match(/^([a-zA-Z0-9]+)=/);
console.assert(m);
s = s.substring(m[0].length);
let propName = m[1];
[s, val] = find(s, /^"([^"]*)"/, fields[i]);
stack[stack.length - 1].props[propName] = val;
}
break;
case MODE_CLOSE:
console.assert(s[0] === '>');
stack[stack.length - 2].children.push(stack.pop());
s = s.substring(1);
mode = MODE_TEXT;
break;
}
}
if (mode === MODE_TEXT) {
stack[stack.length - 1].children.push(fields[i]);
}
});
return stack[0].children[0];
};
这个解析器大概极其笨拙缓慢,不过看起来可以工作:
const Hello = ({onClick}, children) => x`
<div className="foo" onclick=${onClick}>
${children}
</div>
`;
render(h(Hello, {onClick: () => {}}, 'Hello world'), document.body);
更多
现在我们得到了 React 的草率克隆。不过我决定稍微深入一下,把它放到 GitHub 上。在 GitHub 上的代码略微修改了渲染算法,以支持键。上面还有一些测试,让这个玩笑煞有其事起来。我特想支持 hooks,看起来是做到了。
这个库的名字是「O!」听起来既像是在你理解了它是多么简单之后发出的顿悟的叹声,又像是你决定在生产环境使用它碰到严重错误后发出的绝望的吼声。同时,它看起来像是零,这是一个关于它的尺寸大小和有用程度的双重隐喻——我有没有说过,这个包含「JSX」、hook 等艺术的库压缩之后小于 1 KB?
Github 项目在此:https://github.com/zserge/o 请别指望能有什么实际使用的技术支持,不过我很乐意收到你的反馈(你可以提工单或合并请求)!
不管怎么说,这是一个美妙的早晨。谢谢阅读,愿你我都能从中有所收获!
end
LeanCloud,领先的 BaaS 提供商,为移动开发提供强有力的后端支持。更多内容请关注「LeanCloud 通讯」