加快编译速度(一)

这会是一个很长的篇章

首先谈谈怎么加快js的编译速度吧

在过去7年时间里,js一直都是用于编码前端应用程序最常用的语言。这篇并不是告诉你js到底有多强大,审题清楚很重要。如果一个网页内容未能在2秒钟内完成加载,那么访问者就会离开,就算你的网站设计有多么的漂亮、厉害都白搭。这就需要优化js代码以获得更好的性能。开始喽

  1. 尽量减少对DOM的访问
    每次网页加载的背后其实都是在构建一棵DOM树,如果你的程序需要多次访问DOM元素,可以改成访问一次DOM树并将其用作局部变量。如果从DOM树中删除此值,则该变量也需要设置成null值,不然会造成内存泄漏。
  • React和vue利用了虚拟DOM(做前端的人不会没听过这几个名词)

在像React和Vue这些框架中,简化了对DOM树的访问,并使用了虚拟DOM。 通过在实际DOM上使用虚拟DOM,React和Vue提供了出色的性能,就是每次做出修改都是在虚拟DOM,闭关不是直接修改实际DOM,虚拟DOM的作用就是做一个映射。(对实际DOM的修改少了,页面加载的次数减少,加载速度得到提升)

如果您使用的是纯JavaScript,尽可能减少对DOM的访问。

  1. 删除未使用的代码和依赖项

这个技巧适用于所有编程语言。 删除未使用的代码和依赖项,将确保您的代码编译速度更快,执行速度更快。 如果遇到用户从未使用过的功能,最好就是弃用与该功能相关的所有代码(或者注释)。 使用分析可以洞悉用户如何使用应用。 如果有些功能是绝对无法触及的,那么您随时可以与您的团队讨论并撤消这些功能。 这将确保您的Web应用加载速度更快。

我们还倾向于向我们的项目中添加大量的依赖包。 重点是要确保您的项目中没有任何不需要的依赖项。 如果您能够在没有其他依赖项的情况下对它们进行本地编码,那么也不要向第三方添加依赖项。

简洁明了的软件包将确保您的网站加载速度更快,并提高整体性能。

  1. 异步调用API

通过将异步代码用于API调用等功能,可以大大提高JavaScript代码的性能。 使用异步代码,不是阻塞JavaScript具有的单个线程,而是将异步事件推送到在所有其他代码执行之后启动的队列。 始终在代码中使用异步API。

  1. 避免使用全局变量

听过已经使用JavaScript进行编码一段时间的同行给到的一些建议,就是尽可能避免使用全局变量。 其背后的原因是,JavaScript引擎在运行时搜索全局范围中存在的变量所花费的时间更长。 避免全局变量的另一个原因是避免变量名冲突。 在javascript中,所有代码共享一个全局名称空间。 如果代码中有许多全局变量,则可能导致同一页面上的各个脚本之间发生冲突。

在下面的示例中, name是全局变量,并且具有全局范围。 它可以在任何地方使用。

var name = "Adhithi Ravichandran" ;
// can access name
function myFunction() {
// can access name
}

可以将其重写为具有本地范围。 我们可以重新编写代码并在函数中定义变量。 现在, 名称变量的范围仅在函数内。

function myFunction() {
  var name = "Adhithi Ravichandran" ;
  // can access name
}

除非绝对必要,否则始终尝试在局部范围内使用变量,并避免编写全局变量。 可以使用比var具有本地作用域的letconst

  1. 分析代码并消除内存泄漏

内存泄漏是性能的杀手。 内存泄漏会占用越来越多的内存,最终会占用所有可用的内存空间,有时会使您的应用程序崩溃。 为了提高JavaScript性能,应该确保代码没有内存泄漏。 这是我们开发人员都会面临的普遍问题。 您可以使用Chrome开发工具来跟踪内存泄漏。性能选项卡可让您深入了解任何内存泄漏。 定期当心代码中的任何泄漏。

  1. 利用缓存的力量

