wenkai

我是怎么把我的 React 应用换成 VanillaJS 的(这是不是一个坏主意)

wenkai · 2017-02-20翻译 · 661阅读 原文链接

这是一个又长又曲折(有很多代码)的故事。我尝试用 VanillaJS 重写 JSX 语法,组件结构,服务端渲染,以及 React 的组件更新魔法。


上周我写了一篇文章,“学会这 10 件事,我创建了世界上最快的网站”。一切都进展顺利,我照常在 medium 上收到了非常有建设性的评论,在 reddit 上收到了尖刻的批评。但后来我偶然看到这个友好但是令人恼怒的评论:

然而 http://motherfuckingwebsite.com/ 可能要打败你了。;) 在开启一个新项目时,所有开发者都应该记住这个网站提到的相关信息。

我很不高兴。我让我的读者们失望了。最重要的是,我让这些狐狸失望了。

我决定要比 motherfuckingwebsite.com 快,不然不休息。

然后我就休息了一下。

然后我做了使我的网站更快的最后一项工作 — 把 React 换成 VanillaJS。

网站在这里,代码在这里,这个游戏的目的就是为了得到反馈, 所以不要害羞。

什么是 React?

你一定知道什么是 React,那为什么还要问?

但既然你问了,我就明确地告诉你:“React” 是一个统称,指 React、Preact 及相关概念,或者来自 flux/redux 的概念。

什么是 VanillaJS?

VanillaJS 是很多年前一个叫 Brendan 的哥们写的框架,现在很少有人用了。它有一些很有趣的特性,在我的项目里可能会很有用。所以我清理掉蜘蛛网,在这个框架的框架中重新认识了我自己。

对于那些 web 开发的新手,可能有些困惑。请允许我说的直白些:(严肃的声音)我说的 VanillaJS 其实就是 JavaScript 和 DOM API。我只是轻轻地调侃一下,我们大部分人会在写代码前至少挑选一个框架或库,所以提到原生 JavaScript 就好像它也是一个框架(严肃的声音结束)。

把 React 换成 VanillaJS 要怎么做?

你这么问让我非常非常高兴。我尽力尝试了一些不寻常的东西,然后写了这篇博客文章。在我写文章的同时,我想象评论里的批评者会如何批评我的每一个决定(你们真卑鄙),效果很好。

类似于小黄鸭调试法,它帮助我在写代码前做出稳健的决定。

具体的决定之一就是使 React 变得伟大的那三个部分:

  1. JSX 语法
  2. 组件/可组合性
  3. 单向数据流(UI > action > state > magic > UI)

当我把这些拆开时,我意识到了什么。React 带来的性能开销来自于两个地方:

  1. 至少解析 60KB 的 JavaScript
  2. state 更新后,DOM 更新前发生的魔法

第一项 — 解析框架的时间消耗 — 我可以通过不用框架来解决(就像通过永不说话来避免争吵)。

第二项 — 更新 UI 的时间消耗 — 就难多了;你会在大概 9 分钟后看到。

所以对于使 React 变得伟大的那三个部分,只有第三个会带来时间开销。

结论:我希望重写 JSX,我希望重写组件,我希望找到在 state 更新时更好的 UI 更新方法。

[电影预告片的声音]

并且他只花了 48 小时就完成了。

[电影预告片的声音结束]

#1 重写 JSX

我喜欢 JSX.

因为它展现了输出的 HTML,并且让我依然使用 HTNL。它让我学会了“概念分离”并不意味着“不同的语言放在不同的文件中”。

我重写 JSX 的主要目标是使我可以用一种接近 HTML 的方式定义我的组件 — 就像 JSX 所做的 — 并使用 VanillaJS。

首先,我不想一再使用 document.createElement()。所以写了一个简化函数:

function makeElement(type) {
  return document.createElement(type);
}

这运用了 VanillaJS 里的“虚拟 DOM”技术,在不把他写入 document 的情况下创建了一个元素。

然而我的懒惰没有到此为止。我不想一直输入 makeElement('h1'),所以又写了另一个简化函数。

