十一、使用 Webpack 分块 JavaScript 来优化性能
正如我们在上一章中讨论的,将 React 应用转换为渐进式 Web 应用的最大问题是 React;更具体地说,构建现代 JavaScript 应用的本质是 JavaScript 的数量。解析和运行 JavaScript 是 Chatastrophe 性能的最大瓶颈。
在最后一章中,我们采取了一些措施,通过将内容从 JavaScript 转移到index.html
中,来提高应用的启动时间。虽然这是一种非常有效的方法,可以尽可能快地向用户显示内容,但您会注意到,我们没有做任何事情来实际更改 JavaScript 的大小,也没有减少初始化所有这些内容所需的时间。
现在是时候做点什么了。在本章中,我们将了解如何拆分 JavaScript 包以更快地加载。我们还将介绍一种新的渐进式 Web 应用理论——PRPL 模式。
在本章中,我们将介绍以下主题:
- 什么是 PRPL 模式?
- 什么是代码拆分,我们如何实现它?
- 创建我们自己的高阶组件
- 按路由拆分代码
- 延迟加载附加路由
PRPL 模式
在最后一章中,我们介绍了性能应用的一些基本原则。您希望用户花费尽可能少的时间等待,这意味着尽可能快地加载基本内容,并将加载应用的其余部分推迟到处理器的“空闲”时间。
这两个概念构成了轨道度量的“I”和“L”。我们通过应用外壳的概念朝着改进“L”迈出了一步。现在,我们将把一些“L”(初始负载)移到“I”(应用的空闲时间)中,但在此之前,让我们先介绍另一个首字母缩略词。
PRPL代表推送、渲染、预缓存、延迟加载;对于理想的应用应该如何从服务器获取所需的内容,这是一个循序渐进的过程。
然而,在我们深入讨论之前,我想提醒读者,PRPL 模式在撰写本文时相对较新,随着渐进式 Web 应用进入主流,它可能会迅速发展。与本书中讨论的许多概念一样,它依赖于仅适用于某些浏览器的实验技术。这是最前沿的东西。
艾迪·奥斯马尼就是这样说的:
For most real-world projects, it’s frankly too early to realize the PRPL vision in its purest, most complete form, but it’s definitely not too early to adopt the mindset, or to start chasing the vision from various angles. (https://developers.google.com/web/fundamentals/performance/prpl-pattern/)
让我们依次阅读每个字母,并解释它对我们和我们的申请意味着什么。
推
Addy Osmani将 PRPL 的推力定义如下:
“Push critical resources for the initial URL route.”
本质上,这意味着您的首要任务是只加载您需要的内容,以尽可能快地呈现初始路线。听起来熟悉吗?这正是我们在应用外壳中遵循的原则。
推送的温和定义可以是“先加载关键内容,然后再加载其他内容”。这一定义与应用外壳模式完全吻合,但并不完全符合Osmani的意思。
下面是对服务器推送技术的理论介绍。由于我们无法控制我们的服务器(又名 Firebase),因此我们不会实施这种方法,但最好了解未来与您自己的服务器通信的 PWA。
如果您查看我们的index.html
,您可以看到它引用了多个资产。它要求使用favicon
、icon.png
和secrets.js
。在 Webpack 构建之后,它还请求我们的主 JavaScriptbundle.js
。
网站的正常工作方式是这样的:浏览器请求index.html
。一旦获得该文件,它将遍历并向服务器请求前面列出的所有依赖项,每个依赖项都作为一个单独的请求。
这里的核心低效之处在于index.html
已经包含了关于其依赖关系的所有信息。换句话说,当服务器响应index.html
时,它已经“知道”浏览器下一步将请求什么,那么为什么不预测这些请求并发送所有依赖项呢?
输入 HTTP 2.0 服务器推送。该技术允许服务器对单个请求创建多个响应。浏览器请求index.html
,得到index.html
+bundle.js
+icon.png
等。
正如伊利亚·格里戈里克所说,服务器推送“废弃内联”(https://www.igvita.com/2013/06/12/innovating-with-http-2.0-server-push/ )。我们不再需要内联 CSS 来节省到服务器的行程;我们可以对服务器进行编程,在一次行程中发送初始路线所需的所有信息。这是令人兴奋的事情;有关更多信息(快速教程),请查看前面的链接。
提供
在(理想情况下)将所有必要的资源推送到客户机之后,我们呈现初始路线。同样,由于应用外壳模式,我们已经通过快速渲染讨论了这一点。
预缓存
完成初始路线后,我们仍然拥有其他路线所需的资产。预缓存意味着一旦加载这些资产,它们将直接进入缓存,如果再次请求它们,我们将从缓存中加载它们。
随着我们进入缓存世界,我们将在下一章中更详细地介绍这一点。
惰性负载
这是本章的主要内容。
我们希望首先加载初始路线所需的资源,以尽可能快地完成初始渲染。这意味着不会加载其他路由所需的资源。
实际上,这意味着我们希望先加载LoginContainer
(如果用户未登录),然后推迟加载UserContainer
。
但是,一旦呈现了初始路由,用户可以看到登录屏幕,我们就要为未来做好准备。如果他们点击UserContainer
,我们希望尽快显示它。这意味着加载初始路由完成后,在后台加载UserContainer
资源。
这个过程称为延迟加载——加载不需要的资源,但将来可能需要。
我们使用的工具是代码拆分。
什么是代码拆分?
代码拆分是将 JavaScript 文件拆分成有意义的块以提高性能的行为,但我们为什么需要它?
好的,当用户第一次访问我们的应用时,我们只需要他们当前所在路径的 JavaScript。
这意味着当它们在/login
上时,我们只需要LoginContainer.js
及其依赖项。我们不需要UserContainer.js
,所以我们希望立即加载LoginContainer.js
和延迟加载UserContainer.js
。然而,我们当前的网页设置创建了一个bundle.js
文件。我们所有的 JavaScript 都绑定在一起,必须一起加载。代码拆分是解决这个问题的一种方法。我们得到多个 JavaScript 文件,每个路由一个,而不是一个单一的 JavaScript 文件。
因此,我们将为/login
、一个/user/:id
和一个/
获得一个捆绑包。此外,我们将获得另一个包含所有依赖项的main
包。
无论用户首先访问哪条路线,他们都将获得该路线的捆绑包和主捆绑包。同时,在后台,我们将加载其他两条路由的捆绑包。
代码拆分不一定要逐个路由进行,但它对我们的应用最有意义。此外,以这种方式使用 Webpack 和 React Router 进行代码拆分相对简单。
事实上,只要您提供一些基本设置,Webpack 将自动为您处理此问题。让我们开始设置吧!
Webpack 配置
如前所述,我们的策略是这样的:我们希望根据路线将bundle.js
分成不同的块。
本节的目的是做两件事:一是为 JavaScript 块建立命名约定,二是添加对条件导入的支持(稍后将有更多介绍)。
打开webpack.config.prod.js
让我们做第一步(这只适用于PRODUCTION
构建,所以只修改我们的生产 Webpack 配置;我们不需要在开发中拆分代码)。
目前,我们的输出配置如下所示:
output: {
path: __dirname + "/build",
filename: "bundle.js",
publicPath: './'
},
我们在build
文件夹中创建一个名为bundle.js
的 JavaScript 文件。
让我们将整个部分更改为以下内容:
output: {
path: __dirname + "/build",
filename: 'static/js/[name].[hash:8].js',
chunkFilename: 'static/js/[name].[hash:8].chunk.js',
publicPath: './'
},
这是怎么回事?
首先,我们将 JavaScript 输出移动到build/static/js
,只是为了组织目的。
接下来,我们在命名中使用两个变量:name
和hash
。名称变量由 Webpack 自动生成,使用块的编号约定。我们马上就会看到。
然后,我们使用一个hash
变量。每次 Webpack 构建时,它都会生成一个新的哈希值——一个随机字母和数字字符串。我们使用它们来命名文件,以便每个构建都有不同的文件名。这在下一章中将非常重要,因为这意味着我们的用户将永远不会遇到应用已更新的问题,但缓存仍然保留着旧文件。由于新文件将具有新名称,因此将下载这些文件,而不是下载缓存中的任何文件。
接下来,我们将在代码拆分文件(每个路由的文件)中添加一个.chunk
。这不是必需的,但如果您想对块执行任何特殊缓存,建议您这样做。
一旦我们的代码拆分完成,上述所有内容都会变得更有意义,所以让我们尽快到达那里吧!然而,在我们继续之前,我们还需要在我们的网页配置中添加一个东西。
巴别塔 1 期
正如我们在 Webpack 一章中所解释的,Babel 是我们用来使用尖端 JavaScript 功能的工具,然后将这些功能转换成浏览器可以理解的 JavaScript 版本。
在本章中,我们将使用另一个尖端功能:条件导入。然而,在开始之前,我们需要改变我们的巴别塔配置。
JavaScript 语言在不断发展。负责更新的委员会称为 TC39,他们根据TC39 流程进行更新。其工作原理如下:
- 建议使用一个新的 JavaScript 特性,此时称为“阶段 0”
- 为其工作方式创建提案(“第 1 阶段”)
- 创建一个实现(“阶段 2”)
- 经过抛光处理,以便纳入(“第 3 阶段”)
- 它被添加到语言中
在任何时候,每个阶段都有多个功能。问题是 JavaScript 开发人员不耐烦,任何时候当他们听到一个整洁的特性时,他们都想开始使用它,即使它处于第 3 阶段、第 2 阶段甚至第 0 阶段。
巴贝尔通过其舞台预设提供了一种实现这一点的方法。您可以为每个阶段安装预设,并访问该阶段当前的所有功能。
我们感兴趣的功能(条件导入)目前处于第 2 阶段。为了使用它,我们需要安装适当的巴别塔预设:
yarn add --dev babel-preset-stage-2
然后,在两个 Webpack 配置中,将其添加到模块|加载程序| JavaScript 测试|查询|预设下:
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: {
presets: ['es2015','react','stage-2'],
plugins: ['react-hot-loader/babel', 'transform-class-properties']
}
},
记住将此添加到webpack.config.js
和webpack.config.prod.js
中。我们在生产和开发中都需要它。
有条件进口
完成后,是时候问什么是有条件的导入了。
现在,我们在每个 JavaScript 文件的顶部导入所有依赖项,如图所示:
import React, { Component } from 'react';
我们总是需要做出反应,所以这一点很有意义。它是静态的,因为它永远不会更改,但前面的 React 是此文件的依赖项,因此始终需要加载它。
目前,在App.js
中,我们对每个集装箱都做了相同的处理:
import LoginContainer from './LoginContainer';
import ChatContainer from './ChatContainer';
import UserContainer from './UserContainer';
这样做意味着这些容器是App.js
的依赖项,因此 Webpack 将始终将它们捆绑在一起;我们不能把他们分开。
相反,我们希望有条件地进口它们,只有在我们需要它们的时候。
这样做的机制有点复杂,但本质上看起来如下所示:
If (path === ‘/login’)
import('./LoginContainer')
} else if (path === ‘/user/:id’)
import(‘./UserContainer)
} else {
import(‘./ChatContainer)
}
那么,我们如何实现这一点呢?
高阶分量
我们在第 5 章中讨论了高阶组件,路由与 React的关系,以及 React 路由对withRouter
的讨论;现在,我们将建立一个,但首先,让我们快速复习。
高阶组分在 React 中是非常有用的模式。如果您学会了如何很好地使用它们,您将为保持大型代码库的可维护性和可重用性打开一系列的可能性,但是它们不像常规组件那样直观,所以让我们确保很好地介绍它们。
在最基本的层次上,高阶组件是返回组件的函数。
假设我们有一个button
组件:
function Button(props) {
return <button color={props.color}>Hello</button>
}
如果您更熟悉class
语法,也可以使用class
语法编写:
class Button extends Component {
render() {
return <button color={this.props.color}>Hello</button>
}
}
我们使用颜色道具来控制文本的颜色。假设我们在应用中都使用此按钮。通常,我们发现自己将文本设置为红色——大约有 50%的时间是这样。
我们只需继续将color=”red”
道具传递给按钮即可。在这个人为的例子中,这将是一个更好的选择,但是我们也可以制作一个更高阶的组件,在更复杂的用例中,这是一条路要走(我们将看到)。
让我们创建一个名为RedColouredComponent
的函数:
function colorRed(Component) {
return class RedColoredComppnent extends Component {
render () {
return <Component color="red" />
}
}
}
该函数接受组件作为参数。它所做的只是返回一个组件类,该类反过来返回应用了color=”red”
属性的组件。
然后,我们可以在另一个文件中呈现按钮,如图所示:
import Button from './Button';
import RedColouredComponent from './RedColouredComponent';
const RedButton = RedColouredComponent(Button);
function App() {
return (
<div>
<RedButton />
</div>
)
}
然后,我们可以将任何组件传递给RedColouredComponent
,从而创建一个红色版本。
这样做打开了组合的新世界——从高阶组件的组合中创建组件。
毕竟,这就是 React 的本质——用可重用的代码组成 UI。高阶组件是保持应用干净和可维护性的一种很好的方法,但是已经有足够的人为示例了,现在让我们自己制作吧!
异步组件
本节的目标是创建一个高阶组件,帮助我们进行代码拆分。
此组件仅在呈现时加载其依赖项,或者在我们明确告诉它加载时加载。这意味着如果我们传递它LoginContainer.js
,它将只在用户导航到/login
时加载该文件,或者如果我们告诉它加载该文件。
换句话说,这个组件将让我们完全控制何时加载 JavaScript 文件,并打开延迟加载的世界。但是,这也意味着,无论何时渲染管线,都会自动加载相关文件。
如果这听起来很抽象,让我们看看它的实际效果。
在您的components/
目录中创建一个名为AsyncComponent.js
的新文件,并添加基本骨架,如图所示:
import React, { Component } from 'react'
export default function asyncComponent(getComponent) {
}
asyncComponent
是一个将导入语句作为参数的函数,我们称之为getComponent
。我们知道,作为高阶组件,它将返回一个component
类:
export default function asyncComponent(getComponent) {
return class AsyncComponent extends Component {
render() {
return (
)
}
}
}
AsyncComponent
的关键是componentWillMount
生命周期方法。此时AsyncComponent
将知道如何获取依赖项文件。通过这种方式,组件在加载任何文件之前会等待,直到需要它为止。
但是,在得到组件后,我们该如何处理它?简单,将其存储在以下状态:
componentWillMount() {
if (!this.state.Component) {
getComponent().then(Component => {
this.setState({ Component });
});
}
}
如果我们还没有加载组件,那么就导入它(我们假设getComponent
返回一个Promise
)。导入完成后,将状态设置为导入的组件,这意味着我们的render
应该如下所示:
render() {
const { Component } = this.state;
if (Component) {
return <Component {...this.props} />;
}
return null;
}
所有这些对您来说都应该很熟悉,除了return
声明中的{...this.props}
之外。这是 JavaScript 扩展操作符。这是一件复杂的小事(更多信息请参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator )但在本例中,基本上是指取this.props
对象,将其所有键和值复制到Component
的props
上。
这样,我们可以将道具传递给asyncComponent
返回的组件,并将它们传递给Component
渲染。应用于AsyncComponent
的每个道具将应用于Component
的render
功能。
供参考的完整组成部分如下:
import React, { Component } from 'react';
export default function asyncComponent(getComponent) {
return class AsyncComponent extends Component {
state = { Component: null };
componentWillMount() {
if (!this.state.Component) {
getComponent().then(Component => {
this.setState({ Component });
});
}
}
render() {
const { Component } = this.state;
if (Component) {
return <Component {...this.props} />;
}
return null;
}
};
}
分路
让我们跳回到App.js
,并将其全部汇集在一起。
首先,我们将消除应用对这三个容器的依赖。将这些导入替换为AsyncComponent
的导入,以便文件顶部如下所示:
import React, { Component } from 'react';
import { Route, withRouter } from 'react-router-dom';
import AsyncComponent from './AsyncComponent';
import NotificationResource from '../resources/NotificationResource';
import './app.css';
接下来,我们将定义三个load()
函数,每个容器一个。这些是我们将传递给asyncComponent
的函数。他们必须回报承诺:
const loadLogin = () => {
return import('./LoginContainer').then(module => module.default);
};
const loadChat = () => {
return import('./ChatContainer').then(module => module.default);
};
const loadUser = () => {
return import('./UserContainer').then(module => module.default);
};
看看有条件进口的魔力吧。调用这些函数时,将导入三个 JavaScript 文件。然后,我们从每个文件中获取导出默认值,并使用它来resolve()
导出Promise
。
这意味着我们可以在App.js
中重新定义组件,如图所示,在前面的函数声明之后(在文件顶部的导入语句之后):
const LoginContainer = AsyncComponent(loadLogin);
const UserContainer = AsyncComponent(loadUser);
const ChatContainer = AsyncComponent(loadChat);
没有其他的改变是必要的!你可以保持应用的render
声明完全相同。现在,当我们提到ChatContainer
时,它指的是loadChat…
周围的AsyncComponent
包装,需要时它会得到ChatContainer.js
。
让我们看看它是否有效。运行yarn build
,查看输出:
我们有四个 JavaScript 文件,而不是一个。我们有我们的main.js
文件,其中包含App.js
和必要的node_modules
。然后,我们有三个块,每个容器一个。
看一下文件大小,你会发现我们并没有通过代码拆分获得太多的收益,主文件中有几千字节被删除了。然而,随着我们的应用的发展,每一条路线都变得更加复杂,代码拆分的好处也将随之扩大。那有多容易?
延迟加载
延迟加载是 PRPL 难题的最后一部分,是使用应用的空闲时间加载其余 JavaScript 的过程。
如果您yarn deploy
打开我们的应用并导航到 DevTools 中的“网络”选项卡,您将看到类似以下内容:
我们加载主文件,然后加载与当前 URL 相关的任何块,但随后停止。
在应用空闲期间,我们不会加载其他路由!我们需要一些方法来触发加载过程,只要初始路线渲染完成,App
安装完成。
我想你知道这是怎么回事。在App
的componentDidMount
方法中,我们只需要调用我们的三种加载方法:
componentDidMount() {
this.notifications = new NotificationResource(
firebase.messaging(),
firebase.database()
);
firebase.auth().onAuthStateChanged(user => {
if (user) {
this.setState({ user });
this.listenForMessages();
this.notifications.changeUser(user);
} else {
this.props.history.push('/login');
}
});
this.listenForMessages();
this.listenForInstallBanner();
loadChat();
loadLogin();
loadUser();
}
现在,无论何时渲染完当前路线,我们都将准备好其他路线。
如果再次打开 DevTools 的“性能”选项卡,您将在网络请求中看到这一点:
在左边,底部的黄色区块是我们正在加载的main.js
文件。这意味着我们的应用可以启动初始化。右边的三个黄色区块对应于我们的三个路线区块。我们需要的那一个先装上,紧接着是另外两个。
我们现在使用了更多的应用空闲时间,分散了初始化应用的工作。
总结
我们在本章中介绍了很多内容,在更具表现力的应用方面取得了巨大的进步。我们按路径分割 JavaScript,并简化加载过程,以便加载所需内容,并将其余内容推迟到空闲时间。
然而,所有这些实际上只是为下一节铺路。我们需要我们的应用在所有网络条件下运行,即使没有任何网络。我们如何使应用脱机工作?
接下来,我们深入到缓存领域,进一步提高应用在任何网络条件下的性能,即使没有网络。
版权属于:月萌API www.moonapi.com,转载请注明出处