首页 > 编程语言 >从源码看webpack3打包流程

从源码看webpack3打包流程

时间:2024-03-04 19:24:19浏览次数:47  
标签:__ vue webpack3 module js webpack 源码 loader 打包

在javascript刚刚流行时,前端项目通常比较简单,不需要考虑项目的开发效率、性能和扩展性等。

随着前端项目越来越复杂,需要更正式的软件开发实践,比如单元测试(unit testing)、代码检查(linting)、文件缩小(minification)、文件捆绑(bundling)和代码编译(compilation)等[1]

  • 单元测试确保代码修改不影响已有功能
  • 代码检查保证一致的代码风格,没有错误
  • 文件压缩用于提高资源的访问速度,比如jpg/png、js和css等
  • 文件捆绑可以解决页面异步请求数百个js和css文件而导致的性能下降。因为每个异步请求都会有微小开销(请求头、握手等)[1],通常将这些js和css分别捆绑到一个文件中,这样将会请求一个单独的JS和CSS文件而不是数百个单独的文件
  • 代码编译是指语言预处理和转译等,比如语言预处理器SASS和JSX将CSS和JS编译成原生的,转译器Babel将ES6转译为ES5获取更好的兼容性

将上面的开发实践看成一个个任务,这些任务与web应用的逻辑没有关联,开发这些任务也会耗费大量时间和精力。所以像grunt这样的构建工具出现了,可以通过一个命令依次运行多个任务。这些任务自动化地在开发环境运行,开发者可以专注于写应用的代码。[1]grunt的出现是跨时代的。在它之前我们经常都是通过 bash 或者 make 调用 closure-compiler 之类的工具。前端并不存在一个统一的构建工具和标准,甚至我们自己写过一些简单的构建工具。[2]

gulp的功能类似于grunt,可以通过一个命令运行多个任务。但gulp是基于Node stream的,一个任务中的多个操作在内存中进行的;相比于grunt执行一个任务中的多个操作时,每个操作后将临时文件输出到硬盘更高效。[3]gulp优化了任务的配置,gulp的配置更简单,代码量也更少。

当开始使用node中require()或import写浏览器代码[1],并加载npm安装的模块时,需要打包工具webpack或browserify。node样式的代码无法直接在浏览器运行。webpack或browerify会递归解析所有的require()或import的js文件,然后捆绑到一个可以通过<script>引入的js文件中,浏览器可以正常运行该文件。[4]相比browserify,webpack还实现了gulp/grunt中的大量的任务功能[1],webpack可以单独使用。而browerify的功能单一,通常和gulp/grunt搭配使用。

webpack是一个强大的工具,除了模块捆绑,还实现了gulp/grunt中的大量的任务功能。Webpack实现了捆绑后的文件的代码缩小和sourceMap。webpack可以作为一个中间件运行在webpack-dev-server上,它支持热更新和实时更新。[1]一些系统或框架内置了webpack工具,比如通过vue-cli创建的脚手架、angular框架[3]等。所以了解webpack的原理是十分重要的。本文以简单的vue-cli项目为例,从webpack的打包入口开始,详细介绍webpack的打包流程,源文件时如何被打包的,打包后的文件格式是怎样的。

在介绍webpack打包vue-cli项目之前,先介绍webpack打包html和js的简单项目。

1. 环境安装

vue-cli是一个用于快速Vue.js开发的完整系统。在介绍vue-cli项目的打包之前,需要先进行环境安装。安装时需要注意Node.js和包之间以及包和包之间的版本兼容性。通常github仓库下就有相应的兼容性信息。本文安装的Node.js和各包的版本如图1。

软件或包的名称 Node.js npm(包含在Node.js中) @vue-cli webpack webpack-dev-server
版本 v16.20.2 8.19.4 5.0.8 3.12.0 2.11.5

图1 Node.js版本及npm安装的包版本

1.1 Node.js

Node.js是基于V8引擎的JavaScript开发环境[5]。当和其它的服务端语言(PHP和Python等)结合起来,就可以构建成熟的web应用程序。Node.js中的原生模块(比如http、path和Stream模块等)通常是使用C++实现的,在安装时使用Python构建工具(gyp)编译成类似DLL的内容,Node.js在运行时可为目标环境加载该模块。[6]Demo1是Node.js最常见的案例HelloWorld[7],它实现了一个web服务器,其中使用require加载原生http模块,调用http模块的createServer创建服务器,服务器监听域名为127.0.0.1端口号为3000的http请求。Node.js中的require函数不能直接在浏览器运行,直接在浏览器运行会报错,如图2。Demo1修改为ES6模块导入语法后,因为浏览器不存在http模块,在浏览器运行也会报错,如图3。

//CommonJS语法加载模块
const http = require('node:http');
//ES6语法加载模块
//import http from 'http';
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});
server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

Demo1 Node.js最常见的案例HelloWorld

图2 Node.js中的require函数在浏览器运行报错

图3 浏览器不存在http模块,运行报错

1.2 npm

npm是世界上最大的软件注册中心。世界各地的开源开发者使用npm分享和借鉴包,一些组织也使用npm管理私有开发。npm包含3个部分,npm网站、npm命令行工具和npm注册中心。npm网站用于发现包,设置配置文件和管理npm使用的其它方面,比如你可以设置组织来管理对公有和私有包的访问。npm命令行工具用于在终端运行命令,是大多数开发者和npm交互的途径。npm注册中心是一个包含JavaScript软件及其周围元信息的大型公共数据库。[8]

要使用npm命令行工具,需要先安装npm。你需要安装Node.js和npm命令行工具,目前Node.js软件包中已经包含了npm工具。建议你使用 nvm安装Node.js[9],以方便进行Node.js的版本切换。安装完成后,可以使用node -vnpm -v命令检查是否安装成功。

可以使用npm install来下载和安装包。常见的用途有以下3个。

  • 使用npm install下载包,在自己的模块中通过require或import依赖该包;
  • 使用npm install下载和安装包,安装包后在node_modules/.bin目录下生成相应的.cmd文件,可以通过npx命令(详见5.2节)运行包;比如webpack,webpack-dev-server包的安装;
  • 使用npm install -g全局下载和安装包,安装后在node的安装目录下生成.cmd文件,在终端可以像运行node命令一样运行包。比如使用npm install vue-cli -g全局安装vue-cli后,在node的安装目录下生成vue.cmd文件,如果node安装目录在环境变量Path中配置了,可以直接在终端运行vue -v检查vue-cli是否安装成功。

1.3 @vue-cli

CLI(@vue/cli)是全局安装的npm包,安装后可在终端使用vue命令,通过vue create命令可快速创建项目的脚手架[10]。如Demo2,使用npm install -g @vue/cli安装vue-cli,安装后使用vue -v检查是否安装成功。vue init是vue2中的命令,如果在vue2中,可直接使用vue init webpack my-project快速创建项目的脚手架;如果要在vue>=3中使用vue init,需要先通过npm install -g @vue/cli-init安装全局桥[11],然后使用vue init webpack my-project创建项目的脚手架,创建时需要进行如图4的一些配置。实际在使用vue init webpack my-project命令时可能会网络超时,需要先修复网络超时的问题或采用离线方式初始化[12]

创建的项目脚手架my-project中,使用webpack作为打包工具,webpack的配置文件中已经做好了常用配置,你不用花很多时间在配置webpack上,可以更专心地进行应用的开发。

npm install -g @vue/cli
vue -v

#安装全局桥
npm install -g @vue/cli-init
vue init webpack my-project

Demo2 vue-cli安装及基于vue-cli创建项目脚手架

图4 基于vue-clli创建项目脚手架时的配置

1.4 webpack,webpack-cli

vue-cli项目中已经添加了webpack包的依赖,使用npm install即可安装好项目依赖的所有包。项目中已经添加了webpack的配置文件,并做好了常用配置,可以直接使用npm run dev运行项目,或者使用npm run build打包项目。如Demo3,npm run build实际执行的是node build/build.js命令[13],build.js中使用调用webpack()函数进行项目打包。

"scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "build": "node build/build.js"
},

Demo3 项目package.json中的配置

如果你想单独安装webpack,并进行webpack的简单使用,可参考第2节。在单独安装webpack的时候,官方会建议同时安装webpack-cli。webpack运行时将配置文件中的配置解析为options,webpack-cli提供了修改options的接口。webpack-cli的options会覆盖配置文件中的options。webpack-cli提供了丰富的命令帮助你更快地开发应用。比如webpack-cli b表示运行webpack,webpack-cli t表示对webpack配置文件进行验证,webpack-cli c E:\myproject表示在目录E:\myproject下创建一个新的webpack项目。[14]

1.5 webpack-dev-server

webpack-dev-server为你提供了基本的web服务器和实时重新加载的能力[15]。可以使用webpack-dev-server方便地进行开发调试。

vue-cli项目在执行如1.4节的npm install会安装webpack-dev-server,然后可以直接使用npm run dev运行项目。如1.4节的Demo3,npm run dev实际执行的是webpack-dev-server --inline --progress --config build/webpack.dev.conf.js命令(详见5.2节)。

2. webpack的简单使用

本节通过一个简单案例,学习webpack的简单使用。这个案例从基础的HTML和JS,到使用ES6进行改造,到在项目中使用webpack。使用webpack后,项目的文件目录发生变化,最终浏览器访问的文件格式也发生变化。从输出的文件可以看出webpack的一些特点,比如代码缩小和模块捆绑等。本节使用的webpack版本号为5.90.0。

2.1 使用HTML和JS的简单项目[16]

如Demo4先创建一个空项目,并进行项目初始化,然后安装webpack和webpack-cli。安装后项目目录如图5。

mkdir webpack-demo
cd webpack-demo
npm init -y  
npm install webpack webpack-cli --save-dev

Demo4 简单案例的环境准备

图5 环境准备好后的项目目录

在项目中新增index.html和index.js文件,如图6。如Demo5,index.html中同时引入了lodash.js和index.js两个js文件;其中index.js使用了lodash.js中的字符串拼接函数_.join,如Demo6。可以直接在浏览器访问index.html。

图6 在项目中新增index.html和index.js文件

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Getting Started</title>
    <script src="https://unpkg.com/[email protected]/lodash.js"></script>
</head>
<body>
<script src="./src/index.js"></script>
</body>
</html>

Demo5 项目文件index.html

function component() {
  const element = document.createElement('div');

  // Lodash, currently included via a script, is required for this line to work
  element.innerHTML = _.join(['Hello', 'webpack'], ' ');

  return element;
}

document.body.appendChild(component());

Demo6 项目文件.src/index.js

2.2 基于ES6修改简单项目

ES6 在语言标准的层面上,实现了模块功能。使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块[17]。基于ES6的模块功能修改简单案例。改写的时候要注意,因为lodash.js是Node.js中一个CommonJS模块,但浏览器不支持CommonJS模块,lodash.js在浏览器不能正常编译运行(详见5.5节)。具体的修改步骤如下:

  • 下载远程库文件loadsh.js文件[18];将loadsh.js由CommonJs格式改写为ES6格式(如Demo7),并放在./lib目录下;
  • 改写index.html和index.js,改写后如Demo8和Demo9所示。如Demo8,浏览器加载 ES6 模块,<script>标签要加入type="module"属性[19]
var arrayProto = Array.prototype;
var nativeJoin = arrayProto.join;
export function join(array, separator) {
    return array == null ? '' : nativeJoin.call(array, separator);
}

Demo7 由CommonJS模块lodash.js改写成的ES6模块

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Getting Started</title>
</head>
<body>
    <!--引入ES6模块,需要添加type="module-->
	<script type="module" src="./src/index.js"></script>
</body>
</html>

Demo8 项目文件index.html

/*使用ES6的import输入函数join*/
import {join} from './lib/lodash.js' 

 function component() {
    const element = document.createElement('div');

    element.innerHTML =  join(['Hello', 'webpack'], ' ');

    return element;
}

document.body.appendChild(component());

Demo9 项目文件.src/index.js

修改后,不可直接浏览器直接访问index.html,因为ES6遵循同源策略,如果直接访问会报错”Access to Script at ' from origin 'null' has been blocked by CORS policy“[66]。可以将静态资源放到nginx服务器上进行访问。

相比于基础的HTML和JS,使用ES6的import输入函数有2个优点[16]

  • 可以明确的看到index.js中依赖哪些JS文件;
  • JS文件的加载顺序也很明确;在2.1节的案例中,JS文件的相互依赖不清晰,可能因为JS文件的加载顺序不对,而导致程序错误。

2.3 在简单项目中使用webpack

除了浏览器直接加载ES6模块,也可以在项目中使用webpack,在源代码中使用node样式(ES6或CommonJS),生成的发布物代码中不包含node样式。在简单项目中使用webpack后,项目将会分为源代码(./src)和发布物代码(./dist)两个文件夹,如图7。实际开发时在./src下创建和编辑文件;项目构建后的经过压缩和优化的代码会输出到./dist下,最终在浏览器加载的是./dist下文件。[16]

在项目中使用webpack,需要进行以下操作:

  • 使用npm install --save lodash安装lodash包。
  • 创建文件夹./dist。将index.html移到./dist下。并对2.1节的index.html和index.js分别做如Demo10和Demo11的修改。
  • 使用npx webpack 进行打包。打包后的项目目录如图8。npx运行webpack的原理其实很简单,详见5.2节。

图7 项目目录

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Getting Started</title>
</head>
<body>
<script src="main.js">
</script>
</body>
</html>

Demo10 项目文件dist/index.html

import _ from 'lodash';

 function component() {
    const element = document.createElement('div');

    // Lodash, now imported by this script
    element.innerHTML =  _.join(['Hello', 'webpack'], ' ');

    return element;
}

document.body.appendChild(component());

Demo11 项目文件src/index.js

图8 webpack打包后的文件main.js

如图8,webpack将index.js及其依赖的模块lodash都打包进了main.js文件。并进行了代码缩小。总结起来,使用webpack后主要有2个优点:

  • 将node样式的代码(import或require)转译为浏览器可运行的代码。从配置的入口文件开始,将文件所有的依赖模块捆绑到一个js文件中。生成的捆绑文件是完全独立的,包含应用所需的所有信息,而且开销很小。
  • 代码缩小。代码缩小用于提高资源的访问速度。

你可能发现了index.html是手动在dist文件下创建的,这里只是做简单演示。当webpack配置HtmlWebpackPlugin插件后,dist下的index.html会在打包时自动生成。在第3节打包vue-cli项目时,就不用对dist下的文件做任何修改。

除了上述了2个优点外,还可以通过配置loader和plugin等来增强webpack的功能。比如配置vue-loader后,webpack可以加载扩展名为.vue的文件;配置html-webpack-plugin插件后,目标目录下的index.html会在打包时自动生成。

2.4 使用webpack的配置文件

webpack4.0在使用时可以不进行任何配置,使用默认配置,但实际项目中通常需要更复杂的配置。可以使用配置文件对webpack进行配置,相比于在终端命令行中配置要简单高效。如图9,在项目中添加了webpack.conf.js文件,文件中配置了打包的入口和出口,如Demo12。执行npx webpack命令,可以正常打包。

还可以在配置文件中配置loader rules、plugin、resolve options等,实际项目的配置通常比较复杂。

图9 在项目中添加的webpack.conf.js文件

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
};

Demo12 项目文件webpack.conf.js

实际运行webpack时可能有不同的命令,本节的项目中使用的是npx webpack,第3节中的vue-cli项目使用的是node build/build.js。可以在package.json的scripts对象下进行配置,通过统一的命令运行webpack。如图10,在scripts中配置build属性后,可以通过npm run build来运行webpack[13]。注意scripts中build属性的值是”webpack“,而不是”npx webpack“,原因详见5.2节。

图10 在package.json配置scripts对象

3. webpack打包vue-cli项目

本节基于在1.3节通过vue-cli创建的项目脚手架(简称为vue-cli项目),学习webpack的打包流程。vue-cli项目的项目目录如图11所示。

图11 vue-cli项目的目录

3.1 源码流程图

为了简化源码流程图,在实际调试的时候先将webpack.prod.conf.js中的UglifyJsPlugin、ExtractTextPlugin、OptimizeCSSPlugin和HtmlWebpackPlugin注释掉,如Demo13。

plugins: [
    // http://vuejs.github.io/vue-loader/en/workflow/production.html
    new webpack.DefinePlugin({
      'process.env': env
    }),
    /*new UglifyJsPlugin({
      uglifyOptions: {
        compress: {
          warnings: false
        }
      },
      sourceMap: config.build.productionSourceMap,
      parallel: true
    }),*/
    // extract css into its own file
   /* new ExtractTextPlugin({
      filename: utils.assetsPath('css/[name].[contenthash].css'),
      // Setting the following option to `false` will not extract CSS from codesplit chunks.
      // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack.
      // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`,
      // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110
      allChunks: false,
    }),
    // Compress extracted CSS. We are using this plugin so that possible
    // duplicated CSS from different components can be deduped.
    new OptimizeCSSPlugin({
      cssProcessorOptions: config.build.productionSourceMap
        ? { safe: true, map: { inline: false } }
        : { safe: true }
    }),*/
    // generate dist index.html with correct asset hash for caching.
    // you can customize output by editing /index.html
    // see https://github.com/ampedandwired/html-webpack-plugin
   /* new HtmlWebpackPlugin({
      filename: config.build.index,
      template: 'index.html',
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
        // more options:
        // https://github.com/kangax/html-minifier#options-quick-reference
      },
      // necessary to consistently work with multiple chunks via CommonsChunkPlugin
      chunksSortMode: 'dependency'
    }),*/
    // keep module.id stable when vendor modules does not change
    new webpack.HashedModuleIdsPlugin()
]