让我们试试:

function makeElement(type) {
  return document.createElement(type);
}

const h1 = () => makeElement(`h1`);

document.body.appendChild(h1());

太棒了。

我可能还想在 h1 里加一些文字,把这个函数扩展一下…

function makeElement(type, text) {
  const el = document.createElement(type);
  const textNode = document.createTextNode(text);

  el.appendChild(textNode);

  return el;
}
const h1 = (text) => makeElement(`h1`, text);

// and then
document.body.appendChild(h1(`Hello, world.`));

我震惊了。

我还想给元素加个 class。可能以后还会加一些别的属性。我知道了!我可以传入一个对象,带上一些属性和值。然后在这个对象上迭代所有属性。

因为我现在传入了一些不同的参数,我得更新 h1 函数,把他收到的所有参数传给 makeElement

function makeElement(type, props, text) {
  const el = document.createElement(type);

  Object.keys(props).forEach(prop => {
  el[prop] = props[prop];
  });

  const textNode = document.createTextNode(text);

  el.appendChild(textNode);

  return el;
}

const h1 = (...args) => makeElement(`h1`, ...args);

// and then ...
document.body.appendChild(
 h1(
 { className: `title` },
 `Hello, world.`,
 )
);

我说不出话了。

还是说不出话来。

这很好,但是如果不能嵌套元素对我来说还是没用的。就像一行里九个单词却只有两三个字母!

在下一步之前,我要从我的网站里找出一段 HTML,想想如何把它创建出来。

<div id="app">
  <header class="header">
    <h1 class="header__title">Know It All</h1>
    <a
      class="header__help"
      target="_blank"
      rel="noopener noreferrer"
      title="Find out more about know it all"
      href="https://hackernoon.com/what-you-dont-know-about-web-development-d7d631f5d468#.ex2yp6d64"
    >
      What is this?
    </a>
  </header>
  <div class="skill-table"></div>
</div>

看好了吗?

为了生成这段 HTML,我需要扩展 makeElement 函数,让它能处理传入的其他元素。就是简单地加入到它返回的元素中。例如把一个 header 传给 div。再给那个 header 传入一个 h1 和一个 a。你注意到了吗? HTML 标签就像函数一样。

没有?好吧。

这时我遇到了一些必要的复杂度,参数可能是各种奇怪顺序的各种东西。我得做一些工作识别每一个参数是什么。

[未来的 David:只有这里会有些难,坚持住。]

我知道 makeElement 的第一个参数永远是标签名,例如 'h1'。但是第二个参数可能是:

  • 定义元素属性的对象
  • 定义一些文字显示的字符串
  • 单独一个元素
  • 元素的数组

任何第二个参数之后的参数都会是一个元素或者元素的数组,我会再次使用 rest 语法(所有语法中最让人放松的)把这些存入一个叫 otherChildren 的变量里。这里大部分的复杂度在于提高 makeElement 参数的灵活性。

const attributeExceptions = [
  `role`,
];

function appendText(el, text) {
  const textNode = document.createTextNode(text);
  el.appendChild(textNode);
}

function appendArray(el, children) {
  children.forEach((child) => {
    if (Array.isArray(child)) {
      appendArray(el, child);
    } else if (child instanceof window.Element) {
      el.appendChild(child);
    } else if (typeof child === `string`) {
      appendText(el, child);
    }
  });
}

function setStyles(el, styles) {
  if (!styles) {
    el.removeAttribute(`styles`);
    return;
  }

  Object.keys(styles).forEach((styleName) => {
    if (styleName in el.style) {
      el.style[styleName] = styles[styleName]; // eslint-disable-line no-param-reassign
    } else {
      console.warn(`${styleName} is not a valid style for a <${el.tagName.toLowerCase()}>`);
    }
  });
}

