The Future is Now
2023 年 3 月 17 日

为你的 Remix 应用提供未来保障

Matt Brophy
资深开发者

在 Remix,我们深知进行主要版本升级有多么痛苦。特别是对于你的应用程序来说,像框架或路由器这样基础的东西。我们希望尽最大努力为你提供一流的升级体验 —— 让我们来谈谈“未来标志”

现状

每个1框架(或库)在某个时候都必须引入破坏性更改。这些更改会导致你的代码按照今天的写法在新版本上崩溃。这可能会导致构建时(甚至更糟糕的是,运行时)错误。但这些更改是好的!这是我们的框架如何演进、变得更快、采用新的平台特性、实现社区驱动的功能请求等等的方式。

由于这种对破坏性更改的固有需求,出现了 语义版本控制 (SemVer) 规范,该规范定义破坏性更改会指示一个新的主要版本发布。这很棒,因为它让应用程序开发人员知道他们何时应该期望他们的代码在升级时需要更改,而不是他们何时应该期望升级“正常工作”。但请记住,你始终应该阅读发行说明,而不仅仅是盲目升级😉。

恰好,在我开始撰写本文的同一天,@devagrawal09 发推文 如下,引发了关于框架当前状态及其处理“重大重写”的相关讨论。

Tweet from @devagrawal09 asking 'which javascript framework has lived more than 5 years without causing major rewrites?'
2026 年再来关注 Remix!

从讨论中可以清楚地看出,人们对“重大重写”的理解各不相同,并且多年来,框架在这方面的成功程度也各不相同。事情之所以不那么明确,部分原因是虽然 SemVer 提供了一种方法来沟通何时存在破坏性更改,但我们没有类似的、关于如何在我们的框架中引入破坏性更改并将它们传达给应用程序开发人员的商定流程。

一般来说,主要 SemVer 版本的最低标准是一组发布说明,其中概述了主要版本中的破坏性更改。理想情况下,这些还包括关于如何更改代码以采用破坏性更改的说明。但这真的就这些了 —— 除此之外,关于如何最好地准备和帮助用户跨主要版本采用破坏性更改的标准化程度非常低。

因此,多年来我们看到了各种不同的方法,包括但不限于

  • 编写详细的迁移指南 1 2 3
  • 在主要版本发布之前发布一个准备版本,以便更好地准备你的代码来采用破坏性更改 1
  • 发布一个兼容性包,允许你同时运行两个版本 1 2 3

我们已经看到了效果很好的方法,也有一些效果不好的方法。但是,在成功案例中似乎存在一个共同的概念,那就是为应用程序开发人员提供迭代升级其应用程序的路径。在规模上,无法迭代升级应用程序的各个部分会变得有问题。你最终会得到一个长期存在的 version-N-upgrade 分支,一些工程师会不遗余力地定期将其 rebase 到最新的 main 分支上,并可能在这个过程中一点一点地抓破自己的头发。

这些长期存在的功能分支也往往进展缓慢。我们的利益相关者不希望为了升级我们的堆栈(对客户不可见)而停止几周的功能开发 —— 他们希望并行地不断发布新功能。因此,团队不仅只将一部分精力分配给升级,而且还在处理新旧世界之间固有的上下文切换。这导致升级速度甚至更慢。

功能分支

如果我们看一下上面的一些方法对应用程序开发人员的影响,我们经常看到它们都涉及某种形式的长期存在的功能分支,这会带来上面提到的缺点。在所有情况下,功能分支的生命周期都取决于破坏性更改的数量,但即使只有少数破坏性更改 —— 在大型代码库中解决这些更改也可能需要一些时间。

迁移指南 通常在功能分支中遵循和实现。

Diagram of a long lived feature branch for implementing the changes from a migration guide
迁移指南的长期存在的功能分支

准备版本 倾向于将工作分为 2 个功能分支 —— 一个用于升级到准备版本,另一个用于升级到主要版本。这是一种稍好一点的方法,但这些单独的分支仍然存在相同的缺点。

Diagram of 2 shorter-lived feature branches for implementing the changes from a preparation version
准备版本的 2 个中等生命周期的功能分支