在浏览器中缓存文件将大大提高应用程序的性能并加快网站的加载时间。 浏览器将对在初始加载后加载的所有网页使用本地缓存的副本,而不是来回从网络中获取该副本。 这为用户提供了无缝的体验。

JavaScript服务工作程序可用于缓存用户脱机时可以使用的文件。 这是创建渐进式Web应用程序(PWA)必不可少的部分。 在离线状态下,用户仍可以使用缓存的文件访问网站。

  1. 缩小代码

在所有开发团队中,js代码的缩减都是一种常见的做法。它是指从js源中删除所有不需要的或者麻烦的元素。缩小过程将删除注释,空格,缩短变量和函数名称等内容。

您可以使用Google Closure编译器等构建工具或JS Compress , JS minifier等在线工具来最小化代码。

这也可以显着改善您的JavaScript代码性能。

  1. 对循环谨慎

这技巧几乎适用于所有编程语言,尤其是JavaScript。 当我们使用大量循环或嵌套循环时,它会影响浏览器。要记住的一点是,要在循环外保持尽可能多的状态,并且仅在循环内进行所需的操作。循环越少,JavaScript代码就越快。 在这方面也要确保避免不必要的循环嵌套。

以下对一个实验体的优化,可以借此来整理下上面的思路

实验开始喽

前端打包 webpack v4编译速度优化实践

当应用还处于一个比较小的规模的时候,可能就不会去在乎webpack的编译速度。规模小、且执行速度也在人的感知下显得足够快,直接点说就是至少没让你等得不耐烦。但随着业务的增多,一下子项目的逻辑会变得复杂,具体也会有上百个组件,这是一个非常令人头疼的问题,要维护起来的话。这时候当你再独立编写新的前端模块的生产包时,或者CI工具中编整个项目的包时,如果Webpackp配置没经过优化,编译速度就会变得出奇的慢。编译耗时十多秒的和编译耗时一两分钟的体验是迥然不同的。从开发期间做临时部署时的效率以及CI的整体效率这两点出发,我们都有必要去加快前端项目的编译速度,这是对前端开发工作效率上的提升。(积少成多,每个人省下一个小时,会是一个很可观的数字)

一、已存在的专门针对编译速度的优化

实验使用的这套Webpack架子是ejectCRA种子脚手架并且改进而来的,基于Webpack4.x,选择CRA的愿意在于其本身在对不同资源模块的处理上已是最佳实践的方案,在这方面基本上无需改动什么。但在编译速度优化上较弱,在项目规模扩大后,劣势非常明显,这忍不了,所以我们需要对其剖析和对其进行优化。

除了code split分chunk、common chunk、include、exclude等等外,其原有的针对编译速度的优化配置主要还有这三处:

  1. 通过terser-webpack-plugin的parallel和cache配置来并行处理并缓存之前的编译结果。terser-webpack-plugin是之前UglifyPlugin的一个替代品,因为UglifyPlugin已经没有继续维护了,从Webpack4.x起,已经推荐使用terser-webpack-plugin来进行代码压缩、混淆,以及Dead Code Elimination以实现Tree Shaking。对于parallel从整个设置的名称大家就会知道它有什么用(不懂英文,出门左转),没错,就是并行,而cache也就是缓存该插件的处理结果,在下一次的编译中对于内容未改变的文件可以直接复用上一次编译的结果。

  2. 通过babel-loader的cache配置来缓存babel的编译结果。

  3. 通过IgnorePlugin设置对moment的整个locale本地化文件夹导入的正则匹配,来防止将所有的本地化文件进行打包。如果你确实需要某国语言,仅手动导入那国的语言包即可。

在项目逐渐变大的过程中,生产包的编译时间也从十几秒增长到了一分多钟,哎哟我去!

总体对整个实验体的优化思路就是–>开多线程,尽可能地做增量编译,加缓存用空间换时间。

二、多线程(多进程模拟)支持

