前端性能优化详细讲解(1)

2023-05-31 0 294

一、序言

知识体系: 从一道丘托韦讲起

在进行操控性强化的热门话题之前,我想先放出两个可有可无的复试难题:

从输出 URL 到网页读取顺利完成,发生了什么?

那个难题非常重要,因为他们先期的文本都将以那个难题的答案为骨架进行。我希望正在阅读该书小册子的诸位能在心里思量一下那个难题——无需你充分调动太多计算机系统的专业技能,只须要你用最慢的速率在脑子里构架起那个抽象化的操作过程——他们接下去所有的工作,是围绕那个操作过程来下工夫。我们现在站在操控性强化的角度,一起简单地备考一遍那个经典之作的操作过程:首先他们须要通过 DNS(域名导出系统)将 URL 导出为对应的 IP 门牌号,然后与那个 IP 门牌号确定的那台伺服器建立起 TCP 网络相连,随后他们向伺服器端放出他们的 HTTP 允诺,伺服器端处置完他们的允诺之后,把目标统计数据放在 HTTP 积极响应里回到给应用程序,领到积极响应统计数据的应用程序就能开始走两个图形的业务流程。图形完,网页便呈现给了使用者,并时刻等待积极响应使用者的操作(如下表右图图右图)。
前端性能优化详细讲解(1)

他们将那个操作过程切分为如下表右图的操作过程短片:

DNS 导出TCP 相连HTTP 允诺放出伺服器端处置允诺,HTTP 积极响应回到应用程序领到积极响应统计数据,导出积极响应文本,把导出的结果展现给使用者

大家遵行,他们任何两个使用者端产品,都须要把这 5 个操作过程波澜不惊地考虑到自己的操控性强化方案内、反复取舍,从而雕琢出使用者令人满意的速率。

从原理到实践:主动出击

他们接下去要做的事情,是针对这四个操作过程进行降解,各个发问,主动出击。

简而言之,DNS 导出花时间,能不能尽量避免导出单次或者把导出后置?能——应用程序 DNS 内存和 DNS prefetch。TCP 每次的四次击掌都急死人,是不是软件系统?有——长相连、预相连、网络连接 SPDY 协议。在我看来这两个操作过程的强化往往须要他们和项目组的伺服器端技师协同顺利完成,后端断然能做的努力有限,那么 HTTP 允诺呢?——在减少允诺单次和增大允诺表面积方面,他们应该是专家!由此可见,伺服器越近,一次允诺就越慢,那部署时就把静态资源放在离他们更近的 CDN 上是不是就能更快一些?

以上提到的都是网络层面的操控性强化。再往下走是应用程序端操控性强化——这部分涉及资源读取强化、伺服器端图形、应用程序内存机制的利用、DOM 树的构建、网页排版和图形操作过程、回流与重绘的考量、DOM 操作的合理规避等等——这正是后端技师能真正一展拳脚的地方。学习这些知识,不仅能帮助他们从根本上提升网页操控性,更能够大大加深个人对应用程序底层原理、运行机制的理解,一举两得!

他们整个的知识图谱,用思维导图展现如下表右图:

前端性能优化详细讲解(1)

二、网络篇 1:webpack 操控性调优与 Gzip 原理

从现在开始,他们进入网络层面的操控性强化世界。

大家能从第一节的示意图中看出,他们从输出 URL 到显示网页那个操作过程中,涉及到网络层面的,有三个主要操作过程:

DNS 导出TCP 相连HTTP 允诺/积极响应

对于 DNS 导出和 TCP 相连两个步骤,他们后端能做的努力非常有限。相比之下,HTTP 相连这一层面的强化才是他们网络强化的核心。因此他们开门见山,抓主要矛盾,直接从 HTTP 开始讲起。

HTTP 强化有两个大的方向:

减少允诺单次减少单次允诺所花费的时间

