P3M2:模块化开发和规范化标准

258 阅读11分钟

T1:模块化开发

模块化的演变 #2

早期的模块化

没有相关工具帮助

文件划分方式 => 命名空间方式 => IIFE

解决了 污染全局作用域、命名冲突问题、无法管理模块依赖关系 这三个问题。

模块化规范出现

Node.js 下有 CommonJS 规范,即:

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过 module.exports 导出成员
  • 通过 require 函数载入模块

但是 CommonJS 是同步的,不适合浏览器端。

适应浏览器端,出现了AMD (Asynchronous Module Definition) + Require.js,它提供了 define() 来定义模块、require() 来添加依赖。

也出现了 CMD (Common Module Definition) + Sea.js,它采用了与 CommonJS 相似的规范,便于上手。

当前的模块化规范

CommonJS in Node.js

ES Module in Browsers

ES Module 的使用

特性 #5

在 HTML 中使用 <script type="module"> 引入模块,内容中的 js 可以正常执行。

  1. ESM 自动采用严格模式,忽略 use strict

    严格模式下,全局的 this 不指向全局对象 window,而是 undefined

  2. 每个 module 都是运行在单独的私有作用域中

  3. ESM 是通过 CORS 去请求外部 JS 模块的。

    跨域请求外部模块时,需要标头注明源地址,且服务端同意。

  4. ESM 的 script 标签会延迟执行脚本

    即默认 differ,在网页渲染完成后执行脚本

导出 export #6

模块内的变量、函数、类都可以导出,使用 export 有两种方式:

  • 直接写在导出对象的声明前,如 export var name = 'Tom'
  • 【常用】(在模块尾)将各项以 {} 包裹导出,如 export { name, foo }

导出时可以设置默认项和重命名,如 export { name as newName, foo as default }

重命名的项在被导入时,需要以新名字导入,即 import newName;

默认项需在被导入时重命名,如 import default as newFoo,或简写为 import newFoo

【注意】

  • 多项导出时,并非导出了一个对象,而是导出的固定语法。同样的,导入时,也非解构对象。
  • 导入的项获取到的是引用地址,而非该项的一份新拷贝。也即,导出项的变化,导入端可以实时获取到,但它是只读的,不能在导入端修改。

导入 import #8

  • 导入时,from 后跟具体的模块文件URL

    • 模块文件的扩展名不能省略
    • URL 可以是相对 URL,以 ./ 开头,也可以是绝对 URL,以 / 开头,还可以是完整的 URL 地址,例如 CDN 上的模块文件。
  • 导入的另一种用法:import {} form './module.js' 或简写为 import './module.js',执行该模块,而不提取其中导出。

  • 全部导入某一模块的导出:import * as mod。mod 则是包含所有导出项的对象

  • 动态导入模块使用函数 import(),如 import('./module.js').then(function (mod) {console.log(mod)}) 。模块内的导出通过 then 得到。

  • 同时导入默认和命名成员:import {name, age, default as newFoo} form './module.js'import newFoo, {name, age} from './module.js'

同时导入和导出 #9

export {name, age} from './module.js'

这一写法通常用在使用一个模块管理多个模块导入导出的情况。将多个模块的导出导入到 index.js,并同时导出,这样在主页只需导入 index.js 模块即可。

ES Module 的兼容性处理 #10

对于不支持 ES Module 的浏览器如何处理呢?

  1. 引入 babel-browser-buildbrowser-es-module-loader,转化为通用语法。

    可以使用 script 标签从 unpkg.com 引入。

  2. 也不支持 Promise 的浏览器还需引入 promise-polyfill

  3. 为以上引入添加 nomodule 属性,避免正常浏览器执行两次代码。

上述的引入方法请求过多,不适用于上线运行。

ES Module in Node.js

Node.js 也逐步加入了对 ES Module 的支持