从段落(一)的terser-webpack-plugin的parallel设置中,我们可以得到一个启发:启用多进程来模拟多线程(因为一个Node.js进程是单线程的),并行处理资源的编译。于是就引入了HappyPack,之前的那套老架子也用了它,但之前没写东西来介绍那套架子,这里就一并说了。关于HappyPack,经常玩Webpack的同学应该不会陌生,网上也有一些关于其原理是使用的介绍文章,也写得很不错。HappyPack的工作原理大致就是在Webpack和Loader之间多加了一层,改成了Webpack并不是直接去和某个Loader进行工作,而Webpack test到了需要编译的某个类型的资源模块后,将该资源的处理任务交给了HappyPack,由HappyPack在内部线程池中进行任务调度,分配一个线程调用处理该类型资源的Loader来处理这个资源,完成后上报处理结果,最后HappyPack把处理结果返回给Webpack,最后由Webpack输出到目的路径。通过这一系列操作,将原本都在一个Node.js线程内的工作,分配到了不同的线程(进程)中并行处理。

使用方法如下:

首先引入HappyPack并创建线程池(threadpool):

const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({size: require('os').cpus().length - 1});

替换之前的Loader为HappyPack的插件:

{
    test: /\.(js|mjs|jsx|ts|tsx)$/,
    include: paths.appSrc,
    use: ['happypack/loader?id=babel-application-js']
}

将原Loader中的配置,移动到对应插件中:

new HappyPack({
    id: 'babel-application-js',
    threadPool: happyThreadPool,
    verbose: true,
    loaders: [
        {
            loader: require.resolve('babel-loader'),
            options: {
                ...省略
            },
        },
    ],
})

大致使用方式如上所示,使用方式都千篇一律,HappyPack的配置讲解文章有很多,不会配的同学可以自己搜索,这里就不管了。

HappyPack老早也没有维护了,它对url-loader的处理是有问题的,会导致经过url-loader处理的图片都无效,我之前也去提过一个Issue,有别的开发者也发现过这个问题。总之,用的时候一定要测试一下。

对于多线程的优势,我们举个例子:
比如我们有四个任务,命名为A、B、C、D。

任务耗时
A5秒
B7秒
C4秒
D6秒

单线程串行处理的总耗时大约在22秒。

改成多线程并行处理后,总耗时大约在7秒,也就是那个最耗时的任务B的执行时长,仅仅通过配置多线程处理我们就能得到大幅的编译速度提升。

到了这里,是不是觉得编译速度优化就可以到此结束了?天真,当然不是啦,上面这个例子在实际的项目中根本不具有广泛的代表性,实际项目的情况是这样的:

我们有四个任务,命名为A、B、C、D。

任务耗时
A5秒
B60秒
C4秒
D6秒

单线程串行处理的总耗时大约在75秒。

改成多线程并行处理后,总耗时大约在60秒,从75秒优化到60秒,确实有速度上的提升,但是因为任务B的耗时太长了,导致整个项目的编译速度并没有发生本质上的变化。事实上之前那套Webpack3.X的架子就是因为这个问题导致编译速度慢,在大多数项目中也都是这个情况,处理某种资源类型的Loader的执行时间非常长,远远超过了其他Loader的执行时间,它成为了一个速度瓶颈。

so ====> 只靠引入多线程编译就想解决规模化项目在生产环境下编译速度慢的问题是不现实的

还有其他办法吗?当然有,你以为我是谁啊,其实可以从TerserPlugin得到灵感,那就是依靠缓存:在下一次的编译中能够复用上一次的结果而不执行编译永远是最快的。

至少存在有这三种方式,可以让我们在执行构建时不进行某些文件的编译,从最本质上提升前端项目整体的构建速度:

  1. 类似于terser-webpack-plugin的cache那种方式,这个插件的cache默认生成在node_modules/.cache/terser-plugin文件下,通过SHA或者base64编码之前的文件处理结果,并保存文件映射关系,方便下一次处理文件时可以查看之前同文件(同内容)是否有可用缓存。其他Webpack平台的工具也有类似功能,但缓存方式不一定相同。

  2. 通过externals配置在编译的时候直接忽略掉外部库的依赖,不对它们进行编译,而是在运行的时候,通过<script>标签直接从CDN服务器下载这些库的生产环境文件。

  3. 将某些可以库文件编译以后保存起来,每次编译的时候直接跳过它们,但在最终编译后的代码中能够引用到它们,这就是Webpack DLLPlugin所做的工作,DLL借鉴至Windows动态链接库的概念。

