使用原生 JavaScript 构建响应式 UI - 第 1 部分:纯粹函数式风格

Avatar of Brandon Smith
Brandon Smith

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

上个月,Chris Coyier 撰写了一篇关于“项目何时需要 React?”的 文章 。换句话说,何时使用 React(作为数据驱动 Web 框架的代表)的优势超过了设置必要工具、构建过程、依赖项等带来的额外复杂性,而不是服务器端模板和 jQuery?一周后,Sacha Greif 发表了一篇 反驳文章,论证了为什么你应该始终在每种类型的 Web 项目中使用这种框架。他的观点包括面向未来、简化项目到项目的工作流程(单一架构;无需跟进多种类型的项目结构)以及由于客户端重新渲染而改善的用户体验,即使内容变化并不频繁。

在这两篇文章中,我深入研究了中间立场:使用纯 JavaScript 编写响应式风格的 UI - 没有框架,没有预处理器。

文章系列

  1. 纯粹函数式风格(您当前位置!)
  2. 基于类的组件

编写 React 组件有两种截然不同的方法。

  1. 您可以将它们编写为类。具有生命周期钩子和内部数据的有状态对象。
  2. 或者,您可以将它们编写为函数。只是一段 HTML 代码,根据传入的参数进行构建和更新。

前者通常更适合大型、复杂的应用程序,这些应用程序有很多活动部件,而后者是显示信息的更优雅方式,如果您没有很多动态状态。如果您曾经使用过 Handlebars 或 Swig 这样的模板引擎,它们的语法看起来与函数式 React 代码非常相似。

在这两篇文章中,我们的目标用例是可能原本是静态的网站,但如果不用像 React 这样的框架,就可以从基于 JavaScript 的渲染中获益。博客、论坛等。因此,这篇第一篇文章将重点关注编写基于组件的 UI 的函数式方法,因为它在那种场景中会更实用。第二篇文章更像是一个实验;我会真正突破极限,看看在没有框架的情况下,我们可以将事情进行到什么程度,尝试用纯 JavaScript 尽可能地复制 React 的基于类的组件模式,这可能会以牺牲一些实用性为代价。

关于函数式编程

函数式编程在过去几年中越来越受欢迎,这主要得益于 Clojure、Python 和 React。对函数式编程的完整解释超出了本文的范围,但与我们现在相关的部分是**值是其他值的函数**的概念。

假设您的代码需要表示矩形的概念。矩形有宽度和高度,但它还有面积、周长和其他属性。起初,人们可能会想到用以下对象来表示矩形

var rectangle = {
  width: 2,
  height: 3,
  area: 6,
  perimeter: 10
};

但是,很快就会发现存在问题。如果宽度发生变化会怎样?现在我们还必须更改面积和周长,否则它们就不正确了。可能会出现冲突的值,您不能只更改一个值而不必更新其他值。这被称为具有**多个真相来源**。

在矩形示例中,函数式编程风格的解决方案是将areaperimeter设为**矩形的函数**

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

这样,如果widthheight发生变化,我们就不必手动修改任何其他内容来反映这一事实。areaperimeter只是正确的。这被称为具有**单个真相来源**。

当您用应用程序可能拥有的任何数据替换矩形,用 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 中合理完成的领域。但是,我们还是会尝试一下,这会很有趣。

文章系列

  1. 纯粹函数式风格(您当前位置!)
  2. 基于类的组件