Demo13 将webpack.prod.conf.js中的UglifyJsPlugin、ExtractTextPlugin、OptimizeCSSPlugin和HtmlWebpackPlugin注释掉

webpack的打包流程可分2个阶段,一是从一个或多个入口文件开始构建依赖关系图,如流程图1;二是将项目所需的所有模块合并到一个或多个捆绑文件中,捆绑文件是包含所有内容的最终发布物,如流程图2。

如流程图1,终端运行npm run build命令后,实际执行的命令是node build/build.js。在build.js文件中,调用了webpack函数。webpack函数中调用new WebpackOptionsApply().process()注册了make函数,然后通过compiler.run()调用了make函数。在make函数中,主要进行的操作如流程图1中的步骤①-④。根据入口文件entry创建module;构建module;为module的依赖创建和构建module;递归为module的依赖创建和构建module。在经过步骤①-④后,从入口文件开始完整构建了依赖关系图,同时还为部分依赖创建和构建了module。入口文件的module创建是在步骤①中的moduleFactory.create()方法,module的构建是在步骤②的this.buildModule()方法。依赖文件的module创建是步骤③中的factory.create()方法,module的构建是步骤④中的_this.buildModule方法,具体的细节详见3.2.1.2节。在依赖关系图构建完成后,会调用callback函数,callback函数回调的流程从“callback函数回调1”开始,直到"callback函数回调3的函数体"结束。在"callback函数回调3的函数体"中,会调用compilation.seal方法将所有模块合并到一个或多个捆绑文件中,详见流程图2。

流程图1 从入口文件entry开始构建依赖关系图

如流程图2,compilation.seal方法主要包括步骤①-⑤。其中步骤①-③是模块捆绑前的准备,获取依赖中的所有module;将module分别放入app、mainifest和vendor等3个chunk中;将app的chunk中module分为NormalModule和ConcatenatedModule。步骤④将根据代码生成的module捆绑到app.js(app.[chunkhash].js的简写)的ConcatSource中;将函数“webpackJsonp”和__webpack_require__捆绑到manifest.js(manifest.[chunkhash].js的简写)的ConcatSource中;将通过require或import引入的所有node_moudes下的module捆绑到vendor.js(vendor.[chunkhash].js的简写)的ConcatSource中。步骤⑤分别基于app.js、manifest.js和vendor.js的ConcatSource生成一个RawSource,RawSource最终被写入目标目录下的相应js文件中。执行完compilation.seal后,开始调用回调函数。回调函数中调用this.emitAssets将发布物文件输出到目标目录下,输出细节详见步骤⑥。

流程图2 将项目所需的所有模块合并到3个捆绑文件中

虽然整个流程看着并不复杂,但实际调试的过程中遇到了很多问题。首先,webpack是基于plugin的,源码中通过plugin()注册了很多函数,并通过applyPlugins()(或者applyPluginsAsync和applyPluginsParallel等)调用这些函数。applyPlugins()通常和plugin()在不同的js文件中,调试源码时,需要根据applyPlugins()找到plugin()的位置。根据applyPlugins()找到plugin()的位置通常需要2步,第一步根据applyPlugins()参数中的函数名找到plugin()注册函数的代码位置,需要确认applyPlugins()和plugin()的调用对象是同一个;第二步在找到plugin()的位置后,plugin()的位置通常有多个,在plugin()注册的函数体中打断点,确认在调试的时候是否会进入plugin()中,因为必须在applyPlugins()之前已经调用了plugin()所在的plugin中的apply()方法进行了函数的注册,applyPlugins()才能正常调用plugin()参数中的函数;如果能正常进入到plugin()参数中的函数体内,则已经进行了函数的注册,可以再查找下调用plugin的apply()方法进行函数注册的位置。

webpack运行时调用了很多的applyPlugins,如果每个applyPlugins都通过这种方式进行查找和调试,是比较耗时的。如何快速地找到webpack中主要流程的代码呢?我是偶然的机会找到webpack的主要流程的。在刚开始尝试阅读webpack源码时,笔者从wepback的运行入口node build/build.js开始调试,发现在源码中有很多applyPlugins()的调用,同时也有plugin中apply方法的调用,比如流程图1中的new WebpackOptionsApply().process()就通过compiler.apply()调用很多plugin的apply方法进行函数注册,这些plugin有JsonpTemplatePlugin、FunctionModuleTemplatePlugin和NodeSourcePlugin等。当时就对这些plugin的用途很感兴趣。就选择对其中的JsonTemplatePlugin.js和FunctionModuleTemplatePlugin.js进行调试,查看其中注册的函数在调用时的入参。在调试FunctionModuleTemplatePlugin.js中的render函数时,发现入参中有项目代码的痕迹(如图12),所以判断render函数是主要流程中的一个环节。从FunctionModuleTemplatePlugin.js中的render函数开始进行逐步调试,render函数调用结束返回到ModuleTemplate的render函数中,ModuleTemplate的render函数调用结束后返回到Template的renderChunkModules()函数中,基于函数调用结束后会返回到上一层函数中,最后调试到了Compilation的seal方法和Compiler的compile方法中。然后根据上述调试流程梳理出整个打包流程的雏形。

图12 文件FunctionModuleTemplatePlugin.js中的render函数入参moduleSource

其次,Node.js中的异步操作使得代码调试变得复杂。Node.js在发生IO操作时是异步的。Node.js不会等这个IO操作结束才去执行接下来的操作,而是直接去执行后续的操作[20]。webpack打包时有读写文件的IO操作,在源代码调试时需要注意这些IO操作。比如流程图5中调用_this.buildModule()进行module构建,构建完成后会调用回调函数。_this.buildModule()执行时会进行IO操作,IO操作具体在LoaderRunner.js的函数processResource中。IO操作的先后顺序与IO操作的回调函数的顺序不一定是一致的(详见5.7节),这使得_this.buildModule()方法的执行顺序与其回调函数的顺序不一定一致。在基于vue-cli项目调试webpack的源代码时,根据入口文件构建依赖关系图的流程中会多次调用_this.buildModule()方法,如流程图1。图13对部分_this.buildModule()和其回调函数的执行次序进行了记录。显然,_this.buildModule方法的回调函数不一定是紧接着_this.buildModule方法执行的;它的执行顺序与_this.buildModule方法也不完全一致。当调试某个dependentModule的_this.buildModule方法和其回调函数时会变得更复杂,需要花费更多的时间调试到_this.buildModule方法和其回调函数的调用。

入参dependentModule的rawRequest _this.buildModule的执行序号(与回调函数的执行一并排序) 回调函数的执行序号(与_this.buildModule的执行一并排序)
vue 1 2
./../../webpack/buildin/global.js 3 5
./App 4 6
!!babel-loader!../node_modules/vue-loader/lib/selector?type=script&index=0!./App.vue 7 8
!../node_modules/vue-loader/lib/component-normalizer 9 12
!!../node_modules/vue-loader/lib/template-compiler/index?{"id":"data-v-5c20e860","hasScoped":false,"transformToRequire":{"video":["src","poster"],"source":"src","img":"src","image":"xlink:href"},"buble":{"transforms":{}}}!../node_modules/vue-loader/lib/selector?type=template&index=0!./App.vue 10 11
./components/HelloWorld 13 16
!!../node_modules/extract-text-webpack-plugin/dist/loader.js?{"omit":1,"remove":true}!vue-style-loader!css-loader?{"sourceMap":true}!../node_modules/vue-loader/lib/style-compiler/index?{"vue":true,"id":"data-v-5c20e860","scoped":false,"hasInlineConfig":false}!../node_modules/vue-loader/lib/selector?type=styles&index=0!./App.vue 14 15

图13 部分_this.buildModule()和其回调函数的执行次序

最后,webpack的部分源码很难读懂。笔者只是对webpack的打包流程有个大概了解,对很多webapck的细节还未读懂。比如进行模块捆绑的createChunkAssets函数,插件uglifyjs-webpack-plugin中的方法asset.sourceAndMap()和runner.runTasks()等。同时,根据entry文件构建依赖关系图的过程中一些方法的源码还未全面读过,比如module的创建方法和解析module中依赖的方法。

3.2 源码中的重要细节

3.2.1 从entry到outputPath下的文件

如流程图3,从entry文件到outputPath下的文件经过了几个步骤。从入口文件entry(包含在dependency中,详见流程图1)开始,每个步骤依次对输入的数据做不同的处理,最终生成了发布物this.assets,并输出到目标目录outputPath下。下面对整个流程中每个节点的数据进行了记录,并对步骤”流程图1①-④“的源码进行分析。对其它步骤的源码也尝试阅读过,但阅读的时候还有很多不懂的问题,这里先不深入源码。

流程图3 从entry文件到outputPath下的文件经历的步骤

3.2.1.1 dependency(包含entry)

如流程图1①,根据入口文件dependency(包含entry)创建module,其中dependency的值如图14。

图14 流程图1①中_addModuleChain函数的入参dependency

3.2.1.2 this.preparedchunks

入口文件entry经过流程图1①-④步骤处理后,生成的this.preparedChunks的值如图15。rawRequest为./src/main.js的module有6个dependency,索引为2的dependency的request为./App,该denpendency对应App.vue,该denpendency下有13个denpendencies。rawRequest为./App的module下的索引为5的dependency的request为!!babel-loader!../node_modules/vue-loader/lib/selector?type=script&index=0!./App.vue,表示对app.vue中<script>部分的处理,该dependency下有5个dependencies,如图16。!!babel-loader!../node_modules/vue-loader/lib/selector?type=script&index=0!./App.vue下的索引为1的dependency的request为./components/HelloWorld,该dependency对应Helloworld.vue,该denpendency下有13个denpendencies,如图16。rawRequest为./components/HelloWorld的module下的索引为5的dependency的request为!!babel-loader!../../node_modules/vue-loader/lib/selector?type=script&index=0!./HelloWorld.vue,表示对Helloworld.vue中<script>部分的处理,该dependency下有3个dependencies,如图17。将所有dependencies的个数相加,共有(6+13+5+13+3)等于40个dependency。

图15 入口文件entry经过流程图1中的步骤后生成的this.preparedChunks

图16 入口文件entry经过流程图1中的步骤后生成的this.preparedChunks

图17 入口文件entry经过流程图1中的步骤后生成的this.preparedChunks

如图15,request为"./App"的dependency构建生成了NormalModule,且module下有13个dependencies。rawRequest为"./App"的NormalModule及module下的dependency是如何生成的呢?request为"./App"的dependency是入口文件的依赖,由dependency生成module可分为流程图1中③和④两个步骤。如流程图1③,在addModuleDependencies函数中调用factory.create()方法创建module;如流程图1④,在factory.create()创建module成功后的回调函数中,调用this.buildModule方法构建module。如果module有dependency,则调用processModuleDependencies递归创建和构建module。request为"./App"的dependency是入口文件的依赖,它的NormalModule的创建也是从processModuleDependencies开始的,module创建流程详见流程图4,构建流程详见流程图5。

流程图4中,processModuleDependencies方法中调用了addModuleDependencies方法,addModuleDependecies方法中调用factory.create方法创建module。factory.create方法中包含函数"factory","resolve"的执行。函数"resolve"执行时,会根据源文件E:\hello-vue-cli\src\App.vue的文件扩展名获取相应的loader,相应的loader为E:\hello-vue-cli\node_modules\vue-loader\index.js??ref--0。module创建成功后,会调用_this.buildModule进入module构建流程。

流程图4 从processModuleDependencies开始的module创建流程

流程图5中,_this.buildModule方法中进行了module构建,构建时会先迭代所有的loader,并按索引由低到高依次执行所有的pitchLoader(步骤2-1),然后按索引由高到低并依次执行所有的normalLoader(步骤2-2),这些loader对源文件进行预处理,生成预处理代码。源文件E:\hello-vue-cli\src\App.vue的代码如Demo14,loader为E:\hello-vue-cli\node_modules\vue-loader\index.js??ref--0,loader预处理后的代码如Demo15。如流程图5步骤3,this.doBuild的回调函数中调用this.parser.parse()方法,将预处理代码赋予module对象的_source._value属性中,解析预处理代码中的dependency并赋予到module对象的dependencies属性中。经过步骤3处理后,rawRequest为”./App“的module的dependencies如图1。在_this.buildModule方法执行完后,调用callback函数,callback函数的调用从流程图5中的”callback函数回调1“开始,直到”callback回调函数6的函数体“结束。在”callback回调函数6的函数体“中,又调用了processModuleDependencies方法,processModuleDependencies是递归调用的,直到无法由dependency创建module或module下没有dependency时递归终止。

流程图5 从_this.buildModule开始的module构建流程

图15中rawRequest为”./App“的module下索引为8的dependency的request是!!../node_modules/vue-loader/lib/template-compiler/index?{"id":"data-v-6cbc4d12","hasScoped":false,"transformToRequire":{"video":["src","poster"],"source":"src","img":"src","image":"xlink:href"},"buble":{"transforms":{}}}!../node_modules/vue-loader/lib/selector?type=template&index=0!./App.vue,该dependency表示"./App.vue"文件的template部分。该dependency的module构建流程与request为”./App“的NormalModule的构建流程相似,如流程图4和5。该dependency的源代码也是E:\hello-vue-cli\src\App.vue,先后执行loader函数E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=template&index=0E:\hello-vue-cli\node_modules\vue-loader\lib\template-compiler\index.js;然后经过buildModule后,生成的预处理代码如Demo16。Demo16中已经将template处理为render函数;在html中直接引入vue.js的案例[20]中,将template处理为render函数是在浏览器加载的时候才执行的。在打包的时候就将template处理为render函数,减少了浏览器的性能开销。

<template>
  <div id="app">
    <img src="./assets/logo.png">
    <HelloWorld/>
    <button v-on:click="handleClick"></button>
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld'
export default {
  name: 'App',
  components: {
    HelloWorld
  },
  methods: {
    handleClick() {
        alert(1)
    }
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Demo14 源文件E:\hello-vue-cli\src\App.vue的代码

function injectStyle (ssrContext) {
  require("!!../node_modules/extract-text-webpack-plugin/dist/loader.js?{\"omit\":1,\"remove\":true}!vue-style-loader!css-loader?{\"sourceMap\":true}!../node_modules/vue-loader/lib/style-compiler/index?{\"vue\":true,\"id\":\"data-v-6cbc4d12\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector?type=styles&index=0!./App.vue")
}
var normalizeComponent = require("!../node_modules/vue-loader/lib/component-normalizer")
/* script */
export * from "!!babel-loader!../node_modules/vue-loader/lib/selector?type=script&index=0!./App.vue"
import __vue_script__ from "!!babel-loader!../node_modules/vue-loader/lib/selector?type=script&index=0!./App.vue"
/* template */
import __vue_template__ from "!!../node_modules/vue-loader/lib/template-compiler/index?{\"id\":\"data-v-6cbc4d12\",\"hasScoped\":false,\"transformToRequire\":{\"video\":[\"src\",\"poster\"],\"source\":\"src\",\"img\":\"src\",\"image\":\"xlink:href\"},\"buble\":{\"transforms\":{}}}!../node_modules/vue-loader/lib/selector?type=template&index=0!./App.vue"
/* template functional */
var __vue_template_functional__ = false
/* styles */
var __vue_styles__ = injectStyle
/* scopeId */
var __vue_scopeId__ = null
/* moduleIdentifier (server only) */
var __vue_module_identifier__ = null
var Component = normalizeComponent(
  __vue_script__,
  __vue_template__,
  __vue_template_functional__,
  __vue_styles__,
  __vue_scopeId__,
  __vue_module_identifier__
)

export default Component.exports

Demo15 源文件App.vue经过vue-loader/index.js处理后的代码

var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{attrs:{"id":"app"}},[_c('img',{attrs:{"src":require("./assets/logo.png")}}),_vm._v(" "),_c('HelloWorld'),_vm._v(" "),_c('button',{on:{"click":_vm.handleClick}})],1)}
var staticRenderFns = []
var esExports = { render: render, staticRenderFns: staticRenderFns }
export default esExports

Demo16 源文件App.vue经过vue-loader\lib\selector.js?type=template&index=0vue-loader\lib\template-compiler\index.js处理后的代码

3.2.1.3 this.chunks(1)

如流程图2①,在函数this.processDependenciesBlocksForChunks执行完后,将this.preparedChunks中所有dependecies下的module抽取到this.chunks中,共13个module,如图18。这13个module的request分别如图19所示。这里有个疑问,this.chunks中的索引为12的module的含义是什么,暂未在dependecies下找到该module。

图18 流程图2①步骤执行后的this.chunks