后面的段落将针对这几种方式做讲解。

三、Loader的Cache

除了段落一中提到的terser-webpack-plugin和babel-loader支持cache外,Webpack还直接另外提供了一种可以用来缓存前序Loader处理结果的Loader,它就是cache-loader。通常我们可以将耗时的Loader都通过cache-laoder来缓存编译结果。比如我们打生产环境的包,对于Less文件的缓存你可以这样使用它:

{
    test: /\.less$/,
    use: [
        {
            loader: MiniCssExtractPlugin.loader,
            options: {
                ...省略
            },
        },
        {
            loader: 'cache-loader',
            options: {
                cacheDirectory: paths.appPackCacheCSS,
            }
        },
        { 
            loader: require.resolve('css-loader'),
            options: {
                ...省略
            },
        },
        {
            loader: require.resolve('postcss-loader'),
            options: {
                ...省略
            }
        }
    ]
}

Loader的执行顺序是从下至上,因此通过上述配置,我们可以通过cache-laoder缓存postcss-loader和css-loader的编译结果。

但我们不能用cache-loader去缓存mini-css-extract-plugin的结果,因为它的作用是要从前序Loader编译成的含有样式字符串的JS文件中把样式字符串单独抽出来打成独立的CSS文件,而缓存这些独立CSS文件并不是cache-loader的工作。

但如果是要缓存开发环境的Less编译结果,cache-loader可以缓存style-loader的结果,因为style-loader并没有从JS文件中单独抽出样式代码,只是在编译后的代码中添加了一些额外代码,让编译后的代码在运行时,能够创建包含样式的

在对样式文件配置cache-loader的时候,一定要记住上述这两点,要不然会出现样式无法正确编译的问题。

除了对样式文件的编译结果进行缓存外,对其他类型的文件(除了会打包成独立的文件外)的编译结果进行缓存也是可以的。比如url-laoder,只要大小没有达到limitation的图片都会被打成base64,大于limitation的文件会打成单独的图片类文件,就不能被cache-loader缓存了,如果遇到了这种情况,资源请求会404,这是在使用cache-loader时需要注意的。

当然,通过使用缓存能得到显著编译速度提升的,依旧是那些耗时的Loader,如果对某些类型的文件编译并不耗时,或者说文件本身数量太少,都可以先不必做缓存,因为即便做了缓存,编译速度的提升也不明显。

最后将所有Loader和Plugin的cache默认目录从node_modules/.cache/移到了项目根目录的build_pack_cache/目录(生产环境)和dev_pack_cache目录(开发环境),通过NODE_ENV自动区分。这么做是因为显示当前项目的CI工程每次会删除之前的node_modules文件夹,并从node_modules.tar.gz解压一个新的node_modules文件夹,所以将缓存放在node_modules/.cache/目录里面会无效,CI的这套逻辑对于前段项目来说是比较坑的,也不太想去动CI的代码。经过这个改动后,对cache文件的管理更直观一些,也能避免node_modules的体积一直增大的问题。如果想清除缓存,直接删掉对应目录即可。当然了,这两个目录是不需要被Git跟踪的,所以需要在.gitignore中添加上。CI环境中如果没有对应的缓存目录,相关Loader会自动创建。这样做还考虑到这个原因:因为开发环境和生产环境编译出的资源是不同的,在开发环境下对资源的编译往往都没有做压缩和混淆处理等,因此即使某个源文件的源码没变,其在开发环境下和生产环境下的缓存也是不能通用的,而且还会相互覆盖。所以为了有效地缓存不同环境下的编译结果,是有必要区分开不同编译环境下的缓存目录的。

四、外部扩展externals

按照Webpack官方的说法:我们的项目如果想用一个库,但我们又不想Webpack对它进行编译(因为它的源码很可能已是经过编译和优化的生产包,可以直接使用)。并且我们可能通过window全局方式来访问它,或者通过各种模块化的方式来访问它,那么我们就可以把它配置进extenals里。

