问题
模块化是个重要的问题。
目前,开发框架中每个脚本目录的所有业务逻辑都写在index.js这一个文件中。模块化的主要手段是将需求拆分为多个独立的小功能,用多个脚本目录分别实现,各脚本目录的代码之间没有关联。由于需求都没有特别复杂,所以这种模式运行了很久,也没有太大的问题。主要的不便就是公共代码复用,但因为公共代码比较少,通常只有几个函数,所以就直接复制了。
但是,随着系统演进,已经出现了大型的、复杂的需求(需要在一个OA页面中做复杂的填数与实时计算),代码量很大,大到这一块业务功能本身就需要做专门的架构设计。我们仍然是先尝试拆分成多个脚本目录,但是不可行,最主要的技术限制是:
- 如果还像之前那样直接使用OA提供的API,就需要在每个地方都操心OA运行机制的细节、理解和处理OA的怪异特性,书写繁琐、可读性差,在这个量级上必然导致难以维护,因此必须封装好大量的基础功能以提高开发、维护的效率,那么公共代码的复制和同步修改就是噩梦。
- OA对代码块中的多个脚本文件是异步加载、运行的,这与正常的HTML加载JS不同,因此,将公共代码放在单独的脚本文件中是行不通的,因为无法保证公共代码一定先执行。此外,业务代码的各个功能模块之间也有着复杂的依赖关系,谁应该先执行、谁应该后执行?时序控制是噩梦。
可见,框架已经无法满足开发需求了,亟待改进。
思路
基本思路是,让框架支持更好的模块化机制,这也是Web前端领域从蛮荒走向工程化的核心思想之一。
框架至少要支持:
- 多源文件:分开编写多个JS源文件,每个源文件只实现一小块功能。
- 目录结构:多个源文件可以按需组织成任意多个、多层目录,可以放在
src/中的任意位置。 - 代码复用:多个JS源文件可以复用同一份公共代码。
- 时序编排:可以稳定控制代码的执行先后顺序。
- 单一脚本:最终仍然构建成单个脚本文件,方便部署。
最好的实现当然是引入JS模块机制,通过导入、导出来组织代码。但考虑到目前工期紧张,我要先保证业务层面的架构设计,无法在框架上投入太多学习和研究时间,因此,我们先做一个简单的实现:JS源文件包含。
JS源文件包含,就是在一个JS源文件中,将另一个JS源文件的代码包含进来。显然,这种思想参考自C语言的#include预处理器指令。在C语言中,#include的本质就是让预处理器读取指定的源文件,然后直接用源文件中的所有代码替换掉该#include,从而实现将分开编写的代码合并到一起。
实现
源文件命名约定
在各个前端框架中,index.js一般用作目录的入口文件,用于导入目录(形如import xxx from "./dir";),导入目录就是导入目录中的index.js,我们的框架也准备遵守这个约定。
但是,index.js现在在我们的框架中用作脚本目录标志了,只要一个目录中有index.js,就会将其构建为一个脚本文件。那么,这会导致一个只是存放部分代码的目录,只因为有index.js,就构建了一个没必要的脚本文件。
因此,我们将作为脚本目录标志的源文件名由index.js改为main.js(取自C语言的main函数,即整个程序的执行入口之意)。为了保持一致,同时将index.scss改名为main.scss,将index.css改名为main.css。
包含标记
JS源文件要通过特殊的标记来告诉框架,要包含哪个源文件,我们称这种标记为包含标记。
我们规定包含标记的格式如下:
//#include: 源文件路径
正则表达式严格描述为:
/^\s*\/\/\s*#\s*include\s*:\s*(\S+)\s*$/m
要点:
- 包含标记必须单独写在一行,与其他代码行的顺序任意。
- 包含标记在语法上是JS中的单行注释,避免IDE报语法错误。
- 源文件路径是相对于
src/的,例如建模布局/公共代码/XXX.js。 - 源文件路径可以是目录路径,等效于包含该目录中的
index.js。
解析包含
原来是直接从config..js、main.js就能得到完整的JS代码,但是现在不行了,config..js、main.js只是一个入口,必须把其中所有的包含都正确处理掉,才能得到完整的JS代码。
解析包含的算法需要解决以下问题:
- 支持任意数量包含:一个源文件中可以有任意数量的包含标记,都要能正确解析。
- 支持嵌套包含:A包含B,B又包含C,任何层级的包含都要能正确解析。
- 防止递归包含:任何源文件(包括入口),无论是直接包含自身,还是间接包含自身,都不允许。
源文件之间可以构成复杂的有向无环图结构,但实际上我们可以用一个简单的迭代算法实现解析包含:
- 读取入口文件的代码,作为初始代码。
- 定义已涉及的文件列表,初始只有入口文件。
- 循环,如果当前代码中存在包含标记:
- 取出第一个包含标记中的文件路径。
- 如果该文件已在中,则报错,说明存在递归包含。
- 读取该文件的内容。
- 替换第一个包含标记。
- 将该文件放入。
实现如下:
/** 解析JS源文件的包含 */
function parseInclude(jsFilePath) {
// 涉及的JS源文件路径列表
const involved = new Set([jsFilePath]);
// 已解析的JS代码
let code = fs.readFileSync(jsFilePath, {encoding: "utf-8"});
// 包含标记的正则模式
const pattern = /^\s*\/\/\s*#\s*include\s*:\s*(\S+)\s*$/m;
let matched;
// 提取第一个包含标记
while ((matched = code.match(pattern))) {
// 要包含的文件路径
const includeFilePath = path.resolve(SRC_DIR_PATH, matched[1]);
// 如果要包含的文件不存在,则抛异常
if (!fs.existsSync(includeFilePath)) {
throw new Error(`解析${jsFilePath}时要包含的${includeFilePath}不存在`);
}
// 如果要包含的文件已涉及过,则说明出现了重复包含,抛异常
if (involved.has(includeFilePath)) {
throw new Error(`解析${jsFilePath}时发现重复包含${includeFilePath}`);
}
// 要包含的代码
const includeCode = fs.readFileSync(includeFilePath, {encoding:"utf-8"});
// 替换第一个包含标记
code = code.replace(matched[0], includeCode);
// 标记已涉及
involved.add(includeFilePath);
}
return {code, involved};
}
优化:支持包含目录,目录自动加上index.js。
优化:报错时简化路径,且报在重复包含来源于哪个源文件,方便排查。
持续构建
原来的持续构建逻辑是,src中的任何文件发生变化,都统一以该文件所在目录为源目录,重新构建。但是在引入包含机制后,就不能这么做了,必须记录构建源目录时涉及的所有源文件,
WebStorm直接支持跨文件代码提示,但非同一个源目录的也会提示,这也是后续需要改进的地方之一。
持续构建:
config.env.js、index.js、index.css、index.scss有变化时,直接重新构建所在的目录
新建、修改、删除都重新构建。
过程中涉及的JS源文件,需要在构建过程中维护映射表:JS源文件路径 → 要构建的源目录列表。
因为一个JS源文件可能被多个程序引用。
config.env.js、index.js也可能被include,场景:多个相似的流程,一模一样的代码,为了清晰起见,还是按流程分目录,在一个流程中写,在其他流程中引用。
构建config.env.js也要支持include。
包含关系是动态的,包含标记可以随时增删。所以每次构建源目录时,都要重新设置映射关系。
构建失败时,不重新设置映射关系。两种情况:重复包含,在过程中检测到,此时后面可能还有正常的包含没有走到,那就不能更新,否则后面的正常包含不会正确触发;JSX编译语法错误或其他运行时错误,不影响包含关系,无需更新。但SCSS编译失败不影响,是要更新的。所以,更新应该在成功获取JS代码和涉及源文件列表之后。
收到文件变化时,如果是JS文件,则查阅involvedMap,找出要重新构建的源目录列表。如果是样式文件,则重新构建所在的目录。其他情况,不重新构建。
如果是样式文件,则直接重构所在目录,增改删都是如此。如果是JS文件,则看已有的映射关系,改删可以,增不行,因为没有关系。中间JS这样可以,但是,config、main不行。config、main除了查表,一定总是重构所在目录。
多层模块拆分,按目录组织,为了简单起见,目录中用index.js做统一包含入口。index.js是JS模块化时表示目录代码的惯例名称,这与我们框架的约定冲突,我们沿用这个惯例,因为后面会实现真正的JS模块化,修改框架的约定。将index.js改为main.js,将index.scss、index.css改为main.scss、main.css。
弊端:
- 所有代码最终都在同一个作用域中,因此必须手动做作用域控制,例如用IIFE
(function () {...})();。 - 包含标记是框架自己的约定,并非标准的JS语法,IDE不知道源文件之间的先后顺序关系,代码提示只能很泛,不够精确。
- 不容易阅读,IDE不支持跳转源文件,需要开发人员自行理解代码之间的编排关系。
- IDE不能帮助重构。