一、模块化概述
- 模块化开发是当下最重要的前端开发范式之一。
- 随着前端应用的日益复杂,我们的项目代码已经逐渐膨胀到了不得不花大量时间去管理的程度了。
- 模块化就是一种最主流的代码组织方式,它通过把我们的复杂代码按照功能的不同,划分为不同的模块单独维护的这种方式,去提高我们的开发效率,降低维护成本。
- 模块化只是思想,不包含具体实现。
二、模块化演变过程
Stage1 - 文件划分方式
具体做法:
将每个功能以及它相关的一些状态数据,单独存放到不同的文件当中,我们去约定每一个文件就是一个独立的模块。我们去使用这个模块,就是将这个模块引入到页面当中,然后直接调用模块中的成员(变量 / 函数)。一个script标签就对应一个模块,所有模块都在全局范围内工作。
缺点:
- 污染全局作用域
- 命名冲突问题
- 无法管理模块依赖关系
早期模块化完全依靠约定。
Stage2 - 命名空间方式
具体做法:
我们约定每个模块只暴露一个全局的对象,我们所有的模块成员都挂载到这个全局对象下面。
在第一阶段的基础上,通过将每个模块「包裹」为一个全局对象的形式实现,有点类似于为模块内的成员添加了「命名空间」的感觉。
缺点:
- 没有私有空间
- 模块成员仍然可以在外部被访问/修改
- 无法管理模块依赖关系
Stage3 - IIFE
具体做法:
使用立即执行函数的方式,去为我们的模块提供私有空间。将模块中每个成员都放在一个函数提供的私有作用域当中,对于需要暴露给外部的成员,我们可以通过挂载到全局对象上的这种方式去实现。确保了私有成员的安全。
有了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问。
Stage4 - 利用 IIFE 参数作为依赖声明使用
具体做法:
在第三阶段的基础上,利用立即执行函数的参数传递模块依赖项。
这使得每一个模块之间的关系变得更加明显。
以上四个阶段就是早期在没有工具和规范的情况下,通过约定的方式,对模块化的落地方式。
三、模块化规范
我们需要的是模块化标准+模块加载器。
CommonJS规范(nodeJS):
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过module.exports导出成员
- 通过require函数载入模块
CommonJS约定的是以同步模式加载模块,在浏览器端使用会导致效率低下。
AMD:(异步的模块定义规范)
Require.js实现了这个规范。
目前绝大多数的第三方库都支持AMD规范。
- AMD使用起来相对复杂
- 模块JS文件请求频繁
Sea.js+CMD
类似CommonJS规范,使用上跟Require.js差不多。
模块化标准规范
模块化的最佳实践:
ES Modules
通过给script添加type=module的属性,就可以以ES Module 的标准执行其中的JS代码。
<script type="module">console.log('This is es module.'); //This is es module.</script>
ES Modules基本特性
- ESM 自动采用严格模式,忽略 ‘use strict’
<script>console.log(this); //Window </script> <script type="module">console.log(this); //undefined </script>
- 每个 ES Module 都是运行在单独的私有作用域中
<script type="module">var bar = 88console.log(bar); //88 </script> <script type="module">console.log(bar); //Uncaught ReferenceError: bar is not defined </script>
- ESM 是通过 CORS(服务端必须支持) 的方式请求外部 JS 模块的
<script type="module" src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script> <!-- Access to script at 'https://libs.baidu.com/jquery/2.0.0/jquery.min.js' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. --> <script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script> <!-- 成功请求到 -->
- ESM 的 script 标签会自动延迟执行脚本 (类似defer属性,等待网页渲染完成,再去执行脚本,这样不会阻塞页面元素的显示)
<script type="module" src="demo.js"></script> <p>hello world</p>
ES Modules导入和导出
//导出const bar = 'hello'export {bar}//导入import {foo} from './module.js'console.log(foo)
注意:
- 导出的成员并不是一个字面量对象;导入的时候也不是解构,是固定语法
- 导出时,并不是导出的成员的值,只是导出的成员的存放地址
- 外部导入的成员是只读的,不可以修改
ES Modules导入的注意事项:
- 导入模块路径(路径必须写完整,不能省略后缀名、相对路径/不可省略、可以使用绝对路径或者完整的URL)
import { name, age } from './module.js'
- 执行模块,并不提取其中的成员
import {} form './module.js' import './module.js' //简写法
- 导入成员多
import * as obj from './module.js' console.log(obj.name);
- 动态导入模块
import('./module.js').then(module => {console.log(module); })
- 同时导入默认成员和命名成员
//导出默认成员和命名成员 export { name, age } export default 'default export' //导入默认成员和命名成员 import { name, age, default as title } from './module.js' //简写 import title, { name, age } from './module.js'
ES Modules导出导入成员:
// import { Button } from './button.js'
// import { Avatar } from './avatar.js'// export { Button, Avatar }//简化-导出导入成员方式
export { default as Button } from './button.js'
export { Avatar } from './avatar.js'
ES Modules浏览器环境Polyfill
<script nomodule src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
<script type="module">import { foo } from './module.js'console.log(foo);
</script>
ES Modules in Node.js
import { foo, bar } from './module.mjs'
console.log(foo, bar); //hello china//载入原生模块
import fs from 'fs'
fs.writeFileSync('./foo.txt', 'ES Module working')//系统内置模块兼容了 ESM 的提取成员方式
import { writeFileSync } from 'fs'
writeFileSync('./bar.txt', 'ES Module working')//载入第三方模块
import _ from 'lodash'
console.log(_.camelCase('ES Module')); //esModule//不支持,因为第三方模块都是导出默认值
// import { camelCase } from 'lodash'
// console.log(camelCase('ES Module')); //SyntaxError: The requested module 'lodash' does not provide an export named 'camelCase'//运行命令
// node --experimental-modules index.mjs
ES Modules in Node.js与CommonJS交互
注意事项:
- ES Module中可以导入CommonJS模块
- CommonJS中不能导入ES Module模块
- CommonJS模块始终只会导出一个默认成员
- 注意import不是解构导出对象
ES Modules in Node.js与CommonJS的差异
四、常用的模块化打包工具
引入模块化后,带来的新问题:
- ES Modules存在环境兼容问题
- 模块文件过多,网络请求频繁
- 所有的前端资源都需要模块化
无容置疑,模块化是必要的。
预期的工具需要满足以下设想:
- 新特性代码编译
- 模块化JavaScript打包
- 支持不同类型的资源模块
模块打包工具-Webpack
打包工具解决的是前端整体的模块化,并不单指JavaScript模块化。
Webpack
- 模块打包器
- 模块加载器
- 代码拆分
- 资源模块
Webpack配置
Webpack 4 以后支持零配置方式直接启动打包,整个打包过程会按照约定将src/index.js作为打包的入口,最终打包结果会存放到dist/main.js中。
大多时候,我们需要自定义配置文件:
Webpack资源模块加载
Webpack内部默认只会处理JavaScript文件,其他类型文件,我们需要去添加不同类型的loader。
const path = require('path')
module.exports = {mode: 'none',entry: './src/main.css',output: {filename: 'bundle.js',path: path.join(__dirname, 'dist')},module: {//其他类型文件加载器的规则配置rules: [{test: /.css$/,use: ['style-loader','css-loader']}]}
}
Loader是Webpack的核心特性,借助于Loader就可以加载任何类型的资源。
Webpack导入资源模块
Webpack建议我们在编写代码过程中,根据代码的需要动态导入资源,因为需要资源的不是应用,而是代码。
JavaScript驱动了整个前端应用:
- 逻辑合理,JS确实需要这些资源文件。
- 确保上线资源不缺失,都是必要的。
Webpack文件资源加载器
用来加载图片、字体等资源文件。通过拷贝物理文件的形式去处理文件资源。
WebpackURL加载器
我们可以通过Data URLs形式去表示文件,如:
Data URLs是一种特殊的URL协议,可以用来直接去表示一个文件。传统URL要求服务器上有一个对应的文件,通过请求这个地址得到服务器上对应的这个文件。Data URLs是一种当前URL就可以直接去表示文件内容的方式,这种URL当中的文本就已经包含了文本的内容。使用这种URL时,就不会再去发送任何的HTTP请求。
加载器:url-loader
最佳实践:
- 小文件使用Data URLs,减少请求次数
- 大文件单独提取存放,提高加载速度
可配置:
rules:[{test: /.jpg$/,//use: 'url-loader'//'file-loader'use: {loader: 'url-loader',options: {limit: 10 * 1024 //10KB}}}
]
- 超出10KB文件单独提取存放
- 小于10KB文件转换为Data URLs嵌入代码中
- 必须配合file-loader一起使用
Webpack常用加载器分类
- 编译转换类 (如:css-loader)
- 文件操作类 (如:file-loader)
- 代码检查类 (如:eslint-loader)
Webpack处理ES2015
因为模块打包需要,所以Webpack默认就可以处理import和export,但是它并不能转换代码中其他的ES6特性。
- Webpack只是打包工具
- 加载器可以用来编译转换代码
rules: [{test: /.js$/,use: {loader: 'babel-loader',options: {presets: ['@babel/preset-env']}}}
]
Webpack加载资源的方式
- 遵循ES Modules标准的import声明
- 遵循CommonJS标准的require函数
- 遵循AMD标准的define函数和require函数
- *样式代码中的@import指令和url函数
- *HTML代码中图片标签的src属性
Loader加载的非JavaScript也会触发资源加载,如样式中的@import指令和url函数,HTML代码中图片标签的src属性。
Webpack核心工作原理
Loader机制是Webpack的核心。
Loader负责资源文件从输入到输出的转换。对于同一个资源可以依次使用多个Loader,管道概念。
Loader专注实现资源模块加载,Plugin解决其他自动化工作。
Webpack+Plugin,实现大多前端工程化工作。
Webpack插件
插件机制是Webpack当中另外一个核心特性,目标是增强Webpack自动化能力。
Loader专注实现资源模块加载,Plugin解决其他自动化工作。
相比Loader,Plugin拥有更宽的能力范围。
Plugin通过钩子机制实现,类似于web中的事件。
Webpack+Plugin,实现大多前端工程化工作。
Webpack
Webpack常用插件
- clean-webpack-plugin: 自动清理输出目录
- html-webpack-plugin: 自动生成HTML插件
- copy-webpack-plugin:拷贝静态文件
Webpack开发一个插件
Webpack要求我们的插件必须是一个函数或者是一个包含apply方法的对象。
//移除Webpack注释的插件
class VioletPlugin {apply(compiler) {console.log('VioletPlugin 启动');compiler.hooks.emit.tap('VioletPlugin', compilation => {//compilation => 可以理解为此次打包的上下文for (const name in compilation.assets) {// console.log(name);// console.log(compilation.assets[name].source());if (name.endsWith('.js')) {const contents = compilation.assets[name].source()const withoutComments = contents.replace(/\/\*\*+\*\//g, '')compilation.assets[name] = {source: () => withoutComments,size: () => withoutComments.length}}}})}
}
//使用
plugins: [ new VioletPlugin()]
Webpack插件是通过在生命周期的钩子中挂载函数实现扩展。
Webpack开发体验
理想的开发环境:
- 以HTTP Server运行
- 自动编译+自动刷新
- 提供Source Map支持
如何增强Webpack开发体验:
- 实现自动编译(watch工作模式:监听文件变化,自动重新打包)
- 实现自动刷新浏览器(希望编译规划自动刷新浏览器: BrowserSync。(操作上麻烦了,效率上降低了))
- Webpack Dev Server:提供用于开发的HTTP Server,集成自动编译和自动刷新浏览器等功能。
Webpack Dev Server静态资源访问:Dev Server默认只会serve打包输出文件,只要是Webpack 输出的文件,都可以直接被访问,其他静态资源文件也需要serve。
Webpack配置Source Map
在webpack.config.js文件中添加devtool: ‘下表中的方式’。
devtool模式对比:
- eval : 是否使用eval执行模块代码
- cheap-source-map: 是否包含行信息
- cheap-module-source-map: 是否能够得到Loader处理之前的源代码
Source Map会暴露源代码,建议生产模式选择none。
Webpack HMR体验
- 实现页面不刷新的前提下,模块也可以及时更新。(模块热替换,热拔插)
- 模块热替换:应用运行过程中实时替换某个模块,应用运行状态不受影响。
- HMR是Webpack 中最强大的功能之一,极大程度的提高了开发者的工作效率。
- HMR集成在webpack-dev-server中,运行方式:yarn webpack-dev-server --hot
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')module.exports = {mode: 'development',entry: './src/main.js',output: {filename: 'js/bundle.js'},devtool: 'source-map',devServer: {hot: true// hotOnly: true // 只使用 HMR,不会 fallback 到 live reloading},module: {rules: [{test: /\.css$/,use: ['style-loader','css-loader']},{test: /\.(png|jpe?g|gif)$/,use: 'file-loader'}]},plugins: [new HtmlWebpackPlugin({title: 'Webpack Tutorial',template: './src/index.html'}),new webpack.HotModuleReplacementPlugin()]
}
- Webpack中的HMR并不可以开箱即用,需要手动处理模块热替换逻辑。
(1.)JS模块热替换
import createEditor from './editor'
import background from './better.png'
import './global.css'const editor = createEditor()
document.body.appendChild(editor)const img = new Image()
img.src = background
document.body.appendChild(img)// ============ JS模块热替换 ============// console.log(createEditor)if (module.hot) {let lastEditor = editormodule.hot.accept('./editor', () => {// console.log('editor 模块更新了,需要这里手动处理热替换逻辑')// console.log(createEditor)const value = lastEditor.innerHTMLdocument.body.removeChild(lastEditor)const newEditor = createEditor()newEditor.innerHTML = valuedocument.body.appendChild(newEditor)lastEditor = newEditor})
}
(2.)图片模块热替换
module.hot.accept('./better.png', () => {img.src = backgroundconsole.log(background)
})
Webpack 不同环境下的配置
生产环境注重运行效率,开发环境注重开发效率,webpack 4提供了mode属性。
创建 不同环境下的配置方式:
- 配置文件根据环境不同导出不同配置(仅适用于中小型项目)
const webpack = require('webpack')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')module.exports = (env, argv) => {const config = {mode: 'development',entry: './src/main.js',output: {filename: 'js/bundle.js'},devtool: 'cheap-eval-module-source-map',devServer: {hot: true,contentBase: 'public'},module: {rules: [{test: /\.css$/,use: ['style-loader','css-loader']},{test: /\.(png|jpe?g|gif)$/,use: {loader: 'file-loader',options: {outputPath: 'img',name: '[name].[ext]'}}}]},plugins: [new HtmlWebpackPlugin({title: 'Webpack Tutorial',template: './src/index.html'}),new webpack.HotModuleReplacementPlugin()]}if (env === 'production') {config.mode = 'production'config.devtool = falseconfig.plugins = [...config.plugins,new CleanWebpackPlugin(),new CopyWebpackPlugin(['public'])]}return config
}
- 一个环境对应一个配置文件(适用于大型项目)
运行:webpack --config webpack.prod.js
Webpack DefinePlugin
为代码注入全局成员。process.env.NODE_ENV
const webpack = require('webpack')module.exports = {mode: 'none',entry: './src/main.js',output: {filename: 'bundle.js'},plugins: [new webpack.DefinePlugin({// 值要求的是一个代码片段API_BASE_URL: JSON.stringify('https://api.example.com')})]
}
Webpack Tree-shaking
[摇掉]代码中未引用部分。生产模式下会自动开启。
使用前提是必须是ES Modules,即由Webpack 打包的代码必须使用ESM。
module.exports = {mode: 'none',entry: './src/index.js',output: {filename: 'bundle.js'},optimization: {// 模块只导出被使用的成员,usedExports负责标记【枯树叶】usedExports: true,// 尽可能合并每一个模块到一个函数中concatenateModules: true,// 压缩输出结果,minimize负责【摇掉】【枯树叶】// minimize: true}
}
Webpack sideEffects
允许我们通过配置的方式去标识代码是否有副作用,从而为Tree-shaking提供更大的压缩空间。
副作用:模块执行时除了导出成员之外所作的事情。
sideEffects一般用于npm包标记是否有副作用。
module.exports = {mode: 'none',entry: './src/index.js',output: {filename: 'bundle.js'},module: {rules: [{test: /\.css$/,use: ['style-loader','css-loader']}]},optimization: {sideEffects: true}
}
Webpack 代码分割
所有代码最终都被打包到一起,bundle体积过大,应用使用时,并不是每个模块在启动时都是必要的,浪费流量和带宽,因此需要分包,按需加载。
Code Splitting:代码分包/代码分割
HTTP1.1本身缺陷:
- 同域并行请求限制
- 每次请求都会有一定的延迟
- 请求的Header浪费带宽流量
目前,Webpack实现代码分割/分包的方式:
- 多入口打包(一个页面对应一个打包入口,公共部分单独提取)
- 动态导入(按需加载:需要用到某个模块时,再加载这个模块。动态导入的模块会被自动分包)
Webpack 输出文件名Hash
一般我们去部署前端的资源文件时,都会去启用服务器的静态资源缓存。
生产模式下,文件名使用Hash。
plugins: [new CleanWebpackPlugin(),new HtmlWebpackPlugin({title: 'Dynamic import',template: './src/index.html',filename: 'index.html'}),new MiniCssExtractPlugin({// filename: '[name]-[hash].bundle.css' //项目级别Hashfilename: '[name]-[chunkhash].bundle.css' //chunk级别// filename: '[name]-[contenthash:8].bundle.css' //文件级别})]
模块打包工具-Rollup
Webpack大而全,Rollup小而美。
Rollup仅仅是一款ESM打包器,Rollup并不支持类似HMR这种高级特性。
Rollup初衷只是想提供一个充分利用ESM各项特性的高效打包器。
rollup和webpack的区别:
- 特性:rollup 所有资源放同一个地方,一次性加载,利用 tree-shake特性来 剔除未使用的代码,减少冗余;webpack 拆分代码、按需加载 webpack2已经逐渐支持tree-shake
- rollup:
1.打包你的 js 文件的时候如果发现你的无用变量,会将其删掉。
2.可以将你的 js 中的代码,编译成你想要的格式 - webpack:
1.代码拆分
2.静态资源导入(如 js、css、图片、字体等)
拥有如此强大的功能,所以 webpack 在进行资源打包的时候,就会产生很多冗余的代码。 - 项目(特别是类库)只有 js,而没有其他的静态资源文件,使用 webpack 就有点大才小用了,因为 webpack bundle 文件的体积略大,运行略慢,可读性略低。这时候 rollup就是一种不错的解决方案
- 对于应用使用 webpack,对于类库使用 Rollup
rollup基本用法
1.创建目录
2.创建文件
3.package.json配置项
{"name": "rollup_demo","version": "1.0.0","description": "","main": "rollup.config.js","scripts": {"test": "echo \"Error: no test specified\" && exit 1","rollup": "rollup -c rollup.config.js"},"keywords": [],"author": "","license": "ISC","devDependencies": {"babel-core": "^6.26.3","babel-plugin-external-helpers": "^6.22.0","babel-preset-env": "^1.7.0","babel-preset-latest": "^6.24.1","rollup": "^0.64.1","rollup-plugin-babel": "^3.0.7","rollup-plugin-node-resolve": "^3.3.0"}
}
4.rollup.config.js配置
import babel from 'rollup-plugin-babel'
import resolve from 'rollup-plugin-node-resolve'
import { format } from 'path';
export default{entry:'src/index.js', //入口format:'umd', //兼容 规范 script导入 amd commonjsplugins:[resolve(),babel({exclude:'node_modules/**'})],dest:'build/bundle.js'
}
5.运行 npm run rollup
Parcel
- 完全零配置的前端应用打包器。
- Parcel官方建议以html文件作为入口。
- 它不仅会帮我们打包应用,还会帮忙打开一个开发服务器。
- Parcel支持热替换、自动安装依赖、动态导入等特性。
- Parcel给开发者的体验:你想要做什么就去做,其他额外的工作交给Parcel处理。
- Parcel构建速度更快,因为它内部使用了多进程同步工作。
- Webpack有更好的生态,Webpack越来越好用。