比如我要使用jquery可以这样配置:

externals: {
    jquery: 'jQuery'
}

我就可以这样使用了,就像我们直接引入一个在node_modules中的包一样:

import $ from 'jquery';

$('.div').hide();

这样做能有效的前提就是我们在HTML文件中在上述代码执行以前就已经通过了<script>标签从CDN下载了我们需要的依赖库了,externals配置会自动在承载我们应用的html文件中加入:

<script src="https://code.jquery.com/jquery-1.1.14.js">

externals还支持其他灵活的配置语法,比如我只想访问库中的某些方法,我们甚至可以把这些方法附加到window对象上:

externals : {
    subtract : {
        root: ["math", "subtract"]
    }
}

我就可以通过 window.math.subtract 来访问subtract方法了。

对于其他配置方式如果有兴趣的话可以自行查看文档。

五、DllPlugin

要使用DLLPlugiin,我们需要单独开一个webpack配置,暂且将其命名为webpack.dll.config.js,以便和主Webpack的配置文件webpack.config.js进行区分。内容如下:

'use strict';
process.env.NODE_ENV = 'production';
const webpack = require('webpack');
const path = require('path');
const {dll} = require('./dll');
const DllPlugin = require('webpack/lib/DllPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const getClientEnvironment = require('./env');
const paths = require('./paths');
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';

module.exports = function (webpackEnv = 'production') {
    const isEnvDevelopment = webpackEnv === 'development';
    const isEnvProduction = webpackEnv === 'production';

    const publicPath = isEnvProduction ? paths.servedPath : isEnvDevelopment && '/';

    const publicUrl = isEnvProduction ? publicPath.slice(0, -1) : isEnvDevelopment && '';
    const env = getClientEnvironment(publicUrl);

    return {
        mode: isEnvProduction ?
            'production' :
            isEnvDevelopment && 'development',
        devtool: isEnvProduction ?
            'source-map' :
            isEnvDevelopment && 'cheap-module-source-map',
        entry: dll,
        output: {
            path: isEnvProduction ? paths.appBuildDll : undefined,
            filename: '[name].dll.js',
            library: '[name]_dll_[hash]'
        },
        optimization: {
            minimize: isEnvProduction,
            minimizer: [
                ...省略
            ]
        },
        plugins: [
            new webpack.DefinePlugin(env.stringified),
            new DllPlugin({
                context: path.resolve(__dirname),
                path: path.resolve(paths.appBuildDll, '[name].manifest.json'),
                name: '[name]_dll_[hash]',
            }),
        ],
    };
};

为了方便DLL的管理,我们还单独开了个dll.js文件来管理webpack.dll.config.js的入口entry,我们把所有需要DLLPlugin处理的库都记录在这个文件中:

const dll = {
    core: [
        'react',
        '@hot-loader/react-dom',
        'react-router-dom',
        'prop-types',
        'antd/lib/badge',
        'antd/lib/button',
        'antd/lib/checkbox',
        'antd/lib/col',
        ...省略
    ],
    tool: [
        'js-cookie',
        'crypto-js/md5',
        'ramda/src/curry',
        'ramda/src/equals',
    ],
    shim: [
        'whatwg-fetch',
        'ric-shim'
    ],
    widget: [
        'cecharts',
    ],
};

module.exports = {
    dll,
    dllNames: Object.keys(dll),
};

对于要把哪些库放入DLL中,请根据自己项目的情况来定,对于一些特别大的库,又没法做模块分割和不支持Tree Shaking的,比如Echarts,建议先去官网按项目所需功能定制一套,不要直接使用整个Echarts库,否则会白白消耗许多的下载时间,JS预处理的时间也会增长,减弱首屏性能。

然后我们在webpack.config.js的plugins配置中加入DLLReferencePlguin来对DLLPlugin处理的库进行映射,好让编译后的代码能够从window对象中找到它们所依赖的库:

{
    ...省略

    plugins: [
        ...省略
        
        // 这里的...用于延展开数组,因为我们的DLL有多个,每个单独的DLL输出都需要有一个DLLReferencePlguin与之对应,去获取DLLPlugin输出的manifest.json库映射文件。
     // dev环境下暂不采用DLLPlugin优化。
        ...(isEnvProduction ?
            dllNames.map(dllName => new DllReferencePlugin({
                context: path.resolve(__dirname),
                manifest: path.resolve(__dirname, '..', `build/static/dll/${dllName}.manifest.json`)
            })) :
            []
        ),

        ...省略
    ]

    ...
}

我们还需要在承载我们应用的index.html模板中加入<script>,从webpack.dll.config.js里配置的output输出文件夹中前置引用这些DLL库。对于这个工作DLLPlguin和它的搭档不会帮我们做这件事情,而已有的html-webpack-plugin也不能帮助我们去做这件事情,因为我们没法通过它往index.html模板加入特定内容,但它有个增强版的兄弟script-ext-html-webpack-plugin可以帮我们做这件事情,我之前也用过这个插件内联JS到index.html中。可懒得再往node_modules中加依赖包了,另辟了一个蹊径:

CRA这套架子已经使用了DefinePlugin来在编译时创建全局变量,最常用的就是创建process环境变量,让我们的代码可以分辨是开发还是生产环境,既然已有这样的设计,何不继续使用,让DLLPlugn编译的独立JS文件名暴露在某个全局变量下,并在index.html模板中循环这个变量数组,循环创建<script>标签不就行了,html-wepack-plugin自带ejs模板,我们循环语句符合ejs模板语法即可。在上面提到的dll.js文件中最后导出的 dllNames 就是这个数组。

然后我们按照ejs模板语法改造一下index.html:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title></title>
        <% if (process.env.NODE_ENV === "production") { %>
            <% process.env.DLL_NAMES.forEach(function (dllName){ %>
                <script src="/static/dll/<%= dllName %>.dll.js"></script>
            <% }) %>
        <% } %>
    </head>
    <body>
        <noscript>Please allow your browser to run JavaScript scripts.</noscript>
        <div id="root"></div>
    </body>
</html>

最后我们改造一下build.js脚本,加入打包DLL的步骤:

function buildDll (previousFileSizes){
    let allDllExisted = dllNames.every(dllName =>
        fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.dll.js`)) &&
        fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.manifest.json`))
    );
    if (allDllExisted){
        console.log(chalk.cyan('Dll is already existed, will run production build directly...\n'));
        return Promise.resolve();
    } else {
        console.log(chalk.cyan('Dll missing or incomplete, first starts compiling dll...\n'));
        const dllCompiler = webpack(dllConfig);
        return new Promise((resolve, reject) => {
            dllCompiler.run((err, stats) => {
                ...省略
            })
        });
    }
}
checkBrowsers(paths.appPath, isInteractive)
    .then(() => {
        // Start dll webpack build.
        return buildDll();
    })
    .then(() => {
        // First, read the current file sizes in build directory.
        // This lets us display how much they changed later.
        return measureFileSizesBeforeBuild(paths.appBuild);
    })
    .then(previousFileSizes => {
        // Remove folders contains hash files, but leave static/dll folder.
        fs.emptyDirSync(paths.appBuildCSS);
        fs.emptyDirSync(paths.appBuildJS);
        fs.emptyDirSync(paths.appBuildMedia);
        // Merge with the public folder
        copyPublicFolder();
        // Start the primary webpack build
        return build(previousFileSizes);
    })
    .then(({stats, previousFileSizes, warnings}) => {
        ... 省略
    })
    ... 省略