这两个强化点直直地指向了他们日常开发中非常常见的操作——资源的压缩与合并。没错,这是他们每天用构建工具在做的事情。而时下最主流的构建工具无疑是 webpack,所以他们这节的主要任务是围绕业界霸主 webpack 来下工夫。

webpack 的操控性瓶颈

相信每个用过 webpack 的同学都对“打包”和“压缩”这样的事情烂熟于心。这些可有可无的特性,我更推荐大家去阅读文档。而关于 webpack 的详细操作,则推荐大家读读该书关于 webpack 的掘金小册子

,这里他们把注意力放在 webpack 的操控性强化上。

webpack 的强化瓶颈,主要是两个方面:

webpack 的构建操作过程太花时间webpack 打包的结果表面积太大

webpack 强化方案

1. 构建操作过程提速策略

1.1 不要让 loader 做太多事情——以 babel-loader 为例

babel-loader 无疑是强大的,但它也是慢的。

最常见的强化方式是,用 include 或 exclude 来帮他们避免不必要的转译,比如 webpack 官方在介绍 babel-loader 时给出的示例:

module: { rules: [ { test: /\.js$/, exclude: /(node_modules|bower_components)/, use: { loader: babel-loader, options: { presets: [@babel/preset-env] } } } ] }

这段代码帮他们规避了对庞大的 node\_modules 文件夹或者 bower\_components 文件夹的处置。但通过限定文件范围带来的操控性提升是有限的。除此之外,如果他们选择开启内存将转译结果内存至文件系统,则至少能将 babel-loader 的工作效率提升两倍。要做到这点,他们只须要为 loader 增加相应的参数设定:

loader: babel-loader?cacheDirectory=true

以上都是在讨论针对 loader 的配置,但他们的强化范围不止是 loader 们。

举个 ,尽管他们能在 loader 配置时通过写入 exclude 去避免 babel-loader 对不必要的文件的处置,但是考虑到那个规则仅作用于那个 loader,像一些类似 UglifyJsPlugin 的 webpack 插件在工作时依然会被这些庞大的第三方库拖累,webpack 构建速率依然会因此大打折扣。所以针对这些庞大的第三方库,他们还须要做一些额外的努力。1.2 不要放过第三方库

第三方库以 node\_modules为代表,它们庞大得可怕,却又不可或缺。

处置第三方库的姿势有很多,其中,Externals 不够聪明,一些情况下会引发重复打包的难题;而 CommonsChunkPlugin 每次构建时都会重新构建一次 vendor;出于对效率的考虑,他们这里为大家推荐 DllPlugin。DllPlugin 是基于 Windows 动态链接库(dll)的思想被创作出来的。那个插件会把第三方库单独打包到两个文件中,那个文件是两个单纯的依赖库。那个依赖库不会跟着你的业务代码一起被重新打包,只有当依赖自身发生版本变化时才会重新打包。

用 DllPlugin 处置文件,要分两步走:

基于 dll 专属的配置文件,打包 dll 库基于 webpack.config.js 文件,打包业务代码

以两个基于 React 的简单项目为例,他们的 dll 的配置文件可以编写如下表右图:

