概述

模块化是一种解决问题的方案,一个模块就是实现某种特定功能的文件,可以帮助开发者拆分和组织代码。

js模块化

JavaScript语言在设计之初只是为了完成简单的功能,因此没有模块化的设计。但是随着前端应用的越来越庞大,模块化成为了js语言必须解决的问题。

模块化发展

js的模块化发展大致可以划分为四个阶段:

  • 文件划分

按照js文件划分模块,一个文件可以认为是一个模块,然后将文件通过script标签的方式引入。
编写模块:foo.js

var foo = 'foo'
function sayHello() {
    console.log(foo)
}

使用模块:

<html>
<header></header>
<body>
    <!--先引用-->
    <script src="./foo.js"></script>
    <script>
        // 通过全局对象调用
        window.sayHello()
    </script>
</body>
</html>

文件划分方式无法管理模块的依赖关系(不是强制定义模块依赖),而且模块内所有变量都挂载在全局对象上,容易污染全局作用域,命名冲突。

  • 命名空间

将文件内所有的变量都添加到一个命名空间下。
编写模块:

var FooModule = {
    foo: 'foo',
    sayHello() {
        console.log(FooModule.foo)
    }
}

使用模块:

<script>
    // 通过命名空间调用
    FooModule.sayHello()
</script>

使用命名空间的好处是可以尽量避免命名冲突,但是由于命名空间挂载在全局对象下,依然能够在外部修改模块的变量(没有实现模块私有化)。

  • 立即执行函数

利用函数作用域,将模块路径包裹在一个立即执行函数中,可以指定需要暴露给外部的变量。
编写模块:

;(function (w) {
    var foo = 'foo'
    w.sayHello = function () {
        console.log(foo)
    }
})(window)

使用模块:

<script>
    // 通过命名空间调用
    window.sayHello()
</script>

自执行函数利用函数作用域实现了变量私有化。

  • 模块化规范

ES2015提出了标准模块化规范,即ES Modules。它包含一个模块化标准和一个模块加载器。
编写模块

// moduleA.js
export const foo = 'foo'

// moduleB.js
// 会自动从服务器下载moduleA.js文件
import { foo } from './moduleA.js'
console.log(foo)

使用模块

<html>
<header></header>
<body>
    <!--引入moduleB.js-->
    <script type="module" src="./moduleB.js"></script>
</body>
</html>

注意事项:

  1. 引入模块js时,必须添加type=module
  2. 由于模块会自动下载依赖文件,因此html文件必须挂载到服务器下,直接文件浏览会报错。

模块化规范

目前,JavaScript语言大致上有三种模块化规范:CommonJs,AMD,ES Modules

CommonJs

CommonJs是Nodejs中使用的模块化规范,它规定一个文件就是一个模块,每个模块都有单独的作用域,模块中通过require引入模块,通过module.exports导出模块。

// moduleA.js
module.exports = {
    foo: 'foo'
}

// moduleB.js
const { foo } = require('./moduleA.js')
console.log(foo)

可以在命令行中通过node moduleB.js运行。

AMD

AMD是浏览器端规定异步模块定义的规范,通常配合requirejs使用。

//通过数组引入依赖 ,回调函数通过形参传入依赖
define(['ModuleA', 'ModuleB'], function (ModuleA, ModuleB) {
    function foo() {
        // 使用依赖
        ModuleA.test();
    }
    // 导出模块内容
    return { foo: foo }
})

ES Modules

ES Modules是ECMAScript提出的标准模块规范,主要应用在浏览器端,目前并不是所有浏览器均支持该特性。

ES Modules

基本特性

  • script type=module

在html中可以通过script标签引用,需要使用type=module告诉浏览器加载的js文件是一个模块,浏览器会自动下载模块中的依赖模块。

  • 自动采用严格模式

如果某个js文件通过模块的方式被浏览器引入,那么该js文件会自动变成严格模式,也就是在js文件中可以省略use strict

  • 运行在单独的私有作用域中

运行在单独的私有作用域中保证了命名不会冲突。
module.js中:
var foo = 'foo'
index.html中:

<html>
<header></header>
<body>
    <script type="module" src="./module.js"></script>
    <script>
        // 即使模块中的foo变量使用的是var声明的,此时也不能在全局作用域中找到foo变量
        console.log(foo)
    </script>
</body>
</html>
  • 通过CORS方式请求外部js文件

如果script标签的src属性值是一个url地址,那么这个地址必须允许CORS跨域访问。
<script type="module" src="https://dss1.bdstatic.com/5eN1bjq8AAUYm2zgoY3K/r/www/cache/static/protocol/https/jquery/jquery-1.10.2.min_65682a2.js"></script>
上例中,由于dss1.bdstatic.com不允许跨域访问,因此会报错。

  • 自动延迟执行

