科学怪人迁移:与框架无关的方法(第 2 部分)
已发表: 2022-03-10在本文中,我们将按照上一部分的建议,通过执行应用程序的逐步迁移来测试所有理论。 为了使事情变得简单,减少不确定性、未知性和不必要的猜测,对于迁移的实际示例,我决定在一个简单的待办事项应用程序上演示该实践。
一般来说,我假设您对通用待办事项应用程序的工作方式有很好的理解。 这种类型的应用程序非常适合我们的需求:它是可预测的,但所需的组件数量最少,可以展示科学怪人迁移的不同方面。 但是,无论您的实际应用程序的大小和复杂性如何,该方法都具有良好的可扩展性,并且应该适用于任何规模的项目。
对于本文,作为起点,我从 TodoMVC 项目中选择了一个 jQuery 应用程序 — 很多人可能已经熟悉了这个示例。 jQuery 已经足够老了,可能会反映您项目的真实情况,最重要的是,它需要大量维护和 hack 来支持现代动态应用程序。 (这应该足以考虑迁移到更灵活的东西。)
我们要迁移到的这个“更灵活”是什么? 为了展示一个在现实生活中非常实用的案例,我不得不在当今最流行的两个框架中进行选择:React 和 Vue。 然而,无论我选择哪个,我们都会错过另一个方向的某些方面。
因此,在这一部分中,我们将运行以下两个:
- 将 jQuery 应用程序迁移到React ,以及
- 将 jQuery 应用程序迁移到Vue 。
代码库
这里提到的所有代码都是公开的,您可以随时访问。 有两个存储库可供您使用:
- 科学怪人 TodoMVC
此存储库包含不同框架/库中的 TodoMVC应用程序。 例如,您可以在此存储库中找到vue
、angularjs
、react
和jquery
等分支。 - 科学怪人演示
它包含几个分支,每个分支代表应用程序之间的特定迁移方向,可在第一个存储库中使用。 特别是像migration/jquery-to-react
和migration/jquery-to-vue
这样的分支,我们将在稍后介绍。
这两个存储库都在进行中,应定期向其中添加具有新应用程序和迁移方向的新分支。 (您也可以自由贡献! )迁移分支中的提交历史结构良好,可以作为附加文档,其中包含比我在本文中介绍的更多细节。
现在,让我们动手吧! 我们还有很长的路要走,所以不要指望它会一帆风顺。 您可以决定如何阅读本文,但您可以执行以下操作:
- 从 Frankenstein TodoMVC 存储库克隆
jquery
分支,并严格遵循以下所有说明。 - 或者,您可以打开一个专门用于从 Frankenstein Demo 存储库迁移到 React 或迁移到 Vue 的分支,并跟踪提交历史记录。
- 或者,您可以放松并继续阅读,因为我将在这里突出显示最关键的代码,理解过程的机制比实际代码更重要。
我想再提一次,我们将严格遵循本文理论第一部分中提出的步骤。
让我们潜入水中!
- 识别微服务
- 允许主机到外星人访问
- 编写外星微服务/组件
- 围绕 Alien 服务编写 Web 组件包装器
- 用 Web 组件替换主机服务
- 冲洗并重复所有组件
- 切换到外星人
1. 识别微服务
正如第 1 部分所建议的,在这一步中,我们必须将我们的应用程序构建为专用于一项特定工作的小型独立服务。 细心的读者可能会注意到,我们的待办事项应用程序已经很小且独立,可以单独代表一个微服务。 如果这个应用程序存在于更广泛的环境中,我会这样对待它。 但是请记住,识别微服务的过程完全是主观的,没有一个正确的答案。
所以,为了更详细地了解 Frankenstein Migration 的过程,我们可以更进一步,将这个 to-do 应用拆分成两个独立的微服务:
- 用于添加新项目的输入字段。
该服务还可以包含应用程序的标头,纯粹基于这些元素的定位接近度。 - 已添加项目的列表。
该服务更高级,与列表本身一起,它还包含过滤、列表项的操作等操作。
提示:要检查选择的服务是否真正独立,请删除代表这些服务中的每一个的 HTML 标记。 确保其余功能仍然有效。 在我们的例子中,应该可以从没有列表的输入字段将新条目添加到localStorage
(此应用程序用作存储),而即使输入字段丢失,列表仍会呈现来自localStorage
的条目。 如果您在删除潜在微服务的标记时应用程序抛出错误,请查看第 1 部分中的“如果需要重构”部分,了解如何处理此类情况的示例。
当然,我们可以继续将第二个服务和项目列表进一步拆分为每个特定项目的独立微服务。 但是,对于此示例,它可能过于精细。 所以,现在,我们得出结论,我们的应用程序将有两个服务; 他们是独立的,每个人都朝着自己的特定任务努力。 因此,我们将应用程序拆分为微服务。
2. 允许主机到外星人访问
让我简要地提醒你这些是什么。
- 主持人
这就是我们当前应用程序的名称。 它是用我们即将离开的框架编写的。 在这种特殊情况下,我们的 jQuery 应用程序。 - 外星人
简单地说,这是在我们即将迁移到的新框架上对 Host 的逐步重写。 同样,在这种特殊情况下,它是一个 React 或 Vue 应用程序。
拆分 Host 和 Alien 时的经验法则是,您应该能够在不破坏另一个的情况下开发和部署它们中的任何一个——在任何时间点。
保持主机和外星人彼此独立对于科学怪人迁移至关重要。 然而,这使得安排两者之间的沟通有点挑战。 我们如何允许主机访问外星人而不将两者粉碎在一起?
将 Alien 添加为主机的子模块
尽管有几种方法可以实现我们需要的设置,但组织项目以满足此标准的最简单形式可能是 git 子模块。 这就是我们将在本文中使用的内容。 我会让你仔细阅读 git 中的子模块是如何工作的,以便了解这种结构的限制和陷阱。
带有 git 子模块的项目架构的一般原则应如下所示:
- Host 和 Alien 都是独立的,并且保存在单独的
git
存储库中; - Host 引用 Alien 作为子模块。 在此阶段,Host 选择 Alien 的特定状态(提交)并将其添加为 Host 文件夹结构中的子文件夹。
添加子模块的过程对于任何应用程序都是相同的。 教授git submodules
超出了本文的范围,并且与科学怪人迁移本身没有直接关系。 因此,让我们简单地看一下可能的例子。
在下面的代码片段中,我们以 React 方向为例。 对于任何其他迁移方向,将react
替换为来自 Frankenstein TodoMVC 的分支名称或在需要时调整为自定义值。
如果您继续使用原始的 jQuery TodoMVC 应用程序:
$ git submodule add -b react [email protected]:mishunov/frankenstein-todomvc.git react $ git submodule update --remote $ cd react $ npm i
如果您跟随来自 Frankenstein Demo 存储库的migration/jquery-to-react
(或任何其他迁移方向)分支,Alien 应用程序应该已经作为git submodule
在那里,并且您应该看到相应的文件夹。 但是该文件夹默认是空的,需要更新和初始化注册的子模块。
从项目的根目录(您的主机):
$ git submodule update --init $ cd react $ npm i
请注意,在这两种情况下,我们都会为 Alien 应用程序安装依赖项,但这些依赖项会被沙箱化到子文件夹中,不会污染我们的主机。
将 Alien 应用程序添加为 Host 的子模块后,您将获得独立的(就微服务而言)Alien 和 Host 应用程序。 但是,在这种情况下,Host 将 Alien 视为一个子文件夹,显然,这允许 Host 毫无问题地访问 Alien。
3. 编写外星微服务/组件
在这一步,我们必须决定首先迁移什么微服务,并在 Alien 端编写/使用它。 让我们按照我们在步骤 1 中确定的相同服务顺序,从第一个开始:用于添加新项目的输入字段。 然而,在我们开始之前,让我们同意,除此之外,我们将使用更有利的术语组件而不是微服务或服务,因为我们正在朝着前端框架的前提移动,并且术语组件遵循几乎任何现代的定义框架。
Frankenstein TodoMVC 存储库的分支包含一个结果组件,该组件表示第一个服务“用于添加新项目的输入字段”作为 Header 组件:
- React 中的标头组件
- Vue 中的头部组件
在您选择的框架中编写组件超出了本文的范围,也不属于 Frankenstein Migration 的一部分。 但是,在编写 Alien 组件时需要牢记几件事。
独立
首先,Alien 中的组件应该遵循同样的独立原则,之前在 Host 端设置:组件不应该以任何方式依赖于其他组件。
互操作性
由于服务的独立性,主机中的组件很可能以某种成熟的方式进行通信,无论是状态管理系统、通过某些共享存储进行通信,还是直接通过 DOM 事件系统进行通信。 Alien 组件的“互操作性”意味着它们应该能够连接到由 Host 建立的同一通信源,以发送有关其状态更改的信息并侦听其他组件的更改。 实际上,这意味着如果您的主机中的组件通过 DOM 事件进行通信,那么不幸的是,仅在考虑状态管理的情况下构建您的 Alien 组件对于这种类型的迁移将无法完美运行。
例如,看一下js/storage.js
文件,它是我们的 jQuery 组件的主要通信渠道:
... fetch: function() { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); }, save: function(todos) { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); var event = new CustomEvent("store-update", { detail: { todos } }); document.dispatchEvent(event); }, ...
在这里,我们使用localStorage
(因为这个例子不是安全关键的)来存储我们的待办事项,一旦对存储的更改被记录下来,我们就会在document
元素上调度一个自定义 DOM 事件,任何组件都可以监听。
同时,在 Alien 方面(比如说 React),我们可以根据需要设置复杂的状态管理通信。 然而,为将来保留它可能是明智的:为了成功地将 Alien React 组件集成到 Host 中,我们必须连接到 Host 使用的相同通信通道。 在这种情况下,它是localStorage
。 为了简单起见,我们只是将 Host 的存储文件复制到 Alien 中,并将我们的组件连接到它:
import todoStorage from "../storage"; class Header extends Component { constructor(props) { this.state = { todos: todoStorage.fetch() }; } componentDidMount() { document.addEventListener("store-update", this.updateTodos); } componentWillUnmount() { document.removeEventListener("store-update", this.updateTodos); } componentDidUpdate(prevProps, prevState) { if (prevState.todos !== this.state.todos) { todoStorage.save(this.state.todos); } } ... }
现在,我们的 Alien 组件可以与 Host 组件使用相同的语言,反之亦然。
4. 围绕 Alien 服务编写 Web Component Wrapper
尽管我们现在才到第四步,但我们已经取得了很多成就:
- 我们已将 Host 应用程序拆分为独立的服务,这些服务已准备好被 Alien 服务替换;
- 我们将 Host 和 Alien 设置为彼此完全独立,但通过
git submodules
很好地连接; - 我们已经使用新框架编写了第一个 Alien 组件。
现在是时候在 Host 和 Alien 之间建立一个桥梁,以便新的 Alien 组件可以在 Host 中运行。
第 1 部分的提醒:确保您的主机有可用的包捆绑器。 在本文中,我们依赖于 Webpack,但这并不意味着该技术不适用于 Rollup 或您选择的任何其他捆绑器。 但是,我将 Webpack 的映射留给您的实验。
命名约定
如前文所述,我们将使用 Web Components 将 Alien 集成到 Host 中。 在主机端,我们创建一个新文件: js/frankenstein-wrappers/Header-wrapper.js
。 (这将是我们的第一个 Frankenstein 包装器。)请记住,最好将包装器命名为与 Alien 应用程序中的组件相同的名称,例如,只需添加“ -wrapper
”后缀。 你稍后会看到为什么这是一个好主意,但是现在,让我们同意这意味着如果 Alien 组件被称为Header.js
(在 React 中)或Header.vue
(在 Vue 中),则在主机端应称为Header-wrapper.js
。
在我们的第一个包装器中,我们从注册自定义元素的基本样板开始:
class FrankensteinWrapper extends HTMLElement {} customElements.define("frankenstein-header-wrapper", FrankensteinWrapper);
接下来,我们必须为这个元素初始化Shadow DOM 。
请参阅第 1 部分以了解我们为什么使用 Shadow DOM。
class FrankensteinWrapper extends HTMLElement { connectedCallback() { this.attachShadow({ mode: "open" }); } }
有了这个,我们已经设置了 Web 组件的所有基本部分,是时候将我们的 Alien 组件添加到组合中了。 首先,在我们的 Frankenstein 包装器的开头,我们应该导入负责 Alien 组件渲染的所有位。
import React from "../../react/node_modules/react"; import ReactDOM from "../../react/node_modules/react-dom"; import HeaderApp from "../../react/src/components/Header"; ...
在这里,我们必须暂停一秒钟。 请注意,我们不会从 Host 的node_modules
导入 Alien 的依赖项。 一切都来自位于react/
子文件夹中的 Alien 本身。 这就是为什么第 2 步如此重要的原因,确保主机可以完全访问 Alien 的资产至关重要。
现在,我们可以在 Web 组件的 Shadow DOM 中渲染 Alien 组件:
... connectedCallback() { ... ReactDOM.render(<HeaderApp />, this.shadowRoot); } ...
注意:在这种情况下,React 不需要其他任何东西。 但是,要渲染 Vue 组件,您需要添加一个包装节点来包含您的 Vue 组件,如下所示:
... connectedCallback() { const mountPoint = document.createElement("div"); this.attachShadow({ mode: "open" }).appendChild(mountPoint); new Vue({ render: h => h(VueHeader) }).$mount(mountPoint); } ...
原因是 React 和 Vue 渲染组件的方式不同:React 将组件附加到引用的 DOM 节点,而 Vue 用组件替换引用的 DOM 节点。 因此,如果我们为 Vue 执行.$mount(this.shadowRoot)
,它本质上会取代 Shadow DOM。
这就是我们现在对包装器要做的所有事情。 Frankenstein 包装器在 jQuery 到 React 和 jQuery 到 Vue 迁移方向的当前结果可以在这里找到:
- 用于 React 组件的 Frankenstein Wrapper
- Vue 组件的弗兰肯斯坦包装器
总结一下 Frankenstein 包装器的机制:
- 创建自定义元素,
- 启动 Shadow DOM,
- 导入渲染 Alien 组件所需的一切,
- 在自定义元素的 Shadow DOM 中渲染 Alien 组件。
但是,这不会自动在 Host 中渲染我们的 Alien。 我们必须用我们新的 Frankenstein 包装器替换现有的 Host 标记。
系好安全带,这可能不像人们想象的那么简单!
5. 用 Web 组件替换主机服务
让我们继续将新Header-wrapper.js
文件添加到index.html
并用新创建的<frankenstein-header-wrapper>
自定义元素替换现有的标题标记。
... <!-- <header class="header">--> <!-- <h1>todos</h1>--> <!-- <input class="new-todo" placeholder="What needs to be done?" autofocus>--> <!-- </header>--> <frankenstein-header-wrapper></frankenstein-header-wrapper> ... <script type="module" src="js/frankenstein-wrappers/Header-wrapper.js"></script>
不幸的是,这不会那么简单。 如果您打开浏览器并检查控制台,则会出现Uncaught SyntaxError
等着您。 根据浏览器及其对 ES6 模块的支持,它要么与 ES6 导入相关,要么与 Alien 组件的渲染方式相关。 无论哪种方式,我们都必须对此做一些事情,但是对于大多数读者来说,问题和解决方案应该是熟悉和清楚的。
5.1。 在需要的地方更新 Webpack 和 Babel
在集成我们的 Frankenstein 包装器之前,我们应该使用一些 Webpack 和 Babel 魔法。 争论这些工具超出了本文的范围,但您可以查看 Frankenstein Demo 存储库中的相应提交:
- 迁移到 React 的配置
- 迁移到 Vue 的配置
本质上,我们在 Webpack 的配置中设置了文件的处理以及一个新的入口点frankenstein
,以便在一个地方包含与 Frankenstein 包装器相关的所有内容。
一旦 Host 中的 Webpack 知道如何处理 Alien 组件和 Web 组件,我们就可以用新的 Frankenstein 包装器替换 Host 的标记。
5.2. 实际组件的更换
组件的更换现在应该很简单了。 在主机的index.html
中,执行以下操作:
- 将
<header class="header">
DOM 元素替换为<frankenstein-header-wrapper>
; - 添加一个新脚本
frankenstein.js
。 这是 Webpack 中的新入口点,其中包含与 Frankenstein 包装器相关的所有内容。
... <!-- We replace <header class="header"> --> <frankenstein-header-wrapper></frankenstein-header-wrapper> ... <script src="./frankenstein.js"></script>
而已! 如果需要,重新启动您的服务器并见证集成到 Host 中的 Alien 组件的魔力。
然而,似乎仍然缺少一些东西。 Host 上下文中的 Alien 组件与独立 Alien 应用程序的上下文中的外观不同。 它只是没有样式。
为什么会这样? 组件的样式不应该和 Alien 组件自动集成到 Host 中吗? 我希望他们会,但在太多情况下,这取决于。 我们正在进入科学怪人迁移的具有挑战性的部分。
5.3. 外星人组件样式的一般信息
首先,具有讽刺意味的是,事情的运作方式没有错误。 一切都按照它的设计工作。 为了解释这一点,让我们简要介绍一下样式化组件的不同方式。
全局样式
我们都熟悉这些:全局样式可以(并且通常是)在没有任何特定组件的情况下分发并应用于整个页面。 全局样式影响所有具有匹配选择器的 DOM 节点。
全局样式的一些示例是index.html
中的<style>
和<link rel="stylesheet">
标记。 或者,可以将全局样式表导入到某个根 JS 模块中,以便所有组件也可以访问它。
以这种方式为应用程序设置样式的问题很明显:为大型应用程序维护单一样式表变得非常困难。 此外,正如我们在上一篇文章中看到的,全局样式很容易破坏直接在主 DOM 树中呈现的组件,就像在 React 或 Vue 中一样。
捆绑样式
这些样式通常与组件本身紧密耦合,并且很少在没有组件的情况下分发。 样式通常与组件位于同一个文件中。 这种类型的样式很好的例子是 React 或 CSS 模块中的 styled-components 和 Vue 中单个文件组件中的 Scoped CSS。 然而,无论编写捆绑样式的工具种类繁多,其中大多数的基本原理都是相同的:这些工具提供了一种作用域机制来锁定组件中定义的样式,这样样式就不会破坏其他组件或全局样式。
为什么作用域样式会很脆弱?
在第 1 部分中,当证明在 Frankenstein Migration 中使用 Shadow DOM 的合理性时,我们简要介绍了作用域与封装的主题)以及 Shadow DOM 的封装与作用域样式工具的不同之处。 但是,我们没有解释为什么作用域工具为我们的组件提供如此脆弱的样式,现在,当我们面对无样式的 Alien 组件时,理解它变得至关重要。
现代框架的所有范围工具都类似地工作:
- 你以某种方式为你的组件编写样式,而不考虑范围或封装;
- 您通过一些捆绑系统(如 Webpack 或 Rollup)使用导入/嵌入的样式表运行组件;
- 捆绑器生成独特的 CSS 类或其他属性,为您的 HTML 和相应的样式表创建和注入单独的选择器;
- 捆绑器在文档的
<head>
中创建一个<style>
条目,并将组件的样式与独特的混合选择器放入其中。
差不多就是这样。 在许多情况下,它确实可以正常工作。 除非它不这样做:当所有组件的样式都存在于全局样式范围内时,很容易破坏它们,例如,使用更高的特异性。 这解释了作用域工具的潜在脆弱性,但为什么我们的 Alien 组件完全没有样式?
我们来看看当前使用 DevTools 的 Host。 例如,在使用 Alien React 组件检查新添加的 Frankenstein 包装器时,我们可以看到如下内容:
因此,Webpack 确实为我们的组件生成了独特的 CSS 类。 伟大的! 那么风格在哪里呢? 好吧,样式正是它们被设计的地方——在文档的<head>
中。
所以一切正常,这是主要问题。 由于我们的 Alien 组件位于 Shadow DOM 中,并且如第 1 部分所述,Shadow DOM 提供了对来自页面其余部分和全局样式的组件的完全封装,包括那些新生成的组件样式表,这些样式表不能跨越阴影边界和进入外星人组件。 因此,Alien 组件未设置样式。 然而,现在解决问题的策略应该很清楚了:我们应该以某种方式将组件的样式放在我们的组件所在的同一个 Shadow DOM 中(而不是文档的<head>
)。
5.4. 修复外星人组件的样式
到目前为止,迁移到任何框架的过程都是相同的。 然而,事情从这里开始出现分歧:每个框架都有关于如何设置组件样式的建议,因此解决问题的方法也不同。 在这里,我们讨论最常见的情况,但是,如果您使用的框架使用一些独特的组件样式化方式,您需要牢记基本策略,例如将组件的样式放入 Shadow DOM 而不是<head>
。
在本章中,我们将介绍以下修复:
- 在 Vue 中与 CSS 模块捆绑样式(Scoped CSS 的策略是相同的);
- 在 React 中将样式与样式组件捆绑在一起;
- 通用 CSS 模块和全局样式。 我将这些结合起来是因为 CSS 模块通常与全局样式表非常相似,并且可以由任何组件导入,从而使样式与任何特定组件断开连接。
首先是约束:我们为修复样式所做的任何事情都不应破坏 Alien 组件本身。 否则,我们将失去外星人和主机系统的独立性。 因此,为了解决样式问题,我们将依赖捆绑器的配置或 Frankenstein 包装器。
Vue 和 Shadow DOM 中的捆绑样式
如果您正在编写 Vue 应用程序,那么您很可能使用的是单文件组件。 如果你也在使用 Webpack,你应该熟悉vue-loader
和vue-style-loader
这两个加载器。 前者允许您编写这些单个文件组件,而后者将组件的 CSS 作为<style>
标记动态注入到文档中。 默认情况下, vue-style-loader
将组件的样式注入到文档的<head>
中。 但是,这两个包都接受配置中的shadowMode
选项,这使我们能够轻松更改默认行为并将样式(正如选项名称所暗示的那样)注入 Shadow DOM。 让我们看看它的实际效果。
Webpack 配置
Webpack 配置文件至少应该包含以下内容:
const VueLoaderPlugin = require('vue-loader/lib/plugin'); ... module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', options: { shadowMode: true } }, { test: /\.css$/, include: path.resolve(__dirname, '../vue'), use: [ { loader:'vue-style-loader', options: { shadowMode: true } }, 'css-loader' ] } ], plugins: [ new VueLoaderPlugin() ] }
在实际应用程序中,您的test: /\.css$/
块将更加复杂(可能涉及oneOf
规则)以同时考虑 Host 和 Alien 配置。 但是,在这种情况下,我们的 jQuery 在index.html
中使用简单的<link rel="stylesheet">
进行样式设置,因此我们不会通过 Webpack 为 Host 构建样式,并且只满足 Alien 是安全的。
包装器配置
除了 Webpack 配置之外,我们还需要更新我们的 Frankenstein 包装器,将 Vue 指向正确的 Shadow DOM。 在我们的Header-wrapper.js
中,Vue 组件的渲染应该包括shadowRoot
属性,该属性导致我们的 Frankenstein 包装器的shadowRoot
:
... new Vue({ shadowRoot: this.shadowRoot, render: h => h(VueHeader) }).$mount(mountPoint); ...
更新文件并重新启动服务器后,您应该在 DevTools 中得到类似的内容:
最后,Vue 组件的样式在我们的 Shadow DOM 中。 同时,您的应用程序应如下所示:
我们开始得到类似于我们的 Vue 应用程序的东西:与组件捆绑在一起的样式被注入到包装器的 Shadow DOM 中,但组件看起来仍然不像预期的那样。 原因是在原始的 Vue 应用程序中,组件的样式不仅使用捆绑样式,而且部分使用全局样式。 然而,在修复全局样式之前,我们必须让我们的 React 集成到与 Vue 相同的状态。
React 和 Shadow DOM 中的捆绑样式
因为可以通过多种方式设置 React 组件的样式,所以在 Frankenstein Migration 中修复 Alien 组件的特定解决方案取决于我们首先为组件设置样式的方式。 让我们简要介绍最常用的替代方案。
样式化组件
styled-components 是最流行的 React 组件样式之一。 对于 Header React 组件,styled-components 正是我们为它设置样式的方式。 由于这是一种经典的 CSS-in-JS 方法,因此没有像我们对.css
或.js
文件所做的那样,可以将我们的捆绑器挂钩到的具有专用扩展名的文件。 幸运的是,styled-components 允许在StyleSheetManager
帮助组件的帮助下将组件的样式注入自定义节点(在我们的例子中为 Shadow DOM)而不是文档的head
。 它是一个预定义的组件,与接受target
属性的styled-components
包一起安装,定义“用于注入样式信息的备用 DOM 节点”。 正是我们需要的! 此外,我们甚至不需要更改 Webpack 配置:一切都取决于我们的 Frankenstein 包装器。
我们应该使用以下几行更新包含 React Alien 组件的Header-wrapper.js
:
... import { StyleSheetManager } from "../../react/node_modules/styled-components"; ... const target = this.shadowRoot; ReactDOM.render( <StyleSheetManager target={target}> <HeaderApp /> </StyleSheetManager>, appWrapper ); ...
在这里,我们导入StyleSheetManager
组件(来自 Alien,而不是来自 Host)并用它包装我们的 React 组件。 同时,我们发送指向我们的shadowRoot
的target
属性。 而已。 如果你重新启动服务器,你必须在你的 DevTools 中看到类似这样的东西:
现在,我们组件的样式在 Shadow DOM 而不是<head>
中。 这样,我们的应用程序的渲染现在类似于我们之前在 Vue 应用程序中看到的。
同样的故事: styled-components 只负责 React 组件样式的捆绑部分,而全局样式管理其余部分。 在我们回顾了另一种样式组件之后,我们稍后会回到全局样式。
CSS 模块
如果您仔细查看我们之前修复的 Vue 组件,您可能会注意到 CSS 模块正是我们为该组件设置样式的方式。 However, even if we style it with Scoped CSS (another recommended way of styling Vue components) the way we fix our unstyled component doesn't change: it is still up to vue-loader
and vue-style-loader
to handle it through shadowMode: true
option.
When it comes to CSS Modules in React (or any other system using CSS Modules without any dedicated tools), things get a bit more complicated and less flexible, unfortunately.
Let's take a look at the same React component which we've just integrated, but this time styled with CSS Modules instead of styled-components. The main thing to note in this component is a separate import for stylesheet:
import styles from './Header.module.css'
The .module.css
extension is a standard way to tell React applications built with the create-react-app
utility that the imported stylesheet is a CSS Module. The stylesheet itself is very basic and does precisely the same our styled-components do.
Integrating CSS modules into a Frankenstein wrapper consists of two parts:
- Enabling CSS Modules in bundler,
- Pushing resulting stylesheet into Shadow DOM.
I believe the first point is trivial: all you need to do is set { modules: true }
for css-loader
in your Webpack configuration. Since, in this particular case, we have a dedicated extension for our CSS Modules ( .module.css
), we can have a dedicated configuration block for it under the general .css
configuration:
{ test: /\.css$/, oneOf: [ { test: /\.module\.css$/, use: [ ... { loader: 'css-loader', options: { modules: true, } } ] } ] }
Note : A modules
option for css-loader
is all we have to know about CSS Modules no matter whether it's React or any other system. When it comes to pushing resulting stylesheet into Shadow DOM, however, CSS Modules are no different from any other global stylesheet.
By now, we went through the ways of integrating bundled styles into Shadow DOM for the following conventional scenarios:
- Vue components, styled with CSS Modules. Dealing with Scoped CSS in Vue components won't be any different;
- React components, styled with styled-components;
- Components styled with raw CSS Modules (without dedicated tools like those in Vue). For these, we have enabled support for CSS modules in Webpack configuration.
However, our components still don't look as they are supposed to because their styles partially come from global styles . Those global styles do not come to our Frankenstein wrappers automatically. Moreover, you might get into a situation in which your Alien components are styled exclusively with global styles without any bundled styles whatsoever. So let's finally fix this side of the story.
Global Styles And Shadow DOM
Having your components styled with global styles is neither wrong nor bad per se: every project has its requirements and limitations. However, the best you can do for your components if they rely on some global styles is to pull those styles into the component itself. This way, you have proper easy-to-maintain self-contained components with bundled styles.
Nevertheless, it's not always possible or reasonable to do so: several components might share some styling, or your whole styling architecture could be built using global stylesheets that are split into the modular structure, and so on.
So having an opportunity to pull in global styles into our Frankenstein wrappers wherever it's required is essential for the success of this type of migration. Before we get to an example, keep in mind that this part is the same for pretty much any framework of your choice — be it React, Vue or anything else using global stylesheets!
Let's get back to our Header component from the Vue application. Take a look at this import:
import "todomvc-app-css/index.css";
This import is where we pull in the global stylesheet. In this case, we do it from the component itself. It's only one way of using global stylesheet to style your component, but it's not necessarily like this in your application.
Some parent module might add a global stylesheet like in our React application where we import index.css
only in index.js
, and then our components expect it to be available in the global scope. Your component's styling might even rely on a stylesheet, added with <style>
or <link>
to your index.html
. 没关系。 What matters, however, is that you should expect to either import global stylesheets in your Alien component (if it doesn't harm the Alien application) or explicitly in the Frankenstein wrapper. Otherwise, the wrapper would not know that the Alien component needs any stylesheet other than the ones already bundled with it.
Caution . If there are many global stylesheets to be shared between Alien components and you have a lot of such components, this might harm the performance of your Host application under the migration period.
Here is how import of a global stylesheet, required for the Header component, is done in Frankenstein wrapper for React component:
// we import directly from react/, not from Host import '../../react/node_modules/todomvc-app-css/index.css'
Nevertheless, by importing a stylesheet this way, we still bring the styles to the global scope of our Host, while what we need is to pull in the styles into our Shadow DOM. 我们如何做到这一点?
Webpack configuration for global stylesheets & Shadow DOM
First of all, you might want to add an explicit test to make sure that we process only the stylesheets coming from our Alien. In case of our React migration, it will look similar to this:
test: /\.css$/, oneOf: [ // this matches stylesheets coming from /react/ subfolder { test: /\/react\//, use: [] }, ... ]
In case of Vue application, obviously, you change test: /\/react\//
with something like test: /\/vue\//
. Apart from that, the configuration will be the same for any framework. Next, let's specify the required loaders for this block.
... use: [ { loader: 'style-loader', options: { ... } }, 'css-loader' ]
有两点需要注意。 First, you have to specify modules: true
in css-loader
's configuration if you're processing CSS Modules of your Alien application.
Second, we should convert styles into <style>
tag before injecting those into Shadow DOM. In the case of Webpack, for that, we use style-loader
. The default behavior for this loader is to insert styles into the document's head. Typically. And this is precisely what we don't want: our goal is to get stylesheets into Shadow DOM. However, in the same way we used target
property for styled-components in React or shadowMode
option for Vue components that allowed us to specify custom insertion point for our <style>
tags, regular style-loader
provides us with nearly same functionality for any stylesheet: the insert
configuration option is exactly what helps us achieve our primary goal. 好消息! Let's add it to our configuration.
... { loader: 'style-loader', options: { insert: 'frankenstein-header-wrapper' } }
However, not everything is so smooth here with a couple of things to keep in mind.
样式style-loader
的全局样式表和insert
选项
如果您查看此选项的文档,您会注意到,此选项在每个配置中采用一个选择器。 这意味着,如果您有多个需要将全局样式拉入 Frankenstein 包装器的 Alien 组件,则必须为每个 Frankenstein 包装器指定style-loader
器。 实际上,这意味着您可能必须依赖配置块中的oneOf
规则来为所有包装器提供服务。
{ test: /\/react\//, oneOf: [ { test: /1-TEST-FOR-ALIEN-FILE-PATH$/, use: [ { loader: 'style-loader', options: { insert: '1-frankenstein-wrapper' } }, `css-loader` ] }, { test: /2-TEST-FOR-ALIEN-FILE-PATH$/, use: [ { loader: 'style-loader', options: { insert: '2-frankenstein-wrapper' } }, `css-loader` ] }, // etc. ], }
不是很灵活,我同意。 不过,只要您没有数百个要迁移的组件,这没什么大不了的。 否则,它可能会使您的 Webpack 配置难以维护。 然而,真正的问题是我们不能为 Shadow DOM 编写 CSS 选择器。
为了解决这个问题,我们可能会注意到insert
选项也可以采用函数而不是普通选择器来指定更高级的插入逻辑。 有了这个,我们可以使用这个选项将样式表直接插入到 Shadow DOM 中! 在简化形式中,它可能看起来类似于:
insert: function(element) { var parent = document.querySelector('frankenstein-header-wrapper').shadowRoot; parent.insertBefore(element, parent.firstChild); }
很诱人,不是吗? 但是,这不适用于我们的场景,或者远非最佳。 我们的<frankenstein-header-wrapper>
确实可以从index.html
获得(因为我们在步骤 5.2 中添加了它)。 但是当 Webpack 处理 Alien 组件或 Frankenstein 包装器的所有依赖项(包括样式表)时,Shadow DOM 尚未在 Frankenstein 包装器中初始化:在此之前处理导入。 因此,将insert
直接指向 shadowRoot 将导致错误。
只有一种情况我们可以保证在 Webpack 处理我们的样式表依赖之前初始化 Shadow DOM。 如果 Alien 组件本身不导入样式表,而是由 Frankenstein 包装器来导入它,我们可以在设置 Shadow DOM 后使用动态导入并导入所需的样式表:
this.attachShadow({ mode: "open" }); import('../vue/node_modules/todomvc-app-css/index.css');
这将起作用:这种导入,结合上面的insert
配置,确实会找到正确的 Shadow DOM 并将<style>
标记插入其中。 然而,获取和处理样式表需要时间,这意味着您的用户在连接速度较慢或设备较慢的情况下可能会在您的样式表在包装器的 Shadow DOM 中就位之前面临无样式组件的片刻。
所以总而言之,即使insert
接受函数,不幸的是,这对我们来说还不够,我们不得不回退到像frankenstein-header-wrapper
这样的普通 CSS 选择器。 但是,这不会自动将样式表放入 Shadow DOM,并且样式表位于 Shadow DOM 之外的<frankenstein-header-wrapper>
中。
我们还需要一块拼图。
全局样式表和 Shadow DOM 的包装器配置
幸运的是,包装器方面的修复非常简单:当 Shadow DOM 被初始化时,我们需要检查当前包装器中是否有任何挂起的样式表并将它们拉入 Shadow DOM。
全局样式表导入的当前状态如下:
- 我们导入一个必须添加到 Shadow DOM 中的样式表。 样式表可以在 Alien 组件本身中导入,也可以在 Frankenstein 包装器中显式导入。 例如,在迁移到 React 的情况下,导入是从包装器初始化的。 但是,在迁移到 Vue 时,类似的组件本身会导入所需的样式表,我们不必在包装器中导入任何内容。
- 如上所述,当 Webpack 为 Alien 组件处理
.css
导入时,由于style-loader
的insert
选项,样式表被注入到 Frankenstein 包装器中,但在 Shadow DOM 之外。
Frankenstein 包装器中 Shadow DOM 的简化初始化,目前(在我们拉入任何样式表之前)应该类似于以下内容:
this.attachShadow({ mode: "open" }); ReactDOM.render(); // or `new Vue()`
现在,为了避免无样式组件的闪烁,我们现在需要做的是在 Shadow DOM 初始化之后、Alien 组件渲染之前拉入所有需要的样式表。
this.attachShadow({ mode: "open" }); Array.prototype.slice .call(this.querySelectorAll("style")) .forEach(style => { this.shadowRoot.prepend(style); }); ReactDOM.render(); // or new Vue({})
这是一个包含很多细节的冗长解释,但主要是将全局样式表引入 Shadow DOM 所需的全部内容:
- 在 Webpack 配置中添加带有
insert
选项style-loader
,指向所需的 Frankenstein 包装器。 - 在包装器本身中,在 Shadow DOM 初始化之后、Alien 组件渲染之前拉入“待定”样式表。
实施这些更改后,您的组件应该拥有所需的一切。 您可能想要添加的唯一内容(这不是必需的)是一些自定义 CSS 来微调 Host 环境中的 Alien 组件。 在 Host 中使用时,您甚至可以为 Alien 组件设置完全不同的样式。 它超出了本文的重点,但您可以查看包装器的最终代码,您可以在其中找到有关如何在包装器级别覆盖简单样式的示例。
- 用于 React 组件的 Frankenstein 包装器
- Vue 组件的 Frankenstein 包装器
您还可以在迁移的这一步查看 Webpack 配置:
- 使用样式组件迁移到 React
- 使用 CSS 模块迁移到 React
- 迁移到 Vue
最后,我们的组件看起来完全符合我们的预期。
5.5. Alien 组件的修复样式总结
这是总结到目前为止我们在本章中学到的知识的好时机。 看起来我们必须做大量工作来修复 Alien 组件的样式; 然而,这一切都归结为:
- 修复使用 React 或 CSS 模块中的样式组件和 Vue 中的 Scoped CSS 实现的捆绑样式就像 Frankenstein 包装器或 Webpack 配置中的几行代码一样简单。
- 使用 CSS 模块实现的固定样式,只需在
css-loader
配置中的一行开始。 之后,CSS 模块被视为全局样式表。 - 修复全局样式表需要在 Webpack 中使用
insert
选项配置style-loader
包,并更新 Frankenstein 包装器以在包装器生命周期的正确时刻将样式表拉入 Shadow DOM。
毕竟,我们已经将样式正确的 Alien 组件迁移到了 Host 中。 但是,根据您迁移到的框架,只有一件事可能会或可能不会打扰您。
首先是好消息:如果您正在迁移到 Vue ,那么演示应该可以正常工作,并且您应该能够从迁移的 Vue 组件中添加新的待办事项。 但是,如果您正在迁移到 React并尝试添加新的待办事项,您将不会成功。 添加新项目根本不起作用,并且没有条目添加到列表中。 但为什么? 有什么问题? 没有偏见,但 React 对某些事情有自己的看法。
5.6. Shadow DOM 中的 React 和 JS 事件
不管 React 文档告诉你什么,React 对 Web Components 都不是很友好。 文档中示例的简单性经不起任何批评,任何比在 Web 组件中呈现链接更复杂的事情都需要一些研究和调查。
正如您在修复 Alien 组件样式时所看到的,与 Vue 几乎开箱即用的 Web 组件不同,React 还没有为 Web 组件做好准备。 目前,我们已经了解了如何让 React 组件在 Web 组件中至少看起来不错,但还有一些功能和 JavaScript 事件需要修复。
长话短说:Shadow DOM 封装事件并重新定位它们,而React 本身不支持 Shadow DOM 的这种行为,因此不会捕获来自 Shadow DOM 内部的事件。 这种行为有更深层次的原因,如果你想深入了解更多细节和讨论,React 的错误跟踪器中甚至还有一个未解决的问题。
幸运的是,聪明人为我们准备了解决方案。 @josephnvu 为解决方案提供了基础,Lukas Bombach 将其转换为react-shadow-dom-retarget-events
npm 模块。 所以你可以安装包,按照包页面上的说明,更新你的包装器代码,你的 Alien 组件将神奇地开始工作:
import retargetEvents from 'react-shadow-dom-retarget-events'; ... ReactDOM.render( ... ); retargetEvents(this.shadowRoot);
如果您想让它具有更高的性能,您可以制作包的本地副本(MIT 许可证允许这样做)并限制要侦听的事件数量,因为它在 Frankenstein Demo 存储库中完成。 对于此示例,我知道我需要重新定位哪些事件并仅指定这些事件。
有了这个,我们终于(我知道这是一个漫长的过程)完成了第一个样式化和功能齐全的 Alien 组件的正确迁移。 给自己喝点好酒。 你应得的!
6. 冲洗并重复所有组件
迁移第一个组件后,我们应该对所有组件重复该过程。 然而,在 Frankenstein Demo 的情况下,只剩下一个:负责渲染待办事项列表的那个。
新组件的新包装器
让我们从添加一个新包装器开始。 按照上面讨论的命名约定(因为我们的 React 组件称为MainSection.js
),迁移到 React 的相应包装器应该称为MainSection-wrapper.js
。 同时,Vue 中类似的组件称为Listing.vue
,因此迁移到 Vue 时对应的包装器应该称为Listing-wrapper.js
。 但是,无论命名约定如何,包装器本身都将与我们已经拥有的几乎相同:
- React 列表的包装器
- Vue 列表的包装器
我们在 React 应用程序的第二个组件中只介绍了一件有趣的事情。 有时,出于这个或其他原因,您可能希望在组件中使用一些 jQuery 插件。 对于我们的 React 组件,我们引入了两件事:
- 使用 jQuery 的 Bootstrap 工具提示插件,
-
.addClass()
和.removeClass()
等 CSS 类的切换。
注意:使用 jQuery 添加/删除类纯粹是说明性的。 请不要在实际项目中将 jQuery 用于此场景——而应使用纯 JavaScript。
当然,当我们从 jQuery 迁移出来时,在 Alien 组件中引入 jQuery 可能看起来很奇怪,但是您的 Host 可能与本示例中的 Host 不同——您可能会从 AngularJS 或其他任何东西中迁移出来。 此外,组件中的 jQuery 功能和全局 jQuery 不一定是一回事。
然而,问题是即使您确认该组件在 Alien 应用程序的上下文中运行良好,但当您将其放入 Shadow DOM 时,您的 jQuery 插件和其他依赖 jQuery 的代码将无法运行。
影子 DOM 中的 jQuery
让我们看一下随机 jQuery 插件的一般初始化:
$('.my-selector').fancyPlugin();
这样,所有带有.my-selector
的元素都将由fancyPlugin
处理。 这种初始化形式假定.my-selector
存在于全局 DOM 中。 但是,一旦将这样的元素放入 Shadow DOM 中,就像样式一样,阴影边界会阻止 jQuery 潜入其中。 结果,jQuery 无法在 Shadow DOM 中找到元素。
解决方案是为选择器提供一个可选的第二个参数,该参数定义了 jQuery 搜索的根元素。 这就是我们可以提供shadowRoot
的地方。
$('.my-selector', this.shadowRoot).fancyPlugin();
这样,jQuery 选择器和插件就可以正常工作。
请记住,Alien 组件旨在用于以下两种情况:在没有 shadow DOM 的 Alien 中和在 Shadow DOM 中的 Host 中。 因此,我们需要一个更统一的解决方案,默认情况下不会假设存在 Shadow DOM。
分析我们的 React 应用程序中的MainSection
组件,我们发现它设置了documentRoot
属性。
... this.documentRoot = this.props.root? this.props.root: document; ...
因此,我们检查传递的root
属性,如果它存在,这就是我们用作documentRoot
的内容。 否则,我们回退到document
。
这是使用此属性的工具提示插件的初始化:
$('[data-toggle="tooltip"]', this.documentRoot).tooltip({ container: this.props.root || 'body' });
作为奖励,在这种情况下,我们使用相同的root
属性来定义一个用于注入工具提示的容器。
现在,当 Alien 组件准备好接受root
属性时,我们在相应的 Frankenstein 包装器中更新组件的渲染:
// `appWrapper` is the root element within wrapper's Shadow DOM. ReactDOM.render(<MainApp root={ appWrapper } />, appWrapper);
就是这样! 该组件在 Shadow DOM 中的工作与在全局 DOM 中一样好。
多包装器场景的 Webpack 配置
当使用多个包装器时,令人兴奋的部分发生在 Webpack 的配置中。 像 Vue 组件中的 CSS 模块或 React 中的样式组件这样的捆绑样式没有任何变化。 但是,全局样式现在应该有所改变。
请记住,我们说过style-loader
(负责将全局样式表注入到正确的 Shadow DOM 中)是不灵活的,因为它的insert
选项一次只需要一个选择器。 这意味着我们应该使用oneOf
规则或类似规则将 Webpack 中的.css
规则拆分为每个包装器有一个子规则,如果您使用的是 Webpack 以外的捆绑器。
用一个例子来解释总是更容易,所以这次我们来谈谈从迁移到 Vue 的那个(然而,在迁移到 React 的那个几乎相同):
... oneOf: [ { issuer: /Header/, use: [ { loader: 'style-loader', options: { insert: 'frankenstein-header-wrapper' } }, ... ] }, { issuer: /Listing/, use: [ { loader: 'style-loader', options: { insert: 'frankenstein-listing-wrapper' } }, ... ] }, ] ...
我已经排除了css-loader
,因为它的配置在所有情况下都是相同的。 让我们来谈谈style-loader
。 在此配置中,我们将<style>
标记插入*-header-*
或*-listing-*
,具体取决于请求该样式表的文件的名称( issuer
中的发布者规则)。 但我们必须记住,渲染 Alien 组件所需的全局样式表可能会在两个地方导入:
- 外星人组件本身,
- 弗兰肯斯坦包装纸。
在这里,我们应该了解包装器的命名约定,如上所述,当 Alien 组件的名称和相应的包装器匹配时。 例如,如果我们有一个样式表,导入一个名为Header.vue
的 Vue 组件中,它会得到正确的*-header-*
包装器。 同时,如果我们改为在包装器中导入样式表,如果包装器名为Header-wrapper.js
且配置没有任何更改,则此类样式表遵循完全相同的规则。 Listing.vue
组件及其对应的包装器Listing-wrapper.js
也是如此。 使用这个命名约定,我们减少了捆绑器中的配置。
迁移所有组件后,就该进行最后一步的迁移了。
7.切换到外星人
在某些时候,您会发现您在迁移的第一步中确定的组件都被 Frankenstein 包装器替换了。 没有真正留下任何 jQuery 应用程序,而您所拥有的本质上是使用 Host 的方式粘合在一起的 Alien 应用程序。
例如,jQuery 应用程序中index.html
的内容部分——在迁移了两个微服务之后——现在看起来像这样:
<section class="todoapp"> <frankenstein-header-wrapper></frankenstein-header-wrapper> <frankenstein-listing-wrapper></frankenstein-listing-wrapper> </section>
此时,保留我们的 jQuery 应用程序是没有意义的:相反,我们应该切换到 Vue 应用程序并忘记我们所有的包装器、Shadow DOM 和花哨的 Webpack 配置。 为此,我们有一个优雅的解决方案。
让我们谈谈 HTTP 请求。 我将在这里提到 Apache 的配置,但这只是一个实现细节:在 Nginx 或其他任何东西中进行切换应该与在 Apache 中一样简单。
想象一下,您的站点从服务器上的/var/www/html
文件夹提供服务。 在这种情况下,您的httpd.conf
或httpd-vhost.conf
应该有一个指向该文件夹的条目,例如:
DocumentRoot "/var/www/html"
要在将 Frankenstein 从 jQuery 迁移到 React 后切换应用程序,您需要做的就是将DocumentRoot
条目更新为以下内容:
DocumentRoot "/var/www/html/react/build"
构建您的 Alien 应用程序,重新启动您的服务器,您的应用程序直接从 Alien 的文件夹提供服务:React 应用程序从react/
文件夹提供服务。 但是,当然,对于 Vue 或您已迁移的任何其他框架也是如此。 这就是为什么在任何时候保持主机和外星人完全独立和正常运行如此重要的原因,因为在这一步你的外星人将成为你的主机。
现在,您可以安全地删除 Alien 文件夹周围的所有内容,包括所有 Shadow DOM、Frankenstein 包装器和任何其他与迁移相关的工件。 有时这是一条艰难的道路,但您已经迁移了您的网站。 恭喜!
结论
在这篇文章中,我们确实经历了一些崎岖的地形。 然而,在我们开始使用 jQuery 应用程序之后,我们已经设法将它迁移到 Vue 和 React。 在此过程中,我们发现了一些意想不到且不那么微不足道的问题:我们必须修复样式,我们必须修复 JavaScript 功能,引入一些捆绑器配置等等。 但是,它让我们更好地了解了实际项目中的预期。 最后,我们得到了一个现代应用程序,没有任何来自 jQuery 应用程序的剩余部分,尽管在迁移过程中我们有权对最终结果持怀疑态度。
科学怪人迁移既不是灵丹妙药,也不应该是一个可怕的过程。 它只是定义的算法,适用于许多项目,有助于以可预测的方式将项目转换为新的和健壮的东西。