n _modules[n].request
0 E:\hello-vue-cli\node_modules\babel-loader\lib\index.js!E:\hello-vue-cli\src\main.js
1 E:\hello-vue-cli\node_modules\vue\dist\vue.esm.js
2 E:\hello-vue-cli\node_modules\vue-loader\index.js??ref--0!E:\hello-vue-cli\src\App.vue
3 E:\hello-vue-cli\node_modules\extract-text-webpack-plugin\dist\loader.js?{"omit":1,"remove":true}!E:\hello-vue-cli\node_modules\vue-style-loader\index.js!E:\hello-vue-cli\node_modules\css-loader\index.js?{"sourceMap":true}!E:\hello-vue-cli\node_modules\vue-loader\lib\style-compiler\index.js?{"vue":true,"id":"data-v-6cbc4d12","scoped":false,"hasInlineConfig":false}!E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=styles&index=0!E:\hello-vue-cli\src\App.vue
4 E:\hello-vue-cli\node_modules\vue-loader\lib\component-normalizer.js
5 E:\hello-vue-cli\node_modules\babel-loader\lib\index.js!E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=script&index=0!E:\hello-vue-cli\src\App.vue
6 E:\hello-vue-cli\node_modules\vue-loader\lib\template-compiler\index.js?{"id":"data-v-6cbc4d12","hasScoped":false,"transformToRequire":{"video":["src","poster"],"source":"src","img":"src","image":"xlink:href"},"buble":{"transforms":{}}}!E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=template&index=0!E:\hello-vue-cli\src\App.vue
7 E:\hello-vue-cli\node_modules\url-loader\index.js??ref--2!E:\hello-vue-cli\src\assets\logo.png
8 E:\hello-vue-cli\node_modules\vue-loader\index.js??ref--0!E:\hello-vue-cli\src\components\HelloWorld.vue
9 E:\hello-vue-cli\node_modules\extract-text-webpack-plugin\dist\loader.js?{"omit":1,"remove":true}!E:\hello-vue-cli\node_modules\vue-style-loader\index.js!E:\hello-vue-cli\node_modules\css-loader\index.js?{"sourceMap":true}!E:\hello-vue-cli\node_modules\vue-loader\lib\style-compiler\index.js?{"vue":true,"id":"data-v-d8ec41bc","scoped":true,"hasInlineConfig":false}!E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=styles&index=0!E:\hello-vue-cli\src\components\HelloWorld.vue
10 E:\hello-vue-cli\node_modules\babel-loader\lib\index.js!E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=script&index=0!E:\hello-vue-cli\src\components\HelloWorld.vue
11 E:\hello-vue-cli\node_modules\vue-loader\lib\template-compiler\index.js?{"id":"data-v-d8ec41bc","hasScoped":true,"transformToRequire":{"video":["src","poster"],"source":"src","img":"src","image":"xlink:href"},"buble":{"transforms":{}}}!E:\hello-vue-cli\node_modules\vue-loader\lib\selector.js?type=template&index=0!E:\hello-vue-cli\src\components\HelloWorld.vue
12 E:\hello-vue-cli\node_modules\webpack\buildin\global.js

图19 流程图2①步骤执行后的this.chunks中的13个module

3.2.1.4 this.chunks(2)

如流程图2②,在函数"optimize-chunks-basic"、"optimize-chunks"和"optimize-chunks-advanced"执行完后,将this.chunks(1)中的13个module分到app、vendor和manifest等3个chunk中,如图20。其中,app的chunk下有10个module(如图21),vendor的chunk下有3个module,manifest的chunk下有0个module,它们与this.chunks(1)中module的对应关系如图22所示。

图20 流程图2①步骤执行后的this.chunks

图21 流程图2①步骤执行后的this.chunks

Chunk.name 集合大小:对应this.chunks(1)中_module元素的序号
app set(10): 2,3,5-11
vendor set(3): 1,4,12
manifest set(0)

图22 流程图2①步骤执行后的this.chunks与执行前的this.chunks的对比

3.2.1.5 this.chunks(3)

如流程图2③,在函数"optimize-chunk-modules-basic"、"optimize-chunk-modules"和"optimize-chunk-modules-advanced"执行完后,3.2.1.4节this.chunks(2)中app的chunk下生成3个NormalModule和1个ConcatenatedModule,如图23和图24。ConcatenatedModule下的dependencies个数与3.2.1.2节this.preparedchunks中的dependencies总数一致,为40个,如图25。ConcatenatedModule翻译为级联模块,在3.2.1.6节中,它将根据代码生成的module捆绑到app.js(app.[chunkhash].js的简写)的ConcatSource中,ConcatSource中会引用vendor.js(vendor.[chunkhash].js的简写)和manifest.js(manifest.[chunkhash].js的简写)的ConcatSource中的模块。所以级联模块下的dependencies是所有的dependencies。

图23 流程图2③步骤执行后的this.chunks

图24 流程图2③步骤执行后的this.chunks

图25 流程图2③步骤执行后的this.chunks

图26 流程图2③步骤执行后的this.chunks

ConcatenatedModule下索引为31的dependency的importDependency.request为"!!babel-loader!../node_modules/vue-loader/lib/selector?type=script&index=0!./App.vue",对应的importDependency.module._source._value的值如Demo17,Demo17中引入的“./components/HelloWorld”对应的是ConcatenatedModule下索引为19的dependency,如图26。索引为19的dependency的importDependency.module._source._value信息如Demo18。在第3.2.1.6节和第3.2.1.7节中将会跟踪这2个dependency值的变化。

import HelloWorld from './components/HelloWorld';
export default {
  name: 'App',
  components: {
    HelloWorld: HelloWorld
  },
  methods: {
    handleClick: function handleClick() {
      alert(1);
    }
  }
};

Demo17 ConcatenatedModule下索引为31的dependency的importDependency.module._source._value

function injectStyle (ssrContext) {
  require("!!../../node_modules/extract-text-webpack-plugin/dist/loader.js?{\"omit\":1,\"remove\":true}!vue-style-loader!css-loader?{\"sourceMap\":true}!../../node_modules/vue-loader/lib/style-compiler/index?{\"vue\":true,\"id\":\"data-v-d8ec41bc\",\"scoped\":true,\"hasInlineConfig\":false}!../../node_modules/vue-loader/lib/selector?type=styles&index=0!./HelloWorld.vue")
}
var normalizeComponent = require("!../../node_modules/vue-loader/lib/component-normalizer")
/* script */
export * from "!!babel-loader!../../node_modules/vue-loader/lib/selector?type=script&index=0!./HelloWorld.vue"
import __vue_script__ from "!!babel-loader!../../node_modules/vue-loader/lib/selector?type=script&index=0!./HelloWorld.vue"
/* template */
import __vue_template__ from "!!../../node_modules/vue-loader/lib/template-compiler/index?{\"id\":\"data-v-d8ec41bc\",\"hasScoped\":true,\"transformToRequire\":{\"video\":[\"src\",\"poster\"],\"source\":\"src\",\"img\":\"src\",\"image\":\"xlink:href\"},\"buble\":{\"transforms\":{}}}!../../node_modules/vue-loader/lib/selector?type=template&index=0!./HelloWorld.vue"
/* template functional */
var __vue_template_functional__ = false
/* styles */
var __vue_styles__ = injectStyle
/* scopeId */
var __vue_scopeId__ = "data-v-d8ec41bc"
/* moduleIdentifier (server only) */
var __vue_module_identifier__ = null
var Component = normalizeComponent(
  __vue_script__,
  __vue_template__,
  __vue_template_functional__,
  __vue_styles__,
  __vue_scopeId__,
  __vue_module_identifier__
)

export default Component.exports

Demo18 ConcatenatedModule下索引为19的dependency的importDependency.module._source._value

3.2.1.6 this.assets(1)

如流程图2④,在compilation.createChunkAssets时,由app、manifest和vendor等chunk分别生成ConcatSource,并添加到this.assets中。this.assets的值如图27所示。app的chunk进行模块捆绑后,生成的this.assets下的键为app.js的值CachedSource的_source.children是一个长度为42的数组,如图28。数组中索引为25的元素如图29,它的注释是索引为24的元素// CONCATENATED MODULE: ./node_modules/babel-loader/lib!./node_modules/vue-loader/lib/selector.js?type=script&index=0!./src/App.vue

图27 流程图2④步骤执行后的this.assets

图28 键为app.js的值CachedSource的_source.children

图29 _source.children数组中索引为25的元素

图30 Demo19中__WEBPACK_MODULE_REFERENCE__3_64656661756c74__表示引入的是数组中的第3个Concatenated Module

app.js下第25个元素的值如Demo19,其中__WEBPACK_MODULE_REFERENCE__3_64656661756c74__表示引入的是app.js中的第3个Concatenated Module,如图30。相比于3.2.1.5节ConcatenatedModule下的第31个元素,app.js下的第25个元素及其引入的模块都被打包进了同一个ConcatSource中,模块引用由原先的文件间通过import引用变成了ConcatSource内通过变量引用。

第3个Concatenated Module的值如Demo20。它对应于3.2.1.5节中ConcatenatedModule下第19个元素。相比而言,第3.2.1.5节中的var normalizeComponent = require("!../../node_modules/vue-loader/lib/component-normalizer")变成了var normalizeComponent = __webpack_require__("VU/8"),对component-normalizer模块的引用由模块间的require引用变成了同一ConcatSource内的__webpack_require__引用。__webpack_require__的参数VU/8是模块component-normalizer的ID,该模块位于vendor.js中,后续流程中app.js和vendor.js会被同一个index.html通过<script>标签引入,所以对component-normalizer的引用可以看成是同一文件内的引用。Demo20中的其它细节可参考3.2.1.8节打包输出的完整文件。

/* harmony default export */ var __WEBPACK_MODULE_DEFAULT_EXPORT__ = ({
  name: 'App',
  components: {
    HelloWorld: __WEBPACK_MODULE_REFERENCE__3_64656661756c74__
  },
  methods: {
    handleClick: function handleClick() {
      alert(1);
    }
  }
});

Demo19 _source.children数组中索引为25的元素

function injectStyle (ssrContext) {
  __webpack_require__("1uuo")
}
var normalizeComponent = __webpack_require__("VU/8")
/* script */


/* template */

/* template functional */
var __vue_template_functional__ = false
/* styles */
var __vue_styles__ = injectStyle
/* scopeId */
var __vue_scopeId__ = "data-v-d8ec41bc"
/* moduleIdentifier (server only) */
var __vue_module_identifier__ = null
var Component = normalizeComponent(
  __WEBPACK_MODULE_REFERENCE__1_64656661756c74__,
  __WEBPACK_MODULE_REFERENCE__2_64656661756c74__,
  __vue_template_functional__,
  __vue_styles__,
  __vue_scopeId__,
  __vue_module_identifier__
)

/* harmony default export */ var __WEBPACK_MODULE_DEFAULT_EXPORT__ = (Component.exports);

Demo20 _source.children数组中索引为23的元素(第3个Concatenated Module)

3.2.1.7 this.assets(2)

如流程图2⑤,在函数after-optimize-chunk-assets执行后,compilation.assets的值如图31所示。图31中除了app.js、manifest.js和vendor.js,还生成了对应的.map文件,.map文件是用于源代码调试的(详见5.1节)。如图32,3.2.1.6节中ConcatSource下的元素被合并到了同一个RawSource中,RawSource的值如Demo21所示,其中模块的变量名称相比于3.2.1.6节有修改,比如helloworld模块的变量名称由__WEBPACK_MODULE_REFERENCE__3_64656661756c74__修改为src_components_HelloWorld

图31 流程图2⑤步骤执行后的this.assets

图32 ConcatSource下的元素被合并到了同一个RawSource中

/* harmony default export */ var App = ({
  name: 'App',
  components: {
    HelloWorld: src_components_HelloWorld
  },
  methods: {
    handleClick: function handleClick() {
      alert(1);
    }
  }
});

function injectStyle (ssrContext) {
  __webpack_require__("1uuo")
}

var normalizeComponent = __webpack_require__("VU/8")

/* template functional */
var __vue_template_functional__ = false
/* styles */
var __vue_styles__ = injectStyle
/* scopeId */
var __vue_scopeId__ = "data-v-d8ec41bc"
/* moduleIdentifier (server only) */
var __vue_module_identifier__ = null
var Component = normalizeComponent(
  HelloWorld,
  components_HelloWorld,
  __vue_template_functional__,
  __vue_styles__,
  __vue_scopeId__,
  __vue_module_identifier__
)

/* harmony default export */ var src_components_HelloWorld = (Component.exports);

Demo21 app.js下的RawSource的值

3.2.1.8 targetPath(根据outputPath生成)

如流程图2⑥,打包后文件会输出到targetPath(根据outputPath生成)下。如图33,打包后文件app.js输出目录targetPath为/dist/static/js。文件的输出目录是在配置文件中配置的,详见5.6节。打包完成后查看项目的目录dist/static/js,目录下包括app.js、manifest.js和vendor.js,如图34。如果未配置html-webpack-plugin,则需要在输出目录下手动添加如Demo22的index.html。将目录下的文件部署到服务器上,可以正常访问。当index.html加载时,会依次加载manifest.js、vendor.js和app.js文件。如Demo23,app.js文件以调用函数webpackJsonp开头,该函数是manifest.js文件中的。webpackJsonp函数执行时会调用第2个参数中”NHnr“属性下的匿名函数,该匿名函数的最后通过vue_esm创建了vue实例,其中vue_esm是通过函数__webpack_require__输入的模块”7+uW“是vendor.js中的。可以在目标目录下app.js、manifest.js和vendor.js中查看更多细节。

图33 流程图2⑥步骤中的targetPath

图34 打包完成后的目录dist/static/js

<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>myproject</title><link href=/hello/static/css/app.30790115300ab27614ce176899523b62.css rel=stylesheet></head><body><div id=app></div><script type=text/javascript src=/hello/static/js/manifest.16c23ba6bc6bb0b6d86e.js></script><script type=text/javascript src=/hello/static/js/vendor.a174ea6c2bddc077ea39.js></script><script type=text/javascript src=/hello/static/js/app.fb1b7cbbeb63fa1c2bfb.js></script></body></html>

Demo22 手动配置index.html

webpackJsonp([10],{
    "1uuo": (function(module, exports) {...},
    "7Otq": (function(module, exports) {...},
    "NHnr": (function(module, __webpack_exports__, __webpack_require__) {
        // EXTERNAL MODULE: ./node_modules/vue/dist/vue.esm.js
		var vue_esm = __webpack_require__("7+uW");
        new vue_esm["a" /* default */]({
          el: '#app',
          components: { App: src_App },
          template: '<App/>'
        });
    })
},["NHnr"]  
}

Demo23 目标目录下的文件app.js

3.2.2 loader

webpack打包时,会从入口文件开始构建依赖关系图,构建依赖关系图时会根据dependency生成module。如3.2.1.2节,由dependency生成module通常有3个步骤,一是根据源文件(resource)扩展名过滤出需要的loader;如果request中已包含前置loader,则使用前置loader;二是按元素索引从小到大依次执行loaders中的pitchingLoaders,按元素索引从大到小依次执行loaders中的NormalLoader,对源文件进行预处理;三是解析经过loader预处理后的代码,生成module及module下的dependencies。loader允许在根据dependency生成module时,对源文件进行预处理。可以在配置文件中配置loader,比如Demo24中,在rules分别配置了vue-loader、babel-loader和url-loader等3个loader。配置好loader后,webpack打包时,会根据文件扩展名获取需要的loader,loader会对源文件进行预处理。下面分别介绍vue-loader、babel-loader和url-loader是如何预处理的。

module.exports = {
    module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('img/[name].[hash:7].[ext]')
        }
      }
}

Demo24 配置文件webpack.base.conf.js

3.2.2.1 vue-loader

vue-loader会加载和编译vue组件。如3.2.1.2节,在由request为"./App"的dependency生成module时,源文件为E:\hello-vue-cli\src\App.vue,如Demo14;loader为E:\hello-vue-cli\node_modules\vue-loader\index.js??ref--0;loader预处理后的代码如Demo15所示。

如3.2.1.2节,rawRequest为"./App"的module下有13个dependencies。其中,索引为1的dependecy比较特殊,它的request为!!../node_modules/extract-text-webpack-plugin/dist/loader.js?{"omit":1,"remove":true}!vue-style-loader!css-loader?{"sourceMap":true}!../node_modules/vue-loader/lib/style-compiler/index?{"vue":true,"id":"data-v-6cbc4d12","scoped":false,"hasInlineConfig":false}!../node_modules/vue-loader/lib/selector?type=styles&index=0!./App.vue;第一个loader是插件extract-text-webpack-plugin下的函数。在processModuleDependecy中会为该dependency创建和构建module,loader预处理生成的代码如Demo25,这是由于没有配置extract-text-webpack-plugin导致的,未配置则Demo26中的this[NS]为undefined。如果配置了extract-text-webpack-plugin,则会注册函数“normal-module-loader”,函数中为loaderContext[NS]赋值,如Demo27。该函数在流程图5的doBuild方法中调用this.createLoaderContext时执行,在执行后续../node_modules/extract-text-webpack-plugin/dist/loader.js中的pitch函数(如Demo26)时,this[NS]有值,不再抛出异常。此时,打包生成的目标目录dist下,css文件将作为独立的文件,如图35。

