前端模块化二——webpack项目中的模块化

910 阅读12分钟

前言

本文章为第一篇关于前端模块化前端模块化一——规范详述的续篇。
主要将结合webpack实际项目来讲解这些规范,包括webpack项目中用到的NodeJS模块的其路径分析、文件定位、模块解析。
以及webpack对CommonJS、AMD、CMD、ES6模块化的支持情况、使用。
还有webpack对ES6模块静态加载的支持。
webpack tree shaking。

webpack 项目中的模块化


第一篇文章所有的讲解大多都是基于规范来讲的,但是在实际开发中,我们都是结合webpack来使用的。
我们知道webpack是一个打包工具,将我们的代码打包成一个或多个模块,最后这些被打包的模块会被插入到对应HTML模板里面,供浏览器中使用。
或者说,webpack其实是一个前端的打包工具,webpack的entry就是用来配置打包的模块,实际是告诉webpack将哪些文件打成一个或多个包。并且webpack会构建一个依赖关系图,将入口文件的所有依赖文件都会打进这个包里。
在整个webpack项目中,其实CommonJS、AMD、CMD、ES6模块化都可以用到。

下面我们根据vue-cli的项目来结合实战来给大家说明webpack项目中所涉及的所有模块化。
安装vue-cli 2
npm install -g @vue/cli-init

vue init webpack my-project
vue-cli 生成的vue项目,其目录结构如下:
图1 vue-cli 2 生成的项目结构
其实平时我们基于webpack的项目大致如此,项目里的模块化有涉及Nodejs的模块化、ES6、AMD、webpack模块。
下面我们详细讲解,基于两点Nodejs模块,和webpack下的模块。

Nodejs模块化

./build文件夹下,其实都是Node代码,一般写一些webpack的配置及Node运行的脚本。
我们看./build/build.js文件
'use strict'
require('./check-versions')()

process.env.NODE_ENV = 'production'

const ora = require('ora')
const rm = require('rimraf')
const path = require('path')
const chalk = require('chalk')
const webpack = require('webpack')
const config = require('../config')
const webpackConfig = require('./webpack.prod.conf')
...
其实这就是一个标准的Node模块,当在Node模块里require的时候就会根据Node模块的路径分析、文件定位、编译执行来加载模块。举例说明:
  • path——Node的核心模块,因为核心模块早已在Node编译时就编译成二进制文件存入内存了,所以path模块直接从内存加载,比一般模块加载速度快。
  • ora ——其既不是Node的核心模块,又不是以.、..和/开头的绝对路径或者相对路径,因此Node就会到前面所说的模块路径里去寻找,会首先在当前目录的node_modules里去寻找,因为我们require一个模块一般会在当前项目里使用npm 或者yarn安装所使用模块的npm包,所以一般在当前node_modules目录下就能找到,然后做一系列的文件定位、扩展名分析等,找到模块的入口文件,然后将模块执行一遍生成一个对象的拷贝放在内存里,以便以后的加载。
  • ../config——相对定位形式require,一看就知道是普通的文件模块,就是项目里我们自己创建的文件模块,也会经过路径分析、文件定位、编译执行三个步骤,执行也是返回一个对象的拷贝存在内存里,以便以后的加载。

以上代码我们还可以看到其实webpack也是作为一个Node模块引入项目中的。