function makeElement(type, textOrPropsOrChild, ...otherChildren) {
  const el = document.createElement(type);

  if (Array.isArray(textOrPropsOrChild)) {
    appendArray(el, textOrPropsOrChild);
  } else if (textOrPropsOrChild instanceof window.Element) {
    el.appendChild(textOrPropsOrChild);
  } else if (typeof textOrPropsOrChild === `string`) {
    appendText(el, textOrPropsOrChild);
  } else if (typeof textOrPropsOrChild === `object`) {
    Object.keys(textOrPropsOrChild).forEach((propName) => {
      if (propName in el || attributeExceptions.includes(propName)) {
        const value = textOrPropsOrChild[propName];

        if (propName === `style`) {
          setStyles(el, value);
        } else if (value) {
          el[propName] = value;
        }
      } else {
        console.warn(`${propName} is not a valid property of a <${type}>`);
      }
    });
  }

  if (otherChildren) appendArray(el, otherChildren);

  return el;
}

const a = (...args) => makeElement(`a`, ...args);
const button = (...args) => makeElement(`button`, ...args);
const div = (...args) => makeElement(`div`, ...args);
const h1 = (...args) => makeElement(`h1`, ...args);
const header = (...args) => makeElement(`header`, ...args);
const p = (...args) => makeElement(`p`, ...args);
const span = (...args) => makeElement(`span`, ...args);

Boom,这就是我的前端框架,0.96 KB。

我应该把它放在 npm 上,取个名字叫 elementr,一点一点给它加特性,直到它到达 30 KB,我就会意识到维护一个在 npm 上的包是费力不讨好的,然后感到深深的后悔。我唯一的追求只是逃到甜品岛上拼命吃甜品直到我生命的最后一天。

React 另一件伟大的事是它非常有帮助的错误提示。这些错误提示节省了很多时间,所以我也置入了一些检查 (if (propName in el)if (styleName in el.style)),当我难免会设定 herfbackfroundColor时,会得到很好的警告。

我认为,编程能力的一半在于预测你在未来会做的蠢事,然后避免它们不会发生。

我现在有了一个能接受任何东西并返回一个小 DOM 树的函数。

让我们赶走疲倦:

document.body.appendChild(
  div({ id: `app` },
    header({ className: `header` },
      h1({ className: `header__title` }, `Know It All`),
      a(
        {
          className: `header__help`,
          target: `_blank`,
          rel: `noopener noreferrer`,
          title: `Find out more about know it all`,
          href: `https://hackernoon.com/what-you-dont-know-about-web-development-d7d631f5d468#.ex2yp6d64`,
        },
        `What is this?`,
      ),
    ),
    div({ className: `skill-table` }),
  )
);

这段代码可读性很强。事实上它很接近我想要输出的 HTML。懒得翻回去回去看?

<div id="app">
  <header class="header">
    <h1 class="header__title">Know It All</h1>
    <a
      class="header__help"
      target="_blank"
      rel="noopener noreferrer"
      title="Find out more about know it all"
      href="https://hackernoon.com/what-you-dont-know-about-web-development-d7d631f5d468#.ex2yp6d64"
    >
      What is this?
    </a>
  </header>
  <div class="skill-table"></div>
</div>

当我把那些 JavaScript 看成函数,我就会疯狂地想找出什么返回了什么。但当我把这些 h1div 看成只有括号不同的 HTML 标签, 再经过一段适应,把眼睛眯起来时,让大脑做一个实时地转换,我就可以看到 HTML 结果了。

感谢大脑。

红利:因为你传给元素的 props 是对象,JavaScript 是很神奇的,函数也是对象,你可以直接把一个函数作为值传给 onclick 属性, VanillaJS 的事件系统会帮你为那个元素绑定事件。

这是不是很疯狂?直到我写完一半时我才发现没有考虑到事件的处理,然后感到自己好蠢,再然后发现它们居然可以运行,就像编程之神一样。

那就是爱的感觉吧。太棒了!

#2 重写 React 组件

现在是最酷的部分。前面的部分全部只是函数而已,不是吗?嵌套这些函数,我们就得到了返回返回函数的函数的函数,最终返回元素。

React 里的组件是什么?就是返回一个元素的函数(大概)。所以我可以任意把几个函数放到一个函数里,让它以大写字母开头,然后管它叫组件。我甚至可以像 React 一样传入 props。

