上个月,Chris Coyier 撰写了一篇关于“项目何时需要 React?”的 文章 。换句话说,何时使用 React(作为数据驱动 Web 框架的代表)的优势超过了设置必要工具、构建过程、依赖项等带来的额外复杂性,而不是服务器端模板和 jQuery?一周后,Sacha Greif 发表了一篇 反驳文章,论证了为什么你应该始终在每种类型的 Web 项目中使用这种框架。他的观点包括面向未来、简化项目到项目的工作流程(单一架构;无需跟进多种类型的项目结构)以及由于客户端重新渲染而改善的用户体验,即使内容变化并不频繁。
在这两篇文章中,我深入研究了中间立场:使用纯 JavaScript 编写响应式风格的 UI - 没有框架,没有预处理器。
文章系列
- 纯粹函数式风格(您当前位置!)
- 基于类的组件
编写 React 组件有两种截然不同的方法。
- 您可以将它们编写为类。具有生命周期钩子和内部数据的有状态对象。
- 或者,您可以将它们编写为函数。只是一段 HTML 代码,根据传入的参数进行构建和更新。
前者通常更适合大型、复杂的应用程序,这些应用程序有很多活动部件,而后者是显示信息的更优雅方式,如果您没有很多动态状态。如果您曾经使用过 Handlebars 或 Swig 这样的模板引擎,它们的语法看起来与函数式 React 代码非常相似。
在这两篇文章中,我们的目标用例是可能原本是静态的网站,但如果不用像 React 这样的框架,就可以从基于 JavaScript 的渲染中获益。博客、论坛等。因此,这篇第一篇文章将重点关注编写基于组件的 UI 的函数式方法,因为它在那种场景中会更实用。第二篇文章更像是一个实验;我会真正突破极限,看看在没有框架的情况下,我们可以将事情进行到什么程度,尝试用纯 JavaScript 尽可能地复制 React 的基于类的组件模式,这可能会以牺牲一些实用性为代价。
关于函数式编程
函数式编程在过去几年中越来越受欢迎,这主要得益于 Clojure、Python 和 React。对函数式编程的完整解释超出了本文的范围,但与我们现在相关的部分是**值是其他值的函数**的概念。
假设您的代码需要表示矩形的概念。矩形有宽度和高度,但它还有面积、周长和其他属性。起初,人们可能会想到用以下对象来表示矩形
var rectangle = {
width: 2,
height: 3,
area: 6,
perimeter: 10
};
但是,很快就会发现存在问题。如果宽度发生变化会怎样?现在我们还必须更改面积和周长,否则它们就不正确了。可能会出现冲突的值,您不能只更改一个值而不必更新其他值。这被称为具有**多个真相来源**。
在矩形示例中,函数式编程风格的解决方案是将area
和perimeter
设为**矩形的函数**
var rectangle = {
width: 2,
height: 3
};
function area(rect) {
return rect.width * rect.height;
}
function perimeter(rect) {
return rect.width * 2 + rect.height * 2;
}
area(rectangle); // = 6
perimeter(rectangle); // = 10
这样,如果width
或height
发生变化,我们就不必手动修改任何其他内容来反映这一事实。area
和perimeter
只是正确的。这被称为具有**单个真相来源**。
当您用应用程序可能拥有的任何数据替换矩形,用 HTML 替换面积和周长时,这个想法就很有效。如果您能够将 HTML 设为**数据的函数**,那么您只需要担心修改数据 - 而不是 DOM - 并且它在页面上的渲染方式将是隐式的。
UI 组件作为函数
我们要让 HTML 成为数据的函数。让我们以博客文章为例
var blogPost = {
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 PostPage(postData) {
return '<div class="page">' +
'<div class="header">' +
'Home' +
'About' +
'Contact' +
'</div>' +
'<div class="post">' +
'<h1>' + postData.title + '</h1>' +
'<h3>By ' + postData.author + '</h3>' +
'<p>' + postData.body + '</p>' +
'</div>' +
'</div>';
}
document.querySelector('body').innerHTML = PostPage(blogPost);
好的。我们创建了一个以文章对象为函数的函数,它返回一个渲染我们博客文章的 HTML 字符串。但它并没有真正“组件化”。它就是一个大的整体。如果我们还想在首页上按顺序渲染所有博客文章呢?如果我们想在不同的页面上重用那个标题呢?幸运的是,使用其他函数构建函数非常容易。这被称为**组合**函数
var blogPost = {
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 Header() {
return '<div class="header">' +
'Home' +
'About' +
'Contact' +
'</div>';
}
function BlogPost(postData) {
return '<div class="post">' +
'<h1>' + postData.title + '</h1>' +
'<h3>By ' + postData.author + '</h3>' +
'<p>' + postData.body + '</p>' +
'</div>';
}
function PostPage(postData) {
return '<div class="page">' +
Header() +
BlogPost(postData) +
'</div>';
}
function HomePage() {
return '<div class="page">' +
Header() +
'<h1>Welcome to my blog!</h1>' +
'<p>It\'s about lorem ipsum dolor sit amet, consectetur ad...</p>' +
'</div>';
}
document.querySelector('body').innerHTML = PostPage(blogPost);
这样好多了。我们不必为首页重复标题;我们对那个 HTML 代码有一个**单个真相来源**。如果我们想在不同的上下文中显示文章,我们可以轻松地做到。
使用模板字面量更漂亮的语法
好的,但是所有这些加号都很糟糕。它们很麻烦,而且它们让阅读代码变得更加困难。一定有更好的方法,对吧?好吧,W3C 的人比你早一步。他们创建了模板字面量 - 虽然它仍然相对较新,但现在已经有了 相当不错的浏览器支持 。只需将你的字符串用反引号而不是引号括起来,它就会获得一些额外的超能力。
第一个超能力是能够跨越多行。所以我们上面提到的 BlogPost 组件可以变成
// ...
function BlogPost(postData) {
return `<div class="post">
<h1>` + postData.title + `</h1>
<h3>By ` + postData.author + `</h3>
<p>` + postData.body + `</p>
</div>`;
}
// ...
这很好。但是另一个力量更棒:变量替换。变量(或任何 JavaScript 表达式,包括函数调用!)如果用${ }
括起来,可以直接插入字符串中
// ...
function BlogPost(postData) {
return `<div class="post">
<h1>${postData.title}</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
// ...
好多了。它看起来几乎和 JSX 一样了。让我们再看看我们的完整示例,使用模板字面量
var blogPost = {
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 Header() {
return `<div class="header">
Home
About
Contact
</div>`;
}
function BlogPost(postData) {
return `<div class="post">
<h1>${postData.title}</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
function PostPage(postData) {
return `<div class="page">
${Header()}
${BlogPost(postData)}
</div>`;
}
function HomePage() {
return `<div class="page">
${Header()}
<h1>Welcome to my blog!</h1>
<p>It's about lorem ipsum dolor sit amet, consectetur ad...</p>
</div>`;
}
document.querySelector('body').innerHTML = PostPage(blogPost);
不仅仅是填空
所以我们可以填充变量,甚至可以通过函数填充其他组件,但有时需要更复杂的渲染逻辑。有时我们需要循环遍历数据,或者响应条件。让我们来看一些 JavaScript 语言特性,它们使以函数式风格进行更复杂的渲染变得更容易。
三元运算符
我们从最简单的逻辑开始:if-else。当然,由于我们的 UI 组件只是函数,所以如果我们愿意,可以使用实际的 if-else。让我们看看它是什么样子
var blogPost = {
isSponsored: true,
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) {
var badgeElement;
if(postData.isSponsored) {
badgeElement = `<img src="badge.png">`;
} else {
badgeElement = '';
}
return `<div class="post">
<h1>${postData.title} ${badgeElement}</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
这……不太理想。它为一个不那么复杂的事情添加了许多行,并且它将我们渲染代码的一部分与其在其余 HTML 中的位置分开。这是因为经典的 if-else 语句决定哪些代码行要运行,而不是哪个值要计算。这是一个重要的区别,需要理解。您只能在模板字面量中插入一个表达式,而不是一系列语句。
三元运算符就像 if-else,但用于表达式而不是语句集
var wantsToGo = true;
var response = wantsToGo ? 'Yes' : 'No'; // response = 'Yes'
wantsToGo = false;
response = wantsToGo ? 'Yes' : 'No'; // response = 'No'
它的形式是[conditional] ? [valueIfTrue] : [valueIfFalse]
。所以,上面的博客文章示例变成了
var blogPost = {
isSponsored: true,
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} ${postData.isSponsored ? '<img src="badge.png">' : ''}
</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
好多了。
Array.map()
接下来是循环。每当我们想要渲染一个数据数组时,都需要遍历这些值来生成相应的 HTML。但是,如果我们使用 for 循环,就会遇到与上面 if-else 语句完全相同的问题。for 循环不会计算出一个值,而是以特定方式执行一系列语句。幸运的是,ES6 在 Array 类型中添加了一些非常有用的方法来满足这种特定需求。
Array.map()
是一个 Array 方法,它接收一个参数,该参数是一个回调函数。它会遍历它被调用的数组(类似于 Array.forEach()
),并为每个元素调用一次提供的回调函数,并将数组元素作为参数传递给它。与 Array.forEach()
不同的是,回调函数应该返回一个值——可能是基于数组中相应元素的值——并且整个表达式将返回所有从回调函数返回的元素的新数组。例如
var myArray = [ 'zero', 'one', 'two', 'three' ];
// evaluates to [ 'ZERO', 'ONE', 'TWO', 'THREE' ]
var capitalizedArray = myArray.map(function(item) {
return item.toUpperCase();
});
你可能已经猜到为什么这对于我们正在做的事情如此有用。之前我们建立了一个值是另一个值的函数的概念。Array.map()
允许我们获取整个数组,其中每个元素都是另一个数组中相应元素的函数。假设我们有一个我们要显示的博客文章数组
function BlogPost(postData) {
return `<div class="post">
<h1>${postData.title}</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
function BlogPostList(posts) {
return `<div class="blog-post-list">
${posts.map(BlogPost).join('')}
</div>`
}
var allPosts = [
{
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.'
},
{
author: 'Chris Coyier',
title: 'Another CSS Trick',
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
},
{
author: 'Bob Saget',
title: 'A Home Video',
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
}
]
document.querySelector('body').innerHTML = BlogPostList(allPosts);
每个包含单个博客文章信息的 对象都会逐个传递给 BlogPost 函数,返回的 HTML 字符串被放置到一个新的数组中。然后我们只需对那个新数组调用 join()
将字符串数组组合成一个字符串(用空字符串分隔),我们就完成了。没有 for 循环,只有一系列对象被转换为一系列 HTML 元素。
重新渲染
现在我们可以隐式地为给定的数据生成 HTML,以一种可重用且可组合的方式,全部在浏览器中完成。但是,当数据发生变化时,我们如何更新?我们甚至怎么知道何时触发更新?这个问题是当今 JavaScript 框架社区中最复杂且争论最多的问题之一。高效地进行大量 DOM 更新是一个非常困难的问题,Facebook 和 Google 的工程师已经花了几年的时间来研究这个问题。
幸运的是,我们虚拟的网站只是一个博客。内容几乎只在查看不同的博客文章时才会发生变化。没有太多交互需要检测,我们不需要优化 DOM 操作。当我们加载新的博客文章时,我们可以直接丢弃 DOM 并重新构建它。
document.querySelector('body').innerHTML = PostPage(postData);
我们可以通过把它封装在一个函数中使它更友好一些
function update() {
document.querySelector('body').innerHTML = PostPage(postData);
}
现在,每当我们加载新的博客文章时,我们只需调用 update()
,它就会出现。如果我们的应用程序足够复杂,需要频繁重新渲染——可能在某些情况下每秒重新渲染几次——它会很快变得卡顿。你可以编写复杂的逻辑来确定页面中哪些部分在数据发生特定变化时确实需要更新,并且只更新那些部分,但这就是你应该使用框架的时候了。
不仅仅用于内容
目前为止,几乎所有的渲染代码都用于确定元素内的实际 HTML 和文本内容,但我们不必止步于此。因为我们只是在创建 HTML 字符串,所以里面的任何东西都可以使用。CSS 类?
function BlogPost(postData) {
return `<div class="post ${postData.isSponsored ? 'sponsored-post' : ''}">
<h1>
${postData.title} ${postData.isSponsored ? '<img src="badge.png">' : ''}
</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
可以。HTML 属性?
function BlogPost(postData) {
return `<div class="post ${postData.isSponsored ? 'sponsored-post' : ''}">
<input type="checkbox" ${postData.isSponsored ? 'checked' : ''}>
<h1>
${postData.title} ${postData.isSponsored ? '<img src="badge.png">' : ''}
</h1>
<h3>By ${postData.author}</h3>
<p>${postData.body}</p>
</div>`;
}
可以。你可以随意发挥创意。思考你的数据,思考它的所有不同方面应该如何在标记中表示,并编写将一种转换为另一种的表达式。
总结
希望这篇文章能让你获得一套编写简单的响应式、数据驱动 Web 界面而无需任何工具或框架开销的好工具。这种类型的代码比 jQuery 意大利面条更容易编写和维护,而且现在使用它没有任何障碍。我们在这里讨论的所有内容都免费提供给所有比较现代的浏览器,甚至不需要库。
第二部分将重点关注基于类的、有状态的组件,这将接近于过于复杂而无法在 VanillaJS 中合理完成的领域。但是,我们还是会尝试一下,这会很有趣。
文章系列
- 纯粹函数式风格(您当前位置!)
- 基于类的组件
很棒的文章。我将把它作为我 React 学生的必读材料,因为即使他们经常使用 React,了解工具为你做了什么,可以让你更好地、更明智地使用该工具。
精彩的文章…
很喜欢这篇文章,期待尝试这种方法。谢谢!
哈哈,不错,我一直都在使用这个,而且已经使用很长时间了,即使是使用 JQuery 也是如此。
它的性能相当不错(使用 1MB JSON 来生成一些树)。
但正如你计划的那样——使用 ES6 类会更容易管理 :D
这对于更好地理解 JS 框架在幕后到底是如何工作的以及为什么它们有用非常有帮助。
还有另一种更“逐步增强”的方法,我开始在我的工作中使用它。我并没有用 javascript 渲染所有 html,而是在最初的请求中像往常一样在服务器端构建所有 html,但我正在使用一个 php 类来分别渲染界面的每个组件,每个组件都有自己的控制器逻辑、视图/模板文件、js 和 css 文件。
通过这种方式,单个页面/界面的所有 html、js 和 css 都会在服务器端预先渲染,没有任何延迟。然后,当我需要更新视图时,我可以创建一个 ajax 请求来一次只刷新这些组件中的一个。我并没有从 ajax 返回纯数据,然后必须用 javascript 痛苦地重新构建 html,而是在服务器端重新渲染单个组件,并将 html 作为 json 数组的元素返回(通过这样做,我也可以在同一个请求/响应中返回数组中的纯数据,以便在需要时更新页面的其他部分)。
通过进一步为表单提交实现“hijax”方法,这可以产生一个完全逐步增强的应用程序,即使没有 javascript 也可以工作。
我正在使用可爱的 ProcessWire (http://www.processwire.com) 作为我的后端,但我相信类似的策略也可以用于其他平台或根本没有平台。
这正是我现在关注的材料——使用 vanilla JS 来复习前端开发的基础知识,而不是总是依赖框架。
期待这个系列的第二篇文章!
在客户端这样做有什么好处?例如,而不是在 WordPress 模板中通过 php 这样做?
仅仅是为了下一步(第二部分),在其中添加了交互吗?就目前而言,这看起来只是静态内容,搜索引擎无法解析它。
IMO,这种类型的开发最适合你没有能力部署后端代码的情况。例如,在 SharePoint Online 或其他 SAAS 环境中。
它实际上与 php 做的是完全相同的事情,只是在客户端完成。例如,如果用户点击了一个指向不同博客文章的链接,这将非常有用。你无需跳转到一个全新的页面,而是可以轻松地使用 Javascript 加载当前的文章数据,然后动态地重新填充主区域。值得注意的是,这种技术不必应用于页面的根部;它可能只是一个无状态地重新渲染页面子部分的好方法,甚至可能与 jQuery 结合使用。无论是否使用 jQuery,事件处理程序的工作方式都相同(尽管每次重建 DOM 时,你都必须重新附加它们)。这并不是说它不能交互,而是说它更适合交互稀少的网站;例如,你不会像在 React 中那样希望每次输入字段发生变化时都重新渲染。
你关于搜索引擎问题的观点是正确的。如果你使用 Node.js 作为后端,一种解决方法是从一开始就在服务器端渲染页面,然后在客户端重新渲染。由于你的模板是用 JavaScript 编写的,它们可以在 Node 中像在浏览器中一样被评估,所以你仍然没有重复代码。
我完全同意,我一直想知道为什么要用 jQuery 来做这个,现在才意识到它只是静态 HTML。
但正如 @Vanderson 所说,期待在第二部分更新所有这些内容。
最近,人们经常依赖框架,甚至都没有考虑最基本的方法。在这里,我们让浏览器承担了不必要的任务,而服务器应该完成这些任务。
纯 HTML 是浏览器解析的最轻量级的东西……
@Vanderson - 我同意你的 SEO 观点(可被蜘蛛解析),如果想要良好的 SEO,就不能使用客户端模板(尽管搜索引擎在处理这个问题上越来越好,尤其是 Google 实际上可以爬取 AJAX 生成的网站,并且在处理这个问题上越来越好)。
但是,如果你构建的是像 SPA 这样的登录后系统,那么这样做要容易得多(并且在 SPA 中重新加载整个页面不是你想要做的,对吧)。
除此之外,还有其他折衷方案 - 例如同构渲染(在服务器端渲染的 Javascript)也解决了 SEO 问题。
我必须提一下,如今的浏览器对 Javascript 进行了高度优化,有时在客户端渲染页面甚至更快(就像我之前提到的 - 在我的案例中,渲染一个非常大的树通过 JS 运行得更好,基于在后端生成的 JSON)……
非常感谢。这篇文章彻底拓宽了我对框架如何操纵 DOM 幕后机制的思考范围。
我使用 VUE.js,因为它非常简单。我很快就会尝试一下。
很棒的文章。人们喜欢使用新技术,是为了享受该领域最新改进带来的好处。但同时,风险也很高。通过专注于如何优化现有技术来利用新功能,可以实现两者之间的平衡。作为开发者,这给了我不同的视角。感谢分享。
好文章,已经开始期待第二部分了!
直到现在,我对所有这些框架的 raison d’être 几乎一无所知。非常感谢你,Chris 和 Sacha 制作了这个系列。继续制作吧
Array.map、Array.forEach 是 ES5 的一部分,而不是 ES6。
你有没有看过 SAM.js?它是 MVC 的函数式反应式解决方案。
sam.js.org
读者 Jussi Hartzell 写道
我希望你能更新 https://css-tricks.cn/reactive-uis-vanillajs-part-1-pure-functional-style/ 文章以添加 HTML 转义。人们会复制和粘贴这些内容来在他们的网站上使用。我经常看到人们在博客中讨论创建 HTML 字符串。如果你的数据包含任何需要在 HTML 中转义的字符,你的 HTML 将会失效。此外,还可能存在注入脚本标签等攻击。这些字符串应该始终进行转义。
对于模板字符串,使用标签函数非常容易。
以下代码来自这里 https://basarat.gitbooks.io/typescript/docs/template-strings.html 的 htmlEscape 代码
如果你想使用代码使用内部模板字面量,你应该更改 htmlEscape,使其不再转义数组。这样你就可以像这样写代码