大致逻辑就是如果xxx.dll.js文件存在且对应的xxx.manifest.json也存在,那么就不重新编译DLL,如果缺失任意一个,就重新编译。DLL的编译过程走完后再进行主包的编译。由于我们将DLLPlugin编译出的文件也放入build文件夹中,所以之前每次开始编译主包都需要清空整个build文件夹的操作也修改为了仅仅清空除了放置有dll.js和manifest.json的目录。

如果我们的底层依赖库确实发生了变化,需要我们更新DLL,按照之前的检测逻辑,我们只需要删除整个某个dll.js文件即可,或者直接删除掉整个build文件夹。或者增加对webpack.dll.js配置文件进行去注释和压缩操作,再进行MD5检测即可,如果文件MD5值发生了变化,说明依赖的DLL发生了变化,那么就需要重新编译DLL了,并保存本次的MD5结果,用于下一次打包时的比较。

哈哈,到此所有的有关DLL的配置就完成了,大功告成。

接下来就开始有提到过DLLPlugin的使用都是在生产环境下。因为开发环境下的打包情况很特殊而且复杂(还看不懂上文的…继续看下去):

在开发环境下整个应用是通过webpack-dev-server来打包并起一个Express服务来serve的。Express服务在内部挂载了webpack-dev-middleware作为中间件,webpack-dev-middleware可以实现serve由Webpack compiler编译出的所有资源、将所有的资源打入内存文件系统中的功能,并能结合dev-sever实现监听源文件改动并提供HRM。webpack-dev-server接收了一个Webpack compiler和一个有关HTTP配置的config作为实例化时的参数。这个compiler会在webpack-dev-middleware中执行,用监听方式启动compiler后,compiler的outputFileSystem会被webpack-dev-middleware替换成内存文件系统,其执行后打包出来的东西都没有实际落盘,而是存放在了这个内存文件系统中,而ouputFileSystem本身是在Node.js的fs模块基础上封装的。将编译结果存放进内存中是webpack-dev-middleware内部最奇妙的地方。事实上就算将文件资源落盘,也必须先把文件从磁盘读到内存中,再以流的形式返回给客户端,这样一来会多一个从磁盘中将文件读进内存的步骤,反而还没有直接操作内存快。内存文件系统在npm start命令起的进程被干掉后,就被回收了,下一次再起进程的时候,会创建一个全新的内存文件系统。