那我们再看看webpack.base.conf.js模块,这个文件本身也是一个Node模块,前面已经说过了,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 = {
  context: path.resolve(__dirname, '../'),
  entry: {
    app: './src/main.js'
  },
  output: {
    path: config.build.assetsRoot,
    filename: '[name].js',
    publicPath: process.env.NODE_ENV === 'production'
      ? config.build.assetsPublicPath
      : config.dev.assetsPublicPath
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '@': resolve('src'),
    }
  },
  module: {
    rules: [
      ...(config.dev.useEslint ? [createLintingRule()] : []),
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
      },

      ...

    ]
  },
  ...
}
可以看到项目里并没有定义 module 和__dirname却直接使用了,我们可以将其打印出来,打印结果。
//console.log(module)结果
Module {
  id: '/Users/baidu/daisy/demos/vue-cli/my-project/build/webpack.base.conf.js',
  exports: {},
  parent:
   Module {
     id: '/Users/baidu/daisy/demos/vue-cli/my-project/build/webpack.dev.conf.js',
     exports: {},
     parent:
      Module {
        id: '/Users/baidu/daisy/demos/vue-cli/my-project/node_modules/webpack/bin/convert-argv.js',
        exports: [Function],
        parent: [Object],
        filename: '/Users/baidu/daisy/demos/vue-cli/my-project/node_modules/webpack/bin/convert-argv.js',
        loaded: true,
        children: [Array],
        paths: [Array] },
     filename: '/Users/baidu/daisy/demos/vue-cli/my-project/build/webpack.dev.conf.js',
     loaded: false,
     children: [ [Object], [Object], [Object], [Object], [Circular] ],
     paths:
      [ '/Users/baidu/daisy/demos/vue-cli/my-project/build/node_modules',
        '/Users/baidu/daisy/demos/vue-cli/my-project/node_modules',
        '/Users/baidu/daisy/demos/vue-cli/node_modules',
        '/Users/baidu/daisy/demos/node_modules',
        '/Users/baidu/daisy/node_modules',
        '/Users/baidu/node_modules',
        '/Users/node_modules',
        '/node_modules' ] },
  filename: '/Users/baidu/daisy/demos/vue-cli/my-project/build/webpack.base.conf.js',
  loaded: false,
  children:
   [ Module {
       id: '/Users/baidu/daisy/demos/vue-cli/my-project/build/utils.js',
       exports: [Object],
       parent: [Object],
       filename: '/Users/baidu/daisy/demos/vue-cli/my-project/build/utils.js',
       loaded: true,
       children: [Array],
       paths: [Array] },
     Module {
       id: '/Users/baidu/daisy/demos/vue-cli/my-project/config/index.js',
       exports: [Object],
       parent: [Object],
       filename: '/Users/baidu/daisy/demos/vue-cli/my-project/config/index.js',
       loaded: true,
       children: [],
       paths: [Array] },
     Module {
       id: '/Users/baidu/daisy/demos/vue-cli/my-project/build/vue-loader.conf.js',
       exports: [Object],
       parent: [Circular],
       filename: '/Users/baidu/daisy/demos/vue-cli/my-project/build/vue-loader.conf.js',
       loaded: true,
       children: [Array],
       paths: [Array] } ],
  paths:
   [ '/Users/baidu/daisy/demos/vue-cli/my-project/build/node_modules',
     '/Users/baidu/daisy/demos/vue-cli/my-project/node_modules',
     '/Users/baidu/daisy/demos/vue-cli/node_modules',
     '/Users/baidu/daisy/demos/node_modules',
     '/Users/baidu/daisy/node_modules',
     '/Users/baidu/node_modules',
     '/Users/node_modules',
     '/node_modules' ] } 
我其实是这么理解的,就是Module 类的一个实例对象,前面打印的模块路径就是module.paths , 所以module就是代表Module后面的那个字面量对象。
再将__dirname打印出来。
//console.log(__dirname)

/Users/baidu/daisy/demos/vue-cli/my-project/build
__dirname 就是当前文件所在的目录。

我们之所以可以直接使用module和__dirname是因为,前面所说Node对js模块的编译会将其首尾包装,包装之后如下:
(function(exports, require, module, __filename, __dirname){
  //webpack.base.conf.js 内容
  ...
});
因此Node中每一个文件就是一个模块,并且每个模块都有exports, require, module, __filename, __dirname这些变量可以直接使用的。

webpack模块化支持情况

我们知道webpack是根据entry配置的入口来打包,所以项目中的业务逻辑代码都要先经过webpack这一层,其实webpack也有模块化。
我们先来看看webpack的模块化,以下是webpack4对模块化的描述。
"Node.js 从最一开始就支持模块化编程。然而,在 web,模块化的支持正缓慢到来。在 web 存在多种支持 JavaScript 模块化的工具,这些工具各有优势和限制。webpack 基于从这些系统获得的经验教训,并将_模块_的概念应用于项目中的任何文件。"
webpack支持各种方式表达的模块依赖关系。
  • ES2015 import 语句
  • CommonJS require语句
  • AMD define 和 require 语句
  • css/sass/less 文件中的@import 语句
webpack1——的时候需要使用特定的loader来转换ES2015(ES6) 的import,
webpack2—— 默认支持ES2015的import了。
webpack3—— 默认支持 javascript/auto模块类型, 所谓的javascript/auto模块类型是指支持所有的JS模块规范——CommonJS、AMD、ES6
也就是说webpack3就已经完全默认支持CommonJS、AMD/CMD、ES6模块规范,开箱即用。
webpack4——支持5种模块类型(type),在webpack4.0.0release时有说明。
  • javascript/auto: (webpack 3中的默认类型)支持所有的JS模块系统:CommonJS、AMD/CMD、ESM
  • javascript/auto: EcmaScript 模块,在其他的模块系统中不可用(默认 .mjs 文件)
  • javascript/dynamic: 仅支持 CommonJS & AMD,EcmaScript 模块不可用
  • json: 可通过 require 和 import 导入的 JSON 格式的数据(默认为 .json 的文件)
  • webassembly/experimental: WebAssembly 模块(处于试验阶段,默认为 .wasm 的文件)