迁移构建 和/或向后兼容性标志在消除长期存在的功能分支方面做得更好,但它们仍然存在 2 个不理想的方面。首先,它们存在一些潜在的技术风险,因为并排运行两个包(v2 和 v2 “向后兼容”)与运行 v2 完全不同 —— 因此,包的相互通信中存在一个非零的 bug 出现区域。其次,也是可能更重要的,它们仍然会一次性向你转储所有新功能(和破坏性更改)。你通常很少能提前做些什么来准备你的代码库进行升级并减轻影响。一旦 v2 发布,你有可能通过升级到新版本和向后兼容性包来避免长期存在的功能分支。但是,当你迭代地采用破坏性更改并最终删除兼容性构建或向后兼容性标志时,你会在主分支上赶上一段时间。

Diagram of many short-lived branches to implement features via a migration build
迁移构建允许在发布后迭代采用功能

我们对这些方法都不满意,并希望我们可以提供更平稳的主要升级路径。

引入未来标志

当我们第一次开始讨论如何处理 Remix 的破坏性更改时,我忍不住回想起我观看 Yehuda Katz 在 Philly ETE 2016 上做的 无停滞的稳定性 演讲。我不是 Ember 开发人员,但那次演讲给我留下了深刻的印象2,关于框架如何通过使用功能标志来减轻用户采用新功能的痛苦。然而,Ryan Florence 一名活跃的 Ember 开发人员,所以当我提到这个演讲时,他立即就知道了“无停滞的稳定性”这个短语。

在我的职业生涯后期,在一个 Vue SSR 应用程序上工作时,我们正在为 Vue 2 -> 3 升级做准备,我很高兴看到他们在他们的构建中引入的 功能标志(尽管我在执行升级之前换了工作,所以我不知道它进展得有多顺利)。

我们在 Remix 知道,如果我们想为用户提供平稳的升级体验,那么功能标志的概念是至关重要的。但是我们想比我们以前在 OSS 中做得更好。即使在上面使用向后兼容性标志的最佳方法中 —— 开发人员仍然会面临主要版本中“一次性获得所有新内容”的情况 —— 这让他们需要在一段时间内赶上进度。此外,这也将所有 v2 代码更改一个接一个地堆叠起来,为你提供了一个潜在微妙错误的压缩表面积。我们想看看我们是否可以做得更好。

在 Remix,我们在主要版本中引入破坏性更改的目标有两个:

  1. 消除对长期存在的功能分支的需求
  2. 让你在当前版本中发布时单独选择加入下一个版本的破坏性更改

换句话说,我们看到的大多数方法都试图在 v2 发布为你提供从 v1 到 v2 的退出坡道。相反,Remix 的目标是在 v1 版本中发布时为你提供大量通往最终 v2 功能的小入口坡道。如果一切按计划进行,并且你随着新“入口坡道”的出现而保持最新,那么你的代码按照今天的写法将在你升级到新的主要版本时“正常工作”。这有效地使主要版本升级与次要版本升级一样无痛🤯。

Diagram of the lack of a feature branches for adopting v2 features via future flags
未来标志消除了发布后采用的需求

此外,通过在 v1 中随着时间推移引入这些功能 —— 我们为应用程序开发人员提供了一个更大的表面积,他们可以在其中分散与 v2 相关的代码更改。

Diagram of the gradual adoption of v2 feature via future flags through the v1 lifetime
可以在 v1 生命周期内逐步采用功能

我们理解这是一个崇高的目标,并且我们知道它可能并非总是完全按照我们的计划进行,但是我们对稳定性很认真,并且希望确保我们的流程考虑到主要版本升级可能给我们的应用程序开发人员带来的负担。

我们计划通过我们在 remix.config.js 文件中称为未来标志的方式来实现这一点。将这些视为未来功能的特性标志(现在快速说 5 遍😉)。当我们实现新功能时,我们始终尝试以向后兼容的方式进行。但是,当我们不能并且决定需要进行破坏性更改时,我们不会将该功能推迟到最终 v2 版本。相反,我们添加一个未来标志,并在 v1 次要版本中与当前行为一起实现新功能。这允许用户开始使用该功能、提供反馈并立即报告 bug。