const path = require(path) const webpack = require(webpack) module.exports = { entry: { // 依赖的库数组 vendor: [ prop-types, babel-polyfill, react, react-dom, react-router-dom, ] }, output: { path: path.join(__dirname, dist), filename: [name].js, library: [name]_[hash], }, plugins: [ new webpack.DllPlugin({ // DllPlugin的name属性须要和libary保持一致 name: [name]_[hash], path: path.join(__dirname, dist, [name]-manifest.json), // context须要和webpack.config.js保持一致 context: __dirname, }), ], }

编写顺利完成之后,运行那个配置文件,我们的 dist 文件夹里会出现这样两个文件:

vendor-manifest.json vendor.js

vendor.js 不必解释,是他们第三方库打包的结果。那个多出来的 vendor-manifest.json,则用于描述每个第三方库对应的具体路径,我这里截取一部分给大家看下:

{ “name”: “vendor_397f9e25e49947b8675d”, “content”: { “./node_modules/core-js/modules/_export.js”: { “id”: 0, “buildMeta”: { “providedExports”: true } }, “./node_modules/prop-types/index.js”: { “id”: 1, “buildMeta”: { “providedExports”: true } }, … } }

随后,他们只需在 webpack.config.js 里针对 dll 稍作配置:

const path = require(path); const webpack = require(webpack) module.exports = { mode: production, // 编译入口 entry: { main: ./src/index.js }, // 目标文件 output: { path: path.join(__dirname, dist/), filename: [name].js }, // dll相关配置 plugins: [ new webpack.DllReferencePlugin({ context: __dirname, // manifest是他们第一步中打包出来的json文件 manifest: require(./dist/vendor-manifest.json), }) ] }

一次基于 dll 的 webpack 构建操作过程强化,便大功告成了!

1.3 Happypack——将 loader 由单进程转为多进程

大家知道,webpack是单线程的,就算此刻存在多个任务,你也只能排队两个接两个地等待处置。这是 webpack 的缺点,好在他们的 CPU 是多核的,Happypack 会充分释放 CPU 在多核并发方面的优势,帮他们把任务降解给多个子进程去并发执行,大大提升打包效率。

HappyPack 的使用方法也非常简单,只须要他们把对 loader 的配置转移到 HappyPack 中去就好,他们能手动告诉 HappyPack 他们须要多少个并发的进程:

const HappyPack = require(happypack) // 手动创建进程池 const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length }) module.exports = { module: { rules: [ … { test: /\.js$/, // 问号后面的查询参数指定了处置这类文件的HappyPack实例的名字 loader: happypack/loader?id=happyBabel, … }, ], }, plugins: [ … new HappyPack({ // 那个HappyPack的“名字”就叫做happyBabel,和楼上的查询参数遥相呼应 id: happyBabel, // 指定进程池 threadPool: happyThreadPool, loaders: [babel-loader?cacheDirectory] }) ], }

构建结果表面积压缩

1. 文件结构可视化,找出导致表面积过大的原因

这里为大家介绍两个非常好用的包组成可视化工具——webpack-bundle-analyzer

,配置方法和普通的 plugin 无异,它会以矩形树图的形式将包内各个模块的大小和依赖关系呈现出来,格局如官方所提供这张图右图:

在使用时,他们只须要将其以插件的形式引入:

const BundleAnalyzerPlugin = require(webpack-bundle-analyzer).BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin() ] }

2. 拆分资源

这点仍然围绕 DllPlugin 进行,可参考上文。

2.1 删除冗余代码

两个比较典型的应用,是 Tree-Shaking。从 webpack2 开始,webpack 原生支持了 ES6 的模块系统,并基于此推出了 Tree-Shaking。webpack 官方是这样介绍它的:

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination, or more precisely, live-code import. It relies on ES2015 module import/export for the static structure of its module system.

意思是基于 import/export 语法,Tree-Shaking 能在编译的操作过程中获悉哪些模块并没有真正被使用,这些没用的代码,在最后打包的时候会被去除。举个 ,假设我的主干文件(入口文件)是这么写的:

import { page1, page2 } from ./pages // show是事先定义好的函数,大家理解它的功能是展现网页即可 show(page1)

pages 文件里,我虽然导出了两个网页:

export const page1 = xxx export const page2 = xxx

但因为 page2 事实上并没有被用到(那个没有被用到的情况在静态分析的操作过程中是能被感知出来的),所以打包的结果里会把这部分:

export const page2 = xxx;

直接删掉,这是 Tree-Shaking 帮他们做的事情。相信大家不难看出,Tree-Shaking 的针对性很强,它更适合用来处置模块级别的冗余代码。至于粒度更细的冗余代码的去除,往往会被整合进 JS 或 CSS 的压缩或分离操作过程中。这里他们以当下接受度较高的 UglifyJsPlugin 为例,看一下如何在压缩操作过程中对碎片化的冗余代码(如 console 语句、注释等)进行自动化删除:

const UglifyJsPlugin = require(uglifyjs-webpack-plugin); module.exports = { plugins: [ new UglifyJsPlugin({ // 允许并发 parallel: true, // 开启内存 cache: true, compress: { // 删除所有的console语句 drop_console: true, // 把使用多次的静态值自动定义为变量 reduce_vars: true, }, output: { // 不保留注释 comment: false, // 使输出的代码尽可能紧凑 beautify: false } }) ] }

有心的同学会注意到,这段手动引入 UglifyJsPlugin 的代码其实是 webpack3 的用法,webpack4 现在已经默认使用 uglifyjs-webpack-plugin 对代码做压缩了——在 webpack4 中,他们是通过配置 optimization.minimize 与 optimization.minimizer 来自定义压缩相关的操作的。这里也引出了他们学习操控性强化的两个核心的理念——用什么工具,怎么用,并不是他们这本小册子的重点,因为所有的工具都存在用法迭代的难题。但现在大家知道了在打包的操作过程中做一些如上文所述的“手脚”能实现打包结果的最强化,那下次大家再去执行打包操作,会不会对那个操作更加留心,从而自己去寻找彼时操作的具体实现方案呢?我最希望大家掌握的技能是,先在脑子里留下“那个xx操作是对的,是有用的”,在日后的实践中,能基于那个认知去寻找把正确的操作落地的具体方案。

2.2 按需读取

大家想象这样两个场景。我现在用 React 构建两个单页应用,用 React-Router 来控制路由,十个路由对应了十个网页,这十个网页都不简单。如果我把这整个项目打两个包,使用者打开我的网站时,会发生什么?有很大机率会卡死,对不对?更好的做法肯定是先给使用者展现主页,其它网页等允诺到了再读取。当然那个情况也比较极端,但却能很好地引出按需读取的思想:

一次不读取完所有的文件文本,只读取此刻须要用到的那部分(会提前做拆分)当须要更多文本时,再对用到的文本进行即时读取

好,既然说到这十个 Router了,他们就拿其中两个开刀,假设我那个 Router 对应的组件叫做 BugComponent,来看看他们如何利用 webpack做到该组件的按需读取。

当他们不须要按需读取的时候,他们的代码是这样的:

import BugComponent from ../pages/BugComponent …

为了开启按需读取,他们要稍作改动。首先 webpack 的配置文件要走起来:

output: { path: path.join(__dirname, /../dist), filename: app.js, publicPath: defaultSettings.publicPath, // 指定 chunkFilename chunkFilename: [name].[chunkhash:5].chunk.js, },

路由处的代码也要做一下配合:

const getComponent => (location, cb) { require.ensure([], (require) => { cb(null, require(../pages/BugComponent).default) }, bug) }, …

对,核心是那个方法:

require.ensure(dependencies, callback, chunkName)

BugComponent 的文本。这是按需读取。按需读取的粒度,还能继续细化,细化到更小的组件、细化到某个功能点,都是 ok 的。

等等,这和说好的不一样啊?不是说 Code-Splitting 才是 React-Router 的按需读取实践吗?

没错,在 React-Router4 中,他们确实是用 Code-Splitting 替换掉了楼上那个操作。而且如果有使用过 React-Router4 实现过路由级别的按需读取的同学,可能会对 React-Router4 里用到的两个叫“Bundle-Loader”的东西印象深刻。我想很多同学读到按需读取这里,心里的预期或许都是时下大热的 Code-Splitting,而非我呈现出来的这段看似“陈旧”的代码。但是,如果大家稍微留个心眼,去看一下 Bundle Loader 并不长的源代码的话,你会发现它竟然还是使用 require.ensure 来实现的——这也是我要把 require.ensure 单独拎出来的重要原因。所谓按需读取,根本上是在正确的时机去触发相应的回调。理解了那个 require.ensure 的玩法,大家甚至能结合业务自己去修改两个按需读取模块来用。

这也应了我之前跟大家强调那段话,工具永远在迭代,唯有掌握核心思想,才能真正做到举一反三——唯“心”不破!

Gzip 压缩原理

前面说了不少 webpack 的故事,目的还是帮大家更好地实现压缩和合并。说到压缩,可不只是构建工具的专利。他们日常开发中,其实还有两个便宜又好用的压缩操作:开启 Gzip。

具体的做法非常简单,只须要你在你的 request headers 中加上这么一句:

accept-encoding:gzip

相信很多同学对 Gzip 也是了解到这里。之所以为大家开那个彩蛋性的小节,绝不是出于炫技要来给大家展现一下 Gzip 的压缩算法,而是想和大家聊两个和他们后端关系更密切的热门话题:HTTP 压缩。

HTTP 压缩是一种内置到网页伺服器和网页应用程序中以改进传输速率和带宽利用率的方式。在使用 HTTP 压缩的情况下,HTTP 统计数据在从伺服器发送前就已压缩:兼容的应用程序将在下载所需的格式前宣告支持何种方法给伺服器;不支持压缩方法的应用程序将下载未经压缩的统计数据。最常见的压缩方案包括 Gzip 和 Deflate。

以上是摘自百科的解释,事实上,大家能这么理解:

HTTP 压缩是以缩小表面积为目的,对 HTTP 文本进行重新编码的操作过程

Gzip 的内核是 Deflate,目前他们压缩文件用得最多的是 Gzip。能说,Gzip 是 HTTP 压缩的经典之作例题。

1. 该不该用 Gzip

如果你的项目不是极端迷你的超小型文件,我都建议你试试 Gzip。有的同学或许存在这样的疑问:压缩 Gzip,伺服器端要花时间;解压 Gzip,应用程序要花时间。中间节省出来的传输时间,真的那么可观吗?

答案是肯定的。如果你手上的项目是 1k、2k 的小文件,那确实有点高射炮打蚊子的意思,不值当。但更多的时候,他们处置的都是具备一定规模的项目文件。实践证明,这种情况下压缩和解压带来的时间开销相对于传输操作过程中节省下的时间开销来说,能说是微不足道的。

2. Gzip 是万能的吗

首先要承认 Gzip 是高效的,压缩后通常能帮他们减少积极响应 70% 左右的大小。但它并非万能。Gzip 并不保证针对每两个文件的压缩都会使其变小。

Gzip 压缩背后的原理,是在两个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。根据那个原理,文件中代码的重复率越高,那么压缩的效率就越高,使用 Gzip 的收益也就越大。反之亦然。

3. webpack 的 Gzip 和伺服器端 Gzip

一般来说,Gzip 压缩是伺服器的活儿:伺服器了解到他们这边有两个 Gzip 压缩的需求,它会启动自己的 CPU 去为他们顺利完成那个任务。而压缩文件那个操作过程本身是须要耗费时间的,大家能理解为他们以伺服器压缩的时间开销和 CPU 开销(以及应用程序导出压缩文件的开销)为代价,省下了一些传输操作过程中的时间开销。既然存在着这样的交换,那么就要求他们学会取舍。伺服器的 CPU 操控性不是无限的,如果存在大量的压缩需求,伺服器也扛不住的。伺服器一旦因此慢下来了,使用者还是要等。Webpack 中 Gzip 压缩操作的存在,事实上是为了在构建操作过程中去做一部分伺服器的工作,为伺服器分压。因此,这两个地方的 Gzip 压缩,谁也不能替代谁。它们必须和平共处,好好合作。作为开发者,他们也应该结合业务压力的实际强度情况,去做好这其中的取舍。

相关文章

发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务