PS(>.<吐槽官方文档): 虽然在官方文档或者在相关资料上并没有找到是否支持CMD规范的说明,但是通过在webpack4(4.16.3)实际测试中发现,webpack4也是默认支持CMD规范的。

而这5种模块类型在项目里实际是怎么区分的呢,4.0.0release时原文这么说的。
Module type is automatically choosen for mjs, json and wasm extensions. Other extensions need to be configured via module.rules[].type
大致意思是webpak4会自动解析.wasm,.mjs,.js和.json的文件,但是其他扩展名的文件需要在 module.rules[].type 中配置,配置如下:
module: {
  rules:[{
    type: 'javascript/auto',
    test: /\.(json)/,
    exclude: /(node_modules|bower_components)/,
  }]
}
type的值可以是:javascript/auto、javascript/dynamic、javascript/auto、json、webassembly/experimental 5种类型,分别代表上面所说的5种模块类型。

通在webpack4.16.3项目中尝试,发现javascript/auto模块类型是默认支持的,不需要配置type,可以参考我的webpack4.16.3react项目

所以综上所述,前面所讲的全部的前端模块规范,包括CommonJS在webpack4都是默认支持的,也就是说入口文件包括其依赖文件中全部都可以使用这些规范(CommonJS,AMD、CMD、ES6模块化),并且开箱即用,不需要额外的配置。

webpack模块解析

通过阅读webpack4的官方文档,可以发现webpack的模块解析规则和Nodejs非常相似。
webpack使用enhanced-resolve 来解析文件路径,支持三种路径形式
  • 绝对路径——绝对路径不需要进一步路径解析
  • 相对路径——import/require中给定的相对路径,会添加此上下文路径(context path),以产生模块的绝对路径(absolute path)。
  • 模块路径——类似Nodejs的模块路径。
模块路径支持配置,在resolve.modules里配置,如下所示
module.exports = {
  ...
  resolve: {
    modules: [
      "node_modules",
      path.resolve(__dirname, "src")
    ],
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '#': resolve('widget'),
    },
    extensions: ['.web.js', '.mjs', '.js', '.json', '.web.jsx', '.jsx'],
  }
  ...
}
还可以通过resolve.alias来配置一个模块的别名,如上所示,配置完之后
import vue from 'vue$';

//等价于
import vue from 'vue/dist/vue.esm.js';
路径解析后,解析器将检查路径是否指向文件或目录。
(1)如果路径指向一个文件:
  • 如果路径具有文件扩展名,则被直接将文件打包。
  • 否则,将使用 [resolve.extensions] 选项作为文件扩展名来解析,此选项告诉解析器在解析中能够接受哪些扩展名(例如 .js, .jsx)
ps:所以如果项目里配置了resolve.extensions ,import或者require时就能省略扩展名,webpack将根据resolve.extensions中的配置来自动匹配扩展名。
(2)如果路径指向一个文件夹,类似于Nodejs文件夹会被当做一个包来解析,第一步是查找package.json文件:
  • 如果包含package.json,则按顺序查找resolve.mainFields配置选项中指定的字段。
resolve.mainFields——其实是告诉webpack要把package.json中哪个属性定义的文件路径当做包的入口文件,其配置如下:
module.exports = {
  ...
  resolve: {
   ...
   mainFields: ["browser", "module", "main"]
   ...
  }
  ...
}
  • 如果不包含package.json 或者package.json中的main字段没有返回一个有效路径,则按照顺序查找resolve.mainFiles(注意和resolve.mainFields区分) 配置选项中指定的文件名。resolve.mainFields的配置如下:
 module.exports = {
  ...
  resolve: {
   ...
   mainFiles: ["index"]
   ...
  }
  ...
}


就是告诉webpack将包中的index文件作为入口,再进行扩展名匹配。

webpack 对于各种模块规范的模块是如何解析的呢?支持程度完全符合规范吗?