与浏览器下的主要区别:模块扩展名为 .mjs(同时注意修改导入文件的 URL)

ES 模块与 CJS 模块的交互 #11~12

ESM 导入 CJS 模块的方法:

CJS 下,模块的导出是以对象形式,包含了各种属性、方法。因此在 ES 模块下只能默认引入,引入的是 module.exports 对象,使用库时需要调用对象再调用属性方法。

e.g. import _ from 'lodash'; console.log(_.camelCase(Import From CJS Module))

没有对 ESM 做兼容的第三方包便必须以此形式引人。也有第三方包是分离出来一个专用的 ES 包,例如 lodash-es

e.g. import camelCase from 'lodash-es'; console.log(camelCase(Import From ES Module))

Node.js 的内置模块做了兼容,在模块内做了两种导出,因此上述两种导入方式均可。

Node.js 下,CJS 模块不能导入 ES 模块。

ES 模块与 CJS 模块的差异 #13

ESM 中无法获取到 CommonJS 中的全局成员了,包括 modulerequire()exports__filename__dirname

require 和 exports 实现引入和导出,在 ESM 中有 import 和 export 对应负责。

__filename__dirname 使用以下方法替代:

import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

Node.js 对 ESM 的新支持 #14

可在 package.json 中加入 'type': 'module' 字段,那么 js 文件即以 ESM 运行了,而 CJS 模块则需要修改扩展名为 .cjs 才可正常运行。

旧版 Node 中使用 ESM(借助 babel)#15

安装 @babel/node ,配置转码规则为 @babel/plugin-transform-modules-commonjs 或包含它的 @babel/preset-env

即可在命令行中 babel-node esModule.js 直接调用 ES 模块。

T2:Webpack 打包工具

基本使用方法

将多个 js 模块打包为一个 js 文件,此时在 HTML 的引入无需 type="module" 属性

配置文件 #4

在根目录创建 webpack.config.js 文件

  • 入口
  • 输出
const path = require('path')
module.exports = {
	entry: './src/app.js',					//入口,默认是'./src/main.js'
	output: {
		filename: 'bundle.js',				//文件名,默认是'main.js'
		path: path.join(__dirname, 'output')//输出绝对路径
	}
}
  • 工作模式 #5

    e.g. mode: 'none'

    不同的工作模式相当于不同的配置组,也可添加命令行参数 --mode XXX 使用。默认 production 模式。

    • production 上线用
    • development 优化打包速度,添加开发用说明
    • none 无附加

Webpack 的加载器 loader

Webpack 默认 loader 可以打包 js 模块,其他资源模块需要新增 loader 来处理。

loader 是 Webpack 的核心 #14。

常用 loader 分类 #11

  • 编译转换类:转为以 js 形式工作的模块

  • 文件操作类:文件拷贝至目录,并导出访问路径

  • 代码检查类:检查、统一代码风格

加载 CSS 资源 #7

css-loader:将 CSS 文件转换为 js 模块并打包

style-loader:将 CSS 模块追加进 HTML 渲染

需要在配置文件中新增 module 字段:

module: {
    rules: [
        {
            test: /.css$/,		//匹配文件路径,正则表达式
            use: 'css'			//使用的 loader,从下往上执行
        }
    ]
}

且将 CSS 文件引入入口 js 文件

加载文件资源 #9

图片、字体等文件不能像 CSS 转为 JavaScript 代码,它们需要使用文件加载器 file-loader。不过它们也需要 import 到入口文件中。

【注意】Webpack 默认 HTML 文件在 output.path 的位置。如果不在,则需要配置 output.publicPath 值为输出目录相对 HTML 文件的路径,结尾保留 / 。这样,在 HTML 访问静态资源时,publicPath 拼接 loader 配置的路径方为正确路径。

加载 DataURL 资源 #10

url-loader

适用于小文件,使用 DataURL 以减少 HTTP 请求。

