「前端考古系列」一个需求引发的前端模块化考古
零、故事的开始
从前有个流行说法是"全国 13 亿人,每人给我一块钱我就是亿万富翁"
现在老板觉得这个主意很棒,所以让张三来做个网页方便收钱,界面简单点如下所示就好~
image-20210131002533398可以看到这里就两个逻辑,点击红色按钮开始打钱,点击蓝色链接触发举报。是不是很简单~
这时老板跟张三说:"唔使急,最紧要快~ 5分钟后我要看到这个网页",这时候的张三的情绪毫无波动,什么软件工程可维护性模块化直接抛之脑后,满脑子只剩下一句"老夫写代码就是一把梭".
1473308168_167070一、最初的面条代码
五分钟后张三写完了代码直接 scp 传到了公司服务器某现有全静态前端项目的目录下,把链接发给老板后长出了一口气...
现在的代码大概是这样的:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>亿万富翁计划</title>
</head>
<body style="text-align: center;">
<h1>帮助 Nodreame 成为亿万富翁</h1>
<div><button id="pay">话不多说直接打钱</button></div>
<div><a href="#" id="inform">举报这个帅逼</a></div>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.20/lodash.min.js"></script>
<script src="微信支付脚本.js"></script>
<script>
var payElem = document.getElementById('pay')
var informElem = document.getElementById('inform')
var payParams = {} // 支付参数
var payLogic = _.debounce(function () {
// 1. 确定支付方式(暂时只支持微信,且以下支付流程仅供学习使用)
// 2. 确定金额等支付参数
// 3. 调起微信支付, 等待回调
// 4. 根据回调结果进入重新支付流程 or 支付完成感谢界面
}, 200)
var informLogic = _.debounce(function () {
// 举报逻辑,PM说直接稍后弹窗即可无需写功能逻辑
alert('感谢您的举报,处理结果将在7~15个工作日内发送至您的手机')
}, 400)
payElem.onclick = payLogic
informElem.onclick = informLogic
</script>
</body>
</html>
现在的支付逻辑函数 payLogic 看起来似乎还过得去,但是现在老板不满意了:只支持微信支付明显是不够的,要是有土豪就是想要用支付宝、网银、paypal甚至比特币打钱怎么办?
image-20210131013238793没办法,和老板对线不是明智之举,只能肝上去继续一把梭了,然后代码就变成了这样:
<script src="微信支付库.js"></script>
<script src="支付宝支付库.js"></script>
<script src="paypal支付库.js"></script>
...
<script>
var payParams_wx = {} // 微信支付参数
var payParams_zfb = {} // 支付宝支付参数
var payParams_pp = {} // Paypal支付参数
var payLogic = _.debounce(function () {
// 0. 确定支付方式
if (微信支付) {
// 1. 确定金额等支付参数
// 2. 调起微信支付, 等待回调
// 3. 根据回调结果进入重新支付流程 or 支付完成感谢界面
} else if (支付宝) {
// 1. 确定金额等支付参数
// 2. 调起支付宝支付, 等待回调
// 3. 根据回调结果进入重新支付流程 or 支付完成感谢界面
} else if (paypal) {
// 1. 确定金额等支付参数
// 2. 调起paypal支付, 等待回调
// 3. 根据回调结果进入重新支付流程 or 支付完成感谢界面
}
...
}, 200)
</script>
随着支付方式的增加,引入的脚本、支付参数、支付逻辑也随之增加. 这个为小需求而生的网页开始变得复杂.
张三想起上次临时接收"祖传屎山"的通宵分析代码经历,决定为了方便以后对项目的维护,现在尝试一下对项目进行一些优化.
当下最重要的当然是分析一下项目当前存在的问题:
-
无私有空间:各支付渠道的支付参数只需要在模块内可以访问即可;
-
全局变量污染:不应该将操作接口暴露到全局;
-
依赖管理:支付逻辑没有显式标记对应的依赖
OK,就从这三个点的优化开始吧~
二、模块化意识的觉醒
由于每个支付渠道都有对应支付参数和支付流程逻辑,所以第一个想到的是可以将不同的逻辑拆分到不同的文件中:
截屏2021-01-31_上午2_12_45如果是 Java 或者 C# 这个写法确实是能解决 全局变量污染 & 无私有空间 的问题(class 包裹),但是在 JS 中使用上面的分文件写法其实完全没有解决任何问题,即使将支付参数、逻辑函数分到不同文件,它们依旧会被暴露到全局变量中.
为了解决 全局变量污染 & 无私有空间 的问题,有前辈提出了原生解法 -- 利用立即执行函数实现"伪模块". 这样外部就无法访问支付参数,所以 无私有空间 问题就此解决,全局变量污染问题 得到了一部分解决(参数不再暴露,方法依旧挂载到全局):
image-20210131022450619另外如果对立即执行函数传入 依赖 作为参数(例如 lodash),那么"伪模块"就可以"显式"地依赖某个库了:
截屏2021-01-31_上午2_37_52但是很明显的,这个"显式依赖"并没有真正解决了依赖管理的问题.
当前 index.html 引入lodash,而"伪模块"文件中无需引用就直接使用了 lodash,那么如果在 index.html 中删除了对 lodash 的引用,那么"伪模块"逻辑的执行必定报错. 故这里的问题在于:未在调用处显式声明依赖项.
为了更好的解决这些问题,张三决定查看到技术社区找找可选方案.
三、了解社区规范
张三到技术论坛看了一圈,了解到了一堆社区方案 CommonJS、AMD、CMD、UMD,决定逐个了解一下再做决定.
1. CommonJS规范
CommonJS规范是 NodeJS 实现模块化参考的标准,看看其模块定义 & 加载的写法:
image-20210131182742977NodeJS 中一般通过 module.exports 或者 exports 定义模块,再通过 require 加载模块.
由于其模块加载的设计是 同步 的,这对服务端从内存或者硬盘读取模块并无影响,但对于需要通过网络异步下载模块的浏览器端就不太适用了(在网络加载较慢的情况下,模块加载速度过慢会导致长时间白屏)
为了借鉴 CommonJS 的思想来解决浏览器端的模块化问题,大神们提出了 AMD 和 CMD 这两个 "异步加载模块" 的浏览器模块规范. 两者分别是 RequireJS 和 SeaJS 在推广过程中对模块定义的规范化产出.
2. AMD规范
1)概述
- AMD(Asynchronous Module Definition) 即异步模块定义,是为了解决浏览器端模块化问题提出的规范.
- 实现库:require.js
- 主要 API:模块定义 define & 模块加载 require
- 特点:推崇依赖"依赖前置"
2)实践
张三看完文档和教程后觉得方案可行,于是将支付模块基于 AMD 规范重构了,最新文件目录如下(pay/lib 为第三方提供的支付库):
image-20210201022444719将原本的 js 逻辑移入 main.js 中,html 中只留下 require.js 的引入(requirejs会自动完成 main.js 的加载):
<script data-main="./main" src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.min.js"></script>
然后在 main.js 中配置路径(方便引入依赖) & 通过 require(依赖数组,回调函数)的方式写入原先的逻辑,并将支付相关逻辑放入对应的文件并根据 AMD 规范完成定义:
image-20210201022133266从上面的 define 和 require 可以看出,AMD规范推崇"依赖前置",也就是定义模块 & 编写逻辑前先声明依赖的模块.
完成后刷新页面,可以看到 wx.js 的回调函数中的打印语句已经执行,说明AMD规范的 "依赖前置"会使依赖提前加载并执行其回调函数:
image-202102010227084863)小结
AMD 规范通过 define & require 实现了模块的定义和引用,解决了全局污染和私有性的问题.
同时也以"依赖前置"的形式实现了对模块的显式管理,但是写法是相对繁琐的.
3. CMD规范
1)概述
- CMD(Common Module Definition)即通用模块定义,和 AMD 的目标相似,不过推崇的理念和模块处理的方法与 AMD 不同.
- 实现库:sea.js
- 主要 API:模块定义 define & 模块加载 require & use
- 特点:推崇依赖就近 & 懒执行
2)写法
sea.js 的写法其实和 require.js 的写法很相似所以就不重写了,官方给出了下面的代码:
define(function(require, exports) {
var a = require('./a'); // 获取模块 a 的接口, 就近书写
a.doSomething();
});
从上面可以看出 CMD 推崇的写法不同于 AMD 的依赖前置,而是在使用到某模块功能附近才对模块进行 require 引用,称为"依赖就近".
CMD define方法里的函数被称为 factory ,包含三个参数 require, exports, modules,用于在 factory 中引用模块和暴露接口.
4. UMD
UMD 全称 Universal Module Definition,见名知义,该规范的目标就是平台通用.
当需要同时支持浏览器端和 NodeJS 端使用的时候一般会选用 UMD 规范打包的代码,例如 Vue:
image-20210201182209023UMD 规范的代码特点是会通过一些对于 exports 和 define 的判断确定环境,例如 vue.js 中的:
image-20210201182504976社区方案的提出就是在 JS尚未支持模块化的时期解决模块化问题,虽然够用但是还是略显繁琐,这时张三想起上次去某网站下软件导致整个电脑都是大天使之剑后来养成了凡是信官网的习惯,所以决定还是先看看官方模块化方案再做决定.
四、官逼同
image-20210201183224816TC39 将 Modules 加入到 ECMAScript 2015 中,将文件区分成 脚本 Scripts & 模块Modules,使用 import & export 实现模块的导入导出,用起来也比 AMD/CMD 直观,ES Module 成为浏览器和服务器通用的模块解决方案。
张三看了ES6文档和大神文章,觉得这个由官方规定的标准肯定是以后的大趋势,以后现在学一波顺便在项目中练练手肯定不亏,于是马上行动了起来.
image-20210201215851498一切都是那么顺畅美好,并且所有模块都能成功加载,只是在浏览器打开 index.html 运行时出现一点错误:
image-20210203010956967.png这是在 index.html 中的 <script>
使用 type="module" 导致的,这里的 CORS 策略不支持 file 协议,所以要在本地起一个服务器:
yarn global add http-server
http-server
使用 http://127.0.0.1:8080 尝试访问网页:
image-20210201203526947这个错误已经写得很明显了,就是 lodash 文件不是使用 ES Module 的模式暴露导致的. 这里使用 lodash-es 的CDN来替换即可:
import * as _ from 'https://cdn.jsdelivr.net/npm/lodash-es@4.17.20/lodash.min.js'
OK现在页面已经能够成功加载,点击支付按钮也能成功触发打印如下:
image-20210201220158387张三伸了个懒腰长出一口气,这波终于用最新的官方方式实现了前端模块化了,赶紧和老板邀邀功~
五、前端构建
听到张三主动为项目做优化老板频频点头,路过的 Leader 却发现了不对的地方,把他拉到一旁问项目的设备兼容性处理是怎么做的
这时候的张三心里咯噔一下,完蛋没考虑 ES6 的浏览器兼容性!流下冷汗的同时甚至有点想提桶跑路...
image-20210201220934204Leader 帮张三看了一下代码,告诉他可以用 Webpack + Babel 处理一下项目,即可以用最新语法快乐编码又可以防止低版本浏览器出现不兼容的问题. 时间紧急,Leader 就直接告诉张三 Webpack 和 Babel 的知识,让他听完之后再自己去试试.
1. webpack
之前的 AMD/CMD 方案都是需要浏览器在执行主逻辑前先下载一个 require.js 或者 sea.js,之后再运行主逻辑代码的,这两种方案对于模块依赖关系的解析都是在运行时做的,这样随着项目变大加载速度也会随之变慢,如果没上 HTTP/2.0 的话模块文件过多也会导致加载变慢.
为了解决这个问题,官方设计 ES6 的时候是尽量往静态化的方向靠拢的,这样做的好处是如果能在编译期间完成依赖关系的分析,那么就可以在本地提前做好各种优化,运行时只需直接执行代码即可 (理解这个概念可以参考一下 Java、C++直接编译出可执行文件,想用时只需运行这个可执行文件即可)
既然是往静态化和预编译的方向靠,那么就需要有工具来辅助完成依赖关系管理和优化这件事,webpack 就是众多构建工具中脱颖而出的一个,它专注于打包,并且通过各种loader和插件为开发者提供了更多更强的能力.
2. Babel
Babel 是一个 JS 编译器,JS 标准不断更新,每年都有更新更好的语法和能力供开发者使用,但是如果直接按照最新规范给定的方式编码的话,很多版本稍低的浏览器都会出现兼容性问题,为了解决这个问题就有了Babel.
Babel 让开发者在编码过程中可以使用更加简洁高效的新语法,在发布打包的时候通过转移来生成兼容性更高的执行代码. 其过程如下:
- 解析:开发者编写的代码经过解析转化为 ES6+ AST(抽象语法树)
- 转译:ES6+ AST 通过插件转译为兼容性更高的 ES5 AST
- 生成:基于 ES5 AST 生成兼容性高的最终代码
在实际项目中可以结合 webpack + Babel 构建简单项目打包,便捷开发的同时也能实现较好的兼容性支持.
3. webpack 打包实战
张三听完 Leader 的话觉得自己又行了,马上开始边查资料边实践~
首先是 webpack 的安装和现有代码的稍微调整:
yarn init -y
yarn add -D webpack webpack-cli html-webpack-plugin clean-webpack-plugin
yarn add lodash
接下来对现有项目做一些微调:
-
index.html 删除引用 main.js 的 script 标签
-
js 文件中的 lodash 引用改为
import _ from 'lodash'
即可(webpack 具备将 CommonJS 模块编译成 ES Module 的能力) -
编写 webpack.config.js 文件如下:
const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { mode: 'production', entry: './main.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js', }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: 'index.html' }), ], };
OK,至此项目微调已经完成,使用 npx webpack
即可完成打包.
现在可以直接打开编译的 index.html 文件预览页面了,经过测试"支付"功能也能正常使用~
image-202102020040188144. webpack + Babel7.x 实战
上面使用 webpack 实现了项目打包,接下来使用 Babel 需要先安装一下依赖:
yarn add -D babel-loader @babel/core @babel/preset-env @babel/plugin-transform-arrow-functions
yarn add @babel/polyfill
这里逐个解释下依赖:
- babel-loader:js 代码的预处理器,用 webpack+babel 打包必备.
- @babel/core:babel 核心库,必备.
- @babel/preset-env:在.babelrc 中接收配置 target 和 useBuiltIns,用于确定目标浏览器版本和 polyfill 的需求.
- @babel/polyfill:用于目标环境中添加缺失的特性(虽然 7.4.0 之后不推荐使用但是为不引入过多概念故暂时使用)
接下来在 webpack.config.js 中配置上 babel-loader 用以处理 JS 文件:
截屏2021-02-02_下午11_48_59然后在根目录创建 .babelrc 文件存放 babel 的配置:
{
"presets": [
[
"@babel/env",
{
"targets": { // 目标平台
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1",
},
// usage 表示根据 target 自动确定需要 polyfill 的功能
"useBuiltIns": "usage",
}
]
],
"plugins": [
"@babel/plugin-transform-arrow-functions",
],
}
为了方便测试是否 Babel 是否真的生效,在 main.js 末尾中加入两行代码方便对比打包结果:
[1, 2, 3].map((n) => n + 1);
Promise.resolve().finally();
最后用 npx webpack
命令打包即可,查看结果如下:
OK,刚刚添加的箭头函数已经转换为普通函数,finally 也能搜索到两个结果,没展示出来的第一个 finnally 应该就是其对应的 polyfill 了.
这样通过这样的终于粗略解决了兼容性问题,张三再次长出了一口气...
Ending
问题搞定!老板表示十分满意,口头表扬了张三一番,张三也开始幻想起自己升职加薪的样子...
夜深了,就像张三对前端模块化的了解一样更深了...
欢迎拍砖,觉得还行也欢迎点赞收藏~
新开公号:「无梦的冒险谭」欢迎关注(搜索 Nodreame 也可以~)
旅程正在继续 ✿✿ヽ(°▽°)ノ✿