心得体会

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

| 点击:

【www.fsgl168.com--心得体会】

作者: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);  }};// Exampleconst 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 通讯」

    本文来源:http://www.fsgl168.com/fanwen/189190/