这里顺带再说下webppack-dev-middleware源码的流程:

首先在执行wdm这个入口文件导出的方法中,通过setFs方法将原本的Node.js的fs给替换为memory-fs内存文件系统,所以下面提到的文件系统都是指这个内存文件系统。

按照Express的流程,所有请求都会依次进入Express挂载的中间件中,在webppack-dev-middle中间件中,会先通过getFilenameFromUrl方法根据传入的这次请求的url,compiler和webpack的publicPath等有关配置,确定一个finame的URI,这个URI最终会返回三种结果,这三种结果将走三种不同的处理流程:

====> 结果1:一个布尔值false
====> 结果2:一个HTTP API的请求路径
====> 结果3:一个静态文件资源的请求路径

结果1的后续处理:表示请求url带有hostname等特征,也就确定这个发给dev-server的请求并不是需要webpack-dev-middle处理的,将执行goNext(),根据是否是serverSideRender,在goNext方法中的处理过程会有所不同,但最终都会调用Express应用的next()进入下一个中间件。

结果2的后续处理:通过文件系统的statSync方法会判断这个路径并不是一个文件资源的路径,再根据isDirectory方法判断它是否是一个目录的路径,如果是的话,再看看该目录下是否有一个index.html的文件,有就返回。在开发的时候我们在浏览器地址栏输入"localhost:3000"就是走的这个流程,会返回承载前端应用的那个入口index.html文件。

结果3的后续处理:通过文件系统的statSync方法会判断出这个路径就是一个文件资源的路径,会通过文件系统的readFileSync方法从内存中读取到该文件内容,再拿到该资源对应的MIME类型,设置好response的header返回给浏览器,浏览器就能正确解析返回的文件资源流了。我们在开发环境下,所有对js、js.map、图片资源等除了index.html外的请求都是走的这个流程。

