动手实现可能是世上最糟的 React 克隆

Serge Zaitsev 原作,授权 New Frontend 翻译。

德国最近有一个很长的银行假期,空出了不少时间,于是我浮想联翩。我对 React 一直是十动然拒。通常我最终会用一些轻量级的替代品,比如 PreactsuperfinehyperappMithril。我可以浏览它们的源代码,理解各种机制是如何实现的,这种满足感是我选择这些轻量级替代品的原因。另外,提前声明,我不是前端开发者,所以请以批判的眼光阅读这篇文章。

不知怎的,今天早上我产生了这样一个念头,实现一个愚蠢的 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 注释、空格挤压之类的东西。

类 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 请别指望能有什么实际使用的技术支持,不过我很乐意收到你的反馈(你可以提工单或合并请求)!

不管怎么说,这是一个美妙的早晨。谢谢阅读,愿你我都能从中有所收获!

题图:Ferenc Almasi

评论

Loading comments ...