throw new Error("Module build failed: Error: \"extract-text-webpack-plugin\" loader is used without the corresponding plugin, refer to https://github.com/webpack/extract-text-webpack-plugin for the usage example\n    at Object.pitch (E:\\history_bak\\code\\32_mycode_hundsun\\08_code\\03_hello-vue-cli\\hello-vue-cli\\node_modules\\extract-text-webpack-plugin\\dist\\loader.js:57:11)");

Demo25 loader预处理生成的代码

//文件所属目录: hello-vue-cli\node_modules\extract-text-webpack-plugin\dist\loader.js

function pitch(request) {
  var _this = this;

  var query = _loaderUtils2.default.getOptions(this) || {};
  var loaders = this.loaders.slice(this.loaderIndex + 1);
  this.addDependency(this.resourcePath);
  // We already in child compiler, return empty bundle
  if (this[NS] === undefined) {
    // eslint-disable-line no-undefined
    throw new Error('"extract-text-webpack-plugin" loader is used without the corresponding plugin, ' + 'refer to https://github.com/webpack/extract-text-webpack-plugin for the usage example');
  }
}

Demo26 extract-text-webpack-plugin模块中文件dist\loader.js

//文件所属目录: hello-vue-cli\node_modules\extract-text-webpack-plugin\dist\index.js

var ExtractTextPlugin = function () {
  _createClass(ExtractTextPlugin, [{
    key: 'apply',
    value: function apply(compiler) {
      var _this3 = this;

      var options = this.options;
      compiler.plugin('this-compilation', function (compilation) {
        var extractCompilation = new _ExtractTextPluginCompilation2.default();
        compilation.plugin('normal-module-loader', function (loaderContext, module) {
          //为loaderContext[NS]赋值
          loaderContext[NS] = function (content, opt) {
            if (options.disable) {
              return false;
            }
            if (!Array.isArray(content) && content != null) {
              throw new Error(`Exported value was not extracted as an array: ${JSON.stringify(content)}`);
            }
            module[NS] = {
              content,
              options: opt || {}
            };
            return options.allChunks || module[`${NS}/extract`]; // eslint-disable-line no-path-concat
          };
        });
      }
  },...]
}

Demo27 extract-text-webpack-plugin模块中文件dist\index.js

图35 打包生成的目标目录下,css文件将作为独立的文件

如果不想将css打包成单独的css文件,就不需要配置extract-text-webpack-plugin。此时,vue-loader中也应删除cssLoader的相关配置。cssLoader的相关配置删除后rawRequest为"./App"的module下索引为1的dependecy,在创建和构建module时,loader预处理生成的代码如Demo28所示;而不会生成如Demo25所示的错误信息。

/ style-loader: Adds some css to the DOM by adding a <style> tag

// load the styles
var content = require("!!../node_modules/css-loader/index.js?minimize!../node_modules/vue-loader/lib/style-compiler/index.js?{\"vue\":true,\"id\":\"data-v-6cbc4d12\",\"scoped\":false,\"hasInlineConfig\":false}!../node_modules/vue-loader/lib/selector.js?type=styles&index=0!./App.vue");
if(typeof content === 'string') content = [[module.id, content, '']];
if(content.locals) module.exports = content.locals;
// add the styles to the DOM
var update = require("!../node_modules/vue-style-loader/lib/addStylesClient.js")("9ffd9934", content, true, {});

Demo28 删除cssLoader的相关配置后,loader预处理生成的代码(与Demo25对比)

3.2.2.2 babel-loader

babel-loader允许使用webpack和babel对js文件进行转译[21],babel会将ES6格式的代码转为ES5的。在由入口文件"./src/main.js"创建和构建module时,module的request为E:\hello-vue-cli\node_modules\babel-loader\lib\index.js!E:\hello-vue-cli\src\main.js,源文件main.js代码如Demo29,loader预处理后的代码如Demo30,main.js文件的内容并没有变化。babel-loader还有很多其它的功能[22]

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'

Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  el: '#app',
  components: {App},
  template: '<App/>'
})

Demo29 源文件src\main.js

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue';
import App from './App';

Vue.config.productionTip = false;

/* eslint-disable no-new */
new Vue({
  el: '#app',
  components: { App: App },
  template: '<App/>'
});

Demo30 loader预处理后的代码

3.2.2.3 url-loader

url-loader将文件转换为base64编码的URI(统一资源标识符)。如3.2.1.2节,在由rawRequest为"./App"的dependency生成module后,还会为module的dependency生成module。"./assets/logo.png"是rawRequest为"./App"的module的一个dependency,在以此dependency生成module时,module的request为 E:\hello-vue-cli\node_modules\url-loader\index.js??ref--2!E:\hello-vue-cli\src\assets\logo.png,源文件为E:\hello-vue-cli\src\assets\logo.png,loader预处理后的代码如Demo31。

module.exports = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyNpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8....../ij1GoQRpWaxb4HcKJUhL4GW2XTN8vst+p1CCtDw+Oc6Y6/hEoQRpCRxm23rcv7fazxRKEIXFXZRuwBDZvxUC4GsIREHflguDkyQqaVYotIulUChBFAoliEKhBFEolCAKhRJEoVCCKBRKEIVCCaJQKJQgCoUSRKFQgigUShCFIhP8vwADACog5YM65zugAAAAAElFTkSuQmCC"

Demo31 loader预处理后的代码(base64字符串有省略)

3.2.2.4 手写一个loader[23]

在了解完loader的原理和常见的loader以后,下面尝试手写一个loader。新建一个wepack-demo项目并安装webpack[24],手写并测试loader主要分4步:

  • 新建一个loader。新建一个js文件,以”xxxLoader.js“格式命名;
  • 新建用于测试的源文件;
  • 在webpack的配置文件中配置新建的loader;
  • 运行webpack,对比loader预处理后的文件和源文件的不同;

Demo32是新建的loader文件ReplaceLoader.js,Demo33是新建的源文件index.js,Demo34是新建的webpack配置文件webpack.conf.js。运行npx webpack,打包生成的文件main.js如Demo35所示。对比源文件和目标文件,显然源文件经过loader预处理后得到目标文件。

module.exports = function (source) {
    const handleContent = source.replace('框架', 'vue').replace('JS', 'JavaScript')
    return handleContent
}

Demo32 新建的loader文件ReplaceLoader.js

function print() {
    console.log('输出:JS, JavaScript')
}

Demo33 新建的源文件index.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: './src/loaders/replaceLoader.js',
      }
    ],
  }
};

Demo34 在webpack.conf.js中配置loader

console.log("输出:学习vue, 深入理解JavaScript");

Demo35 webpack打包后目标目录dist下的文件main.js

3.2.3 plugin

webpack中大量使用plugin。plugin通常有一个apply方法,在apply方法中注册了一系列函数。webpack通过plugin中的apply方法注册函数,根据函数注册名称动态调用已注册的函数。wepback中已经定义了很多plugin,可以通过在配置文件中配置更多的plugin。当配置plugin后,在webpack打包流程中,会注册更多的函数,也会根据注册名称动态调用到更多的函数,webpack功能得到增强,比如发布物管理和代码优化等。下面以vue-cli项目的配置文件的htmlplugin、cassplugin和uglifyplugin等插件,具体介绍plugin是如何对webpack的功能进行增强的。

3.2.3.1 html-webpack-plugin

当配置了HtmlWebpackPlugin后,系统将会自动在index.html插入src属性为生成的assets中js文件的script标签,并将index.html添加到assets中。打包完毕后,在目标目录下将包含index.html文件。目标目录下的文件可直接部署在服务器上,部署后页面可正常访问。如果不配置HtmlWebpackPlugin,你需要手动添加index.html文件。

HtmlWebpackPlugin的apply方法中注册了"make"和"emit"函数。webpack运行时HtmlWebpackPlugin的apply方法在compiler.run前被调用,完成插件中"make"和"emit"函数的注册,如Demo36。当未配置HtmlWebpackPlugin时,webpack的运行流程可简单表示为流程图6(1),入口文件是”src/main.js“,运行时会调用已注册的”make“和”emit“函数,最后生成的compilation.assets包含app.js、manifest.js和vendor.js三个文件。当配置HtmlWebpackPlugin后,webpack的运行流程可简单表示为流程图6(2),webpack在运行时除了调用webpack中的”make“和”emit“函数,还会调用HtmlWebpackPlugin中的”make“和”emit“函数。

如Demo37,在HtmlWebpackPlugin的”make“函数中,创建childCompiler,运行childCompiler的compile方法。在childCompiler的compile方法中,以entry为”index.html“进行了childCompiler的”make“函数的调用,childCompiler的”make“函数的调用与compiler基本相同,”make“函数中会构建和生成module,在回调函数中调用seal方法,根据module生成childCompiler.compilation的assets。在HtmlWebpackPlugin的”emit“函数中,以compilationPromise开始,compilationPromise的成功回调参数是childCompiler.compilation的assets,以此assets生成原始的index.html,然后根据compilation.assets向index.html插入app、manifest和vendor的script标签,并将修改后的index.html添加到compilation.assets中。打包完毕后,在目标目录下将包含index.html文件,如图36。

流程图6 未配置和配置HtmlWebpackPlugin时,webpack的运行流程的简单示意图

//文件所属路径:\hello-vue-cli\node_modules\webpack\lib\webpack.js

function webpack(options, callback) {
    if(Array.isArray(options)) {
    } else if(typeof options === "object") {
        if(options.plugins && Array.isArray(options.plugins)) {
            //调用配置文件中配置的plugin的apply方法,进行函数注册
            compiler.apply.apply(compiler, options.plugins);
        }
    }
    if(callback) {
        compiler.run(callback);
    }
}

Demo36 文件webpack\lib\webpack.js

//文件所属目录:\hello-vue-cli\node_modules\html-webpack-plugin\index.js

HtmlWebpackPlugin.prototype.apply = function (compiler) {
    var compilationPromise;
    compiler.plugin('make', function (compilation, callback) {
        //compilationPromise是一个Promise,Promise成功回调参数为由index.html生成的assets
        compilationPromise = {}
    }
                    
    compiler.plugin('emit', function (compilation, callback) {
        Promise.resolve()
        .then(function () {
        	return compilationPromise;
        })
        //根据assets生成原始的index.html
        .then(...)
        .then(function (result) {
            //在index.html插入app、manifest和vendor的script标签
        })
        .then(function (html) {
            // 将修改后的index.html添加到compilation.assets中
            compilation.assets[self.childCompilationOutputName] = {
              source: function () {
                return html;
              },
              size: function () {
                return html.length;
              }
        	};
        )
      })
    }              
}

Demo37 文件html-webpack-plugin\index.js

图36 目标目录下包含index.html文件

3.2.3.2 extract-text-webpack-plugin

如3.2.2.1节,extract-text-webpack-plugin需要和vue-loader配合使用。当配置了extract-text-webpack-plugin后,还需要在vue-loader中配置cssLoader。配置好后,打包生成的目标目录下,css文件将作为独立的文件,如3.2.2.1节。

3.2.2.1节中extract-text-webpack-plugin中的文件../node_modules/extract-text-webpack-plugin/dist/loader.js作为loader使用。loader是在由源文件创建和构建module时,对源文件进行预处理的函数,这个函数可以是plugin中定义的。plugin在apply方法中注册函数,webpack运行时根据名称会动态调用调用到这些函数。plugin的apply方法中注册的不同名称的函数,会在wepack运行的不同阶段调用,而loader中定义的函数只在对源文件进行预处理时执行。

3.2.3.3. uglifyjs-webpack-plugin

uglifyjs-webpack-plugin使用ugligy-js来缩小js文件[25]。当配置uglifyjs-webpack-plugin后,hello-vue-cli项目打包输出的js文件为app.js,manifest.js和vendor.js。相比于3.2.3.2节未配置uglifyjs-webpack-plugin时输出的js文件,进行了代码缩小。

uglifyjs-webpack-plugin还具有tree-shaking的功能[26],会删除多余的代码。

3.2.3.4 手写一个plugin[27]

在了解完plugin的原理和常见的plugin以后,下面尝试手写一个plugin。在3.4.2.4的项目基础上添加plugin文件和相应配置。手写并测试plugin主要分3步:

  • 新建一个plugin。新建一个js文件,以”xxxPlugin.js“格式命名;
  • 在webpack的配置文件中配置新建的loader;
  • 运行webpack,对比添加plugin后webpack打包后的文件有何变化;

Demo38是新建的插件CopyrightWebpackPlugin,webpack配置如Demo39。在compiler的钩子函数emit中,向compilation.assets中添加了copyright.txt文件,然后调用callback函数。在callback函数中,webpack将compilation.assets写入到目标目录下。在webpack.conf.js中添加plugin的配置,重新打包后目标目录下将包含copyright.txt文件,如图37。copyright.txt文件内容如Demo40。

class CopyrightWebpackPlugin {
  apply(compiler) {
    // webpack4及以前,使用方式一注册
    // 方式一:compiler.plugin("emit",(compilation, callback) => {
    // webpack5使用方式二注册
    // 方式二:emit 是一个异步串行钩子,需要用 tapAsync 来注册
    compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, callback) => {
      // 回调方式注册异步钩子
      const copyrightText = '版权归 风吹草 所有'
      compilation.assets['copyright.txt'] = {
        source: function () {
          return copyrightText
        },
        size: function () {
          return copyrightText.length
        },
      }
      callback() // 必须调用
    })
  }
}

module.exports = CopyrightWebpackPlugin

Demo38 新建的插件CopyrightWebpackPlugin

const path = require('path');
const CopyrightWebpackPlugin = require('./src/plugins/copyright-webpack-plugin')

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: './src/loaders/replaceLoader.js',
      }
    ],
  }
  plugins: [new CopyrightWebpackPlugin()]
};

Demo39 在webpack.conf.js中配置plugin

版权归 风吹草 所有

Demo40 copyright.txt文件内容

图37 目标目录下包含copyright.txt文件

3.3 webpack配置详解[28]

使用npm run build构建生产包的时候,使用的webpack配置文件为webpack.base.conf.js和webpack.prod.conf.js。webpack.base.conf.js如Demo41,context表示解析配置中的入口点和loader的基础目录,如果不设置context,默认的基础目录是执行webpack命令时所在的目录。entry 是webpack执行模块捆绑的入口点。entry的值为数组时表示入口点有多个[29]output中可以配置发布物如何输出和输出到哪。output.publicPath应与应用在服务器上被访问的目录一致(详见5.6节)。output.filename表示发布物的文件名,[name].js表示使用entry的name作为文件名。resolve.extensions表示在import时可以省略文件的扩展名,比如"import File from '../path/to/file;",这时resolve.extensions的值为['.js', '.vue', '.json'],webpack会搜索名称为file且扩展名包含在数组['.js', '.vue', '.json']中的文件。如果多个文件同名但扩展名不同,webpack会以数组中第一个可以搜索到文件的扩展名为准。resolve.alias表示为路径起别名,使得import或require具体的模块变得简单。比如为常用的路径 vue/dist/vue.esm.js起别名vue$后,就可以用import node_modules/vue$代替import node_modules/vue/dist/vue.esm.jsmodule.rules表示在创建module时,匹配request的一系列规则。如3.2.1.2节,由文件生成module时包含匹配loader、执行loader和执行parser等3个步骤。可以在module.rules中添加loader或修改parser来修改module的创建。在rules中可以配置多个rule。rule.test指匹配的文件类型,rule.include表示要匹配的文件所在的文件夹,它们都表示rule要处理的资源(rule condition)[30]rule.loaderRule.use的别名,指使用什么loader处理匹配的资源,rule.options是loader的参数,它们都表示rule如何处理匹配的资源(rule results)[31]。Demo1中配置了vue-loader、babel-loader和url-loader,它们的作用详见3.2.2节。node配置的含义暂时没看明白。

'use strict'
const path = require('path')
const utils = require('./utils')
const config = require('../config')
const vueLoaderConfig = require('./vue-loader.conf')

function resolve (dir) {
  return path.join(__dirname, '..', dir)
}

module.exports = {
  //解析配置中的入口文件和loader的基础目录
  context: path.resolve(__dirname, '../'),
  //webpack执行模块捆绑的入口点
  entry: {
    app: './src/main.js'
  },
  output: {
    //发布物的输出路径
    path: config.build.assetsRoot,
    //发布物的文件名,[name].js表示使用entry的name作为文件名
    filename: '[name].js',
    //应用在服务器上被访问的目录
    publicPath: process.env.NODE_ENV === 'production'
      ? config.build.assetsPublicPath
      : config.dev.assetsPublicPath
  },
  resolve: {
    //解析import时可以省略的扩展名
    extensions: ['.js', '.vue', '.json'],
    //为路径起别名,import或require具体的模块可以使用别名
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '@': resolve('src'),
    }
  },
  module: {
    rules: [
      {
        test: /\.vue$/,  //匹配的文件类型
        loader: 'vue-loader', //使用vue-loader处理匹配的资源
        options: vueLoaderConfig //处理匹配的资源时的参数
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        //只处理以下文件夹中的文件
        include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('img/[name].[hash:7].[ext]')
        }
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('media/[name].[hash:7].[ext]')
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
        }
      }
    ]
  },
  //node配置的含义暂时没看明白
  node: {
    // prevent webpack from injecting useless setImmediate polyfill because Vue
    // source contains it (although only uses it if it's native).
    setImmediate: false,
    // prevent webpack from injecting mocks to Node native modules
    // that does not make sense for the client
    dgram: 'empty',
    fs: 'empty',
    net: 'empty',
    tls: 'empty',
    child_process: 'empty'
  }
}