在配置文件 module.rules 下的 use.option 添加属性 limit,限制使用 url-loader 处理的文件大小。超过该限制大小的文件仍使用 file-loader 加载,也就是说依赖 file-loader

e.g.

module: {
    rules: [
        {
            test: /.png$/,
            use: {
                loader: 'url-loader',
                option: {
                    limit: 10 * 1024	//文件大小不超过 10 kB
                }
            }
        }
    ]
}

添加对 ES6 的支持 #12

Webpack 仅是一个打包工具,本身不支持 ES6+。可以使用 babel-loader 代替默认 loader 来加载 js 文件,以实现对 ES6 的支持。

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

其他资源和文件的加载 #13

不同标准模块的导入
  • 遵循 ES Module 标准的 import 声明
  • 遵循 CommonJS 标准的 require 声明
  • 遵循 AMD 标准的 define 函数和 require 函数

虽然 Webpack 支持多种标准,但是在同一项目中最好还是使用统一标准的模块。

CSS 中的 @import 和 url()

CSS 内引入的其他 CSS 文件和通过 url() 获取的文件均可正确加载

HTML 中的 <img> 的src 和 <a> 的 href

安装 html-loader 并添加一组规则:

{
    test: '/.html$/',
    use: {
        loader: 'html-loader',
        options: {
            attrs: ['img:src', 'a:href']
        }
    }
}

默认可以处理 img 元素的 src 属性,即 options.attrs 的默认值是 'img:src'

loader 的工作原理 #15

loader 完成的即是资源文件从输入到输出的转换

loader 是一个管道的概念,对同一资源可以使用多个 loader 接续,最终的输出应是 js 代码。

Webpack 的插件 plugin

插件可以完成打包以外的其他一些自动化工作

清理目录用插件 #17

安装插件后,需要在配置文件中引入(require)插件,并在导出项的 plugins 数组内,写入插件的新建实例,如 new CleanWebpackPlugin()

clean-webpack-plugin:打包前清空输出目录

自动生成 HTML 用插件 #18~20

html-webpack-plugin:自动在输出目录生成引用了 Webpack 已捆绑包的 HTML 文件

html-webpack-plugin 可以更详细的配置

预设生成的 HTML

在新建 html-webpack-plugin 实例时,传入一个对象实参,内置一些属性可以自定义修改生成的 HTML 文件。e.g.

{
    title: 'Custom Title',
    meta: {
        viewport: 'width=device-width'
    }
}

更大的自定义可以使用模板来实现,模板内需要动态注入的部分使用 lodash 模板语法。e.g.

{
    template: './src/template.html'
}
<body>
    <h1><%= htmlWepackPlugin.options.title %></h1>
</body>
创建多个 HTML 文件

新建多个实例,并传入参数对象 { filename: 'about.html' } 修改文件名。

移动文件用插件

copy-webpack-plugin:将指定目录下的文件复制到输出目录,比如图标类图片。

在 plugins 下新建实例时,传入实参为要复制的目录

【工作】通常在上线前做一次静态文件并入,开发过程中能访问即可。

plugin 的工作原理 #22

通过在打包过程中的钩子上挂载函数实现扩展

Webpack Dev Server

障碍1:源代码修改后,想要在浏览器端看到效果,需要有重新编译打包和刷新页面的操作。

  • 自动编译:Webpack --watch 工作模式,监听文件修改,自动打包

  • 自动刷新浏览器:利用 browser-sync 上线并监听文件,自动刷新

但这样的“两步自动化”中间产生了反复的磁盘读写,拖累效率。

webpack-dev-server 是 Webpack 推出的开发工具,可以实现自动编译和自动刷新浏览器

添加对静态资源的访问 #27

config.js 配置文件 => devServer 对象 => contentBase 数组属性

e.g. devServer: { contentBase: [ './public', './base'] }

代理 API #28

开发过程页面在本地对服务器请求属于跨域请求,上线后是同源。