通过模块方式引入的js代码会被浏览器延迟执行。
module.js中:
console.log('module')
index.html中:

<html>
<header></header>
<body>
    <script type="module" src="./module.js"></script>
    <script>
       console.log('out module')
    </script>
</body>
</html>

此时会先打印out module,再打印模块内部的module。
效果等同于在script标签上加上defer属性:

<html>
<header></header>
<body>
    <script defer src="./module.js"></script>
    <script>
       console.log('out module')
    </script>
</body>
</html>

导入导出

  • export {...} 是一种语法,不是对象字面量。
const foo = 'foo'
// 此处并不是对象字面量
export {
    foo
}
// 如果是对象字面量,那么应该支持如下写法
// 实际上,这样写会报错
export {
    foo: 'foo'
}
  • import导入之后不能再改变变量
import { foo } from './moduleA.js'
// 不允许改变引用的变量
foo = '123'
  • import可以导入相对路径,绝对路径和url

相对路径
import { foo } from './moduleA.js'
绝对路径
import { foo } from '/moduleA.js'
url:

import { foo } from 'http://localhost:8080/moduleA.js'
  • import后面直接根文件路径,此时是只导入,不引用。

如果某个模版文件module.js中没有通过export导出成员,那么可以通过import '' 的方式导入模块。

import './moduleA.js'
  • import动态导入

如果模块中需要在运行的时候才知道导入模块地址或者在某个逻辑下才导入某个模块,那么import from 的方式就会报错。
错误导入:

// 地址不明确(开发阶段)
const moduleA = './moduleA.js'
import { foo } from moduleA
// 在某些逻辑中导入成员
if(true) {
    import { foo } from './moduleA.js'
}

这种情况下,可以使用Modules 提供的import函数,该函数返回一个promise对象,因为是个函数,所以可以在任何地方使用。

const moduleA = './moduleA.js'
import(moduleA).then(module => {
    // module中包含模块所有的导出成员
    console.log(module.foo)
})
  • import同时导入默认成员和具名成员

在某个模块中,如果需要同时导入默认成员和具名成员,可以以如下方式导入:

import { foo, default as sayHi } from './moduleA.js'
// 或者
import sayHi, { foo } from './moduleA.js'
  • 直接导出导入成员

在某些模块文件中,可能需要从别的模块导入某个成员,然后在这个模块直接导出这个成员。
正常写法:

import { foo } from './moduleA.js'
export {
    foo
}

简略写法:

export { foo } from './moduleA.js'

运行环境兼容

浏览器

目前,并不是所有浏览器都支持ES Modules特性,如IE。利用nomodule可以实现优雅降级。

<html>
<header></header>
<body>
    <script type="module">
        import module from './module.js'
    </script>
    <script nomodule>
        alert('你的浏览器版本不支持ES Modules')
    </script>
</body>
</html>

在支持modules的浏览器中,会运行type='module'的脚本,在不支持的浏览器中,会忽略模块文件,并运行nomodule对应的script脚本。

nodejs

nodejs在8.0版本开始支持ES Modules。想要在nodejs中使用,需要满足两个条件:

  • 文件扩展名为.mjs
  • 运行时需要加--experimental-modules参数
// moduleA.mjs
export const foo = 'foo'
export default function(){
    console.log(foo)
}
// moduleB.mjs
import { foo } from './moduleA.mjs'
console.log(foo)

此时通过命令行运行node .moduleB.mjs --experimental-modules,可以正常工作。

commonjs交互

在mjs的文件中,可以导入commonjs定义的模块,始终导入一个默认对象。

// moduleA.js
module.exports = {
    foo: 'foo'
}
// moduleB.mjs
import * as moduleA from './moduleA.js'
console.log(moduleA.foo)

反过来,不能在commonjs定义的模块中导入ESModules定义的对象。

在最新的nodejs中可以在package.json中添加type:'module'属性,此时,模块文件的扩展名就不需要再使用mjs,但是相应的,使用commonjs定义的文件扩展名需要为cjs。

区别

在commonjs定义的模块文件中,可以使用requie,module, exports, __filename, __dirname全局对象,但是ESModules模块文件中没有这些全局对象,可以使用import.meta属性中的某些属性获取相应的值。

// 可以使用路径解析获取filename和dirname
console.log(import.meta.url)

模块化打包

在浏览器环境中直接使用ESM(ES Modules)特性,会出现如下问题:

  • 并不是所有浏览器都支持ESM特性。
  • 模块化文件过多会导致网络请求频繁。
  • ESM只支持js文件模块化,css、图片、字体等文件不支持模块化。