Demo41 配置文件webpack.base.conf.js

webpack.prod.conf.js如Demo42,它在baseWebpackConfig的基础上引入了新的配置。向module.rules中新增了由utils.styleLoaders生成的rule,utils.styleLoaders为多个样式文件生成对应的rule,比如css对应的rule如Demo43所示。devtool表示source mapping的样式,通过设置source mapping以方便对源码进行debugger,设置不同的source mapping样式后打包速度也差别巨大。source mapping的样式通常设置为"none"、"cheap-module-eval-source-map"和"source-map"等[32];当设置为none时,构建速度是最快的,此时打包后的代码无法映射到源码,也就无法对源码进行debugger;当设置为"source-map",构建速度是最慢的,此时打包后的代码可以映射到源码,可以对源码进行debugger。chunkFilename指输出到目标目录下非entry的chunk文件的名称,比如异步加载模块时,异步的模块会单独生成一个js文件,该文件根据chunkFilename命名[33]。但下文中通过CommonsChunkPlugin生成单独的app、manifest和vendor的js文件时,使用的是filename而不是chunkFilename。plugins是一个plugin数组,每个插件有不同的功能。比如Demo42中的DefinePlugin中定义了变量process.env的值为 env,在webpack打包时会对process.env做变量替换[34]。UglifyJsPlugin会使用ugligy-js来缩小js文件,详细介绍见3.2.3.3节。ExtractTextWebpackPlugin将css抽取为单独的文件,详见3.2.3.2节。OptimizeCSSPlugin需要和ExtractTextPlugin一起使用,用来压缩抽取出的css文件。HtmlWebpackPlugin在目标目录下自动生成index.html,index.html中通过<script>引入assets中的js文件,详见3.2.3.1节。HashedModuleIdsPlugin基于module的相对路径使用hash算法生成4个字符的字符串,以生成的字符串作为module的id。ModuleConcatenationPlugin将所有的module的作用连接在一个闭包中,这使得浏览器运行地更快。在过去,wepack执行模块捆绑时的每个模块都在一个独立的函数闭包中,这些独立的函数闭包会降低浏览器执行js的速度。CommonsChunkPlugin会为不同的入口点共享的通用module生成独立的chunk,Demo42中第一个CommonsChunkPlugin会将通过require或import引入的所有node_moudes下的module抽取出来放入vendor chunk中,它们会被打包到独立的vendor.js文件中;第二个CommonsChunkPlugin将函数“webpackJsonp”和__webpack_require__抽取并打包到manifest.js中,当打包后的代码在浏览器运行时,它们用于连接模块[35];第三个CommonsChunkPlugin将根据代码生成的module抽取出来放入app chunk中,它会被捆绑打包到独立的app.js文件中。CopyWebpackPlugin表示将源代码目录中的静态资源复制到目标目录下,它在Demo42中用于将源代码目录../static拷贝到目标目录dist下。CompressionWebpackPlugin会对发布物文件进行压缩,生成压缩文件;Demo42中它用于对目标目录下的js文件和css文件进行gzip压缩,生成名称格式为[path].gz[query]的文件。BundleAnalyzerPlugin是对打包进行分析的插件,在webpack打包完成后,会启动Bundle Analyzer服务器,你可以在浏览器访问该Bundle Analyzer,如图38。

'use strict'
const path = require('path')
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')

const env = require('../config/prod.env')

const webpackConfig = merge(baseWebpackConfig, {
  module: {
    rules: utils.styleLoaders({
      sourceMap: config.build.productionSourceMap,
      extract: false,
      usePostCSS: true
    })
  },
  devtool: config.build.productionSourceMap ? config.build.devtool : false,
  output: {
    path: config.build.assetsRoot,
    filename: utils.assetsPath('js/[name].[chunkhash].js'),
    //指输出到目标目录下非entry的chunk文件的名称
    chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
  },
  plugins: [
    // DefinePlugin中定义了变量process.env的值为 env,在webpack打包时会对process.env做变量替换
    new webpack.DefinePlugin({
      'process.env': env
    }),
    //UglifyJsPlugin使用ugligy-js来缩小js文件
    new UglifyJsPlugin({
      uglifyOptions: {
        compress: {
          warnings: false
        }
      },
      //通过source maps将错误信息位置映射到modules
      sourceMap: config.build.productionSourceMap,
      parallel: true
    }),
    // 将css抽取为单独的文件
    new ExtractTextPlugin({
      filename: utils.assetsPath('css/[name].[contenthash].css'),
      allChunks: true,
    }),
    // 压缩抽取出的css文件
    new OptimizeCSSPlugin({
      cssProcessorOptions: config.build.productionSourceMap
        ? { safe: true, map: { inline: false } }
        : { safe: true }
    }),
    //在目标目录下自动生成index.html,index.html中通过<script>引入assets中的js文件
    new HtmlWebpackPlugin({
      filename: config.build.index,
      template: 'index.html',
      inject: true,
      //使用html-minifier-terser压缩html文件
      minify: {
        removeComments: true,
        collapseWhitespace: true, //删除空格
        removeAttributeQuotes: true
      },
      //当chunk被包含在html前如何被排序
      chunksSortMode: 'dependency'
    }),
    // 基于module的相对路径使用hash算法生成4个字符的字符串,以生成的字符串作为module的id
    new webpack.HashedModuleIdsPlugin(),
    // 将所有的module的作用连接在一个闭包中,这使得浏览器运行地更快
    new webpack.optimize.ModuleConcatenationPlugin(),
    // 将通过require或import引入的所有node_moudes下的module抽取出来放入vendor chunk中,
    //它们会被打包到独立的vendor.js文件中
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks (module) {
        // any required modules inside node_modules are extracted to vendor
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
        )
      }
    }),
    //将函数“webpackJsonp”和__webpack_require__抽取并打包到manifest.js中,当打包后的代码在浏览器运行时,它们用于连接模块
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity
    }),
    // 将根据代码生成的module抽取出来放入app chunk中,它会被捆绑打包到独立的app.js文件中
    new webpack.optimize.CommonsChunkPlugin({
      name: 'app',
      async: 'vendor-async',
      children: true,
      minChunks: 3
    }),

    // 将源代码目录中的静态资源复制到目标目录下
    new CopyWebpackPlugin([
      {
        from: path.resolve(__dirname, '../static'),
        to: config.build.assetsSubDirectory,
        ignore: ['.*']
      }
    ])
  ]
})

if (config.build.productionGzip) {
  const CompressionWebpackPlugin = require('compression-webpack-plugin')

  webpackConfig.plugins.push
    //对目标目录下的js文件和css文件进行gzip压缩,生成名称格式为[path].gz[query]的文件
    new CompressionWebpackPlugin({
      asset: '[path].gz[query]',
      algorithm: 'gzip',
      test: new RegExp(
        '\\.(' +
        config.build.productionGzipExtensions.join('|') +
        ')$'
      ),
      threshold: 10240,
      minRatio: 0.8
    })
  )
}

if (config.build.bundleAnalyzerReport) {
  const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
  //是对打包进行分析的插件,在webpack打包完成后,会启动Bundle Analyzer服务器,你可以在浏览器访问该Bundle Analyzer
  webpackConfig.plugins.push(new BundleAnalyzerPlugin())
}

module.exports = webpackConfig

Demo42 配置文件webpack.prod.conf.js

{
  test: /\.css$/,
  use: [{
    loader: "./node_modules/extract-text-webpack-plugin/dist/loader.js",
    option: {omit: 1, remove: true}
  },
    {loader: "vue-style-loader"},
    {
      loader: "css-loader",
      option: {sourceMap: true}
    },
    {
      loader: "postcss-loader",
      option: {sourceMap: true}
    }]
}

Demo43 utils.styleLoaders为css文件生成的rule

图38 在浏览器访问Bundle Analyzer

4. 不同打包工具的对比

本节仅从基本概念上介绍构建/打包工具gulp/grunt、browserify和webpack。并以一个开源项目作为具体案例,介绍了使用不同打包工具时项目的配置,以及打包前后文件的变化。在实际开发中,选用哪个打包工具最佳,通常需要在配置简单,调试方便,性能优等方面考虑,本节并不涉及。对构建/打包工具的介绍是基于如图39的版本的。

工具 grunt gulp browserify webpack
版本号 0.4.5 3.9.1 13.1.0 1.15.0

图39 构建/打包工具的版本

4.1 grunt/gulp

grunt可以自动化地执行压缩、编译、单元测试和代码检查(linting)任务,让开发者可以专心开发应用代码。[36]一个grunt任务包含多个操作,如果操作有输出,会输出临时文件到硬盘。

