用 Web 组件化解网页设计的混乱局面
我们的起点
我们的目标
Web 组件来帮忙
动态分布
其他挑战
未来工作
概括
或者说,我是如何学会不再担忧并爱上 Web Components 的。
随着我作为软件工程师的成长,我发现其他工程师遇到的问题和解决方案的故事对于提升技能和学习来说是无比宝贵的资源。因此,我打算开始分享一些我的代码故事。这是(希望是)众多故事中的第一个,而且篇幅不短。本文转载自我的博客。
这是我过去一年参与的一个有趣的工程项目的故事。对我来说,这真是一个非常有趣的业余项目(起初每周大约投入 20 小时,之后每周大约 5 小时),它让我有机会尝试一些有趣的新技术,并实现一些很棒的功能。这个项目的核心是一个大型跨学科团队的努力,旨在为我们众多的网站提供一个简洁统一的主题,使其能够在任何 CMS 或前端框架之间共享代码。
我们的起点
我在杨百翰大学工作,这是一所大型私立大学。
大学是一个独特的环境。像我们这样的大学里,有很多大型学院和系,它们都拥有很大的自主权。历史上,这种自主权的一个体现就是网页设计,这也导致了一些非常……不统一的设计。
例如,去年这个时候,我的一位合作设计师查看了我们所有顶尖大学和学院的主页,发现它们的设计完全不同。它们使用的标志、布局、学校颜色(海军蓝和白色)的色调都不一样,甚至在某个极端情况下,完全没有使用学校颜色。很多页面的设计都粗略地基于2013年发布的一个版本。这个版本从未有过详细的文档记录,而且参考实现也无法跨平台移植,因此随着时间的推移,我们看到了很多差异和变化。
我无意贬低任何学校,因为他们大多数的设计看起来都相当不错,但这里有一个例子:
恐怖的这张有趣的图片可以更好地说明我的意思:
在聘请外部公司对我们的网站进行审核后,我们决定是时候为大学打造统一的品牌形象了。为此,我们在网站社区发起了一项倡议,旨在设计并实施一个简洁、现代、低调的标准主题,并鼓励全校的开发人员和设计师都采用这个主题。
我不打算谈论设计部分,因为我不是设计师。虽然我被邀请参加设计会议,但我是以工程师的身份参与的,负责开发主题的参考实现。不过,需要说明的是,最终决定只需要一个简洁的头部(包含搜索和用户登录功能)、简单的导航和一个底部。其他所有内容都由各个网站自行设计,但我们也一直在努力为他们提供现成的样式和组件,这样每个网站就不必重新设计通用元素了。
我们的目标
设计团队敲定了大家都能接受的设计方案后,就轮到工程团队进行实施了。在设计团队工作的同时,有一个部门挺身而出,开发了一个 Drupal 模块,使他们能够快速迭代多个网站上的设计,从而让设计师能够获得实际应用中的反馈,了解哪些有效,哪些无效。然而,从长远来看,我们最终决定需要一个能够满足以下三个条件的解决方案:
- [ ] 跨平台
- [ ] 简单的
- [ ] 可修复
跨平台
我们校园内有各种各样的网站托管平台,从简单的静态网站到 Drupal 和 WordPress 等内容管理系统,再到用 Angular、React 和其他框架编写的复杂单页应用程序,应有尽有。我们需要一个解决方案,让小型团队能够确保我们的主题在所有平台上都能正常运行。理想情况下,这意味着所有功能都使用同一套代码库。
简单的
我们校园里人才济济,既有经验丰富的30年资深人士,也有兼职维护系网站的计算机科学专业学生。我们需要一个简单易用的解决方案,即使开发人员不完全了解其工作原理也能轻松上手——如果不能可靠地从示例中复制粘贴,那就太复杂了。
可修复
我们过去遇到的一个问题是,随着主题的改进,只有新网站才会采用这些改进。因为每个网站都保留着自己的主题代码副本,所以没有好的方法来分发更新和修复程序。
Web 组件来帮忙
我们小组里有几个人一直在研究 Web Components,而 Chrome 刚刚实现了 v1 版本的规范。在研究了几个实现目标的方案后,我们最终决定 Web Components 可以满足我们的大部分需求。
Web Components解决了我们的很多问题。由于它们是平台级的基本组件,我们不必太担心集成问题——它们(理论上)可以与任何能够操作DOM的东西集成。
为了让大家更直观地了解这一点,几个之前从未见过我们代码的学生程序员只用了大约 30 分钟就实现了一个基于我们 Web Components 的 WordPress 主题。我最喜欢的演示,也是最能体现我们集成便捷性的,是我在向我所在的校园组织做主题演示时。为了现场演示,我当着大家的面,把我们一个非常老旧、糟糕的页面改造成了使用新主题的版本。整个过程只用了大约 10 分钟。当然,我事先练习过,但展示它有多么简单仍然很有趣。
除了能够将它们写入 DOM 之外,Shadow DOM 还为我们提供了非常好的样式隔离,因此我们可以确保网站的任何样式都不会意外地破坏我们的主题组件,并且我们的任何内部样式都不会泄露到外部世界。
虽然过程中遇到了一些小问题,但我们的内容管理系统集成得相当顺利,而且我们已经有使用 React (16+)、Angular (2+)、VueJS 等框架的单页应用上线运行。还有一些 SaaS 工具对 DOM 的处理方式非常糟糕,我们还没能解决,但总的来说,一切都很顺利。
目标确认:
- [x] 跨平台
- [ ] 简单的
- [ ] 可修复
Web Components 也承诺解决我们的一些复杂性问题。如果您熟悉 Bootstrap,就会知道,为了使用其样式,您必须以正确的方式构建标记,其中包含大量的嵌套div和特殊类。在这种结构中很容易迷失方向,尤其是在我们拿到的设计中,移动端和桌面端的布局差异非常大。Shadow DOM 中的插槽概念为我们提供了一种非常简单的方法,可以向用户隐藏标记的复杂性,同时仍然允许他们插入所有特定于站点的内容。
children(如果您不熟悉 Shadow DOM,那么插槽机制与React 或<ng-content>Angular 中的组件组合非常相似。)
我们最终的头部标记如下所示:
(请注意,这些字体并非实际字体,因为实际字体只能在 *.byu.edu 网站上加载。打开完整的 Codepen 即可查看桌面视图。)现在情况不一样了——我们只是换了字体,我根本不用修改 Codepen!#胜利
乍一看,这里似乎有很多东西。然而,大部分结构上的复杂性都被隐藏起来了,只有特定于某个地点的部分才被包含在内。
为了让您了解省略了多少结构,以下是 Shady DOM polyfill 渲染后的效果,它将所有内容合并到一个 DOM 树中:
<byu-header mobile-max-width="1023px" max-width="1200px" class="byu-component-rendered" left-align="">
<div id="header" class="byu-header-root">
<div class="byu-header-content needs-width-setting stretches">
<div class="byu-header-primary">
<a class="byu-logo" id="home-url" name="home-url" href="https://byu.edu/">
<img class="byu-logo" alt="BYU" src="https://cdn.byu.edu/shared-icons/latest/logos/monogram-white.svg">
</a>
<div class="byu-header-title">
<a slot="site-title" href="some-site.byu.edu" class="">Site Title</a>
</div>
</div>
<div id="secondary" class="byu-header-secondary">
<div class="byu-header-user">
<byu-user-info slot="user" class="byu-component-rendered" has-user="" role="button">
<div class="byu-user-wrapper">
<div class="no-user slot-wrapper">
<div class="user-info-image" aria-label="User Icon"> </div>
<span class="text slot-wrapper">
<a slot="login" href="#login">Sign In</a>
</span>
</div>
<div class="has-user">
<span class="name slot-wrapper">
<span slot="user-name">Joe Student</span>
</span>
<div class="user-info-image" aria-label="User Icon"> </div>
<span class="logout slot-wrapper">
<a slot="logout" href="#logout">Sign Out</a>
</span>
</div>
</div>
</byu-user-info>
</div>
<div class="byu-header-search">
<byu-search slot="search" action="submit-form" class="byu-component-rendered">
<div id="search-form" class="">
<div id="search-container" class="">
<form action="search.php">
<input name="search" class="__byu-search-selected-input" placeholder="Search" title="Search" type="search">
</form>
</div>
<button id="search-button" type="submit" class="">
<img id="search-icon" src="https://cdn.byu.edu/shared-icons/latest/fontawesome/search-white.svg"
alt="Run Search" class=""> </button>
</div>
</byu-search>
</div>
</div>
</div>
</div>
<div class="menu-outer-wrapper">
<div class="menu-inner-wrapper slot-wrapper needs-width-setting" style="max-width: 1200px;">
<byu-menu slot="nav" class="byu-component-rendered">
<nav class="outer-nav slot-container needs-width-setting">
<a href="#link1">Link 1</a>
<a href="#link2">Link 2</a>
<a href="#link3">Link 3</a>
<a href="#link4">Link 4</a>
</nav>
</byu-menu>
</div>
</div>
</byu-header>
实际上,我已经稍微简化了一下。它并不美观,而 Web Components 为我们提供了一个简洁的封装层,可以将所有这些复杂性隐藏起来。
目标确认:
- [x] 跨平台
- [x] 简单
- [ ] 可修复
动态分布
(这是我最引以为豪的部分)
一旦我们有了可用的组件集,就需要一种方法将它们分发给校园内的开发人员。我们考虑过使用类似 NPM 的包管理器,但很快发现它并不符合我们的需求。虽然它能让我们轻松推送更新,但却无法保证所有网站都能收到更新。有些网站可能几年都不会更新,因此,一旦出现重要的 bug,我们希望即使是这些网站也能及时收到更新。此外,由于我们需要支持多种平台,很难找到一款所有人都能轻松使用的包管理器。
我们最终确定的解决方案是构建一个中央 CDN,让所有网站都从中加载资源。事实证明,这是一个绝妙的主意。我们构建了一个流程,可以响应来自 GitHub 的 webhook 通知,并将代码更改推送到 CDN。我的办公室一直在积极推进向 Amazon Web Services 的迁移,因此我们使用 Amazon S3、CloudFront 和 CodeBuild 构建了 CDN。
我们 CDN 的秘诀在于它的构建器。每当我们向任何 GitHub 代码库推送代码时,AWS Lambda 函数都会收到 Webhook 通知,并启动在 AWS CodeBuild 上运行的构建。我们使用 CodeBuild 的原因有两点:a) 它无需运行任何服务器;b) 它几乎可以执行任何能在 Docker 容器中运行的操作。构建器会查看 CDN 中包含的代码库列表,然后使用 GitHub API 查找自上次构建器运行以来所有已更改或已创建的分支和标签。它会下载必要的文件,然后将这些文件复制到我们的 S3 存储桶中。
为了实现自动更新,我们在 CDN 中引入了“别名”的概念。别名使用 Git 标签的语义化版本控制(semver)来创建别名,例如`<version> latest`(始终指向最高版本号)、1.x.x`<version>`(获取所有次要版本和 bug 修复版本)以及`<version> 1.1.x`(获取 1.1 主要版本的所有 bug 修复版本)。我们还包含一个unstable指向当前 master 分支的别名。用户也可以通过指定版本号来选择特定的版本。这使我们能够为大多数网站提供自动更新,同时为需要的用户提供更多控制权。
当用户想要使用我们 CDN 中的资源时,他们会构建类似这样的 URL:
https://cdn.byu.edu/{library name}/{version or alias}/{file name}
因此,始终保持最新版本的主题组件的主 JavaScript 文件的 URL 为https://cdn.byu.edu/byu-theme-components/latest/byu-theme-components.min.js。获取当前最新版本并关闭自动更新的 URL 为https://cdn.byu.edu/byu-theme-components/1.2.3/byu-theme-components.min.js。
我们希望最大程度地提高所有资源的缓存率,但这可能会影响新版本的快速发布。我们找到了一个不错的折衷方案:假设标签永远不会改变(通常情况下确实如此)。因此,我们会为来自特定标签的内容添加缓存头,表明其永久可缓存(Cache-Control: immutable, public, max-age=31557600, s-maxage=31557600)。然后,当用户请求别名(例如latest)时,我们会发送一个 302 重定向到该特定标签,并将重定向的缓存过期时间设置为 1 小时。这样,我们就能在频繁发布更新和将有用的内容保留在用户缓存中之间取得良好的平衡。重定向速度足够快,并且传输给用户的数据量也足够小,因此不会增加太多开销。此外,我们还能将绝大多数资源永久缓存在浏览器中(虽然大多数浏览器的缓存时间是一年,但这几乎等同于永久缓存,对吧?毕竟,这比大多数 JavaScript 框架的生命周期都要长)。
目标确认:
- [x] 跨平台
- [x] 简单
- [x] 可修复
其他挑战
在任何项目中,总会遇到一些你始料未及的问题(甚至有些问题你预料到了,但决定稍后再处理)。以下是我们遇到的一些问题。
选择退出框架
一年前我们开始着手这个项目时,Polymer 2距离发布还有几个月的时间,而且我们并不喜欢 Polymer 坚持使用永远不会实现的 HTML Imports 规范。SkateJS也是一个选择,但我们不太喜欢 Skate 处理模板的方式。
面对这些选择,我们很早就决定其实并不需要 Web 组件框架。我们的组件将相当静态——只需在 DOM 中写入一次,允许一些小的动态变化,就大功告成了。所以,我们觉得,直接使用自定义元素 API,再辅以一些用于执行常见操作(例如将模板写入 Shadow DOM)的辅助函数,应该也不错。
如果我们现在启动这个项目,我想我们可能会使用某种框架。
Polymer 3(虽然尚未发布)或 SkateJS 5(带有其优秀的新型可插拔渲染器)LitElement。虽然我们的设置并不算太复杂,但在很多方面我们仍然在重复造轮子。
唱着加载时间的忧郁歌
但这仍然留下了元素加载的问题。使用 HTML Imports 规范,你可以在一个 HTML 文件中定义元素,该文件包含标记、样式和 JS 代码,所有内容都集中在一个文件中。你只需将该文件插入<link rel="import">页面,组件就会神奇地加载所需的一切。
然而,我们校园里有很多比较谨慎的开发者,我们不想向他们引入太多新概念(没错,有些人甚至会被一种新的<link>标签类型吓到)。我们希望能够继续使用简单的<script>标签进行加载。再加上除了 Chrome 之外,几乎没人愿意实现 HTML Imports,Polymer 1 和 2 的加载方式看起来相当糟糕。(题外话:我仍然渴望找到一个比“把所有东西都塞进 JavaScript 里”更好的 HTML 导入方案。)
我们也希望开发体验流畅。如果不能直接导入包含嵌入式 CSS 和 JS 的 HTML 文档,我们希望能够将 CSS、JS 和 HTML 放在不同的文件中,但仍然可以通过一个简单的 script 标签导入它们。
起初,我们写了一个简单的 Gulp 模块来帮我们打包,但它很挑剔,而且依赖于格式非常精确的注释。后来我们发现了 Webpack,它完美地解决了这个问题。现在,我们可以拥有任意的依赖关系图,使用 NPM 上的模块,而且无需编写自己的打包工具就能完成各种工作。这对我们来说非常有效,因此,导入我们的主题组件包也变得非常简单:
<script async src="https://cdn.byu.edu/byu-theme-components/latest/byu-theme-components.min.js"></script>
为无法造型的事物赋予造型
Shadow DOM 非常适合将我们的模板 CSS 控制在一定范围内,但是如何设置用户通过插件提交给我们的内容样式呢?这个项目的全部意义就在于样式!
Shadow DOM 确实提供了一种设置插槽内容样式的方法,但这种方法比较有限。我可以使用::slotted伪元素来设置它们的样式:
<style>
.my-slot-wrapper ::slotted(*) {
color: navy;
}
</style>
<div class="my-slot-wrapper">
<slot></slot>
</div>
你会注意到它::slotted接受另一个选择器。这个选择器可以是任何 CSS 选择器,但有一个限制:它只能选择一级。你不能使用类似 `<select>` 的选择器::slotted(div > a),它只能选择一级。
大多数情况下,这种限制不会造成问题,但我们很快遇到了两个主要问题:链接样式和输入样式。
我们发布主题组件测试版后,很快就发现有人在网站标题中添加嵌套在以下代码块中的链接<h1>:
<byu-header>
<h1 slot="site-title"><a href="my-site.byu.edu">My Site</a></h1>
</byu-header>
通常情况下,嵌套的文本元素不会给我们带来任何问题——我们只需在父元素上设置 `<link>`、`<link>` 和 `<link>` 样式,这些样式就会向下传递。然而,链接却很特殊。我见过的所有用户代理样式表都会将自己的 `<link>` 和 `<link>` 样式应用于color链接font-size,而不会继承父元素的样式。如果 `<link>`元素本身带有 `<link>`属性,这不成问题——样式会直接应用到它。但是,当链接嵌套在另一个元素内时,我们就无法在 Shadow DOM 中对其进行样式设置了。font-familycolortext-decoration<a>slot
我们遇到的另一个问题是搜索输入框的样式。我们的目标是兼容各种框架和内容管理系统,但每个框架和系统都有自己独特的输入框封装方式,而这些方式我们无法完全避免。然而,我们需要确保它们的输入框样式能够正确显示。一种解决方案是提供我们自己样式的输入框,并使用 JavaScript 来保持两者样式的同步,但我们认为这种方法很容易让开发者感到困惑。例如,它会让实现自动完成弹出窗口之类的功能变得非常困难。
所以,我们需要能够拥有这个:
<byu-search>
<div class="my-cms-wrapper">
<input type="search" />
</div>
</byu-search>
打造类似以下造型:
<byu-search>
<input type="search" />
</byu-search>
这两个问题的解决方案其实我们早已很熟悉:外部 CSS 样式表。我们添加了一个样式表(我们称之为“额外”样式),其中包含特殊的选择器来处理这些以及其他特殊情况,并要求开发人员在他们的页面中同时包含该样式表和 JavaScript 代码:
<link rel="stylesheet" href="https://cdn.byu.edu/byu-theme-components/latest/byu-theme-components.min.css">
<script async src="https://cdn.byu.edu/byu-theme-components/latest/byu-theme-components.min.js"></script>
开发人员已经习惯于包含成对的 CSS 和 Javascript 文件(例如 jQuery UI),所以我们并没有向他们引入任何新东西。
由于我们使用了自定义元素名称,因此“额外”样式表中的选择器可以非常简单,但又非常具体:
byu-header > [slot="site-title"] a {
text-decoration: none;
color: white;
}
虽然我们尽量避免使用超过两层深度的选择器,但使用最外层的两层选择器(byu-header > [slot="site-title"])可以提供相当不错的作用域,这样我们的样式就不会相互干扰,影响页面上的其他样式。不过,页面后面的某些样式表仍然有可能像这样进行操作。
a {
color: green;
}
我们决定,既然我们无法解决所有问题,那就先不尝试解决这个问题。毕竟,这只是 CSS 的一个正常表现,所以开发人员应该能够自行找出网站标题突然变成绿色的原因(至少可以借助浏览器的开发者工具)。
<blink> 被认为有害(未样式化内容的闪烁)
使用 JavaScript 渲染 UI 组件的一个弊端是未样式化内容闪烁。这是前端框架普遍存在的问题,并非 Web Components 独有。不过,我希望像声明式 Shadow DOM这样的提案能够帮助减少使用 Web Components 时出现这种闪烁现象的影响。
通常,解决这个问题的策略是在初始 HTML 中嵌入一些 CSS,这些 CSS 只会在框架加载并开始渲染之前生效。我们目前还没有很好的方法既能让用户嵌入样式,又能保证我们轻松推送更新的目标,所以我们采取了折衷方案,将“未样式化内容闪烁”的样式嵌入到我们的“额外”样式中。这个 CSS 文件比我们的 JS 文件小得多,解析速度也快得多,因此我们可以从中获得非常好的用户体验。组件应用模板后,会将类应用byu-component-rendered到自身,所以我们只需要在样式中查找没有该类的组件即可:
byu-header:not(.byu-component-rendered) {
/* nasty fallback styles here */
}
由于我们的 CSS 是同步解析的,因此在等待 JS 完成加载期间,页面看起来几乎是正常的。这使得页面过渡几乎无缝衔接,尤其是在浏览器缓存 CSS 之后。
未来工作
我们未来计划添加的一项功能是客户端分析。虽然我们可以从服务器日志中收集到相当多的信息,但我们无法看到一些关键信息,例如有多少网站正在使用不同的配置选项和组件组合。我们正在考虑使用navigator.sendBeacon轻量级分析工具将数据发送回服务器,其中包含已加载代码的版本、实际使用的组件、每个组件正在使用的不同功能等信息。我们不想依赖外部分析网站,因为我们不希望所有网站都面临加载时间和隐私方面的问题。如果个别网站想要使用功能齐全的分析工具,他们可以自行选择,我们不会干预他们的选择。
我们还想添加自动化测试。由于我们的组件逻辑不多,所以能做的逻辑测试也不多,但我们非常期待添加一些使用Pixelmatch等视觉差异比较工具的测试,以便检测何时对元素样式进行了更改。我们的目标是在 CDN 组装器中添加一个步骤,运行这些测试,如果测试失败则不部署新代码。
概括
这个项目真的很有趣,它迫使我们应对许多有趣的挑战。我为我们最终找到的解决方案感到非常自豪,也为来自不同部门、技能水平各异的开发人员齐心协力,最终找到一个对大家都适用的方案而感到骄傲。虽然我们的代码并不完美,而且我们还没有机会将其统一到一种风格,但我感觉我们已经做得相当不错了。
如果您想查看我们的代码,它完全属于BYU Web Github 组织,并采用 Apache 2.0 许可。
以下是一些快速链接:
- 我们的主要主题组成部分
- 我们的 CDN 源
- 利用 Web 组件的Drupal和WordPress CMS 主题。