下面的代码输出同样的 HTML,但放在了一个“组件中”。

const Header = props => (
  header({ className: `header` },
    h1({ className: `header__title` }, `Know It All`),
    a(
      {
        className: `header__help`,
        target: `_blank`,
        rel: `noopener noreferrer`,
        title: `Find out more about know it all, version ${props.version}`,
        href: `https://hackernoon.com/what-you-dont-know-about-web-development-d7d631f5d468#.ex2yp6d64`,
      },
      `What is this?`,
    ),
  )
);

const Table = props => div({ className: `skill-table` }, props.rows);

const App = props => (
  div({ id: `app` },
    Header({ version: props.version }),
    Table({ rows: props.rows }),
  )
);

document.body.appendChild(App(someData));

就像 React 一样,不是吗?

投入全力写了6个小时代码之后(对,6小时写了73行代码,我感到又自豪又羞耻,不知道为什么告诉你这些)。


我们还没有完成。这只是简单的部分。当数据变化时我们是如何更新这些组件的?

服务器渲染

服务器渲染?嘿,前面的句子没说这个,你无赖!

是的是的,不过服务器渲染很重要,而且碰巧及其简单。

我们反过来想,为什么上面的函数不能运行在 NodeJS 中?很简单,Node 中没有 windowdocument

在使用 App 组件之前初始化 jsdom 会怎样?会得到结果的 outerHTML

这当然不能运行,对吗?

const jsdom = require(`jsdom`);
const App = require(`./components/App/App`);
const someData = { what: `eva` };

global.document = jsdom.jsdom();
global.window = document.defaultView;

const appDom = App(someData);

const html = `<!DOCTYPE html>
<html>
  <head>
    <title>Know it all</title>
  </head>
  <body>
    ${appDom.outerHTML}
    <script>
      window.SOME_DATA = ${JSON.stringify(someData)};
    </script>
    `<script src="app.js">`</script>
  </body>
</html>
`;

// a little web server
const express = require(`express`);
const server = express();

server.get(`/`, (req, res) => {
  res.send(html);
});

server.listen(8080); // callbacks are for wimps

好吧,这运行的很好!

感谢 jsdom 的人。

当我的 HTML 在服务端渲染之后,我得“再水化”客户端的代码,三个简单的步骤:

  1. 得到服务器渲染出的 DOM 的引用。

  2. 使用 window.APP_DATA 里的数据重新渲染。

  3. 用客户端渲染的 DOM 代替服务端渲染的 DOM。

const clientAppEl = App(window.APP_DATA);

const serverAppEl = document.getElementById(`app`);

// 检查服务端和客户端的节点是否相同,然后把它换出来
if (clientAppEl.isEqualNode(serverAppEl)) {
  serverAppEl.parentElement.replaceChild(clientAppEl, serverAppEl);
} else {
  console.error(`The client markup did not match the server markup`);
  console.info(`server:`, serverAppEl.outerHTML);
  console.info(`client:`, clientAppEl.outerHTML);
}

再说一次,好的错误提示是值得努力的,所以我在把它们换出来之前比较了服务器和客户端渲染的 DOM。如果它们不匹配就把它们的 HTML 字符串输出到控制台,然后复制粘贴到到 一个在线diff工具 看看它们的不同。这很有用,并不多余。

# 重新思考单向数据流

现在,如何更新组件?

让你的数据流保持单向,这意味着你永远不会访问到 DOM ,也就不会弄乱 UI。反而你得摆弄底层的数据,并相信 UI 会根据数据更新。

这就是使得 React 非常好用的关键。

React 文档中:

根据我们的经验,考虑 UI 在特定的时刻的样子而不是如何改变它,这消除了一大类的 bug。

单向组合(One Direction) 红极一时的一首歌 Drag me Down

I’ve got fire for a heart I’m not scared of the dark You’ve never seen it look so easy I got a river for a soul And baby you’re a boat Baby you’re my only reason

