在 第 1 部分 中,我介绍了各种函数式风格的技术,用于根据一些 JavaScript 数据干净地渲染 HTML。我们将 UI 分解成组件函数,每个函数根据一些数据返回一段标记。然后我们将它们组合成视图,可以通过进行单个函数调用从新数据重建这些视图。
这是加分环节。在本篇文章中,目标是尽可能接近完整的基于类的 React 组件语法,使用原生 JavaScript(即不使用任何库/框架)。我想声明这里的一些技术并不是非常实用,但我认为它们仍然可以让我们有趣地探索 JavaScript 在近年来的发展,以及 React 究竟为我们做了什么。
文章系列
- 纯函数式风格
- 基于类的组件(您当前所在位置!)
从函数到类
让我们继续使用我们在第一篇文章中使用的相同示例:博客。我们的函数式 BlogPost 组件如下所示
var blogPostData = {
author: 'Brandon Smith',
title: 'A CSS Trick',
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
};
function BlogPost(postData) {
return `<div class="post">
<h1>${postData.title}</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
document.querySelector('body').innerHTML = BlogPost(blogPostData);
在基于类的组件中,我们仍然需要相同的渲染函数,但我们将把它作为类的某个方法来使用。类的实例将保存自己的 BlogPost
数据,并知道如何渲染自身。
var blogPostData = {
author: 'Brandon Smith',
title: 'A CSS Trick',
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
};
class BlogPost {
constructor(props) {
this.state = {
author: props.author,
title: props.title,
body: props.body
}
}
render() {
return `<div class="post">
<h1>${this.state.title}</h1>
<h3>By ${this.state.author}</h3>
<p>${this.state.body}</p>
</div>`;
}
}
var blogPostComponent = new BlogPost(blogPostData);
document.querySelector('body').innerHTML = blogPostComponent.render();
修改状态
基于类(面向对象)编码风格的优势在于它允许封装状态。让我们假设我们的博客站点允许管理员用户直接在读者查看的页面上编辑他们的博客文章。BlogPost
组件的实例能够维护自己的状态,与外部页面和/或其他 BlogPost
实例分开。我们可以通过方法更改状态
class BlogPost {
constructor(props) {
this.state = {
author: props.author,
title: props.title,
body: props.body
}
}
render() {
return `<div class="post">
<h1>${this.state.title}</h1>
<h3>By ${this.state.author}</h3>
<p>${this.state.body}</p>
</div>`;
}
setBody(newBody) {
this.state.body = newBody;
}
}
但是,在任何现实场景中,此状态更改都必须由网络请求或 DOM 事件触发。让我们来探索后者,因为它是最常见的情况。
处理事件
通常,监听 DOM 事件非常简单——只需使用 element.addEventListener()
——但我们的组件仅计算为字符串,而不是实际的 DOM 元素,这使得它变得更加棘手。我们没有可以绑定的元素,并且仅仅将函数调用放在 onchange
中是不够的,因为它不会绑定到我们的组件实例。我们必须以某种方式从全局作用域(代码片段将在其中计算)引用我们的组件。这是我的解决方案
document.componentRegistry = { };
document.nextId = 0;
class Component {
constructor() {
this._id = ++document.nextId;
document.componentRegistry[this._id] = this;
}
}
class BlogPost extends Component {
constructor(props) {
super();
this.state = {
author: props.author,
title: props.title,
body: props.body
}
}
render() {
return `<div class="post">
<h1>${this.state.title}</h1>
<h3>By ${this.state.author}</h3>
<textarea onchange="document.componentRegistry[${this._id}].setBody(this.value)">
${this.state.body}
</textarea>
</div>`;
}
setBody(newBody) {
this.state.body = newBody;
}
}
好的,这里有很多内容。
引用组件实例
首先,我们必须从 HTML 字符串中获取对组件的当前实例的引用。React 能够更轻松地做到这一点,因为 JSX 实际上会转换为一系列函数调用,而不是 HTML 字符串。这允许代码直接传入 this
,并且 JavaScript 对象的引用会被保留。另一方面,我们必须序列化 JavaScript 字符串以插入到 HTML 字符串中。因此,对组件实例的引用必须以某种方式表示为字符串。为了实现这一点,我们在构造时间为每个组件实例分配一个唯一的 ID。您不必将此行为放在父类中,但它是继承的良好用途。本质上发生的事情是,每当构造 BlogPost
实例时,它都会创建一个新的 ID,将其存储为自身的一个属性,并在该 ID 下在 document.componentRegistry
中注册自身。现在,任何地方的任何 JavaScript 代码都可以检索我们的对象,前提是它拥有该 ID。我们可能编写的其他组件也可以扩展 Component
类并自动获取自己的唯一 ID。
调用方法
因此,我们可以从任何任意 JavaScript 字符串中检索组件实例。接下来,我们需要在事件(onchange
)触发时在其上调用方法。让我们隔离以下代码片段并逐步了解正在发生的事情
<textarea onchange="document.componentRegistry[${this._id}].setBody(this.value)">
${this.state.body}
</textarea>
您可能熟悉通过将代码放在 on_______
HTML 属性中来连接事件监听器。当事件触发时,其中的代码将被计算并运行。
document.componentRegistry[${this._id}]
在组件注册表中查找并通过其 ID 获取组件实例。请记住,所有这些都在模板字符串中,因此 ${this._id}
计算为当前组件的 ID。生成的 HTML 将如下所示
<textarea onchange="document.componentRegistry[0].setBody(this.value)">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</textarea>
我们在该对象上调用方法,将 this.value
(其中 this
是事件发生的元素;在我们的例子中,是 <textarea>
)作为 newBody
传递。
响应状态更改进行更新
我们的 JavaScript 变量的值发生了变化,但我们需要实际执行重新渲染才能看到其值反映在页面上。在我们之前的文章中,我们这样重新渲染
function update() {
document.querySelector('body').innerHTML = BlogPost(blogPostData);
}
这是我们必须对类风格组件进行一些调整的另一个地方。我们不希望在每次重新渲染时都丢弃并重建组件实例;我们只需要重建 HTML 字符串。需要保留内部状态。因此,我们的对象将单独存在,我们只需再次调用 render()
var blogPost = new BlogPost(blogPostData);
function update() {
document.querySelector('body').innerHTML = blogPost.render();
}
然后,每当我们修改状态时,我们都必须调用 update()
。这是 React 为我们透明地做的另一件事;它的 setState()
函数修改状态,并触发该组件的重新渲染。我们必须手动执行此操作
// ...
setBody(newBody) {
this.state.body = newBody;
update();
}
// ...
请注意,即使当我们有一个复杂的嵌套组件结构时,也永远只有一个 update()
函数,并且它将始终应用于根组件。
子组件
React(以及几乎所有其他 JavaScript 框架)区分构成组件的元素和组件以及其子组件。子组件可以从外部传入,允许我们编写自定义组件,这些组件是其他任意内容的容器。我们也可以做到这一点。
class BlogPost extends Component {
constructor(props, children) {
super();
this.children = children;
this.state = {
author: props.author,
title: props.title,
body: props.body
}
}
render() {
return `<div class="post">
<h1>${this.state.title}</h1>
<h3>By ${this.state.author}</h3>
<textarea onchange="document.componentRegistry[${this._id}].setBody(this.value)">
${this.state.body}
</textarea>
<div>
${this.children.map((child) => child.render()).join('')}
</div>
</div>`;
}
setBody(newBody) {
this.state.body = newBody;
update();
}
}
这允许我们编写如下用法代码
var adComponents = ...;
var blogPost = new BlogPost(blogPostData, adComponents);
这会将组件插入标记中的指定位置。
总结
React 看起来很简单,但它做了很多微妙的事情来让我们的生活更轻松。最明显的是性能;只渲染状态更新的组件,并最大限度地减少执行的 DOM 操作。但一些不那么明显的事情也很重要。
其中之一是,通过进行细粒度的 DOM 更改而不是完全重建 DOM,React 保留了一些使用我们的技术时会丢失的自然 DOM 状态。当我们放弃 DOM 并重建它时,CSS 过渡、用户调整大小的文本区域、焦点以及输入中的光标位置都会丢失。对于我们的用例来说,这是可行的。但在很多情况下,可能不行。当然,我们可以自己进行 DOM 修改,但那样我们就回到了原点,并且失去了我们的声明式函数语法。
React 使我们能够在更易于维护的声明式风格中编写代码,同时获得 DOM 修改的优势。我们已经证明原生 JavaScript 可以做到这两点,但它无法兼得。
文章系列
- 纯函数式风格
- 基于类的组件(您当前所在位置!)
无论如何 - 你永远不需要在原生 JS 中使用 super 或 constructor 惯例。你甚至不需要使用 Class。模块模式封装了所有这些概念,同时允许人们简洁地组织任何大量代码。
另外,为什么要作用域到 '$'?我知道它很熟悉,但它丑陋得要命,并且混淆了正在发生的事情背后的理念。
类与模块具有不同的用途。类不仅封装;它允许对特定类型的对象进行多次实例化。确实,可以使用原型而不是新的类语法来实现相同的效果,但在适当的情况下,类语法更具可读性,并且与此用例的框架等效项更相似,因为它们中的大多数要么自己使用它,要么使用具有类似语法的 TypeScript。
我认为你误解了
$
的用法。它是 ES6 模板字面量语法的部分内容:https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Template_literals你关于 $ 的说法是正确的。它仍然感觉像是没有描述性的简写,我担心 jQuery 会混淆。
但是,模块允许非常简单地重用,而无需使用笨拙的原型接口。Class 惯例的可读性并不比使用 Crockford 风格的揭示模块更高。我认为模块更简洁 - 特别是因为 super 和 constructor。它可能对使用旨在使用 Classy 结构的语言的人来说更熟悉,但它完全没有必要,并且添加了比糖更多的废料。
我不知道 Crockford 模块模式,我以为你在谈论 ES6 模块。刚刚查了一下,是的,你可以很容易地调整此技术以使用 Crockford 模块而不是 ES6 类。区别在于个人喜好;对于许多人(我甚至敢说大多数人)来说,标准的类语法更具可读性和熟悉性。但当然,它不允许私有成员,并且仅受相对较新的浏览器支持,因此存在权衡。为了演示的目的,我优先考虑熟悉性和与现有解决方案的相似性而不是其他特性,但在现实世界的用例中,每个都有其论点。
我一直期待这篇后续文章。感谢你写这篇文章,因为它有助于澄清为什么有人会使用 React……它似乎是为了所有帮助你构建 web“应用程序”的隐藏内容。
相比之下,你在这里的 vanillaJS 示例似乎就像任何老式的 JS 模板系统,仅此而已,不多不少。我错过了什么吗?
我使用完全由 JS 渲染的“网站”的经验是,它们太糟糕了。就像过去超级 html hacks 和表格布局,或者 Flash 开场动画、动画 GIF 垃圾邮件等……今天最糟糕的是一个 JS“加载”微调器,在一个“网站”上显示 10 秒(而它应该只是这样)。唉
最慢、最无响应、最花哨的垃圾似乎是由臃肿的 JS 框架驱动的。如果他们只使用本文显示的内容,它们可能会立即加载。但 React、Angular 等似乎已将普通网站变成了“应用程序”,这对访问者不利。
我愿意接受教导,因为在过去的 20 年 web 开发中,我不得不改变很多东西,但是像博客(它只是一个网站)这样的东西有什么用 React 风格的“应用程序”?希望得到一个直接的答案,因为在这一点上,React 似乎不是一个很好的模板解决方案,但同样,vanillaJS 也不是。或者换句话说,当工作只是一个网站时,React 是“正确的工具”?
感谢阅读 :)
你说的对,我的示例本质上是基本的模板化,没有太多其他内容。文章的主要目的是 1) 向人们展示现在可以在客户端完成此操作,甚至不需要像 Handlebars.js 这样的常规模板框架,以及 2) 突出显示现代框架确实带来的好处。
关于是否应该为所有网站或仅为“Web 应用程序”使用 React 等框架,以及这甚至意味着什么,存在很多争论。Chris Coyier 关于此主题的文章(https://css-tricks.cn/project-need-react/) 和 Sacha Greif 的反驳(https://css-tricks.cn/projects-need-react/) 是促使我撰写本文的部分原因。
我个人的立场是,对于像博客这样基本上是静态的东西,框架不值得使用,但像我在这里写到的技术可能是值得的(尽管这部分是因为我懒得配置一堆构建工具)。至于客户端与服务器上的模板化,我认为这更多是个人喜好问题。一些没有框架的原生 JavaScript 可能不会明显提高页面加载时间,但我也没有在我的实际网站中尝试过自己的策略,因此它可能有一些限制,只有在尝试扩展时才会变得明显。
感谢 Vanderson 和 Brandon 对此进行了讨论。我已经思考这个问题一段时间了 :)
Brandon,感谢你写了一篇优秀的文章!你能否在 codepen 上添加一些实时示例来补充文章?我拒绝运行最后两个示例。提前感谢
你好 Brandon,感谢你的文章。
它照亮了我的大脑,我可以从中学习很多东西。
我在这个表达式中只看到一个错误
${this.children.map((child) => child.render()).join(”)}
它需要在 child.render() 之前添加一个 return,因为如果没有 return,它将没有任何输出。
实际上,在 ES6 箭头函数中,如果函数体中只有一条语句,则可以省略大括号,甚至可以省略 return 关键字 :)
https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions