我是怎么把我的 React 应用换成 VanillaJS 的(这是不是一个坏主意)
本文转载自:众成翻译
译者:wenkai
链接:http://www.zcfy.cc/article/2314
w3ctech : https://www.w3ctech.com/topic/1978
这是一个又长又曲折(有很多代码)的故事。我尝试用 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 变得伟大的那三个部分:
- JSX 语法
- 组件/可组合性
- 单向数据流(UI > action > state > magic > UI)
当我把这些拆开时,我意识到了什么。React 带来的性能开销来自于两个地方:
- 至少解析 60KB 的 JavaScript
- 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)
),当我难免会设定 herf
和 backfroundColor
时,会得到很好的警告。
我认为,编程能力的一半在于预测你在未来会做的蠢事,然后避免它们不会发生。
我现在有了一个能接受任何东西并返回一个小 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 看成函数,我就会疯狂地想找出什么返回了什么。但当我把这些 h1
、div
看成只有括号不同的 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 }),
)
);
let someData={version:'1.0',rows:'5'};
document.body.appendChild(App(someData));
就像React 一样,不是吗?
投入全力写了6个小时代码之后(对,6小时写了73行代码,我感到又自豪又羞耻,不知道为什么告诉你这些)。
我们还没有完成。这只是简单的部分。当数据变化时我们是如何更新这些组件的?
服务器渲染
服务器渲染?嘿,前面的句子没说这个,你无赖!
是的是的,不过服务器渲染很重要,而且碰巧及其简单。
我们反过来想,为什么上面的函数不能运行在 NodeJS 中?很简单,Node 中没有 window
和 document
。
在使用 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 在服务端渲染之后,我得“再水化”客户端的代码,三个简单的步骤:
- 得到服务器渲染出的 DOM 的引用。
- 使用
window.APP_DATA
里的数据重新渲染。 - 用客户端渲染的 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工具 看看它们的不同。这很有用,并不多余。
3 重新思考单向数据流
现在,如何更新组件?
让你的数据流保持单向,这意味着你永远不会访问到 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 来计算出哪一个组件需要被更新。
它不会知道如何更新组件,只知道通知组件更新自己。
所以,举个例子,在我的页面中点击表上的一行。会发生三件事:
- store 中数据的被选中行被更新为
selected: true
,上次选中的行更新为selected: false
。 - 曾经被选中的组件必须重新渲染。
- 现在被选中的组件必须重新渲染。
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!)
现在我要躺下休息了。
发表评论