为了解决上述问题,就出现了模块化打包工具。此类工具会让开发者在开发阶段使用模块组织资源、代码等,在上线前,通过打包,重新组织模块化的文件,以解决上述问题。

webpack

目前,最常用的模块化打包工具就是webpack,通过webpack可以快速实现模块化打包。

安装依赖: npm install webpack webpack-cli --save-dev
执行打包: npm run webpack
webpack插件会自动认为当前目录下的src/index.js为打包入口文件,查找所有依赖并打包到dist/main.js中。
当然,webpack也支持配置文件,可以在项目根目录下添加webpack.config.js文件:

const path = require('path')
module.exports = {
    // 指定打包入口文件
    entry: './src/index.js',
    output: {
        // 打包输出文件名
        filename: 'bundle.js',
        // 打包输入文件夹,必须使用绝对路径
        path: path.join(__dirname, 'dist')
    }
}

利用配置文件可以修改webpack的默认配置。

工作模式

webpack的工作模式分为三种:node, development,production。可以通过设置工作模式,以应对不同的打包需求。webpack默认使用production模式打包,会自动优化打包结果。

在配置文件中设置模式:

const path = require('path')
module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    mode: 'none' // 'development production'
}

development:打包后的代码和开发代码一样,可读性强,不会自动优化。
none: 删除webpack打包过程中生成的注释代码,其余和development相同。
production:打包后的代码会自动优化。

loader

在webpack中万物皆可模块,只是webpack内置了如何处理js代码,其他资源如css,图片等需要使用相应的loader进行转换。

由于webpack默认应用是由js驱动的,因此想要打包其他资源文件,需要在js代码中建立与其他资源文件的联系,即导入。

import 'logo.png'
import 'common.css'

css

可以利用css-loader和style-loader配置处理导入的css文件。

原理是css-loader将css代码转换为js模块(将css中的内容放到一个数组中并导出)。style-loader获取转换后的字符串并转换为style节点添加到html文件的header节点中。

安装依赖:npm install --save-dev css-loader style-loader。
添加配置:

module.exports = {
  // ...
  module: {
    rules: [
      {
        // 告诉webpack,以css结尾的文件需要通过这里配置的loader进行转换。
        test: /.css$/,
        // 转换用的loader,执行顺序自后向前
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  }
}

图片

图片也是一种资源文件,需要loader进行处理。可以利用file-loader处理图片资源,原理是将图片单独导出为一个文件,然后在js模块中导出转换后的图片路径。
加载依赖:npm install --save-dev file-loader。
添加配置:

const path = require('path')
module.exports = {
  // ...
  module: {
    rules: {
      {
        test: /.png$/,
        use: 'file-loader'
      },
      //...
    ]
  }
}

当然,也可以利用Data URLs协议,该协议允许在文档中嵌入小文件。
Data URLs 由四个部分组成:

  • 前缀(data:)
  • 指示数据类型的MIME类型
  • 如果非文本则为可选的base64标记
  • 数据本身
data: [<mediatype>][;base64], <data>

如果使用该协议,那么可以利用url-loader,该loader可以将图片资源转换为url。针对大文件,可以设置limit属性,当超过limit限制的大小后,url-loader将图片作为单独的文件打包。
添加依赖:npm install --save-dev url-loader
添加配置:

const path = require('path')

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /.png$/,
        use: {
          loader: 'url-loader',
          options: {
            // 添加文件大小限制
            limit: 10 * 1024 // 10 KB
          }
        }
      },
      // ...
    ]
  }
}

触发时机

既然所有资源都可以通过loader进行模块化处理,那么在什么情况下,webpack会将资源识别为一个模块呢?
如下情况会被识别:

  • ES Modules import导入
  • commonjs require导入
  • amd模式下的define和require
  • html节点中的src属性
  • css文件中的import和url

如果想要识别html中的src属性,需要配合html-loader使用:

{
    test: /.html$/,
    use: {
      loader: 'html-loader',
      options: {
        // 指定哪些attr会被识别为模块资源
        attrs: ['img:src', 'a:href']
      }
    }
}

ES特性转换

如果在js代码中使用了ES的新特性,webpack本身并不会转换这些特性,需要使用babel-loader。
加载依赖:npm install --save-dev babel-loader @babel/babel-core @babel/preset-env。
添加配置:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      },
      // ...
    ]
  }
}

自定义loader

webpack提供了大量的用于转换的loader,loader的本质是完成资源文件输入和输出之间的转换。在特定情况下,我们需要自己定义符合要求的loader,字定义loader文件默认导出一个函数,函数的参数是读取到的文件内容或者是另一个loader处理后的内容。
下面是一个转换md文件的自定义loader:

const marked = require('marked')
module.exports = source => {
  // 利用marked将md文档转为html可识别的字符串
  const html = marked(source)
  // 需要返回js能识别的模块字符串
  // return `module.exports = "${html}"`
  // return `export default ${JSON.stringify(html)}`
  // 或者返回 html 字符串交给下一个 loader 处理
  return html
}

添加配置:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /.md$/,
        use: [
           // html-loader将markdown-loader返回的html字符串转换为一个模块
          'html-loader',
          './markdown-loader'
        ]
      }
    ]
  }
}

Plugin

loader实现了资源文件的转换,相比于loader,plugin可以实现其他自动化工作,如清空输出文件夹、自动在html中注入打包后的js文件等。

plugin拥有更宽的能力范围,通过在webpack打包生命周期中挂在函数实现webpack扩展。

清空输出文件夹

添加clean-webpack-plugin插件,可以在每次打包之前自动清空上次的打包结果。
添加依赖:npm install --save-dev clean-webpack-plugin。
添加配置:

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
  // ...
  module: {
    rules: [
        // ...
    ]
  },
  plugins: [
    new CleanWebpackPlugin()
  ]
}

自动生成html

通过html-webpack-plugin插件可自动在打包输出文件夹下生成html文件,生成的html文件中可实现如下自动化功能:

  • 自动添加打包后的js文件。
  • 添加字定义meta属性。
  • 可利用模板编译,自动加入变量。

添加依赖:npm install --save-dev html-webpack-plugin。
添加配置:

const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  // ...
  module: {
    rules: [
      //...
    ]
  },
  plugins: [
    // ...
    // 用于生成 index.html
    new HtmlWebpackPlugin({
      // 模板编译,替换模板文件中的`<%= htmlWebpackPlugin.options.title %>`
      title: 'Webpack Plugin Sample',
      // 添加meta头
      // 相当于在html header中添加`<meta name="viewport" content="width=device-width">`
      meta: {
        viewport: 'width=device-width'
      },
      // 指定模板文件
      template: './src/index.html'
    }),
    // 可以添加多个HtmlWebpackPlugin,用于生成多个html文件
    // 用于生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    })
  ]
}

复制静态资源

在public文件夹下的诸如favicon.ico文件是不需要打包的,可以直接复制到输出目录下。
添加依赖:npm install copy-webpack-plugin --save-dev。
添加配置:

const CopyWebpackPlugin = require('copy-webpack-plugin')

module.exports = {
  // ...
  module: {
    rules: [
    // ...
    ]
  },
  plugins: [
    // 指定直接复制的文件路径
    new CopyWebpackPlugin([
      // 'public/**'
      'public'
    ])
  ]
}

自定义plugin

虽然webpack提供了大量的plugin插件用于完成日常开发工作,但是某些情况下,需要我们添加字定义plugin。

字定义plugin是一个函数或者是一个包含apply方法的类。

下面的例子是自动删除打包后js文件中每一行开头的/******/

class MyPlugin {
  apply (compiler) {
    // 注册生命周期函数
    // 此例中的emit是在完成打包后,将要输出到输出目录的节点执行。
    // compilation 是此次打包的上下文,包含所有的打包的资源文件。
    compiler.hooks.emit.tap('MyPlugin', compilation => {
      for (const name in compilation.assets) {
        // 通过name属性可以获取文件名称
        if (name.endsWith('.js')) {
          // 通过source方法获取相应内容
          const contents = compilation.assets[name].source()
          const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
          // 替换原有的内容,需要实现source方法和size方法。
          compilation.assets[name] = {
            source: () => withoutComments,
            size: () => withoutComments.length
          }
        }
      }
    })
  }
}

添加配置:

module.exports = {
  // ...
  module: {
    rules: [
      // ...
    ]
  },
  plugins: [
    new MyPlugin()
  ]
}

增强开发体验

通过上述的loader和plugin可以完成项目的打包工作,但是我们需要在开发时能够增强开发体验,如自动编译,自动刷新,方便调试等。

自动编译

webpack内置了自动编译功能,其可以自动监听文件变化,自动打包运行。可以在命令执行的时候添加--watch实现。

自动刷新浏览器

当webpack能够自动打包后,我们希望在开发环境下能够自动刷新浏览器。webpack提供了webpack-dev-server插件,可以实现此功能。

安装依赖:npm install webpack-dev-server --save-dev。
在命令行中运行: npm run webpack-dev-server --open 即可实现自动打开浏览器,自动刷新浏览器。

浏览器中打开的资源指向的webpack输出的目录,但是在开发阶段,public中的静态文件并没有被打包进去,此时会造成资源丢失,可以利用配置文件中的devServer属性完成此功能配置。

devServer: {
    // 指定其他静态资源文件地址
    contentBase: './public'
    // 还可以利用proxy实现开发阶段的服务端代理。
}

自动刷新浏览器虽然解决了部分开发优化问题,但是自动刷新会导致页面状态全部丢失(在input中输入测试文字,刷新后测试文字没有了,需要再次手动输入),这样还不是很友好。

为了解决刷新导致的页面状态丢失问题,webpack还提供了HRM热更新,热更新可以保证模块发生变化后,页面只会替换相应变化的部分,不会导致状态丢失。

在webpack中启动热更新,可以添加--hot参数。

测试发现,HRM可以热更新css和图片等资源文件,但是针对js文件,无法做到自动替换,还是需要刷新浏览器,这种情况下,需要我们手动添加热更新处理代码。

例如在某个模块中,当依赖模块发生变化(页面中的某个元素发生变化),可以通过如下代码监控代码变化,并手动完成热更新功能:

let hotEditor = editor
module.hot.accept('./editor.js', () => {
    // 获取元素的状态:即获取用户已经输入的内容
    const value = hotEditor.innerHTML
    // 移除旧有的页面元素
    document.body.removeChild(hotEditor)
    // 创建一个变化后的元素
    hotEditor = createEditor()
    // 将移除之前存储的状态赋值给新的元素
    hotEditor.innerHTML = value 
    // 将新的元素添加到页面上
    document.body.appendChild(hotEditor)
})

其他和热更新相关的:

--hotOnly: 使用这个替代--hot参数可以屏蔽热更新代码中的错误,热更新代码只是辅助开发用的,如果其中出现错误并不需要被控制台输出。

module.hot: 在模块中可以通过判断module.hot来获取当前项目是否开启了热更新,如果没有开启,那么代码打包过程中会自动删除热更新逻辑,不影响生产环境。

source-map 调试

webpack打包后的代码不利于开发阶段调试,因此需要source-map来定位错误,解决源代码和运行代码不一致导致的问题。

感受一下source-map的魅力:
在浏览器控制台输入:eval('console.log("foo")')
image.png
红色框中显示的是代码在哪执行,这个显示很不友好。
再次输入:eval('console.log(123) //# sourceURL=foo.js')
image.png
通过添加sourceURL就可以告诉控制台这个代码是在哪个文件中执行的。

webpack中通过简单的配置即可实现source-map:
devtool: 'eval',
其中devtool的值是source-map的类型,webpack支持12中source-map类型:
image.png
通常情况下,打包速度快的,调试效果一般都不好,调试效果好的,一般打包速度比较慢,在项目中具体使用哪种类型,需要自己去斟酌。

  • eval

模块中的代码通过eval去执行,在eval的最后添加sourceURL,并没有添加source-map,只能定位哪个文件中出现错误。

  • eval-source-map:

在eval的基础上添加了source-map,可以定位错误的具体行列信息。

  • cheap-eval-source-map

阉割版的eval-source-map,只能定位到行,无法定位列信息。

  • cheap-module-eval-source-map

在cheap-eval-source-map的基础上,可以保证定位的行信息和源文件的行相对应。

  • inline-source-map

普通的source-map中,map文件是物理文件,而inline-source-map模式下,map文件是以Data URLs的形式打包到文件的末尾。

  • hidden-source-map

生成了map文件,但是打包后的末尾没有添加该map文件,保证了源代码不会暴露,同时在调试时,可以手动将map文件添加到文件末尾进行调试。

  • nosources-source-map

可以定位错误的行列信息,但是无法在开发工具中看到源代码。

生产环境优化

生产环境和开发环境的关注点是不一样的,开发环境注重开发效率,生产环境注重运行效率。

不同环境,不同配置文件

webpack鼓励为不同的环境设置不同的配置文件,可以通过以下两种方式实现。

  • 在一个配置文件中,通过判断不同环境导出不同的配置。
  • 添加多个配置文件,指定webpack运行时的配置文件。

同一个配置文件中,导出一个函数,此函数返回一个配置对象:

const webpack = require('webpack')
module.exports = (env, argv) => {
  const config = {
    // ...
  }
  // 判断是那种环境
  if (env === 'production') {
    config.mode = 'production'
    config.devtool = false
    config.plugins = [
      ...config.plugins,
      new CleanWebpackPlugin(),
      new CopyWebpackPlugin(['public'])
    ]
  }
  return config
}

多个配置文件:
添加公用配置文件: webpack.common.js

module.exports = {
  // ....
}

添加生产环境配置文件

const merge = require('webpack-merge')
const common = require('./webpack.common')
// 使用webpack-merge实现配置文件的合并
module.exports = merge(common, {
  mode: 'production',
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin(['public'])
  ]
})

使用时,通过--config参数指定配置文件。

DefinePlugin

可以利用webpack内置的DefinePlugin为代码注入全局成员,打包时,webpack会自动利用指定的值替换代码中出现的全局成员。
定义成员:

const webpack = require('webpack')
module.exports = {
  // ...
  plugins: [
    new webpack.DefinePlugin({
      // 值要求的是一个代码片段
      API_BASE_URL: JSON.stringify('https://api.example.com')
    })
  ]
}

使用成员:

console.log(API_BASE_URL)

打包后:

console.log('https://api.example.com')

合并

模块打包会导致最终输出的文件夹中模块文件过多,可以利用模块合并尽可能的将模块合并到一个函数中,减少模块数量。
启用合并:

module.exports = {
  // ...
  optimization: {
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true,
  }
}

Tree-shaking

Tree-shaking指的是去除代码中未引用的代码,也就是无用代码。通过去除无用代码,可以减少代码文件体积,优化加载速度,webpack默认在production模式下启动Tree-shaking。

webpack中没有明确的某个配置用于启动Tree-shaking,它是一系列配置一起完成的功能。

module.exports = {
  // ...
  optimization: {
    // 打包后的模块只导出被使用的成员
    usedExports: true,
    // 压缩输出结果,在压缩的过程中后自动删除未被导出的代码
    minimize: true
  }
}

有的时候,人们会认为Tree-shaking和babel转换相冲突,也就是用了babel转换会导致Tree-shaking失败。

其实,二者是不冲突的,Tree-shaking依赖的是ESM,通过对import的分析达到去除无用代码的效果。babel转换的时候会默认将ESM编写的模块转换为Commonjs规范的模块,因此会导致Tree-shaking失败。

通过为babel-loader的presets添加配置可以让babel转换的时候不将ESM转换为Commonjs:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              // 如果 Babel 加载模块时已经转换了 ESM,则会导致 Tree Shaking 失效
              // ['@babel/preset-env', { modules: 'commonjs' }]
              // ['@babel/preset-env', { modules: false }]
              // 也可以使用默认配置,也就是 auto,这样 babel-loader 会自动关闭 ESM 转换
              ['@babel/preset-env', { modules: 'auto' }]
            ]
          }
        }
      }
    ]
  }
}

副作用

副作用指的是模块除了导出成员之外,还进行了其他操作。如在模块中引入了css文件,这个引入过程并没有使用内部成员,因此在Tree-shaking的时候就会被自动去掉。

为了避免因为Tree-shaking去掉导致项目运行失败,需要进行副作用代码标记。

添加启用副作用配置:

module.exports = {
  // ...
  optimization: {
    sideEffects: true,
  }
}

在package.json中指定副作用文件地址:

"sideEffects": [
    "./src/extend.js",
    "*.css"
]

指定位置的文件不会被Tree-shaking当作无用代码删除。

代码分割

如果将所有的资源都打包到一个文件中,那么这个文件会过大,导致加载时间过长,影响项目体验,此时,需要根据情况,对项目打包进行代码分割,代码分割通常伴随多入口打包。

const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  // 提供多个代码打包入口
  entry: {
    // 将index.js入口的所有文件打包到index的chunk中。
    index: './src/index.js',
    album: './src/album.js'
  },
  plugins: [
    // 针对多个入口生成多个html文件
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      // 指定html文件依赖的chunk
      chunks: ['index']
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}

提取公共模块

在代码分割时,如果多个入口文件依赖一些公用代码,这些公用代码被打包到每个文件中,会增加文件体积,此时需要提取公共模块到一个单独文件中。
添加配置:

module.exports = {
  optimization: {
    splitChunks: {
      // 自动提取所有公共模块到单独 bundle
      chunks: 'all'
    }
  }
}

按需加载

如果在项目启动时,加载所有模块,那么会因为请求过多导致加载时间长,此时可以利用动态导入模块的方式实现按需加载,所有动态导入的模块会自动分包。

import(
// webpackChunkName是一种webpack中的魔法注释,通过魔法注释,可以指定动态导入的模块打包后生成的文件名,同时,多个动态导入的模块如果注释的名字相同,那么会被打包到一个文件中。
/* webpackChunkName: 'components' 
*/'./posts/posts').then(({ default: posts }) => {
      mainElement.appendChild(posts())
    })

MiniCssExtractPlugin

目前情况下,css样式都是包含在html的style标签中,通过MiniCssExtractPlugin插件可以将css提取到单个文件中,但是并不是每个项目中的css都是需要单独提取的,如果提取后的文件中css样式较少,那么会导致项目请求过多。
添加配置:

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', 
          // 配合MiniCssExtractPlugin使用
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
}

OptimizeCssAssetsWebpackPlugin

默认情况下,webpack只针对js文件进行压缩,如果需要对css文件进行压缩,那么需要使用OptimizeCssAssetsWebpackPlugin插件。
添加配置:

const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
 // ....
  plugins: [
    new OptimizeCssAssetsWebpackPlugin()
  ]
}

上面的配置可以实现css压缩,但是webpack官方推荐将OptimizeCssAssetsWebpackPlugin配置在opitimization属性中,这样,在webpack打包的时候,如果启用了项目优化,那么就会进行css压缩,反之则不会启用,便于统一管理。

const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

module.exports = {
  optimization: {
    minimizer: [
      // 实现js文件压缩
      new TerserWebpackPlugin(),
      // 实现css文件压缩
      new OptimizeCssAssetsWebpackPlugin()
    ]
  }
}

文件名hash

浏览器中运行的前端项目避不开的就是缓存,利用缓存可以加快项目的加载速度,但是有的时候缓存会影响项目更新,此时为项目中的文件添加hash,由于文件发生变化,打包后的hash值不同,也就是浏览器下载文件的地址就不同,避开了缓存的影响。

webpack中有三种hash模式:

  • hash

项目级别的hash,项目下所有的打包文件使用同一个hash值。

output: {
    // 8表示生成hash值的位数
    filename: '[name]-[hash:8].bundle.js'
}
  • chunkhash

chunk级别的hash,项目中,属于同一个chunk的文件的hash值相同,如js文件中导入了css文件,那么打包后,对应的js文件和css文件的hash值相同。

output: {
    filename: '[name]-[chunkhash:8].bundle.js'
}
  • contenthash

文件级别的hash,也就是每个文件都有单独的hash值。

output: {
    filename: '[name]-[contenthash:8].bundle.js'
}

rollup

rollup也是一种打包工具,相比webpack来说,rollup更加小巧,是专注于ESM各项特性的高效打包器。

在使用rollup之前,需要安装依赖: npm install --save-dev rollup

快速使用

在项目目录下创建三个测试文件:

  • a.js
import { log } from './b.js'
import foo from './c.js'

// 使用
log(foo)
  • b.js
export function log(msg) {
    console.log(msg)
}

export function error(msg) {
    console.error(msg)
}
  • c.js
export default 'foo' 

在命令行中执行: npx rollup ./a.js --format iife --file dist/bundle.js就可以完成快速打包。

--format 指定打包输出格式,iife表示自执行函数。
--file 指定输出文件目录。

打包后的结果如下:

(function () {
    'use strict';

    function log(msg) {
        console.log(msg);
    }

    var foo = 'foo';

    // 使用
    log(foo);

}());

可以看到,相比于webpack,rollup打包后代码更加简洁,而且其默认执行了Tree-shaking,去除了未使用的代码。

配置文件

rollup也支持使用配置文件,只不过即使在项目下添加了rollup.config.js文件,在使用的时候依然要用--config参数指定配置文件路径。

export default {
    input: 'a.js',
    output: {
        file: 'dist/bundle.js',
        format: 'iife'
    }
}

插件

插件是rollup的唯一扩展方式。

rollup-plugin-json

可以利用此插件在打包的时候读取项目目录下的json文件。

安装依赖: npm install --save-dev rollup-plugin-json

在配置文件中添加该插件:

import json from 'rollup-plugin-json'
export default {
    input: 'a.js',
    output: {
        file: 'dist/bundle.js',
        format: 'iife'
    },
    plugins: [
        json()
    ]
}

此时,我们修改a.js文件,让其读取跟目录下的package.json文件。

import { name, version } from './package.json'
console.log(name, version)

打包后:

(function () {
  'use strict';

  var name = "rollup-test";
  var version = "1.0.0";

  console.log(name, version);

}());

会发现,rollup将json中的相应数据赋值给变量,然后在代码中就可以使用该变量了。

rollup-plugin-node-resolve

rollup并不能像webpack那样直接在项目中导入node_modules中的node模块,需要使用rollup-plugin-node-resolve插件。

安装依赖: npm install --save-dev rollup-plugin-node-resolve

import json from 'rollup-plugin-json'
import reslove from 'rollup-plugin-node-resolve'
export default {
    input: 'a.js',
    output: {
        file: 'dist/bundle.js',
        format: 'iife'
    },
    plugins: [
        json(),
        reslove()
    ]
}

添加完上述配置后,就可以像webpack那样通过import方式引入node模块,需要注意的是此插件仅支持引入符合ESM规范的模块。

rollup-plugin-commonjs

由于rollup在设计时就是专门打包ESM规范的文件,因此其需要配合rollup=plugin-commonjs插件来导入符合commonjs规范的模块。

安装依赖: npm install --save-dev rollup=plugin-commonjs

添加配置:

import json from 'rollup-plugin-json'
import reslove from 'rollup-plugin-node-resolve'
import commonjs from 'rollup=plugin-commonjs'
export default {
    input: 'a.js',
    output: {
        file: 'dist/bundle.js',
        format: 'iife'
    },
    plugins: [
        json(),
        reslove(),
        commonjs()
    ]
}

其他

代码拆分

rollup也支持动态导入,动态导入的模块会自动拆分为一个单独的文件。

多入口打包

将配置文件的input属性改为一个数组,也可以让rollup支持多入口打包。

需要注意的是,如果使用动态导入或者多入口打包,因为最终打包结果会是多个文件,此时输出的格式就不能再使用iife,可以使用amd。

优缺点

rollup相比webpack有着其天然的优缺点。

优点:

  1. 输出结果更加扁平
  2. 自动移除未引用代码
  3. 打包结果依然完全可读

缺点:

  1. 加载非ESM模块比较复杂
  2. 模块最终都被打包到一个函数中,无法实现hrm
  3. 浏览器环境中,代码拆分依赖amd库(requirejs)

因此在开发一个应用程序的时候,可以选用webpack,当开发一个框架或者类库的时候,可以使用rollup(vue中就有rollup的影子)。

Parcel

相比于webpack和rollup,Parcel是最近才出现的打包工具,它的初衷是通过最少的配置完成前端打包,和它形成鲜明对比的是webpack的配置量比较大,需要开发人员掌握的东西比较多。不过随着webpack的流行,人们已经熟悉了其配置,因此Parcel的位置有点尴尬。

不过其也有可以借鉴的地方,如自动安装模块等功能。

规范化

随着前端项目的体量越来越大,对开发人员的要求也越来越高。尤其针对团队协作的项目,规范化要求越来越高。

目前,项目的规范化不仅体现在代码上,也体现在文档编写和提交日志等方方面面。

ESLint

eslint是目前最常用的代码规范检查插件,其不仅可以检查代码规范,同时也可以检查代码中的语法错误。

eslint使用非常方便,通过npm install --save-dev eslint 安装相应模块,通过npx eslint --init安装配置文件,然后就可以使用。

git hooks

虽然项目开发时,可以约定大家在提交代码前需要lint一下代码,但是这样无法避免未lint代码的提交,此时就可以使用git hooks。

git hooks指的是git钩子,每一个钩子对应git的一个命令,命令执行的时候,会触发相应的钩子执行,通过将lint添加到git钩子中,就可以保证代码入库之前经历过lint检查。

钩子体验

每一个git项目中都有一个.git文件夹,在其内部hooks文件夹下默认放置了所有的钩子脚本(shell 脚本)。

image.png

找到其中的pre-commit开头的文件,其内部的脚本会在git commit之前执行,手动去掉.sample后缀,再其内部添加一段简单的脚本:

#!/bin/sh
echo "pre commit"

此时在命令行中执行commit命令,就会发现我们添加的脚本被执行了。

image.png

Husky

并不是所有人都能熟练的写脚本命令,因此可以通过Husky模块,在项目中使用这些git hooks。

安装依赖: npm install --save-dev husky。

在package.json中添加Husky属性:

"Husky": {
   "hooks": {
      "pre-commit": "npm run lint"
   }
 }

上述配置的意思就是说,在pre commit钩子执行的时候执行npm run lint脚本,这样就更加方便项目使用。

lint-staged

虽然husky解决了编写脚本的问题,但是husky内部只能为每个钩子注册一件事,如果我们想在提交之前利用Prettier自动格式化文件,elint检查文件,然后git add提交文件,那么就可以使用lint-staged。

安装依赖:npm install --save-dev lint-staged

修改package.json中的配置:

 "scripts": {
    "precommit": "lint-staged"
  },
  "Husky": {
    "hooks": {
      "pre-commit": "npm run precommit"
    }
  },
  "lint-staged": {
    "*.js": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  }

这样配置的化,就可以在commit提交之前,首先触发pre-commit钩子,该钩子会执行npm run precommit,执行precommit的时候会找到lint-staged下匹配该文件的配置,然后依次执行prettier,eslint和git add,方便使用。

StyleLint

stylelint和eslint在用法上相似,只不过其是检查css代码。

Prettier

prettier是一种前端代码格式化工具,可以通过它一键格式化项目中的js,css,sass,vue,jsx,json等文件。


carry
58 声望7 粉丝

学无止境