Grunt的使用步骤很简单,先安装grunt,然后在gruntfile.js中配置任务。Demo44是一个Gruntfile配置案例,可以通过这个案例快速了解Gruntfile的配置[37]。module.exports = function(grunt){}是一个封装了Grunt配置的包装函数。函数grunt.initConfig({})初始化了一个配置对象。jshint和watch是任务grunt-contrib-jshint和grunt-contrib-watch的配置。我们建议使用grunt-contrib-jshint分析所有的JavaScript对象,包括Gruntfile和测试文件。grunt-contrib-jshint的配置中包含files和options 2个属性。files表示要检查的对象。任务中options属性用于覆盖默认的构建配置[38],其中 globals: {jQuery: true}会告诉jshint全局变量JQuery的值为true[39]grunt-contrib-watch配置表示当files中配置的文件修改时,会立即运行task中的任务[37]<%= jshint.files %>是使用``<% %>`声明的模板,当任务读取配置时会自动获取值[38]

最重要的一步是配置default任务。defualt任务在运行grunt后执行。基于上述的配置方式,可以配置更多的任务,如Demo45。当在终端运行npx grunt命令后,任务'jshint'、'qunit'、 'concat'和 'uglify'依次执行。其中concat任务执行后输出到./dist下的临时文件,uglify任务输入该临时文件进行压缩;concat任务和uglify任务的连续执行是基于临时文件的。

module.exports = function(grunt) {

  grunt.initConfig({
    jshint: {
      files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
      options: {
        globals: {
          jQuery: true
        }
      }
    },
    watch: {
      files: ['<%= jshint.files %>'],
      tasks: ['jshint']
    }
  });

  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-watch');

  grunt.registerTask('default', ['jshint']);

};

Demo44 一个Gruntfile配置案例

module.exports = function(grunt) {

  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    concat: {
      options: {
        separator: ';'
      },
      dist: {
        src: ['src/**/*.js'],
        dest: 'dist/<%= pkg.name %>.js'
      }
    },
    uglify: {
      options: {
        banner: '/*! <%= pkg.name %> <%= grunt.template.today("dd-mm-yyyy") %> */\n'
      },
      dist: {
        files: {
          'dist/<%= pkg.name %>.min.js': ['<%= concat.dist.dest %>']
        }
      }
    },
    qunit: {
      files: ['test/**/*.html']
    },
    jshint: {
      files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
      options: {
        // options here to override JSHint defaults
        globals: {
          jQuery: true,
          console: true,
          module: true,
          document: true
        }
      }
    },
    watch: {
      files: ['<%= jshint.files %>'],
      tasks: ['jshint', 'qunit']
    }
  });

  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-qunit');
  grunt.loadNpmTasks('grunt-contrib-watch');
  grunt.loadNpmTasks('grunt-contrib-concat');

  grunt.registerTask('test', ['jshint', 'qunit']);

  grunt.registerTask('default', ['jshint', 'qunit', 'concat', 'uglify']);

};

Demo45 Gruntfile中进行更多任务配置

gulp的功能类似于grunt,可以通过一个命令运行多个任务。但gulp是基于Node stream的,当一个任务中的多个操作依次执行时,一个操作的输出作为另一个操作的输入基于内存中的stream[3]。同样的情况下,grunt会输出临时文件到硬盘。

gulp的配置是基于API的[39]。常用的API有gulp.src,gulp.dest,gulp.taskgulp.watch。以如Demo46-1所示的简单配置进行说明。通过gulp.task配置了任务lint和watch。任务lint的配置表示依次使用eslint()和eslint.format()对指定目录下的js代码进行检查。任务watch的配置表示如果指定目录下的js文件新增、删除或修改,将会执行lint任务。最重要的一步是配置default任务。defualt任务在运行gulp后执行。

Demo46-2配置了任务css,它会在管道中依次执行sourcemaps.init(),sass(),concat('bundle.css')和minifyCSS()等。sass()是将.scss的文件转换为.css文件,concat('bundle.css')表示将上一步输出的.css文件与bundle.css合并输出新的.css文件,minifyCSS()表示将上一步输出的css文件进行压缩。这些操作的连续性是基于Node Stream的,相比于grunt更高效。

const gulp = require('gulp');
const eslint = require('gulp-eslint');
const livereload = require('gulp-livereload');
const runSequence = require('run-sequence');

const src = 'app';
const config = {
  paths: {
    html: src + '/index.html',
    js: src + '/**/*.js',
    css: src + '/**/*.scss',
  }
};

// Linting
gulp.task('lint', () => {
  return gulp.src(config.paths.js)
  .pipe(eslint())
  .pipe(eslint.format())
});

// Re-runs specific tasks when certain files are changed
gulp.task('watch', () => {
  gulp.watch(config.paths.js, () => {
    runSequence('lint');
  });
});

// Default task, bundles the entire app and hosts it on an Express server
gulp.task('default', (cb) => {
  runSequence('lint', 'watch', cb);
});

Demo46-1 gulpfile的配置案例

// Bundles our vendor and custom CSS. Sourcemaps are used in development, while minification is used in production.
gulp.task('css', () => {
  return gulp.src(
    [
      'node_modules/bootstrap/dist/css/bootstrap.css',
      'node_modules/font-awesome/css/font-awesome.css',
      config.paths.css
    ]
  )
  .pipe(cond(!PROD, sourcemaps.init()))
  .pipe(sass().on('error', sass.logError))
  .pipe(concat('bundle.css'))
  .pipe(cond(PROD, minifyCSS()))
  .pipe(cond(!PROD, sourcemaps.write()))
  .pipe(gulp.dest(config.paths.baseDir))
  .pipe(cond(!PROD, livereload()));
});

Demo46-2 gulpfile中css任务的配置

4.2 browserify

当开始使用node中require()或import写浏览器代码[1],并加载npm安装的模块时,需要打包工具webpack或browerify。node样式的代码无法直接在浏览器运行,浏览器虽然支持ES6中的import,但无法加载npm安装的CommonJS模块。browerify的模块系统与node相同,因此可以将node样式的模块编译成浏览器支持的代码[40]。Browserify从配置的入口文件开始,基于对源代码抽象语法树(AST)的静态分析,搜索每一个它发现的require()或import。如果require()中或import后有模块字符串信息,browerify将模块字符串解析为文件路径,并在该文件路径下搜索require()的使用。整个搜索过程是递归进行的,直到获取所有的依赖的文件。每个文件会被连接到一个js文件中,连接时将静态解析路径映射为内部ID。生成的捆绑文件是完全独立的,包含应用所需的所有信息,而且开销很小。[41]

可以直接在终端使用npx browserify,使用方式是browserify [entry files] {OPTIONS}。比如browserify main.js > bundle.js表示以main.js为入口构建捆绑文件bundle.js。也可以基于API使用browserify,API包括方法、事件等,以如Demo47所示的案例进行说明。首先通过browserify([files] [, opts])以entries等作为options创建了browserify对象b。transform('babelify')表示使用'babelify'模块对源代码进行转换,将ES6格式的代码转换为ES5的。b.on('update', bundle)定义了事件处理器,表示当相关的js文件变更时,会调用函数bundle,它使用热模块替换的方式重新加载[42]b.bundle()表示对文件进行捆绑,并输出stream流。[43].on()表示执行stream的on()函数监听error事件[44]。然后在管道中执行后续gulp插件等。

// Other libraries
const browserify = require('browserify');
const buffer = require('vinyl-buffer');
const minifyJS = require('gulp-uglify');
const sourcemaps = require('gulp-sourcemaps');
const gulp = require('gulp');
const source = require('vinyl-source-stream');

// If gulp was called in the terminal with the --prod flag, set the node environment to production
if (argv.prod) {
  process.env.NODE_ENV = 'production';
}
let PROD = process.env.NODE_ENV === 'production';

// Configuration
const src = 'app';
const config = {
  paths: {
    baseDir: PROD ? 'build' : 'dist',
    entry: src + '/index.js',
  }
};

// Browserify specific configuration
const b = browserify({
  entries: [config.paths.entry],
  debug: true,
  plugin: PROD ? [] : [hmr, watchify],
  cache: {},
  packageCache: {}
})
    .transform('babelify');
b.on('update', bundle);
b.on('log', gutil.log);

// Bundles our JS (see the helper function at the bottom of the file)
gulp.task('js', bundle);

// Bundles our JS using browserify. Sourcemaps are used in development, while minification is used in production.
function bundle() {
  return b.bundle()
      .on('error', gutil.log.bind(gutil, 'Browserify Error'))
      .pipe(source('bundle.js'))
      .pipe(buffer())
      .pipe(cond(PROD, minifyJS()))
      .pipe(cond(!PROD, sourcemaps.init({loadMaps: true})))
      .pipe(cond(!PROD, sourcemaps.write()))
      .pipe(gulp.dest(config.paths.baseDir));
}

Demo47 gulpfile中关于browserify的配置

4.3 webpack

当开始使用node中require()或import写浏览器代码[1],并加载npm安装的模块时,需要打包工具webpack或browerify。node样式的代码无法直接在浏览器运行,浏览器虽然支持ES6中的import,但无法加载npm安装的CommonJS模块。Webpack可以将node样式的require()和import打包成浏览器可运行的代码。webpack从一个或多个入口文件开始构建依赖关系图,将项目所需的所有模块合并到一个或多个捆绑文件中,捆绑文件是包含所有内容的最终发布物[45]。具体打包过程是,如果require()中或import后有模块字符串信息,webpack将模块字符串解析为文件路径,并在该文件路径下搜索require()或import的使用。整个搜索过程是递归进行的,直到获取所有的依赖的文件。每个文件会被连接到一个js文件中,连接时将静态解析路径映射为内部ID。

除了模块捆绑,webpack还具有丰富的plugin和loader,它们实现了gulp/grunt中大量任务的功能。webpack中的plugin和loader与gulp/grunt中的plugin在功能上都属于插件,但它们在工具中运行时机不同。插件是一种遵循一定规范的应用程序接口编写出来的程序。其只能运行在程序规定的系统平台下(可能同时支持多个平台),而不能脱离指定的平台单独运行[46]。以js文件缩小插件为例说明gulp和webpack中plugin的不同,gulp中相应的插件是gulp-uglify,webpack中相应的插件是UglifyJsPlugin。gulp的配置文件gulpfile.js中gulp-uglify的配置如Demo48,插件的执行是按照bundle函数中的配置依次执行的。webpack的配置文件webpack.config.js中UglifyJsPlugin的配置如Demo49,配置文件只是创建了UglifyJsPlugin的实例,看不出插件具体的执行时机。UglifyJsPlugin的源码如Demo50,每个插件都有一个apply方法,apply方法会被webpack编译器调用,通过compiler.plugin()注册了函数"compilation",在"compilation"执行时又注册了函数build-module"、"optimize-chunk-assets"和"normal-module-loader"。webpack执行时,会在整个编译周期的不同时间根据特定的函数名称动态获取不同的函数,执行这些函数。不同的函数名称的函数执行先后顺序是固定的,相同函数名称的函数会根据注册的先后顺序依次执行。babel-loader加载js的loader,以babel-loader为例说明loader的配置和执行时机。babel-loader的配置如Demo51所示,配置中看不出loader的执行时机。loader的执行时机是固定的,它在由源代码构建生成模块(buildModule函数)时执行,多个loader会按配置的先后顺序依次执行(详见3.2.1.2节)。webpack运行时,不同名称的注册函数在不同的时间点执行,而loader在一个确定的时间点执行,loader可以看成是特殊的plugin。

const minifyJS = require('gulp-uglify');

// Bundles our JS using browserify. Sourcemaps are used in development, while minification is used in production.
function bundle() {
  return b.bundle()
  .on('error', gutil.log.bind(gutil, 'Browserify Error'))
  .pipe(source('bundle.js'))
  .pipe(buffer())
  .pipe(cond(PROD, minifyJS()))
  .pipe(cond(!PROD, sourcemaps.init({loadMaps: true})))
  .pipe(cond(!PROD, sourcemaps.write()))
  .pipe(gulp.dest(config.paths.baseDir));
}

Demo48 gulpfile.js中gulp-uglify的配置

const webpack = require('webpack');


plugins: PROD ?
  [
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.DefinePlugin(GLOBALS),
    new ExtractTextPlugin('bundle.css'),
    new webpack.optimize.DedupePlugin(),
    new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}}) //UglifyJsPlugin的配置
  ] :
  [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  ],

Demo49 webpack.config.js中UglifyJsPlugin的配置

//文件所属目录:node_modules\webpack\lib\optimize\UglifyJsPlugin.js

UglifyJsPlugin.prototype.apply = function(compiler) {
	var options = this.options;
	options.test = options.test || /\.js($|\?)/i;

	var requestShortener = new RequestShortener(compiler.context);
	compiler.plugin("compilation", function(compilation) {
		if(options.sourceMap !== false) {
			compilation.plugin("build-module", function(module) {
				// to get detailed location info about errors
				module.useSourceMap = true;
			});
		}
		compilation.plugin("optimize-chunk-assets", function(chunks, callback) {
			// 具体的处理逻辑略去
			callback();
		});
		compilation.plugin("normal-module-loader", function(context) {
			context.minimize = true;
		});
	});
};

Demo50 UglifyJsPlugin的源码

module: {
    loaders: [
      {test: /\.js$/, include: path.join(__dirname, 'app'), loaders: ['babel']}, //babel-loader配置
      {
        test: /\.css$/,
        loader: PROD ?
          ExtractTextPlugin.extract('style', 'css?sourceMap'):
          'style!css?sourceMap'
      },
      {
        test: /\.scss$/,
        loader: PROD ? 
          ExtractTextPlugin.extract('style', 'css?sourceMap!resolve-url!sass?sourceMap') :
          'style!css?sourceMap!resolve-url!sass?sourceMap'
      },
      {test: /\.(svg|png|jpe?g|gif)(\?\S*)?$/, loader: 'url?limit=100000&name=img/[name].[ext]'},
      {test: /\.(eot|woff|woff2|ttf)(\?\S*)?$/, loader: 'url?limit=100000&name=fonts/[name].[ext]'}
    ]
  },

Demo51 webpack.config.js中babel-loader的配置

webpack是开箱即用的,可以不使用配置文件。默认项目的入口文件是src/index.js,输出经过缩小和优化的文件到dist/main.js。通常默认的配置不能满足实际要求,需要创建 webpack.config.js并进行配置。[28]配置中包括entry、output、resolve、loader、plugin和devServer等。以如Demo52所示的简单案例进行说明。

entry 是webpack执行模块捆绑的入口点。entry的值为数组时表示入口点有多个[29]。入口点webpack-hot-middleware/client?reload=true是模块热加载的设置[1]output中可以配置发布物如何输出和输出到哪。output.publicPath应与应用在服务器上被访问的目录一致(详见5.6节)。devServer的配置会被webpack-dev-server读取,devServer.contentBase表示server的根目录[1]。plugins是一个plugin数组,每个插件有不同的功能。比如Demo52中的DefinePlugin中定义了变量process.env.NODE_ENV的值为 JSON.stringify('production'),在webpack打包时会对process.env.NODE_ENV做变量替换[34]。module.loaders中可以配置多个Loader。Demo52中的第一个Loader会对./app下的所有js文件使用babel-loader进行转换。sassLoader加载Sass/SCSS文件并编译成CSS文件[47]。resolve.root通常是项目的根目录,比如说sourcemap中的所有路径都是相对于root目录的。[48]

const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

const GLOBALS = {
  'process.env.NODE_ENV': JSON.stringify('production')
};
const PROD = process.env.NODE_ENV === 'production';

module.exports = {
  debug: true,
  devtool: PROD ? 'source-map' : 'eval-source-map',
  noInfo: false,
  entry: PROD ? './app/index' :
  [
    'webpack-hot-middleware/client?reload=true', // reloads the page if hot module reloading fails.
    './app/index'
  ],
  target: 'web',
  output: {
    path: PROD ? __dirname + '/build' : __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: PROD ? './build' : './app'
  },
  plugins: PROD ?
  [
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.DefinePlugin(GLOBALS),
    new ExtractTextPlugin('bundle.css'),
    new webpack.optimize.DedupePlugin(),
    new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}})
  ] :
  [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  ],
  module: {
    loaders: [
      {test: /\.js$/, include: path.join(__dirname, 'app'), loaders: ['babel']},
      {
        test: /\.css$/,
        loader: PROD ?
          ExtractTextPlugin.extract('style', 'css?sourceMap'):
          'style!css?sourceMap'
      },
      {
        test: /\.scss$/,
        loader: PROD ? 
          ExtractTextPlugin.extract('style', 'css?sourceMap!resolve-url!sass?sourceMap') :
          'style!css?sourceMap!resolve-url!sass?sourceMap'
      },
      {test: /\.(svg|png|jpe?g|gif)(\?\S*)?$/, loader: 'url?limit=100000&name=img/[name].[ext]'},
      {test: /\.(eot|woff|woff2|ttf)(\?\S*)?$/, loader: 'url?limit=100000&name=fonts/[name].[ext]'}
    ]
  },
  sassLoader: {
    includePaths: [path.resolve('./app')]
  },
  resolve: {
    root: [path.resolve('./app')]
  }
};

Demo52 webpack.config.js的配置

4.4 具体案例

开源项目task-runner-bundler-comparison中使用了3种不同打包方式和配置进行打包,3种不同的方式分别位于3个代码分支上。项目前端使用react。本节介绍其中的2种打包方式,gulp+browserify打包和gulp+webpack打包。2种不同的打包方式和配置均实现了下述任务[1]

  • 项目文件更改时,开发服务器进行热加载;
  • 当监视文件更改时,以可扩展的方式对JS和CSS文件进行捆绑(包括ES6转译为ES5,SASS转译为CSS和sourcemaps);
  • 作为独立任务或在监视模式下运行单元测试;
  • 作为独立任务或在监视模式下运行代码检查(linting);
  • 通过终端的一个命令,执行上述所有的任务;

4.4.1 gulp + browserify

4.4.1.1 配置

使用gulp + browserify打包的配置是gulpfile.js。配置中主要是gulp的配置,gulp的配置是基于API的(详见4.1节)。其中browserify的配置主要是通过browserify创建了browserify对象b。b.bundle()表示对文件进行捆绑,并输出stream流,输出的stream流可调用pipe()函数继续在管道中执行gulp的操作(详见4.2节)。

4.4.1.2 打包前后文件对比

使用gulp + browserify打包,打包后的文件位于/dist文件夹下。browserify从配置的入口文件app/index.js开始,将所有的文件连接到同一个js文件bundle.js中。bundle.js加载时会先调用如Demo54所示的外层的匿名函数,匿名函数返回函数r(e, n, t)。调用函数r(e, n, t),入参e在Demo54中略去,详见Demo55。函数r中会先调用o(t[i]),即o(1)。o(1)中调用e[i][0](i值为1),即调用Demo55中的匿名函数,函数入参require是function(r) { var n = e[i][1][r];return o(n || r)}。在Demo55的匿名函数中,会调用 require(originalEntries[i]),即调用require("E:\\history_bak\\code\\32_mycode_hundsun\\08_code\\task-runner-bundler-comparison\\app\\index.js");require函数调用,e[i][1][r](i值为1)的值为7,调用o(7)。

如Demo54,o(7)中调用e[7][0],即调用Demo56中的匿名函数。在Demo56的匿名函数中,还调用require('react')、require('react-dom')和require('react-router')等。require的调用时递归的,直到require的文件中没有require。

Demo56中的匿名函数对应的是如Demo53的文件app/index.js。可以看出import的文件被连接到了一个js文件bundle.js中。连接时,模块字符串信息被映射为内部ID。以字符串信息为入参调用require函数,函数中先根据字符串信息获取模块内部ID,然后以ID为入参调用函数o,函数o中调用ID对应的函数,为p.exports赋值,p.exports作为require的返回值返回。

import React from 'react';
import {render} from 'react-dom';
import {Router, browserHistory} from 'react-router';
import routes from './routes';
// CSS imports
import '../node_modules/bootstrap/dist/css/bootstrap.css';
import '../node_modules/font-awesome/css/font-awesome.css';
import './styles/global.scss';

render(<Router history={browserHistory} routes={routes} />, document.getElementById('app'));

Demo53 源文件app/index.js

(function() {
    function r(e, n, t) {
        function o(i, f) {
            if (!n[i]) { //为true
                if (!e[i]) { //为false
                    var c = "function" == typeof require && require;
                    if (!f && c)
                        return c(i, !0);
                    if (u)
                        return u(i, !0);
                    var a = new Error("Cannot find module '" + i + "'");
                    throw a.code = "MODULE_NOT_FOUND",
                    a
                }
                var p = n[i] = {
                    exports: {}
                };
                e[i][0].call(p.exports, function(r) { //调用e[i][0]
                    var n = e[i][1][r];
                    return o(n || r)
                }, p, p.exports, r, e, n, t)
            }
            return n[i].exports
        }
        for (var u = "function" == typeof require && require, i = 0; i < t.length; i++)
            o(t[i]); //调用函数o(i, f)
        return o
    }
    return r
}
)()({...}, {}, [1])

Demo54 目标文件bundle.js

{ ...
1: [function(require, module, exports) {
        (function(global, _main, moduleDefs, cachedModules, _entries) {
            'use strict';

            var moduleMeta = {
                "node_modules\\browserify-hmr\\lib\\has.js": {
                    "index": 124,
                    "hash": "Hky4QYVrU1+kFHIEuxPy",
                    "parents": ["node_modules\\browserify-hmr\\lib\\str-set.js", "node_modules\\browserify-hmr\\inc\\index.js"]
                }
                ...
            }
             var originalEntries = ["E:\\history_bak\\code\\32_mycode_hundsun\\08_code\\task-runner-bundler-comparison\\app\\index.js"];
            if (isFirstRun) {
                for (var i = 0, len = originalEntries.length; i < len; i++) {
                    require(originalEntries[i]); // 通过require引入文件/app/index.js
                }
            }
        }).call(this, typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}, arguments[3], arguments[4], arguments[5], arguments[6]);
},
    //e[1][1]
    {
        "./node_modules\\browserify-hmr\\inc\\index.js": 123,
        "./node_modules\\socket.io-client\\lib\\index.js": 723,
        "E:\\history_bak\\code\\32_mycode_hundsun\\08_code\\task-runner-bundler-comparison\\app\\index.js": 7
    }]
 ...
}

Demo55 目标文件bundle.js中省略的函数入参

{...
7: [function(require, module, exports) {
    _hmr["websocket:null"].initModule("app\\index.js", module);
    (function() {
        'use strict';

        var _react = require('react');

        var _react2 = _interopRequireDefault(_react);

        var _reactDom = require('react-dom');

        var _reactRouter = require('react-router');

        var _routes = require('./routes');

        var _routes2 = _interopRequireDefault(_routes);

        function _interopRequireDefault(obj) {
            return obj && obj.__esModule ? obj : {
                default: obj
            };
        }

        (0,
         _reactDom.render)(_react2.default.createElement(_reactRouter.Router, {
            history: _reactRouter.browserHistory,
            routes: _routes2.default
        }), document.getElementById('app'));

    }
    ).apply(this, arguments);

}
    , {
        "./routes": 8,
        "react": 722,
        "react-dom": 503,
        "react-router": 687
    }]
...
}

Demo56 目标文件bundle.js中省略的函数入参

在如Demo57所示的index.html中,通过<script>引入bundle.js。可通过浏览器访问index.html,如图40。

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="app"></div>
    <script src="bundle.js"></script>
  </body>
</html>

Demo57 目标文件index.html中通过script引入bundle.js

图40 通过浏览器访问index.html

4.4.2 gulp + webpack

4.4.2.1 配置

使用gulp + webpack打包的配置是gulpfile.jswebpack.config.js。在终端运行npx gulp命令时,会依次执行gulpfile.js中的任务lint、test、build、server和watch。其中代码检查(lint)、单元测试(test)是webpack单独无法实现的功能;build任务在管道中运行webpack(require('./webpack.config')),由webpack进行项目打包;server是将打包后的文件在Node.js服务器上运行;watch任务中监听了js文件的变化,如果有变化则重新执行lint和test任务。build任务执行时,webpack会读取webpack.config.js中配置的plugins、loaders和resolve等(详见4.3节),进行项目打包。

项目打包是由webpack独立完成的,所以也可以在终端使用npx webpack命令进行打包。只是打包前不会进行代码检查和单元测试。

4.4.2.2 打包前后文件对比

使用gulp + webpack打包,打包后的文件位于/dist文件夹下。webpack从配置的入口文件app/index.js开始,将所有的文件连接到同一个js文件bundle.js中。bundle.js加载时会调用如Demo59所示的匿名函数,函数的入参modules详见Demo61。Demo59中的匿名函数中调用hotCreateRequire(0)(0),hotCreateRequire(0)返回函数__webpack_require__,然后执行__webpack_require__(0)。如Demo60,函数__webpack_require__(0)执行时会调用如Demo61中的modules[0]中的函数。modules[0]中,调用__webpack_require__(15);如Demo60,__webpack_require__(15)执行时会调用如Demo61的modules[15]中的函数。module[15]中,调用__webpack_require__(16);如Demo60,__webpack_require__(16)执行时会调用如Demo61的modules[16]中的函数。

Demo61的modules[15]对应的是如Demo58所示的文件app/index.js。可以看出import的文件被连接到了一个js文件bundle.js中。连接时,模块字符串信息被映射为内部ID。以ID为入参调用__webpack_require__,会根据ID获取modules中的函数,执行该函数为module.exports赋值,module.exports作为返回值返回。

import React from 'react';
import {render} from 'react-dom';
import {Router, browserHistory} from 'react-router';
import routes from './routes';
// CSS imports
import '../node_modules/bootstrap/dist/css/bootstrap.css';
import '../node_modules/font-awesome/css/font-awesome.css';
import './styles/global.scss';

render(<Router history={browserHistory} routes={routes} />, document.getElementById('app'));

Demo58 源文件/app/index.js

(function(modules) {
    function hotCreateRequire(moduleId) {
        // eslint-disable-line no-unused-vars
        /******/
        var me = installedModules[moduleId];
        /******/
        if (!me)
            return __webpack_require__;
        //省略部分逻辑   
    }
    
   function __webpack_require__(moduleId) {};//详见Demo2
    
	/******/
    // Load entry module and return exports
    /******/
    return hotCreateRequire(0)(0);  //调用hotCreateRequire
    /******/
}
)
(...) //此处省略的参数详见Demo3

Demo59 目标文件bundle.js

 function __webpack_require__(moduleId) {
        /******/
        // Check if module is in cache
        /******/
        if (installedModules[moduleId])
            /******/
            return installedModules[moduleId].exports;

        /******/
        // Create a new module (and put it into the cache)
        /******/
        var module = installedModules[moduleId] = {
            /******/
            exports: {},
            /******/
            id: moduleId,
            /******/
            loaded: false,
            /******/
            hot: hotCreateModule(moduleId),
            /******/
            parents: hotCurrentParents,
            /******/
            children: []/******/
        };

        /******/
        // 调用modules中指定ID对应的函数
        /******/
        modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));

        /******/
        // Flag the module as loaded
        /******/
        module.loaded = true;

        /******/
        // Return the exports of the module
        /******/
        return module.exports;
        /******/
    }

Demo60 目标文件bundle.js中的__webpack_require__函数

[/* 0 */
/***/
(function(module, exports, __webpack_require__) {

    __webpack_require__(1);
    module.exports = __webpack_require__(15);

    /***/
}
), 
//数组的1-14元素此处略去
    /* 15 */
/***/
(function(module, exports, __webpack_require__) {
    eval("'use strict';\n\nvar _react = __webpack_require__(16);\n\nvar _react2 = _interopRequireDefault(_react);\n\nvar _reactDom = __webpack_require__(52);\n\nvar _reactRouter = __webpack_require__(199);\n\nvar _routes = __webpack_require__(262);\n\nvar _routes2 = _interopRequireDefault(_routes);\n\n__webpack_require__(694);\n\n__webpack_require__(701);\n\n__webpack_require__(709);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\n(0, _reactDom.render)(_react2.default.createElement(_reactRouter.Router, { history: _reactRouter.browserHistory, routes: _routes2.default }), document.getElementById('app'));\n// CSS imports//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9hcHAvaW5kZXguanM/ZGFkYyJdLCJuYW1lcyI6WyJkb2N1bWVudCIsImdldEVsZW1lbnRCeUlkIl0sIm1hcHBpbmdzIjoiOztBQUFBOzs7O0FBQ0E7O0FBQ0E7O0FBQ0E7Ozs7QUFFQTs7QUFDQTs7QUFDQTs7OztBQUVBLHNCQUFPLHFEQUFRLG9DQUFSLEVBQWlDLHdCQUFqQyxHQUFQLEVBQTREQSxTQUFTQyxjQUFULENBQXdCLEtBQXhCLENBQTVEO0FBTEEiLCJmaWxlIjoiMTUuanMiLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnO1xyXG5pbXBvcnQge3JlbmRlcn0gZnJvbSAncmVhY3QtZG9tJztcclxuaW1wb3J0IHtSb3V0ZXIsIGJyb3dzZXJIaXN0b3J5fSBmcm9tICdyZWFjdC1yb3V0ZXInO1xyXG5pbXBvcnQgcm91dGVzIGZyb20gJy4vcm91dGVzJztcclxuLy8gQ1NTIGltcG9ydHNcclxuaW1wb3J0ICcuLi9ub2RlX21vZHVsZXMvYm9vdHN0cmFwL2Rpc3QvY3NzL2Jvb3RzdHJhcC5jc3MnO1xyXG5pbXBvcnQgJy4uL25vZGVfbW9kdWxlcy9mb250LWF3ZXNvbWUvY3NzL2ZvbnQtYXdlc29tZS5jc3MnO1xyXG5pbXBvcnQgJy4vc3R5bGVzL2dsb2JhbC5zY3NzJztcclxuXHJcbnJlbmRlcig8Um91dGVyIGhpc3Rvcnk9e2Jyb3dzZXJIaXN0b3J5fSByb3V0ZXM9e3JvdXRlc30gLz4sIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdhcHAnKSk7XG5cblxuLy8gV0VCUEFDSyBGT09URVIgLy9cbi8vIC4vYXBwL2luZGV4LmpzIl0sInNvdXJjZVJvb3QiOiIifQ==");

    /***/
}
),
    /* 16 */
/***/
(function(module, exports, __webpack_require__) {

    eval("'use strict';\n\nmodule.exports = __webpack_require__(17);\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9+L3JlYWN0L3JlYWN0LmpzPzNkNjciXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7O0FBRUEiLCJmaWxlIjoiMTYuanMiLCJzb3VyY2VzQ29udGVudCI6WyIndXNlIHN0cmljdCc7XG5cbm1vZHVsZS5leHBvcnRzID0gcmVxdWlyZSgnLi9saWIvUmVhY3QnKTtcblxuXG5cbi8vLy8vLy8vLy8vLy8vLy8vL1xuLy8gV0VCUEFDSyBGT09URVJcbi8vIC4vfi9yZWFjdC9yZWFjdC5qc1xuLy8gbW9kdWxlIGlkID0gMTZcbi8vIG1vZHVsZSBjaHVua3MgPSAwIl0sInNvdXJjZVJvb3QiOiIifQ==");

    /***/
}
// 此处略去剩余的数组元素
]

Demo61 目标文件bundle.js中省略的入参

4.5 小结

gulp/grunt可以自动化地执行压缩、编译、单元测试和代码检查(linting)任务,让开发者可以专心开发应用代码。

当开始使用node中require()或import写浏览器代码[1],并加载npm安装的模块时,需要打包工具webpack或browerify。它们可以将node样式的模块编译成浏览器支持的代码。它们从配置的入口文件开始,将文件所有的依赖模块捆绑到一个js文件中。除了模块捆绑,webpack还实现了gulp/grunt中大量任务的功能。webpack通常单独使用;而browserify功能相对单一,所以通常和gulp/grunt搭配使用。

5. 问题记录

5.1 如何调试源代码

在浏览器或其它应用中调试源代码,你需要更新 webpack 配置以构建 source map。做了这件事之后,我们的调试器就有机会将一个被压缩的文件中的代码对应回其源文件相应的位置。这会确保你可以浏览器或其它应用中调试,即便你的资源已经被 webpack 优化过了也没关系。[65]

对于 Vue CLI 3,设置并更新 vue.config.js 内的 devtool属性。如Demo62,设置devtool属性为source-map,可以通过webpack-dev-server直接启动项目,也可以重新打包生成发布物文件并将发布物文件部署到生产服务器上,然后通过浏览器访问页面时可以调试源代码。此时打包生成发布物文件中每个文件都有一个对应.map文件,打包后的代码是通过.map文件映射到源代码的。devtool属性也可以设置为"none"、"cheap-module-eval-source-map"等其它值[32]。当devtool属性设置为none时,生产构建速度是最快的,当devtool设置为"source-map",生产构建速度是最慢的。

module.exports = {
  configureWebpack: {
    devtool: 'source-map'
  }
}

Demo62 Vue CLI 3项目配置文件vue.config.js

5.2 webpack-dev-server的启动

vue-cli项目中可以在终端运行npm run dev命令以启动wepback-dev-server,这是因为在package.json的scripts块中做了如Demo63的配置。那么可以直接在终端运行webpack-dev-server --inline --progress --config build/webpack.dev.conf.js命令吗?

"scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "build": "node build/build.js"
},

Demo63 项目文件package.json

正常情况下,要想到在终端(比如cmd窗口)运行程序或脚本文件(比如node.exe),需要先切换到文件所在的目录,或者将文件所在目录设置到系统环境变量Path中。如图41,将Node.js的安装目录添加到环境变量Path中后,如图42可以在任意目录下运行node命令。npm包webpack-dev-sever安装后,会在node_modules/.bin下生成webapck-dev-server.cmd文件(如图43)。所以你可以切换到node_modules/.bin下运行webpack-dev-server命令,直接在项目目录下运行webpack-dev-server命令,会报如图44的错误“webpack-dev-server不是内部或外部命令,也不是可运行的程序”。

图41 将Node.js的安装目录添加到环境变量Path中

图42 在cmd窗口中直接运行node命令

图43 项目目录node_modules/.bin目录下的webpack-dev-server.cmd文件

图44 直接在项目根目录下运行webpack-dev-server会报错

在npm安装包时用到的package.json中,也可以配置scripts对象(如Demo63),scripts中的指令可以使用npm run运行。npm在运行scripts中的命令时,会将node_modules/.bin添加到系统的环境变量Path中[13]。npm会到node_modules/.bin检查命令是否存在。所以scripts中的指令webpack-dev-server是可以通过npm run dev正常运行的。

node_modules/.bin下的webpack-dev-server.cmd是如何生成的呢?如图45,在webpack-dev-server包的package.json文件中配置了bin。bin项用来指定各个内部命令对应的可执行文件的位置。图45中的bin对象中指定webpack-dev-server命令对应的可执行文件为 bin 子目录下的 webpack-dev-server.js。npm会寻找这个文件,在node_modules/.bin/目录下建立符号链接[49]。如图46,在node_modules/.bin生成了webpack-dev-server.cmd文件,该文件中会运行webpack-dev-server包bin目录下的文件/webapck-dev-server.js。

不通过在package.json中配置scripts的方式,也可以通过npx webpack-dev-sever直接运行webpack-dev-server,如图47。npx 的原理很简单,就是运行的时候,会到node_modules/.bin路径和环境变量$PATH里面,检查命令是否存在[50]

图45 在webpack-dev-server包的package.json文件中配置了bin

图46 文件webpack-dev-server.cmd

图47 通过npx webpack-dev-sever运行webpack-dev-server

5.3 package.json与package-lock.json[51]

packge.json和package-lock.json与npm install命令有密切联系。先介绍npm install下载包的流程。npm install 命令用来安装模块到node_modules目录。npm install会先检查,node_modules目录之中是否已经存在指定模块。如果存在,就不再重新安装了,即使远程仓库已经有了一个新版本,也是如此。如果不存在,则会先去registry查询依赖包的下载地址。registry是npm模块仓库提供的一个查询服务。[52]以webpack-dev-server为例,它的查询服务网址是https://registry.npmjs.org/webpack-dev-server/2.11.5;网址的组成是基础查询服务网址https://registry.npmjs.org,加上模块名webpack-dev-server,加上版本号2.11.5。查询返回的结果是一个json对象,里面有一个dist.tarball属性,是该版本压缩包的下载地址,如图48。

图48 webpack-dev-server的npm查询服务网址下的内容

开发者在下载依赖包时,就会在package-lock.json中记录依赖包的信息;当使用–save 命令时,package.json也会记录依赖包的信息,比如npm install --save-dev webpack-dev-server。package-lock.json中记录了所有的依赖包,它和node_modules是一一对应的关系;而package.json中依赖包信息可能不全。

当依赖包同时被package-lock.json和package.json记录时,package-lock.json记录的信息更详细准确。如Demo64,package-lock.json中记录了依赖包vue的准确版本2.7.15,下载地址resolved,以及依赖的包;相比Demo65中的package.json记录的版本^2.5.2会更新补丁版本和次版本。

当使用npm install安装包时,如果package-lock.json存在,则会下载package-lock.json记录的所有包,这些包的版本是准确的。如果只有package.json,没有package-lock.json,则会下载package.json记录的所有包,并将包的准确版本等信息记录到package-lock.json中。

"vue": {
      "version": "2.7.15",
      "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.15.tgz",
      "integrity": "sha512-a29fsXd2G0KMRqIFTpRgpSbWaNBK3lpCTOLuGLEDnlHWdjB8fwl6zyYZ8xCrqkJdatwZb4mGHiEfJjnw0Q6AwQ==",
      "requires": {
        "@vue/compiler-sfc": "2.7.15",
        "csstype": "^3.1.0"
      }
    }

Demo64 项目文件package-lock.json

"vue": "^2.5.2"

Demo65 项目文件package.json

5.4 dependencies与devDependencies[53]

npm install在安装node模块时,使用命令参数–save和–save-dev可以把它们的信息写入package.json文件;其中,–-save会把依赖包名称添加到package.json文件dependencies属性下,–-save-dev则添加到package.json文件devDependencies属性下,如Demo66。

"dependencies": {
    "vue": "^2.5.2"
},
"devDependencies": {
	"vue-loader": "^13.3.0",
	"webpack-dev-server": "^2.11.5",
}

Demo66 项目文件package.json

dependencies与devDependencies的区别:devDependencies下列出的模块,是我们开发时用的依赖项,比如webpack-dev-server用于开发环境的热部署,vue-loader用于webpack打包时.vue文件的解析,它们不会被部署到生产环境。dependencies下的模块,则是我们生产环境中需要的依赖,即正常运行该包时所需要的依赖项,比如依赖包vue会用于打包后的单页面的js中render函数的编译。

5.5 CommonJS模块与ES模块

CommonJS模块与ES模块是2个不同的模块系统。CommonJS模块和ES模块有很多差异,包括输入输出代码的语法、输出值的类型以及代码是同步还是异步加载等[19]。本节仅介绍了CommonJS和ES模块在输入输出代码语法上的差异。额外介绍了在Node.js中如何识别CommonJS和ES模块,以及Node.js中CommonJS和ES模块的互用。

5.5.1 ES模块

ES6(ES2015)是继2009年的ES5之后的首次重大更新。ES6中开始支持模块,借鉴了流行模块加载器AMD和commonJS的思想。[17]目前主流的浏览器已支持ES6语法[55]。Node.js的v10版本后也完全支持ES6模块[56],Node的v8版本(实测版本v8.17.0)是不支持的。

ES模块打包了可重用的JavaScript代码。使用export和import输出和输入模块。如Demo67,它输出了函数addTwo;如Demo68,它输入了函数addTwo。Demo67和Demo68是在Node.js中运行的案例,所以文件后缀是.mjs。如果要在浏览器中运行,需要在HTML中通过<script>引入模块app.js,详见2.2节。

ES11(ES2020)中引入了动态加载函数import(),可以动态地输入ES模块[57]。Node的v10版本支持动态加载函数import()[56],Node的v8版本(实测版本v8.17.0)是不支持的。import()调用与import语句具有不同的含义,他们只是拼写相同。import()返回一个promise,可以通过解构promise成功时应答的value值来获取ES6模块的输出。如Demo69,通过动态函数import()输入模块。

// addTwo.mjs
function addTwo(num) {
  return num + 2;
}

export { addTwo };

Demo67 文件addTwo.mjs中使用export输出模块

// app.mjs
import {addTwo} from './addTwo.mjs';

// Prints: 6
console.log(addTwo(4));

Demo68 文件app.mjs中通过import输入模块

// app.mjs
import('./addTwo.mjs').then(obj=> {
    // Prints: 6
    console.log(obj.addTwo(4));
});

Demo69 文件app.mjs中通过动态加载函数import()输入模块

5.5.2 CommonJS模块

CommonJS模块是Node.js打包JavaScript代码的原始方式。[58]CommonJS模块也可用于浏览器侧的JavaScript代码中,但必须使用打包工具进行转译,因为浏览器不支持CommonJS模块。[59]

在Node.js中,每个文件被看成一个模块。CommonJS模块使用require()函数输入模块,使用module.exports或exports对象输出模块。将5.5.1节中的案例进行改写,如Demo70和Demo71所示。Demo70中输出了函数addTwo,Demo71加载了函数addTwo。

// addTwo.js
function addTwo(num) {
  return num + 2;
}

exports.addTwo = addTwo;

Demo70 文件addTwo.js中使用exports对象输出模块

// app.js
require('./addTwo.js');

// Prints: 6
console.log(addTwo(4)); 

Demo71文件app.js中使用require()函数输入模块

5.5.3 Node.js中ES6和CommonJS的互用[60]

Node.js中有2个模块系统:CommonJS模块和ES6模块。

开发者可以通过以下方式告诉Node.js使用ES6模块编译JavaScript代码。一是使用.mjs文件后缀,二是在package.json中将”type“属性设置为”module“,三是JavaScript代码以--eval或--print的参数传入(如Demo72),或通过管道命令执行node(如Demo73),命令执行时加上--input-type=modue标记[61][62]

node --input-type=module --eval "import { delimiter } from 'path'; console.log(delimiter);"

Demo72 告诉Node.js使用ES6模块编译JavaScript代码的方式三

echo "import { delimiter } from 'path'; console.log(delimiter);" | node --input-type=module

Demo73 告诉Node.js使用ES6模块编译JavaScript代码的方式四

相反地,开发者可以通过以下方式Node.js使用CommonJS模块编译JavaScript代码。一是使用.cjs文件后缀,二是在package.json中将”type“属性设置为”commonjs“,三是JavaScript代码以--eval或--print的参数传入,或通过管道命令执行node,命令执行时加上--input-type=commonjs标记。

当Node.js编译器无法显式地获得模块类型,默认使用CommonJS模块。但在未来Node.js的默认模块可能会变为”module“。所以建议在任何地方都显式地声明模块的类型[61]

5.5.3.1 在ES模块中输入CommonJS

在ES模块中可以通过import语句输入CommonJS模块,就像输入ES6模块一样。在ES6模块中输入模块时,你不用担心输入的模块是CommonJS模块还是ES模块。如Demo74在CommonJS模块中使用exports对象输出了函数addTwo,如Demo75在ES6模块中使用import输入了函数addTwo。

// addTwo.js
function addTwo(num) {
  return num + 2;
}

exports.addTwo = addTwo;

Demo74 CommonJS模块中使用exports对象输出函数addTwo

// app.mjs
import { addTwo } from './addTwo.mjs';

// Prints: 6
console.log(addTwo(4)); 

Demo75 在ES6模块中使用import输入函数addTwo

5.5.3.2 在CommonJS模块中输入ES模块

在CommonJS模块中可以使用动态函数import()输入ES模块,import仅允许在ES模块中使用。也不可以使用require输入ES6模块,因为ES6模块是异步执行的。[63]

import()调用与import语句具有不同的含义,他们只是拼写相同。import()返回一个promise,可以通过解构promise成功时应答的value值来获取ES6模块的输出。[63]如Demo76在ES模块中使用export输出了函数addTwo,如Demo77在CommonJS模块中使用import()函数动态加载了addTwo.mjs模块。

// addTwo.mjs
function addTwo(num) {
  return num + 2;
}

export { addTwo };

Demo76 在ES模块中使用export输出了函数addTwo

// app.js
import('./addTwo.mjs').then(obj=> {
    // Prints: 6
    console.log(obj.addTwo(4));
});

Demo77 在CommonJS模块中使用import()函数动态加载了addTwo.mjs模块

5.6 webpack配置中output.publicPath的含义

webpack配置中output.publicPath的含义,以第3节介绍的vue-cli项目中的webpack配置为例进行说明。

如Demo78所示的webpack配置中,output对象中配置了path、filename和publicPath;ExtractTextPlugin和HtmlWebpackPlugin中也配置了filename。打包后的发布物文件目录如图49,发布物中文件的目录是由Demo78中配置的path和filename决定的。

const config = require('../config')

module.exports = {
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename:'static/js/[name].[chunkhash].js'),
    publicPath: '/'
  }
  plugins: [
     // extract css into its own file
    new ExtractTextPlugin({
      filename:'static/css/[name].[contenthash].css'),
      allChunks: true,
    }),
    // generate dist index.html with correct asset hash for caching.
    // you can customize output by editing /index.html
    new HtmlWebpackPlugin({
      filename: path.resolve(__dirname, '../dist/index.html'),
      template: 'index.html',
      inject: true
    })
  ]
}

Demo78 简化后的webpack配置(主要来源于webpack.base.conf.js,webpack.prod.conf.js)

图49 打包后的发布物文件目录

将发布物上传至云服务器的目录/app/hello-vue-cli下(如图50),然后进行如Demo79的nginx配置,配置后可通过http://47.xxx.xxx.11:8000/访问应用页面(如图51)。修改Demo79的nginx配置如Demo80,通过http://47.xxx.xxx.11:8000/hello访问应用页面(需先清除页面的js文件缓存),此时请求js文件报错,请求js文件的url的路径还是/static/js(如图52),但根据Demo80的配置访问js资源的url路径已经变成了/hello/static/js(如图53)。请求js文件是由index.html发起的,查看发布物index.html文件中<srcipt>中引入app.js的根路径还是/static/js(如Demo81),该路径是由Demo78中output的publicPath和filename拼接而来的。修改publicPath为/hello,再次打包后,发布物index.html中<srcipt>中引入app.js的根路径变成了/hello/static/js(如Demo82)。将重新打包后的发布物上传至云服务器的目录/app/hello-vue-cli下,nginx的配置保持如Demo80不变,此时可通过http://47.xxx.xxx.11:8000/hello正常访问应用页面,如图54。

publicPath应与应用在服务器上被访问的目录一致。如果服务器上配置的是”/“,那么publicPath就配置"/";如果服务器上配置的是”/hello“,那么publicPath也要配置为”/hello“,否则会导致打包生成的index.html中<srcipt>引入js的根路径缺少/hello,而导致js文件无法访问。

当然,你也可以配置相对路径,但实际访问时也可能会出现问题,此时还需要调整项目中的其它配置[64]

图50 将发布物上传至云服务器的目录/app/hello-vue-cli

  location / {
        root /app/hello-vue-cli;
        index index.html;
  }

Demo79 nginx.conf中的配置

  location /hello {
        alias /app/hello-vue-cli;
        index index.html;
  }

Demo80 nginx.conf中的配置

图51 通过http://47.xxx.xxx.11:8000/访问应用页面

图52 http://47.xxx.xxx.11:8000/hello访问应用页面

图53 访问js资源的url路径已经变成了/hello/static/js

<script type=text/javascript src=/static/js/manifest.16c23ba6bc6bb0b6d86e.js></script><script type=text/javascript src=/static/js/vendor.a174ea6c2bddc077ea39.js></script><script type=text/javascript src=/static/js/app.fb1b7cbbeb63fa1c2bfb.js></script>

Demo81 发布物中index.html中通过<srcipt>引入app.js等js文件(发布物中的文件是经过缩小处理的)

<script type=text/javascript src=/hello/static/js/manifest.16c23ba6bc6bb0b6d86e.js></script><script type=text/javascript src=/hello/static/js/vendor.a174ea6c2bddc077ea39.js></script><script type=text/javascript src=/hello/static/js/app.fb1b7cbbeb63fa1c2bfb.js></script>

Demo82 修改publicPath为/hello,重新打包生成的index.html

图54 重新打包后可通过http://47.xxx.xxx.11:8000/hello正常访问应用页面

5.7 Node.js中的异步

Node.js在发生IO操作时是异步的。Node.js不会等这个IO操作结束才去执行接下来的操作,而是直接去执行后续的操作[54]。webpack打包时有读写文件的IO操作,在源代码调试时需要注意这些IO操作。IO操作是异步的,IO操作的先后顺序与IO操作的回调函数的顺序不一定是一致的,也就是说先发生的IO操作不一定先完成并进入回调函数,以Demo83的简单案例进行说明。如Demo83,fs是Node.js中的原生模块,fs中的readFile是读取文件的异步操作。代码执行时会按顺序先后读取一个大文件large.txt和一个小文件small.txt;待IO读取完毕后会触发回调函数,读取小文件small.txt的回调函数先被触发,然后是读取大文件large.txt的回调函数。回调函数的触发顺序与IO操作的执行顺序相反,这是由于读取大文件的耗时较长,虽然读取大文件的操作是先执行的,但是是后完成的,所以后触发读取大文件的回调函数。

const { readFile } = require('node:fs');

console.log('read large.txt')
readFile('./large.txt',function(){console.log('read large.txt callback')});
console.log('read small.txt')
readFile('./small.txt',function(){console.log('read small.txt callback')});
console.log('read end');

//执行结果:
//read large.txt
//read small.txt
//read end
//read small.txt callback
//read large.txt callback

Demo83 Node.js中的文件读取操作是异步的

参考资料:

[1] https://www.toptal.com/front-end/webpack-browserify-gulp-which-is-better

[2] https://github.com/lihongxun945/diving-into-webpack/blob/master/1-introduction.md

[3] https://www.cleveroad.com/blog/gulp-browserify-webpack-grunt/

[4] https://github.com/browserify/browserify#usage

[5] https://nodejs.org/docs/latest/api/documentation.html

[6] https://www.quora.com/Is-Node-js-compiled-or-interpreted

[7] https://nodejs.org/en/learn/getting-started/introduction-to-nodejs

[8] https://docs.npmjs.com/about-npm

[9] https://docs.npmjs.com/downloading-and-installing-node-js-and-npm

[10] https://cli.vuejs.org/guide/#components-of-the-system

[11] https://cli.vuejs.org/guide/creating-a-project.html#pulling-2-x-templates-legacy

[12] https://blog.csdn.net/JakeMa1024/article/details/108543246

[13] https://docs.npmjs.com/cli/v7/commands/npm-run-script#description

[14] https://github.com/webpack/webpack-cli?tab=readme-ov-file

[15] https://webpack.js.org/guides/development/#using-webpack-dev-server

[16] https://webpack.js.org/guides/getting-started/

[17] https://babeljs.io/docs/learn

[18] https://unpkg.com/[email protected]/lodash.js

[19] https://es6.ruanyifeng.com/#docs/module-loader

[20] https://www.cnblogs.com/jann8/p/17840127.html

[21] https://v4.webpack.js.org/loaders/babel-loader/

[22]https://github.com/lihongxun945/diving-into-webpack/blob/master/2-babel-loader.md

[23] https://juejin.cn/post/6882895689773383694

[24] https://webpack.js.org/guides/getting-started/

[25] https://v4.webpack.js.org/plugins/uglifyjs-webpack-plugin/

[26] https://github.com/lihongxun945/diving-into-webpack/blob/master/8-tree-shaking.md

[27] https://juejin.cn/post/6884866016565084173

[28] https://webpack.js.org/configuration/

[29]https://webpack.js.org/configuration/entry-context/#entry

[30] https://v4.webpack.js.org/configuration/module/#rule-conditions

[31] https://v4.webpack.js.org/configuration/module/#rule-results

[32] https://webpack.js.org/configuration/devtool/#devtool

[33] https://v4.webpack.js.org/configuration/output/#outputchunkfilename

[34] https://juejin.cn/post/6844903458974203911

[35] https://webpack.js.org/concepts/manifest/

[36] https://gruntjs.com/

[37] https://gruntjs.com/sample-gruntfile

[38] https://gruntjs.com/configuring-tasks

[39] https://gulpjs.com/docs/en/getting-started/javascript-and-gulpfiles

[40] https://github.com/browserify/browserify-handbook?tab=readme-ov-file#introduction

[41] https://github.com/browserify/browserify-handbook?tab=readme-ov-file#how-browserify-works

[42] https://www.toptal.com/front-end/webpack-browserify-gulp-which-is-better

[43] https://github.com/browserify/browserify#usage

[44] https://javascript.plainenglish.io/streams-piping-and-their-error-handling-in-nodejs-c3fd818530b6

[45] https://webpack.js.org/concepts/

[46] https://baike.baidu.com/item/插件/369160?fr=ge_ala

[47] https://webpack.js.org/loaders/sass-loader/

[48] https://github.com/webpack/webpack/issues/472

[49] https://zhuanlan.zhihu.com/p/136410967

[50] https://www.ruanyifeng.com/blog/2019/02/npx.html

[51] https://blog.csdn.net/qq_43837235/article/details/122129402

[52] https://www.ruanyifeng.com/blog/2016/01/npm-install.html

[53] https://www.cnblogs.com/hao-1234-1234/p/9718604.html

[54] https://www.zhihu.com/question/392578769/answer/2622020886?utm_id=0

[55] https://en.wikipedia.org/wiki/ECMAScript

[56] https://stackoverflow.com/questions/58858782/using-the-dynamic-import-function-on-node-js

[57] https://www.freecodecamp.org/news/javascript-new-features-es2020/

[58] https://nodejs.org/docs/latest/api/modules.html

[59] https://en.wikipedia.org/wiki/CommonJS

[60] https://nodejs.org/docs/latest/api/esm.html

[61] https://nodejs.org/docs/latest/api/packages.html#determining-module-system

[62] https://www.javascripttutorial.net/nodejs-tutorial/nodejs-es-module/

[63] https://sliceofdev.com/posts/commonjs-and-esm-modules-interoperability-in-nodejs

[64] https://www.cnblogs.com/karila/p/9522836.html

[65] https://v2.cn.vuejs.org/v2/cookbook/debugging-in-vscode.html#在浏览器中展示源代码

[66] https://stackoverflow.com/questions/52919331/access-to-script-at-from-origin-null-has-been-blocked-by-cors-policy

标签:__,vue,webpack3,module,js,webpack,源码,loader,打包
From: https://www.cnblogs.com/jann8/p/18052360

相关文章

  • ChatGLM3 源码解析(一)
    MLPclassMLP(torch.nn.Module):"""MLP把隐藏状态的尺寸从HidSize映射到4HidSize,执行非线性激活,然后再映射回HidSize"""def__init__(self,config:ChatGLMConfig,device=None):super(MLP,self).__init__()#控制是否添加偏......
  • 拯救php性能的神器webman-打包二进制
    看了看webman的官方文档,发现居然还能打包为二进制,这样太厉害了吧!先执行这个  composerrequirewebman/console^1.2.24 安装这个console的包,然后执行  phpwebmanbuild:bin8.1 结果谁想到它报错提示:好吧我就按照他说的执行了  php-dphar.readonly=0./webmanb......
  • Vue源码解读:render和VNode
    Vue2.0相比Vue1.0最大的升级就是利用了虚拟DOM。在Vue1.0中视图的更新是纯响应式的。在进行响应式初始化的时候,一个响应式数据key会创建一个对应的dep,这个key在模板中被引用几次就会创建几个watcher。也就是一个key对应一个dep,dep内管理一个或者多个watcher......
  • Vue源码解读:更新策略
    之前介绍过初始化时Vue对数据的响应式处理是利用了Object.defifineProperty(),通过定义对象属性getter方法拦截对象属性的访问,进行依赖的收集,依赖收集的作用就是在数据变更的时候能通知到相关依赖进行更新。通知更新setter当响应式数据发生变更时,会触发拦截的setter函数......
  • Docker应用程序打包和分发的最佳实践
    1、使用多阶段构建:对于复杂的应用程序,可以使用多个阶段来构建Docker镜像。每个阶段可以专注于特定的任务,从而提高构建速度和镜像大小。2、最小化镜像大小:使用合适的基础镜像,并确保只安装必需的依赖项。可以使用多阶段构建和镜像分层来减小镜像的大小,并提高镜像的可维护性和可重复......
  • 网页浏览器Chrome开发者调试工具-Source(源码)-断点调试、条件断点、日志断点
    前言全局说明网页浏览器Chrome开发者调试工具-Source(源码)-断点调试、条件断点、日志断点断点,是某行代码要执行,还没有执行的一个暂停点一、截图对照1.1Chrome浏览器1.1.1蓝色,普通断点1.1.2设置断点类型图中分别是:backpoint:普通断点(蓝色)Conditionalbreakp......
  • Glide源码解析四(解码和转码)
    本文基于Glide4.11.0Glide加载过程有一个解码过程,比如将url加载为inputStream后,要将inputStream解码为Bitmap。 从Glide源码解析一我们大致知道了Glide加载的过程,所以我们可以直接从这里看起,在这个过程中我们以从文件中加载bitmap为例:DecodeJob的一个方法:privatevoiddec......
  • 【Mybatis】【三】源码分析- MapperFactoryBean 的创建过程以及 Mapper 接口代理的生
    1 前言本节我们续前两节(调试查看Mapper接口生成过程、源码分析Mapper生成注入入口分析)的内容,看下MapperFactoryBean是如何代理掉我们的@Mapper接口的。上节我们看到我们的Mapper接口的BeanDefinition,已经放进spring的上下文中了,也就是在BeanFactory的BeanDefin......
  • 网狐核心源码阅读分析
    框架结构网狐服务器整体上分为4个部分:中转服务器,房间服务器,大厅服务器,sqlserver数据库。其中大厅服务器主要负责帐号管理器:管理用户选择服务器登录地址,校验用户数据等。必需与中转服务器保持长连接,用于更新获取最新数据。房间服务器:用于加载处理每款子游戏逻辑与公共游戏逻辑(例......
  • 直播app系统源码,Android端如何实现禁止截屏或录屏
    直播app系统源码,Android端如何实现禁止截屏或录屏引言相信大家在使用某些平台应用的时候,都会有限制的规定。通常情况下,录屏、截图软件都可以在手机的运行过程中进行录屏、截图,普通的平台也不会阻止录屏、截图软件运行。但是在直播app系统源码的某些比较敏感的业务上镜上面......