泛微Ecology 9代码块开发框架:模块化的简单实现——JS源文件包含

问题

模块化是个重要的问题。

目前,开发框架中每个脚本目录的所有业务逻辑都写在index.js这一个文件中。模块化的主要手段是将需求拆分为多个独立的小功能,用多个脚本目录分别实现,各脚本目录的代码之间没有关联。由于需求都没有特别复杂,所以这种模式运行了很久,也没有太大的问题。主要的不便就是公共代码复用,但因为公共代码比较少,通常只有几个函数,所以就直接复制了。

但是,随着系统演进,已经出现了大型的、复杂的需求(需要在一个OA页面中做复杂的填数与实时计算),代码量很大,大到这一块业务功能本身就需要做专门的架构设计。我们仍然是先尝试拆分成多个脚本目录,但是不可行,最主要的技术限制是:

  1. 如果还像之前那样直接使用OA提供的API,就需要在每个地方都操心OA运行机制的细节、理解和处理OA的怪异特性,书写繁琐、可读性差,在这个量级上必然导致难以维护,因此必须封装好大量的基础功能以提高开发、维护的效率,那么公共代码的复制和同步修改就是噩梦。
  2. OA对代码块中的多个脚本文件是异步加载、运行的,这与正常的HTML加载JS不同,因此,将公共代码放在单独的脚本文件中是行不通的,因为无法保证公共代码一定先执行。此外,业务代码的各个功能模块之间也有着复杂的依赖关系,谁应该先执行、谁应该后执行?时序控制是噩梦。

可见,框架已经无法满足开发需求了,亟待改进。

思路

基本思路是,让框架支持更好的模块化机制,这也是Web前端领域从蛮荒走向工程化的核心思想之一。

框架至少要支持:

最好的实现当然是引入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

要点:

解析包含

原来是直接从config.envenv.js、main.js就能得到完整的JS代码,但是现在不行了,config.envenv.js、main.js只是一个入口,必须把其中所有的包含都正确处理掉,才能得到完整的JS代码。

解析包含的算法需要解决以下问题:

源文件之间可以构成复杂的有向无环图结构,但实际上我们可以用一个简单的迭代算法实现解析包含:

  1. 读取入口文件的代码,作为初始代码。
  2. 定义已涉及的文件列表involvedinvolved,初始只有入口文件。
  3. 循环,如果当前代码中存在包含标记:
    1. 取出第一个包含标记中的文件路径。
    2. 如果该文件已在involvedinvolved中,则报错,说明存在递归包含。
    3. 读取该文件的内容。
    4. 替换第一个包含标记。
    5. 将该文件放入involvedinvolved。

实现如下:

/** 解析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。

弊端: