🚗 Sidecar 用于代码拆分
代码拆分
关于代码拆分的真相
听起来不太好
蝙蝠侠和罗宾
边车
实现细节
不好的部分
未来
全面的
代码拆分。代码拆分无处不在。但是,为什么呢?因为现在JavaScript 太多了,而且并非所有 JavaScript 都在同时使用。
JS 非常繁重。它并非针对你的 iPhone Xs 或全新的 i9 笔记本电脑,而是针对数百万(甚至可能是数十亿)运行速度较慢的设备用户。或者,至少针对你的手表。
所以——JS 确实很糟糕,但如果我们禁用它会发生什么呢?——对于某些网站来说,这个问题就消失了……而对于基于 React 的网站来说,这个问题“随网站一起”消失了。但无论如何——有些网站即使没有 JS 也能运行……我们应该从中学习一些东西……
代码拆分
今天,我们有两条路可走,两条路可以让情况变得更好,或者不会变得更糟:
1. 编写更少的代码
这是你能做的最好的事情。虽然React Hooks
有些解决方案可以让你少交付一些代码,也有一些解决方案可以让你生成比平时Svelte
更少的代码,但这并不容易做到。
这不仅关乎代码,也关乎功能——要保持代码“紧凑”,就必须保持代码“紧凑”。如果应用程序包要处理这么多事情(而且还要支持 20 种语言),那么根本没办法保持包体小巧。
有方法可以编写简短而合理的代码,也有方法可以编写相反的实现——血腥的企业。而且,你知道,两者都是合法的。
但主要问题在于代码本身。一个简单的 React 应用可以轻松绕过“建议”的 250KB 大小限制。你可能要花一个月的时间进行优化,让它变得更小。“小”的优化方法都有很好的文档记录,而且非常有用——只需坚持bundle-analyzer
下去size-limit
,就能恢复正常。
市面上有很多库,它们争夺每一个字节,试图让你保持在限制之内——例如preact和storeon 。
但是我们的应用程序略大于 200kb,接近100Mb。删除 KB 是没有意义的,甚至删除 MB 也是没有意义的。
一段时间后,你的应用程序就不可能再保持小巧了。它会随着时间的推移变得越来越大。
2. 减少代码交付
或者,code split
换句话说——投降吧。把你的 100mb 包拆分成 20 个 5mb 的包。说实话——如果你的应用程序很大,这是唯一可行的处理方法——用它创建一个更小的应用程序包。
只要我们讨论它,您可能希望确保您了解 2019 年 React 代码拆分的最新和最好的内容。或者只是阅读一些实现细节。
但是现在您应该知道一件事:无论您选择哪种选项,这都是一个实现细节,而我们正在寻找更可靠的东西。
关于代码拆分的真相
代码拆分的本质是时间分离。你不仅仅是在拆分代码,而是以一种在单个时间点尽可能少地使用代码的方式进行拆分。
不要发送你现在不需要的代码。删除它。
说起来容易做起来难。我有几个很重的应用,但拆分得不够,每个页面的加载量都只有50%。有时候,我的意思code splitting
是code separation
,你可能会把代码移到不同的代码块,但仍然会用到所有代码。回想一下那句“不要发布你现在不需要的代码” ——我需要50%的代码,这才是真正的问题所在。
有时只是
import
在这里或那里添加是不够的。直到它不再是时间的分离,而只是空间的分离——这根本无关紧要。
代码分割有 3 种常见方式:
- 只是动态的
import
。最近很少单独使用。它更多的是关于跟踪状态的问题。 Lazy
组件,当你需要推迟 React 组件的渲染和加载时。目前大概有 90% 的 React 代码拆分都是这样。- Lazy
Library
,实际上就是.1
,但会通过 React 的 render props 获得一个库代码。已在react-imported-component和loadable-components中实现。相当有用,但不太为人所知。
组件级代码拆分
这是最流行的。作为每个路由的代码拆分,或者每个组件的代码拆分。这样做并不容易,而且很难保持良好的感知效果。这简直是致命的Flash of Loading Content
。
好的技术是:
- 负载
js chunk
和data
路线并行。 - 使用
skeleton
在页面加载之前显示与页面类似的内容(如 Facebook)。 prefetch
块,您甚至可以使用guess-js来获得更好的预测。- 使用一些延迟、加载指示器,
animations
以及Suspense
(将来)软化过渡。
你知道,这全都与感知表现有关。
听起来不太好
你知道,我可以称自己为代码分割专家 - 但我也有自己的失败。
有时我可能无法减小包的大小。有时我可能无法提升最终的性能,只要the _more_ code-splitting you are introducing - the more you spatially split your page - the more time you need to _reassemble_ your page back
*。这被称为加载波动。
- 无需 SSR 或预渲染。此时,合适的 SSR 可以改变游戏规则。
上周我遇到了两次失败:
- 我曾经在一次库比较中失败过,因为我的库更好😉,但比另一个库大得多。我没能做到“1. 少写代码”。
- 优化了我妻子用 React 做的一个小网站。它使用了基于路由的组件拆分,但为了使过渡效果更“可接受”,
header
我们footer
保留了 和 两个组件在主 bundle 包中。只是一些紧密耦合的东西导致 bundle 包大小飙升至 320kb(gzip 压缩前)。这些组件都没什么重要,也没什么可以移除的。真是千刀万剐。我没能做到减少代码交付量。
React-Dom
是 20%,core-js
是 10%react-router
,,,jsLingui
...react-powerplug
20% 自己的代码...我们已经完成了。
解决方案
我开始思考如何解决我的问题,以及为什么常见的解决方案不适合我的用例。
我做了什么?我列出了所有关键位置,如果没有这些位置,应用程序根本无法运行,并且试图理解为什么我保留了其余位置。
这让我很惊讶——问题出在 CSS 上。我之前用过原生 CSS 过渡动画,以实现更流畅的 UI,而且我实现它的方式也一样。长话短说——过渡动画之前必须有一个底层 DOM 节点存在。
这是代码
- 一个控制变量——
componentControl
,最终将被设置为DisplayData
应该显示的内容。 - 一旦设置了值 -
DisplayData
就会变得可见,并不断变化className
,从而触发精美的过渡效果。同时,它FocusLock
会变为活动状态,形成DisplayData
模态窗口。
<FocusLock
enabled={componentControl.value}
// ^ initially it's "disabled". And when it's disabled - it's dead.
>
{componentControl.value && <PageTitle title={componentControl.value.title}/>}
// ^ it's does not exists. Dead-dead
<DisplayData
data={componentControl.value}
visible={componentControl.value !== null}
// ^ would change a className basing on visible state
/>
// ^ that is just not visible, but EXISTS
</FocusLock>
我想将这部分代码作为一个整体进行拆分,但由于以下两个原因,我无法做到这一点:
- 一旦需要,信息应该立即可见,没有任何延迟。这是业务需求。所以最好不要对信息进行代码拆分。
- 信息“skeleton”应该先存在,才能处理 CSS 转换的属性。
这个问题可以通过CSSTransitionGroup或recondition来部分解决——先创建隐藏类名,然后应用可见类名——但是,你知道,修复一个代码后再添加另一个代码听起来很奇怪,即使实际上这样做已经足够了。我的意思是,添加更多代码反而有助于删除更多代码。但是……但是……
应该有更好的办法!
TL;DR - 这里有两个关键点:
DisplayData
必须已安装,并且先前存在于 DOM 中。FocusLock
也应该事先存在,以容纳DisplayData
,但它的大脑一开始是不需要的。
所以让我们改变我们的思维模式
蝙蝠侠和罗宾
假设我们的代号是蝙蝠侠和罗宾。蝙蝠侠可以对付大多数坏人,但当他无力应对时,他的伙伴罗宾就会来拯救他。
蝙蝠侠将再次参与战斗,罗宾稍后将到达。
这是蝙蝠侠:
+<FocusLock
- enabled={componentControl.value}
+>
- {componentControl.value && <PageTitle title={componentControl.value.title}/>}
+ <DisplayData
+ data={componentControl.value}
+ visible={componentControl.value !== null}
+ />
+</FocusLock>
这是他的助手罗宾:
-<FocusLock
+ enabled={componentControl.value}
->
+ {componentControl.value && <PageTitle title={componentControl.value.title}/>}
- <DisplayData
- data={componentControl.value}
- visible={componentControl.value !== null}
- />
-</FocusLock>
蝙蝠侠和罗宾可以组成一个团队,但实际上他们是两个不同的人。
别忘了——我们还在讨论代码拆分。那么,说到代码拆分,Sidekick 在哪里?Robin 在哪里?
在边车里。罗宾正在边车里等候。
边车
Batman
这里提供所有视觉内容,您的客户必须尽快看到。最好是立即看到。Robin
这里充满了逻辑和奇特的交互功能,它们可能在一秒钟后可用,但不会一开始就可用。
最好将其称为垂直代码拆分,其中代码分支并行存在,与常见的水平代码拆分(其中代码分支被切断)相反。
这只是另一种关注点分离,仅适用于可以推迟加载组件某些部分但不能推迟加载其他部分的情况。
图片来自使用 React、GraphQL 和 Relay 构建新的 facebook.com,其中
importForInteractions
、 或importAfter
是sidecar
。
还有一个有趣的观察——虽然Batman
对客户来说更有价值,但只要是客户可以看到的东西,他总是保持良好的身材(并且拥有秘密的腹肌)......但是Robin
,你知道,他可能有点超重,需要更多的字节来生活。
因此,单凭蝙蝠侠的魅力,顾客还是可以承受的——他以更低的价格提供了更高的价值。你是我的英雄,蝙蝠侠!
哪些内容可以移至 Sidecar:
- 大多数
useEffect
,componentDidMount
和朋友。 - 就像所有Modal效果一样。例如,
focus
和scroll
锁。你可能先显示一个 Modal,然后再制作 Modal模态窗口,也就是“锁定”客户的注意力。 - 自定义
Selects
- 它们自然地分为蝙蝠侠(输入)和罗宾(拖拽)。自定义Calendars
或任何其他显示其他(最大和最复杂的)部分或点击/悬停的 UI 组件 - 都是一样的。 - 表单。将所有逻辑和验证移至 Sidecar,并阻止表单提交,直到逻辑加载完成。客户可以开始填写表单,却不知道这只是
Batman
…… - 一些动画。
react-spring
对我来说是一个整体。 - 一些视觉元素。比如自定义滚动条,可能会在一秒后显示漂亮的滚动条。🤷♂️ 设计师们 🤷♂️
另外,不要忘记 - 卸载到 sidecar 的每段代码,也会卸载被删除代码所使用的 core-js poly- 和 ponyfills 之类的东西。
代码拆分可以比我们今天的应用程序更加智能。我们必须意识到,需要拆分的代码有两种:1)视觉方面 2)交互方面。后者可以稍后再进行。Sidecar
这使得拆分这两个任务变得无缝衔接,让人感觉所有功能都加载得更快。事实也确实如此。
最古老的代码分割方法
虽然可能还不是很清楚 a 是什么时候以及是什么sidecar
,但我将给出一个简单的解释:
Sidecar
是你所有的脚本。Sidecar 是我们在今天得到所有前端内容之前进行代码分割的方式。
我说的是服务器端渲染(SSR),或者只是纯HTML,我们昨天才习惯。Sidecar
当页面包含 HTML 并且逻辑分别存在于可嵌入的外部脚本(关注点分离)中时,事情变得像以前一样简单。
我们有 HTML、CSS、一些内联脚本以及提取到文件中的其余脚本.js
。
HTML
++是,而外部脚本是,网站在没有罗宾的情况下也能正常运行,说实话,CSS
在没有蝙蝠侠的情况下也能部分正常运行(蝙蝠侠会在双腿(内联脚本)断掉的情况下继续战斗)。那只是昨天的事,如今许多“既不现代又不酷”的网站也依然如此。inlined-js
Batman
Robin
如果您的应用程序支持 SSR,请尝试禁用 JS,并使其在没有 JS 的情况下运行。这样,哪些内容可以移到 Sidecar 中就一目了然了。
如果您的应用程序是纯客户端 SPA,请想象一下,如果存在 SSR,它会如何工作。
例如 - theurge.com是用 React 编写的,无需启用任何 js即可完全正常运行。
你可以把很多事情转移到 Sidecar 上。例如:
- 评论。您可以将代码发送到
display
评论区,但不要这样做answer
,因为它可能需要更多代码(包括所见即所得的编辑器),而这些代码最初并非必需。最好延迟评论框,甚至将代码加载隐藏在动画之后,而不是延迟整个页面。 - 视频播放器。发送“视频”而不发送“控件”。过一秒钟再加载,客户可能会尝试与其进行交互。
- 图片库,就像。画起来
slick
不是什么大问题,但动画和管理起来就难多了。哪些内容可以移到 Sidecar 上就一目了然了。
只需考虑一下对于您的应用程序来说什么是必不可少的,什么不是那么重要......
实现细节
(DI)组件代码拆分
最简单的形式sidecar
很容易实现——只需将所有内容移至子组件即可,您可以使用“旧”方法进行代码拆分。这几乎是智能组件和哑组件之间的区分,但这次智能组件并不包含哑组件——而是相反。
const SmartComponent = React.lazy( () => import('./SmartComponent'));
class DumbComponent extends React.Component {
render() {
return (
<React.Fragment>
<SmartComponent ref={this} /> // <-- move smart one inside
<TheActualMarkup /> // <-- the "real" stuff is here
</React.Fragment>
}
}
这也需要将初始化代码移动到 Dumb 代码,但您仍然能够对代码中最重的部分进行代码分割。
你现在能看到
parallel
或vertical
代码分割模式吗?
使用Sidecar
用 React、GraphQL 和 Relay 构建新的 facebook.comloadAfter
,我在这里已经提到过,有一个或 的概念importForInteractivity
,它与 sidecar 概念非常相似。
同时,我不会建议创建类似的东西,useSidecar
只要你可能故意尝试hooks
在里面使用,但这种形式的代码拆分会破坏钩子规则。
请选择更具声明性的组件方式。并且您可以使用hooks
内部SideCar
组件。
const Controller = React.lazy( () => import('./Controller'));
const DumbComponent = () => {
const ref = useRef();
const state = useState();
return (
<>
<Controller componentRef={ref} state={state} />
<TheRealStuff ref={ref} state={state[0]} />
</>
)
}
预取
不要忘记 - 您可以使用加载优先级提示来预加载或预取sidecar
,并使其传输更加透明和隐形。
重要的事情 - 预取脚本将通过网络加载它,但不会执行(并花费 CPU),除非它确实需要。
苏维埃社会主义共和国
与常规代码拆分不同,SSR 无需特殊操作。它Sidecar
可能不属于 SSR 流程,并且在hydration
步骤之前不需要执行。它可能“根据设计”被推迟。
因此 - 请随意使用React.lazy
(理想情况下没有 Suspense
,这里不需要任何故障回复(加载)指示器),或任何其他带有 SSR 支持的库,但最好没有 SSR 支持,以便在 SSR 过程中跳过sidecar 块。
不好的部分
但这个想法也有一些不好的地方
蝙蝠侠不是制作名称
虽然Batman
/Robin
可能是一个不错的概念,并且sidecar
与技术本身完美匹配,但并没有一个“好”的名称来代表。maincar
没有 这样的名称maincar
,显然Batman
、Lonely Wolf
、Solitude
和不应该用于命名非侧边车部件。Driver
Solo
Facebook 已经使用了display
和interactivity
,这可能是我们所有人的最佳选择。
如果你有好名字给我 - 请留在评论中
摇树
这更多的是从打包器的角度来分离关注点。假设你有Batman
和Robin
。并且stuff.js
export * from `./batman.js`
export * from `./robin.js`
那么你可以尝试基于组件的代码拆分来实现 Sidecar
//main.js
import {batman} from './stuff.js'
const Robin = React.lazy( () => import('./sidecar.js'));
export const Component = () => (
<>
<Robin /> // sidecar
<Batman /> // main content
</>
)
// and sidecar.js... that's another chunk as long as we `import` it
import {robin} from './stuff.js'
.....
简而言之 - 上面的代码可以工作,但不能完成“工作”。
- 如果您仅使用
batman
fromstuff.js
- tree shake 将仅保留它。 - 如果您仅使用
robin
fromstuff.js
- tree shake 将仅保留它。 - 但是如果您同时使用两者,即使是在不同的块中 - 两者都将捆绑在第一次出现的中
stuff.js
,即主捆绑包中。
Tree Shaking 对代码拆分并不友好。你必须按文件来分离关注点。
取消导入
还有一件事,大家都忘记了,那就是 JavaScript 的成本。在 jQuery 时代,也就是 Payload 时代,jsonp
加载脚本(使用json
Payload)、获取 Payload,然后移除脚本,这在当时相当常见。
如今我们都
import
编写脚本,并且它将永远被导入,即使不再需要。
正如我之前所说,JS 代码太多了,持续的导航迟早会加载所有代码。我们应该找到一种方法来取消导入不再需要的块,清除所有内部缓存并释放内存,以提高 Web 可靠性,避免内存不足异常压垮应用程序。
可能这个能力un-import
(webpack可以做到)是我们应该坚持使用基于组件的API的原因之一,只要它让我们有能力处理unmount
。
到目前为止 - ESM 模块标准还没有涉及此类内容 - 也没有涉及缓存控制,也没有涉及逆转导入操作。
创建启用 Sidecar 的库
到目前为止,只有一种方法可以创建sidecar
启用的库:
- 将您的组件拆分成几个部分
- 通过以下方式公开
main
一部分和connected
一部分(不破坏 API)index
sidecar
通过单独的入口点公开。- 在目标代码中 - 导入
main
部分并且sidecar
- tree shake 应该剪切一部分connected
。
这次 tree shake 应该可以正常工作,唯一的问题是如何命名该main
部分。
//main.js
export const Main = ({sidecar, ...props}) => (
<div>
{sidecar}
....
</div>
);
// connected.js
import Main from './Component';
import Sidecar from './Sidecar';
export const Connected = props => (
<Main
sidecar={<Sidecar />}
{...props}
/>
);
//index.js
export * from './Main';
export * from './Connected';
//sidecar.js
import * from './Sidecar';
// -------------------------
//your app BEFORE
import {Connected} from 'library'; //
// -------------------------
//your app AFTER, compare to `connected.js`
import {Main} from 'library';
const Sidecar = React.lazy(import( () => import('library/sidecar')));
// ^ all the difference ^
export SideConnected = props => (
<Main
sidecar={<Sidecar />}
{...props}
/>
);
// ^ you will load only Main, Sidecar will arrive later.
理论上dynamic import
可以在 node_modules 内部使用,使组装过程更加透明。
无论如何 - 它只不过是
children
/slot
模式,在 React 中很常见。
最终形态
根据上面列出的所有原则,最终sidecar
形式是:
import {Main} from 'library';
const Sidecar = React.lazy(import(/* webpackPrefetch: true */ () => import('library/sidecar')));
export SideConnected = ({enabled, props}) => (
<Main
sidecar={enabled && <Sidecar />}
{...props}
/>
);
它预取sidecar 块,并且不是在组件刚“使用”时使用,而是在以“活动”形式使用时使用(如果该形式存在)。
如果不提取“活动形式”,sidecar 将会改善渲染时间,将其与交互时间分开,只要“交互性”能够自行加载,第二个渲染时间就会稍微延迟the main bundle
。
这个“一点点”可能是第一次加载主块并呈现应用程序所需的全部时间。
请记住,在初始渲染后立即提取所需的小型“汽车”可能并非最佳选择。就我而言,我能够“提取”近 70% 的代码,从而大大缩短渲染时间。
未来
Facebook
证明了这个想法是正确的。如果你还没看过那个视频,那就赶紧去看吧。我刚刚从稍微不同的角度解释了同样的想法(这篇文章是在 F8 大会前一周开始写的)。
目前,它需要将一些代码更改应用到您的代码库中。它需要更明确的关注点分离来真正地分离它们,并且代码拆分不是水平的,而是垂直的,以更少的代码交付更好的用户体验。
Sidecar
除了老式的 SSR 之外,这可能是处理大型代码库的唯一方法。当你有大量代码时,这是交付最少量代码的最后机会。
它可以使大型应用程序变得更小,也可以使小型应用程序变得更小。
十年前,Medium 网站“准备就绪”只需 300 毫秒,而真正准备就绪则需要几毫秒。如今,几秒甚至十几秒都成了常态。真是可惜。
让我们停下来思考一下——如何解决这个问题,并让用户体验再次变得出色……
全面的
sidecar
提供时间和/或空间分离。您可以import
稍后再使用动态导入来获取所有需要的脚本,或者require
在需要时再获取它们。第二次导入将使事情变得更简单,同步性更强,但仍然可以节省一些初始打包启动时间,例如推迟模块评估。
// time and space separation
const ImportSidecar = sidecar( () => import("./sidecar"));
export function ComponentCombination(props) {
return (
<ComponentUI
{...props}
sideCar={RequireSideCar}
/>
);
}
// only time separation
const RequireSideCar = (props: any) => {
const SideCar = require('./sidecar').default;
return <SideCar {...props} />;
};
export function ComponentCombination(props) {
return (
<ComponentUI
{...props}
sideCar={RequireSideCar}
/>
);
}
- 1. 组件代码拆分是一个非常强大的工具,它能够让你彻底拆分某些组件,但它也有一定的代价——你可能会暂时只显示一个空白页面或一个框架。这就是水平分离。
- 2. 当组件拆分无济于事时,库代码拆分可能会有所帮助。这是一种水平分离。
- 3. 将代码卸载到 Sidecar 上可以完成整个流程,并可能让你提供更好的用户体验。但这也需要一些工程方面的努力。这就是垂直分离。
让我们讨论一下这个问题。
停!那么你试图解决的问题呢?
react-focus-lock、react-focus-on和react-remove-scroll已经实现了这种模式。
嗯,这只是第一部分。现在我们已经进入最后阶段,还需要几周时间才能完成提案的第二部分。与此同时……
文章来源:https://dev.to/thekashey/sidecar-for-a-code-splitting-1o8g