这样,你不仅可以逐步采用功能(并且可以在没有主要版本升级的情况下急切地采用),我们还可以在发布 v2 之前逐步解决任何问题。最终,我们还会向 v1 版本添加弃用警告,以促使用户使用新行为。然后在 v2 中,我们删除旧的 v1 方法、删除弃用并删除标志 —— 从而使标记的行为成为 v2 中的新默认行为。如果在发布 v2 时,一个应用程序已选择加入所有未来标志并更新了他们的代码 —— 那么他们应该只需将其 Remix 依赖项更新到 v2 并从其 remix.config.js 中删除未来标志,并且可以在几分钟内运行 v2。

不稳定标志 vs. V2 标志

未来的标志可以有两种形式:future.unstable_featurefuture.v2_feature,标志的生命周期将取决于更改的性质以及它是否是破坏性的。引入新功能的决策流程大致如下所示:

Flowchart of the decision process for how to introduce a new feature

引入新功能的流程图(点击在新标签页中打开)

因此,生命周期是以下之一:

  • 非破坏性 + 稳定 API 功能 -> 进入 v1
  • 非破坏性 + 不稳定 API -> future.unstable_ 标志 -> 进入 v1
  • 破坏性 + 稳定 API 功能 -> future.v2_ 标志 -> 进入 v2
  • 破坏性 + 不稳定 API -> future.unstable_ 标志 -> future.v2_ 标志 -> 进入 v2

为了澄清一下,这里的 unstable_ *并不意味着* 我们认为该功能存在错误!它意味着我们不确定 API 在稳定之前是否会进行一些小的更改。我们*绝对*希望早期采用者开始使用这些功能,以便我们可以在 API 上进行迭代(或获得信心)。

此外,v2_ 标志并不意味着该功能没有错误——没有软件是完美的!这意味着我们对 API 有信心,并认为它是 v2 中默认行为的稳定 API。这意味着如果你更新你的代码以在 v1 中使用这个新的 API,你可以使你的 v2 升级*更加*平滑。

Remix v1 中当前的未来标志

以下是今天 Remix v1 中当前标志的列表:

  • unstable_cssModules - 启用 CSS 模块支持
  • unstable_cssSideEffectImports - 启用 CSS 副作用导入
  • unstable_dev - 启用新的开发服务器(包括 HMR/HDR 支持)
  • unstable_postcss - 启用 PostCSS 支持
  • unstable_tailwind - 启用 TailwindCSS 支持
  • unstable_vanillaExtract - 启用 Vanilla Extract 支持
  • v2_errorBoundary - 将 ErrorBoundary/CatchBoundary 合并为一个 ErrorBoundary
  • v2_meta - 为你的 meta 函数启用新的 API
  • v2_routeConvention - 启用基于文件的路由的扁平路由样式

我们正在准备 v2 版本的发布,因此所有 future.unstable_ 标志都将被稳定为 future.v2_ 标志(除了那些不属于破坏性更改的标志,例如 PostCSS/Tailwind/Vanilla Extract 支持)。这包括为仍在使用旧方法的应用程序添加弃用警告。一旦我们将它们全部稳定下来,我们将发布最终的 Remix 1.15.0 版本,并让它运行一段时间,以便人们有时间选择他们尚未添加的任何标志。然后,我们将计划发布 Remix 2.0.0,并开始致力于发布标志驱动的 Remix v3 功能。

将来,请查看关于此策略的文档,以获取最新的活动未来标志列表。

脚注

  1. 之所以说是“每个”而不是“所有”,是因为我确信有一些像 add 这样的库,多年来一直以 v1.0.0 版本运行,而没有破坏性更改,因为...嗯,数学的语义不会经常变化。但是你明白了——事物在发展,需要进行破坏性更改,除非你是 DOM,它在向后兼容性方面做得非常出色。

  2. 这可能与我如此相关,因为就在那次谈话的 2 个月前,AngularJs 1.5.0 发布,试图为 Angular v2 提供更平滑的路径。当时,我是一个大型 AngularJs 1.4.0 电子商务结账应用程序的首席开发人员,我们正在意识到 Angular v2 不是一次升级,而是一次完全重写 😕。


获取最新的 Remix 新闻更新

成为第一个了解新的 Remix 功能、社区活动和教程的人。