如果服务器不支持 CORS 跨域,那么便需要将本地代理到同源的开发用服务器上进行开发。

config.js 配置文件 => devServer 对象 => proxy 对象属性

devServer: {
    proxy: {
        '/api': {			// 以 '/api' 开头的地址
            target: 'https://api.github.com',
            // 👆 https://localhost:8080 替换为 https://api.github.com
            pathRewrite: {	// 重写
                '^/api': ''
                // 👆 https://localhost:8080/api/users 修正为 https://api.github.com/users
            },
            changeOrigin: true
        }
    }
}

模块热更新 MHR

很多时候,我们需要在保留页面状态下调试代码,而页面自动刷新会导致效率降低。

模块热更新 (Hot Module Replacement) 能够实现 CSS、JS、图片等资源文件随改随上,而无需刷新。

开启 MHR #37

MHR 是 Webpack Dev Server 的内置插件。

在命令 webpack-dev-server 后加 --hot 命令行参数即可。

或配置 devServer: { hot: true },且需引入 webpack 和新建插件实例。

这时,CSS 已经可以热替换了。

进一步配置热替换逻辑 #38

【注意】通过框架生成的项目可能不需要再配置,因为框架已做好了 MHR 完整配置。

在引入模块的文件(也就是入口文件)处理热替换。

module.hot.accept('./editor', ()=>{})

module.hot 是 MHR APIs 的核心对象,它提供 accept(),第一个参数是模块 URL,第二个参数是该模块更新后的处理函数。

不同的模块需要不同的处理函数,根据模块逻辑自行书写。

js 模块 #40

e.g. 编辑器模块

let lastEditor = editor					// 保留原模块结果
module.hot.accept('./editor', ()=>{
    const value = lastEditor.innerHTML	// 保留原状态
    document.body.removeChild(lastEditor)// 移除原模块结果
    const newEditor = createEditor()	// 生成新模块结果
    newEditor.innerHTML = value			// 新模块结果得到原状态
    document.body.appendChild(newEditor)
    lasteditor = newEditor
})
图片模块 #41
module.hot.accept('./pic.png', ()=>{
    img.src = background		// 重赋一次图片地址,即实现图片刷新
})
MHR 的注意事项 #42
  1. hot: true 下,如果修改模块出现错误,页面自动重载修改前的模块。

    可使用 hotOnly: true,出错情况下页面也不重载,便于观察 bug

  2. 为 MHR 加前置判断条件 if(module.hot),避免报错,且上线生产环境时相关代码也不会进入打包。

Source Map

障碍2:项目经过打包编译后,变量名和文件结构等都被修改,在浏览器端调试无法与源码对应。

源码地图建立打包后代码与源代码间的映射,便于直观调试。

配置 Webpack 的 Source Map #30

添加字段:devtool: 'source-map'

Source Map 的模式 #32~33

eval(类 Source Map)

devtool: 'eval'

能定位到源文件,没有 Source Map,也就没有具体行、列信息。速度最快,效果简陋。

  • 带有 eval 表示使用了 eval 的执行模块代码

  • 带有 cheap 表明简易,速度更高

  • 带有 module 表明能得到 loader 处理前的源代码

模式选择:

开发:cheap-module-eval-source-map

生产:none / nosources-source-map

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
}

这样在打包时加命令行参数 --env production 即可使用生产模式配置。

大型项目使用多个配置文件更为可靠,通常有三个文件:webpack.common.jswebpack.dev.jswebpack.prod.js

common 作为基础配置文件,被 dev 和 prod 引入作为一个对象常量。const common = require('./webpack.common')

再将修改值写入。这里使用 webpack-merge 包的 merge() 处理配置对象融合。module.exports = common, {···} 。merge 函数能自动处理数组类型值的追加,而不是覆盖。

打包时需要 webpack --config webpack.prod.js,可以写进 npm scripts 使用。