“宝贝你是艘船(Baby you're a boat)”。 晕。

我真的不想回到那个用选择器查找元素、根据用户交互添加/移除一些零碎的东西的世界了。

想到这,我全身抽搐了一下。

现在请和我一起想象。想象一个用户点击了 TODO 列表上的一个按钮,这时需要显示一些列表项。在 React 的世界里,你的代码决定了 UI 根据给定状态所显示的样子,你的代码会在某些事情发生时更新状态,React 的代码会随着状态变化魔法般的更新 UI。

魔法般的,你说呢?

太碎了,你说呢?

上面是在 10 个项目的 TODO 列表上点击 “All” 所生成的。然后把我的显示器侧过来截的图。

在一个很快的手机上显示 10 个东西大概要 60 毫秒。在中档手机上估计接近要 200 毫秒。

编辑: TodoMVC 不是我想要的展示性能的例子,而且用的是 React dev build。我马上会更新这个 “60” 和 “200”。其他的图示用的是正确的 production build。

可以这样总结:你在顶层组件输入一些新的状态,它们向下流入每个子组件,然后每个组件计算出它是否需要更新。然后,当 React 计算出了哪个组件需要更新,它就可以计算出这么做的最快方法(或者是 DOM 的整体替换,或者是独立更新属性)。

这是一些文字和一些箭头。

我不会自己写更新逻辑,那可能是很大的一部分工作,那有没有别的选择呢?

我把状态当做我应用真理的来源,引申开来,我想让用户交互只产生对状态的更新(而不是直接给元素加上 class 或者类似的糟糕的东西)。并且当我的组件更新时,它们只根据当前状态更新,而不是任何别的。

从更新状态到组件重新渲染这个过程,我该怎么做呢?

思考了一会,买了个新鼠标(无关),我意识到 React 所做的,在某种程度上,是在“运行时”计算要更新的东西。

那么如果我“提前”知道了要更新的东西会怎么样呢?就可以绕过整个 DOM 比较算法,省去在它上面花费的时间。

我想出的方法是由 store 来计算出哪一个组件需要被更新。

它不会知道如何更新组件,只知道通知组件更新自己。

所以,举个例子,在我的页面中点击表上的一行。会发生三件事:

  1. store 中数据的被选中行被更新为 selected: true,上次选中的行更新为 selected: false

  2. 曾经被选中的组件必须重新渲染。

  3. 现在被选中的组件必须重新渲染。

store 看起来是这样的:

selectItemById(id) {
  this.updateItem(id, { selected: true });

  if (this.selectedItem) {
    this.updateItem(this.selectedItem.id, { selected: false });
  }

  this.selectedItem = this.getItemById(id);
},

每当 updateItem() 被调用,store 中的数据就会被更新,并且与那个 item 相关的组件重新渲染。

这是一个糟糕的主意,为什么?让我们先看看结果:

120ms (React) 会让人感到有些延迟,页面上的 DOM 越多,延迟越高。这方面 Preact 做的不错。

VanillaJS 的版本不会随着页面上 DOM 的多少而变化 -- 它始终直到要更新哪两个节点。

这只是选择一行,那点击一行之后展开这一行呢(渲染一串新的子节点)?

顺便说一下,这些图标是运行五次取得中位数。所以 Preact 更慢不是反常,它一直这样。

漂亮的图标,但我们先研究下当我展开一看是到底发生了什么。

忘了是 React 还是 Preact 的了

VanillaJS

你可以看到样式/布局/绘制的任务(紫色和绿色)在 React 和 VanillaJS 中是大致相同的。但是 JavaScript (橙色)在 VanillaJS 版本中只花费了 1/3 的时间。这是因为它没有去根据比较两个 DOM 来计算更新什么。它只知道有人点击了“展开”按钮,意味着把 expanded 设为 true,重新渲染相关的组件。在这 61.04ms 中 99% 用于 DOM 的创建。

所以,组件是怎么“更新自己”的?

我正要解释这个问题。这有一个组件 TableRow,有三件事值得注意:

  • render(),负责初始渲染。返回一个元素,组件返回那个元素

  • store.listen,根据 ID 在 store 中注册组件

  • update(),通过 store.listen 的回调函数传入,在 store 想要组件更新时被调用

const TableRow = (initialData) => {
  let el = null;

  // called when the component is called
  const render = data => (
    div(
      { className: `table-row` },
      div(
        {
          className: `table-row__content`,
          onclick: () => selectRow(data),
        },
        button(
          { onclick: e => expandOrCollapseRow(e, data) },
          `click me`,
        ),
        p(data.name),
      ),
    )
  );

  // when the data changes, update() will be called with the new data
  const update = (prevEl, newData) => {
    const nextEl = render(newData);

    if (nextEl.isEqualNode(prevEl)) {
      console.warn(`render() was called but there was no change in the rendered output`, el);
    } else {
      prevEl.parentElement.replaceChild(nextEl, prevEl);
    }

    return nextEl;
  };

  el = render(initialData);

  // the store will call this when data has changed that will affect this component
  store.listen(initialData.id, (newData) => {
    el = update(el, newData);
  });

  return el;
};

update() 函数检查是否真的需要更新。这有助于在开发时抢先发现错误。

就这样。可以运行。

这并不伟大,它不像 React 的方式那么好。有可变性在其中,可能引发麻烦。同样,数据和 DOM 之间关系比较简单(数组里每一个“项目”都整齐地映射到一个 div 上),在更复杂的应用中可能不会运行地那么好。所以把这个模式用到大项目中会让我感到有些紧张。不过换句话说,焦虑是生活的调味品。

红利: 重写 redux devtools

好吧,Redux 开发工具非常惊艳,我甚至很难去模仿。但我想要一些简单的日志,这就非常简单了(所有的变化都经过 updateItem 方法)。

updateItem(id, data, triggerListener = true) {
  const item = this.getItemById(id);

  Object.assign(item, data); // gasp, mutability

  if (window.APP_DEBUG === true) {
    console.info(`Updated`, item, `with data`, data);
  }

  if (triggerListener) this.triggerListener(id);
},

这样我就可以在控制台输入 APP_DEBUG = true 来开启日志,在每次更新中就得到了这样的东西:

足够好了,我说。


结尾

这个小练习以一个 React 替代项目开始,但迅速变成一个 React 应用项目。我确定如果我没有站在 React 的肩膀上,我的努力会变成一团糟。

[开始自言自语地总结]

框架经常会做一点额外的工作,事实上,如果你自己写就可以避免。但总的来说,我认为这是一种公平的交易,善用写好的框架可以提高生产力。

对于一个小项目,或者说对于性能非常重要的项目,我很高兴现在准备了一些技巧,以后如果要使用 VanillaJS 创建一个完整的应用,就不会这么畏缩了。

[结束自言自语地总结]

序言

序言是放在结尾的,对吧?

好了 motherfuckingwebsite,就你和我,Chrome DevTools 上,放学后女厕所后边,不要用那些无法理解的污言秽语嘲讽我。

公平起见,我们测试初次访问,没有缓冲,没有 service worker,网络状况设为 “Good 3G”,OK?

用那条小红线作为标准。

http://motherfuckingwebsite.com/

那就是你全部的能耐?600ms?你的网站第一次绘制完,我都吃完午饭了。你的完整显示出任何生命迹象时,我都写完一本伊丽莎白时代的小说了。你的网站可以浏览时你的妈妈已经织完一件毛衣了。

轮到我了。

https://knowitall-9a92e.firebaseapp.com/

[气喘吁吁]


好吧那可能听起来非常骄傲,在人们开始赞美我之前,我先敲打一下我自己:

  • 在更慢的 CPU 上,我输了

  • webpagetest.org 上的 “speed index”比较,我输了

  • Chrome 很不公平地把红色标记放在了异步的 Google 分析代码的后面(虽然它没有任何影响)

  • 把两个网站放在同一个虚拟主机上,我输了 (Firebase FTW!)


现在我要躺下休息了。

译者wenkai尚未开通打赏功能

相关文章