作为《纽约时报》的软件工程师,我们非常重视页面性能、搜索引擎优化(SEO)以及保持对最新技术的跟进。考虑到这些优先事项,React 18的发布在我们看来是网络开发不断扩展的世界中一个重要且具体的飞跃。对于我们的基于React的网站来说,这次升级承诺了性能的提升和对激动人心的新特性的访问。去年冬天,我们开始在我们的旗舰核心新闻网站上拥抱React 18的力量。在这个过程中,我们遇到了一些独特的特性——无论是在React本身还是我们自己的网站上——我们都不得不学会如何导航。最终,我们取得了巨大的性能提升,并开启了一个我们仍在探索的未来改进的世界。
在我们深入探讨升级过程之前,让我们先看看React 18中的一些主要好处和变化:
- 平滑渲染与并发模式:React 18引入了并发模式,这是一种范式转变,允许同时渲染更新和用户交互。这意味着更平滑的动画、更少的屏幕抖动和累积布局偏移,以及更灵敏的用户体验。
- 自动批处理和过渡:为了充分利用并发性,React 18自动在一个渲染周期内批处理状态更新,优化性能。它通过分解主线程中的任务来实现这一点,这是与之前几乎所有任务都是同步执行的机制相比的一个重大转变。引入新的useTransition 钩子也允许工程师确保某些状态会在不阻塞UI的情况下更新。
- 激动人心的新特性:React 18为激动人心的功能铺平了道路,如通过react server components进行服务器端渲染和流式更新,以及选择性水合,为创新的UI模式和更快的初始渲染打开了大门。
性能提升对我们来说尤为重要,因为它们承诺了我们在交互到下一次绘制(INP)得分上的显著改进。INP是页面响应性的衡量标准,是Google用来在搜索结果中对网站进行排名的一系列指标——核心网络关键指标中的最新成员。对于新闻机构来说,SEO得分至关重要,提高我们的INP得分对我们来说一直是一个艰巨的挑战,这使得React升级成为一个高优先级(和高风险)的举措。
我们的迁移过程
- 移除弃用的依赖
在我们开始迁移本身之前,我们需要移除一个与React 18不兼容的弃用的Enzyme测试库。为了做到这一点,我们必须手动将我们所有的测试文件迁移到更更新的库,@testing-library/react。 就时间承诺而言,这可能是整个项目中最大的部分。我们的仓库中有数百个测试文件使用了Enzyme,它需要大量的手动努力和数十个拉取请求才能完全替换。我们在几个月的时间里通过增量拉取请求完成了这一努力,以适应其他产品工作并避免开发者疲劳。在努力的最后,我们绝对感觉自己是@testing-library/react API的专家,我们很高兴能够继续进行React 18的升级本身。
2. 基础设置
测试文件迁移完成后,我们可以开始整合React 18的工作。为了安全地完成这项工作,我们首先开始升级我们所有的主要依赖项、类型和测试,以符合React 18,而没有实现最新的特性本身。这涉及到从@types/react、react-test-renderer、react-dom和@testing-library简单地升级到我们的package.json文件中的最新版本。升级所有主要依赖项也涉及到重构一些测试和类型定义,以符合最新版本。
3. 启动引擎
一旦我们对我们的包升级感到自信,我们就准备安全地整合React 18的新功能。要将这些特性变为现实,我们需要利用最新的API:createRoot 和 hydrateRoot。我们在多个网络服务器上有多个实例,我们已经在其中集成了React水合,有一组共享的UI组件在它们之间渲染,因此对我们来说,能够在尽可能多的地方启用React 18功能是很重要的。乍一看,这看起来就像从ReactDOM.hydrate更改为hydrateRoot一样简单。但真的是这样吗?
意外的挑战
作为开发者,当你点击“部署到生产环境”按钮时,很容易过于自信。你的端到端集成和单元测试都通过了,你已经在各种表面和设备上进行了QA,你马上就要推出最新功能了。当我们最初将React的最新版本部署到《纽约时报》网站时,我们都有这样的感觉。在我们最初部署新升级后不久,我们遇到了一些高流量内容的问题,特别是在我们称之为“嵌入式交互”的内容类型上。
使嵌入式交互适应React 18
我们的图形开发人员构建的一个定制嵌入式交互:https://www.nytimes.com/article/hurricane-norma-baja-california.html
在《纽约时报》,我们使用dangerouslySetInnerHTML在服务器端渲染自定义嵌入式交互。这些交互有他们自己的HTML、链接和脚本,独立于React树运行。这允许编辑和记者在我们的页面中注入一次性的、自包含的视觉和交互元素,而不必更改或重新部署核心基础设施。嵌入式交互是我们一些最有影响力的报道的关键,但它们也可能为开发者带来真正的挑战。
一个简化的例子可能如下所示(脚本标签将在页面打开后立即修改DOM):
const embeddedInteractiveString = `
<div id="server-test">server</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const serverTestElement = document.getElementById("server-test");
serverTestElement.textContent = "client";
});
</script>
`;
return <div dangerouslySetInnerHTML={{ __html: embeddedInteractiveString }} />;
在这个设置中,脚本在页面加载后将“server-test”元素的内容从“server”修改为“client”。这是因为浏览器渲染的脚本在React水合DOM之前执行。这本质上是一个“黑匣子”,我们信任注入的HTML及其脚本按预期行为。
水合障碍
进入React 18,它对水合不匹配的要求更加严格。在新规则下,任何在初始浏览器加载和客户端水合之间的DOM修改都会触发回退到客户端渲染。在我们的示例中,尽管脚本标签在水合之前修改了“server-test”元素,但在水合不匹配的情况下,React将丢弃服务器渲染的内容,并回退到客户端渲染,本质上抵消了脚本的影响。在React的以前的版本中,即使存在水合不匹配,React团队选择保留DOM的无效状态,而不是完全在客户端重新渲染,这就是为什么我们过去没有遇到任何问题。
在实践中,这意味着什么?嗯,当使用dangerouslySetInnerHTML属性在客户端渲染组件时,任何包含<script>
标签的HTML都不会运行,因为浏览器安全考虑。这意味着,由于水合不匹配而在客户端重新渲染的任何嵌入式交互,将基本上呈现为如果JavaScript从未被执行过一样。在我们上面的示例中,文本内容将从“server”更改为“client”,但在水合不匹配的情况下,它将重新呈现为“server”。这最终使得我们的一些嵌入式交互与预期的渲染大相径庭。
预期:
实际:
那我们该怎么办?
鉴于React 18对水合不匹配的敏感性比React 16要高得多,我们面前基本上有两个选择。第一是修复我们网站上所有潜在的水合不匹配。第二是适应嵌入式交互,在水合不匹配发生时作为回退在客户端重新挂载。这让我们陷入了一点困境。《纽约时报》已经发布了数百万篇文章,有数百种不同的组件和数万个自定义嵌入式交互。当然,我们想要修复我们所有的水合不匹配,但我们如何才能安全地做到这一点呢?
最后,我们决定同时解决这两个问题。
手动提取和执行嵌入式交互脚本
我们知道,通过innerHTML属性(或在客户端重新渲染期间)添加的脚本标签不会自动运行,因为浏览器安全考虑。那么我们如何绕过这个问题呢?脚本标签只有在作为子节点手动附加或替换到DOM中的另一个元素时才会运行。这意味着为了正确运行脚本标签,我们必须首先从交互HTML中提取并移除它们,然后在组件重新渲染时将它们重新附加回嵌入式交互 HTML的正确位置。
// 这个函数用空占位符替换通用HTML中的脚本标签。
// 这允许我们稍后在客户端挂载时用实际脚本替换脚本标签引用。
export const addsPlaceholderScript = (scriptText, id, scriptCounter) => {
let replacementToken = '';
let hoistedText = scriptText;
replacementToken = `<script id="${id}\-script-${scriptCounter}"></script>`;
hoistedText = hoistedText.replace('<script', `<script id="${id}\-script-${scriptCounter}"`);
return {
replacementToken,
hoistedText,
};
};
// 这个函数从交互HTML字符串中提取并移除<script>标签
// 并返回一个对象,包含:
// - scriptsToRunOnClient:一个要在客户端挂载时运行的脚本文本数组。
// - scriptlessHtml:移除脚本后带有空脚本引用的修改后的HTML字符串。
export const extractAndReplace = (html, id) => {
const SCRIPT_REGEX = /<script[\s\S]*?>\s*?<\/script>/gi;
let lastMatchAdjustment = 0;
let scriptlessHtml = html;
let match;
const scriptsToRunOnClient = [];
let scriptCounter = 0;
while ((match = SCRIPT_REGEX.exec(html))) {
const [matchText] = match;
if (matchText) {
let hoistedText = matchText;
let replacementToken = '';
({ hoistedText, replacementToken } = addsPlaceholderScript(hoistedText, id, scriptCounter));
scriptCounter += 1;
const start = match.index - lastMatchAdjustment;
const end = match.index + matchText.length - lastMatchAdjustment;
scriptlessHtml = `${scriptlessHtml.substring(
0,
start
)}${replacementToken}${scriptlessHtml.substring(end, scriptlessHtml.length)}`;
scriptsToRunOnClient.push(hoistedText);
lastMatchAdjustment += matchText.length - replacementToken.length;
}
}
return {
scriptsToRunOnClient,
scriptlessHtml,
};
};
// 在客户端运行脚本
const runScript = (clonedScript) => {
const script = document.getElementById(document.getElementById(`${clonedScript.id}`))
script.parentNode.replaceChild(clonedScript, script);
}
你可能在问,《为什么我们不保留服务器上的脚本,然后在客户端重新运行它们?》在某些情况下,这是不可能的一个原因是,一些脚本标签声明全局变量而不是在函数闭包内。如果你在服务器上预先渲染这些脚本标签,然后在客户端重新运行它们,你会因为重新声明全局变量而遇到错误,这是不可能的。
这个初始解决方案修复了我们的许多嵌入式交互。不幸的是,并非每个交互都能很好地适应任意顺序的脚本执行。这里是我们处理一些细微差别的地方:
脚本加载顺序
当将一些交互脚本重新附加回嵌入式交互HTML时,它们必须按正确的顺序加载。以前的脚本执行策略自动假设所有的<script>
标签都已经在服务器上声明和预先渲染。现在我们正在剥离脚本标签并在客户端重新挂载它们,一些基于这些原则的固有逻辑将会中断。让我们通过一个例子来说明。
<script>
const
results = document.getElementById("RESULTS_MANIFEST").innerHTML.ELECTION_RESULTS;
// 基于结果做额外的逻辑处理
</script>
<div>
交互式DOM内容在这里
</div>
<script id="RESULTS_MANIFEST">{"ELECTION_RESULTS": ['result1', 'result2', ...]}</script>
在上面的场景中,我们有一个初始脚本,它通过ID搜索另一个脚本标签,然后利用基于第二个脚本标签的innerHTML的现有逻辑。在以前的迭代中,由于脚本标签以前是在服务器上预先渲染的,所以引用ID的脚本标签默认情况下会在DOM中可用,不会有任何问题。
为了最佳交互,当重新附加到DOM时,脚本执行需要遵循特定的顺序。这包括:
- 首先附加包含静态数据的非功能性清单脚本。
- 接下来异步执行具有src属性的脚本。
- 最后,附加并执行innerHTML中有纯JavaScript的脚本。
这种排序可以防止脚本在它们正确加载之前相互引用。
// 解析提供的脚本标签,返回一个优先级用于排序。
// 优先级1:用于JSON或其他元数据内容。
// 优先级2:用于其他纯JS或src内容
export const getPriority = template => {
let priority;
try {
JSON.parse(template.innerHTML);
priority = 1;
} catch (err) {
priority = 2;
}
return priority;
};
scripts.sort((a, b) => getPriority(a) - getPriority(b));
立即性能收益
在我们整合了这些非常精细的——几乎是外科手术式的——对我们的嵌入式交互代码的操纵之后,我们觉得我们能够再次安全地将React 18释放到野外。虽然我们永远无法对近4万个自定义创建的嵌入式交互进行广泛的QA,但我们能够依赖图形团队经常返回的一些可重用模板。这让我们在基于Svelte或Adobe Illustrator的嵌入式交互中验证特定的行为。从长远来看,我们致力于消除我们剩余的水合不匹配,并实现完全的安心。但从短期来看,我们准备再次按下“部署”按钮。
一旦我们发布了新特性(并紧张地监控内部警报一个小时以寻找任何问题),我们几乎立即看到了性能提升。
从这张图表中你可以看到,p75范围内的INP得分下降了大约30%!
在升级之前,我们面临的最大挑战之一是我们的新闻网站在加载页面时经常进行重新渲染。当用户试图与仍在加载的页面交互时,这会导致糟糕的用户体验(和较差的INP得分)。
在React 18升级后,我们的重新渲染基本上减少了一半!
这两个非常显著和重要的改进是React 18自动批处理和并发特性的直接结果。这为我们提供了一个非常明确和积极的信号,表明我们正在朝着正确的方向前进。
我们去向哪里
React 18的整合已经为我们带来了显著的改进,开启了以前无法获得的可能性的大门。我们现在专注于探索新特性如startTransition 和 React Server Components 的潜在好处。我们的核心意图是持续降低我们的INP得分并改善整体功能。然而,我们还在思考我们仍然需要回答的关于这些增强的问题。就目前而言,我们的主要承诺是确保我们当前使用的React版本的稳定和可靠的性能。
基于我们在新闻网站上的结果,我们有信心追求我们其他一些网站的升级,在那里我们看到了类似的性能提升。我们能够在Google的3月截止日期之前让我们的INP得分摆脱“差”区域,并且在它成为他们搜索算法的一部分时,我们没有看到任何负面的SEO结果。我们喜欢认为我们的读者正在享受稍微更敏捷的体验。我们的新闻编辑室继续每天发布强大 和 有趣 的交互,而不必再考虑他们的渲染框架。