对于CommonJS、AMD、CMD通代类似下面的代码来测试(具体查看源码 webpack4.16.3react项目
//utils.js
var age = 18;
module.exports = {
 age,
 addAge: ()=>{
  age++;
 }
}
//index.js
let person = require('./util.js');
console.log(person.age);
person.addAge();
console.log(age);

//输出
18
18
可以发现CommonJS 、AMD、CMD确实都是运行时加载,并且加载的都是第一次运行时返回的对象的拷贝,模块内值的改变,再次加载的模块对象也不会受影响。
这里重点结合实际看webpack下的——ES6模块化的静态引入(只引入{}中的API)——支持情况。
创建一greeting.js文件,内容如下:
//greeting.js
export function sayHi() {
  console.log('Hi');
}
export function sayBye() {
  console.log('Bye');
}


在项目src/index.js里引入
//src/index.js
...
import{ sayHi } from './greeting';
...
将build/webpack.prod.conf.js文件中的mode改为development
//webpack.prod.conf.js 
mode: 'development',
为了看出代码的打包情况,将mode改为development,build的时候就不会将代码压缩
运行
npm run build 
会在项目根目录生成dist文件夹,结构如下(文件结构根据配置生成的)
图2 vue-cli项目结构图
我们主要看dist/static/js/index.js模块,因为本项目只配置了一个入口文件就是src/index.js,
vendor.js是依赖的npm包打成的一个文件,具体配置参考项目源码。在dist/static/js/index.js中看到整个greeting.js模块都加载了,但是其实按照ES6规范里说的,只引入了sayHi,就应该只加载sayHi方法,但是看到其实没有引入的sayBye方法也加载了。
图3 没有tree shaking的es6没有完全实现静态引入
我们再给项目加上webpack的treeShaking(参考tree shaking | webpack 中文网

webpack4 tree shaking


(1)项目的package.json添加sideEffects配置
{
  "name": "webpack-demo",
  "sideEffects": [
     "*.less",
     "*.css",
  ]
}


因为treeShaking可以删除文件中未使用的部分。需要配置sideEffects属性告诉webpack哪些文件可以安全treeShaking,没有副作用,但是项目里用到类似css-loader并导入css/less,就需要在sideEffects中配置,表明.css,.less文件不应用treeShaking。
(2)mode改为production
因为webpack的tree shaking 依赖uglifyjs将dead code去掉,webpack4 mode 为production时默认启动uglifyjs
(3)npm run build之后查看dist/static/js/index.js, 格式化之后,发现'Bye'字符串再也找不到了,说明sayBye方法完全去掉了。相关代码如图:
图4 tree shaking 之后
为了更清楚看看tree shaking之后的效果,我手动配置了一下uglifyjs,关掉compress 等功能,并格式化,配置如下:(具体查看源码 webpack4.16.3react项目
//webpack.prod.conf.js

var baseWebpackConfig = require('./webpack.base.conf');

...

const UglifyJS = require('uglify-es');

const DefaultUglifyJsOptions = UglifyJS.default_options();
const compress = DefaultUglifyJsOptions.compress;
for(let compressOption in compress) {
    compress[compressOption] = false;
}
compress.unused = true;

var webpackConfig = merge(baseWebpackConfig, {
 mode: 'production',
 ...
 optimization: {
    splitChunks: {
      name: true,
      chunks: 'all',
    },
    minimize: true,
    minimizer: [
      new UglifyJsPlugin({
        uglifyOptions: {
          compress,
          mangle: false,
          output: {
              beautify: true
          }
        },
      }),
    ],
  }
 ...
}
...
再npm run build,可以看到如下结果,代码已经格式化,不需要再借助vscode手动格式化
图5 没有compress 的tree shaking 效果
我们可以更直观的看到tree shaking之后的效果没有引入的sayBye方法被完全移除了。

tree shaking 踩坑:之前各种尝试tree shaking 都无法成功,最后发现是因为添加了
// .babelrc
{
  "plugins": ["react-hot-loader/babel"]
}
这是因为项目里使用了react-hot-loader,其要求添加如上代码,奇怪的是,将以上代码删除,不仅可以成功tree shaking 还不影响 react 的hot-reload时的状态保存。

总结

前端模块化,话其规范,种类繁多,包括CommonJS/ AMD/CMD/ES6模块化,通晓全部方能成器。webpack4都已经默认支持,无需额外配置,但是ES6的静态模块引入需要配合tree shaking方能实现。

参考

  1. github.com/webpack/web…
  2.  Webpack 4 不完全迁移指北 · Issue #60 · dwqs/blog
  3.  webpack 中文文档(@印记中文) https://docschina.org/
  4. ECMAScript 6入门
  5.  wanago.io/2018/08/13/…
  6. 朴灵 (2013) 深入浅出Node.js. 人民邮电出版社, 北京。
  7. JavaScript 标准参考教程(alpha)