所以,由于开发环境打包的特殊性,在开发环境下将编译DLL的compiler的和主compiler结合起来还需要再看看,因此怎么在开发环境使用DLLPlugin还需要等有时间的时候再研究下,大概率感觉是用不上,也不需要用。只是在开发环境使用了多线程和各种缓存。由于开发环境下,编译的工作量少于生产环境,并且对所有资源的读写都是走内存的,因此速度很快,每次检测到源文件变动并进行重编译的过程也会很快。所以开发环境的编译速度在目前来看还可以接受,暂时不需要优化。

这里顺带说一句,之前在看有关DLLPlugin的文档和文章时,也注意到了一个现象,就是很多人都说DLLPlugin过时了,而externals使用起来更方便,配置更简单,甚至在CRA、vue-cli这些最佳实践的脚手架中都已经没再继续使用DLLPlugin了,因为Webpack4.x的编译速度已经足够快了。这里就客观地说下自己的感受:我这个项目就是基于Webpack4.X的,项目规模变大以后,没有觉得4.X有多么地快。通过配置externals将资源请求导向CDN的方式对项目来说没有用,只能通过使用DLLPlugin提高生产包的编译速度。我想这也是为什么Webpack到了4.X版本依然没有去掉DLLPlugin的原因,因为不是所有的前端项目都一定是互联网项目,实践出真知。

六、编译速度提升了多少

测试硬件环境是I7-9700,3.0GHz,16G,RTX2080ti,windows系统,所有测试都基于已对Echarts进行功能定制的前提下(移除不需要的功能带来的额外大小)。

  1. 最原始的CRA脚手架编译我这个实验项目的速度。

初次编译,无任何CRA原始配置的缓存,这和最初在CI上进行编译的情况完全一样,每次差不多都是这个耗时。

因为CI的脚本原因,每次构建,node_moduels都要被删除掉,无法缓存任何之前的编译结果:

在这里插入图片描述

大概1分10多秒。CI上的话,大概会稍微快点,因为硬件性能好些。

改掉CI脚本,并将有关loader和plugin的缓存目录放到了node_modules外面,有缓存以后:

在这里插入图片描述

如果不定制Echarts的话,直接引入整个Echarts,在没缓存的时候大概会多5秒耗时。

  1. 引入dll后。

初次打包,并且无任何DLL文件和CRA原始配置的缓存:

在这里插入图片描述

先编译DLL,再编译主包,整个过程耗时直接变成了57秒。

有DLL文件和缓存后:

在这里插入图片描述

降到27秒多了。

3.最后我们把多线程和cache-loader上了:

无任何DLL文件、CRA原始配置的缓存以及cache-loader的缓存时:
在这里插入图片描述

大概在接近60秒左右。

有DLL文件和所有缓存后:
在这里插入图片描述

最终,耗时已经下降至17秒左右了。

编译速度受硬件性能、以及运行编译时的计算机资源使用率有关系,有正负几秒的差距。后面在自己的笔记本上单核基础频率2.9GHz运行编译时,耗时只有13秒多,在同事的3.5GHz的台式机上执行了下,速度还会更快些,只要10秒多点点。更强的硬件会使编译的耗时进一步减小。

这比最原始的1分10多秒耗时已经有本质上的提升了,优化后的编译耗时只有之前的零头那么多了。

这才是我们想要优化到的速度!

使用合理的工具并进行最优化的配置对工作效率的提升是很有帮助的。

七、总结

第一篇就先说了前端,不过很多都对其他编程环境也能适用。

至此,已经将所有的底层依赖DLL化了,把几乎所有能缓存的东西都缓存了,并支持多线程(多进程模拟)编译。当项目稳定后,无论项目规模多大,小幅度的修改将始终保持在这个编译速度,耗时不会有太大的变化,可能后期模块数量增大很多倍以后,对比原始文件的指纹可能会多花一些时间。因为所有的源代码已被缓存,工程基础包已经DLL化,除初次编译是全量编译外,后续的编译仅仅需要增量编译那些有变化的源码而已。

据说在Webpack5.X带来了全新的编译性能体验,很期待使用它的时候。谈及到此,技术更新快,我觉得这会是一件好事,能感受到这个时代每天、每时每刻都在进步,我生在这个科技昌盛的时代,是一种幸运。我与代码同在

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值