最新动态
- 泛微Ecology 9代码块开发框架:模块化的简单实现——JS源文件包含
问题 模块化是个重要的问题。 目前,开发框架中每个脚本目录的所有业务逻辑都写在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.$env$.js、main.js就能得到完整的JS代码,但是现在不行了,config.$env$.js、main.js只是一个入口,必须把其中所有的包含都正确处理掉,才能得到完整的JS代码。 解析包含的算法需要解决以下问题: 支持任意数量包含:一个源文件中可以有任意数量的包含标记,都要能正确解析。 支持嵌套包含:A包含B,B又包含C,任何层级的包含都要能正确解析。 防止递归包含:任何源文件(包括入口),无论是直接包含自身,还是间接包含自身,都不允许。 源文件之间可以构成复杂的有向无环图结构,但实际上我们可以用一个简单的迭代算法实现解析包含: 读取入口文件的代码,作为初始代码。 定义已涉及的文件列表$involved$,初始只有入口文件。 循环,如果当前代码中存在包含标记: 取出第一个包含标记中的文件路径。 如果该文件已在$involved$中,则报错,说明存在递归包含。 读取该文件的内容。 替换第一个包含标记。 将该文件放入$involved$。 实现如下: /** 解析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不能帮助重构。
- 泛微Ecology 9代码块开发框架:浏览器调试增强
我们来解决几个在浏览器中排查、调试时遇到的问题。 同一页面多脚本文件时不容易区分 问题 我们的脚本文件用中文命名,中文在URL中会自动编码成%E4%B8%AD%E6%96%87这种形式,因此如果一个页面有多个脚本文件,在浏览器DevTools的Network中就无法直接通过URL区分谁是谁,必须看代码。但是,开发环境的脚本文件前面是一大段模板代码,生产环境的脚本文件又是压缩的,都很难快速从代码看出是什么功能。 解决办法 构建时,在写入脚本文件之前,在脚本文件的开头附加脚本目录的路径(相对于src/,形如流程布局/流程名称/功能名称),相当于脚本文件的名称。 // code是编译、压缩(根据环境)后的脚本文件代码 // name是脚本文件的名称 code = `// ${name}\n${code}`; 现在,在Network中就只要看脚本文件的第一行就能知道是什么功能。 更进一步,很多时候看Network主要是想知道某个脚本文件是否加载了、代码是否执行了,那么,可以在脚本文件的开头写一个固定的打印,直接看控制台即可。 code = `// ${name}\nconsole.log("执行脚本: ${name}");\n${code}`; 无法断点调试 问题 虽然我们在代码块中写的是<script src="http://xxx.js">,但是OA加载脚本文件时会自动在URL后加上时间戳?_=123,这导致我们在浏览器DevTools中对脚本文件打断点,刷新页面后,断点并不会保留,因为浏览器认为前后是不同的资源。 解决办法 利用浏览器提供的sourceURL标记,该标记可以让JS文件显式声明自己的URL。 构建时,在写入脚本文件之前,先根据脚本目录的路径计算出脚本文件的URL路径部分urlPath(形如/A/B/C.js),然后在脚本文件的开头附加sourceURL标记。 // code是编译、压缩(根据环境)后的脚本文件代码 // urlPath是脚本文件的URL路径部分 code = `//# sourceURL=${urlPath}\n${code}`; 设置sourceURL后,因为每次声明的URL都相同,所以浏览器就认为前后是同一个脚本文件,断点就可以正常保留、生效了。 为什么这里sourceURL的取值为URL路径部分?sourceURL最精确的取值当然是Network中脚本文件的URL去掉时间戳,但这会包含服务器/本地的主机名、端口号,构建时无法确定。实际上,sourceURL本质就是一个别名机制,完全不影响脚本文件的获取和执行。因此,我们取对任何部署位置都统一的URL路径部分。 因为sourceURL以/开头,所以在浏览器DevTools的Source中,会看到脚本文件显示在OA的主机名下,而不是脚本文件实际部署的那个主机名。这没什么问题,因为我们的脚本文件的路径是以中文开头的,所以很容易跟OA自己的资源文件区分开。如果希望在Source中让脚本文件显示在一个单独的主机名下,则可以给sourceURL加上前缀,例如http://codeblock,此时在Source中就会看到codeblock这个主机名(同样因为是别名,这个主机名完全可以不存在)。 sourceURL还解决了在Source中脚本文件名显示为%E4%B8%AD%E6%96%87这种形式的问题,现在显示为中文了。
- 泛微Ecology 9代码块开发框架:压缩JS和CSS
问题 前端工程中,一般最佳实践是在生产环境中压缩HTML、CSS、JS代码,提高网络传输性能。我们的软件项目仅面向公司内部用户,在公司内网中运行,一般网络质量很好,不用担心传输问题,但在通过VPN接入内网、通过外网IP直接访问OA的场景下,也无法保证网络总是高质量。因此,我们遵循这一最佳实践,对生产环境的脚本文件进行压缩。由于开发环境的脚本文件需要在浏览器中阅读、调试,因此不压缩。 实现 开发框架支持多环境和增加环境,const ENVS = ["dev", "prod"];定义了所有环境的标识。我们再通过const COMPRESS_ENVS = ["prod"];定义哪些环境需要压缩。 压缩JS 技术选型 使用UglifyJS作为JS代码压缩器。 实现 安装依赖uglify-js。 通过以下代码实现压缩: const uglifyJs = require("uglify-js"); /** 压缩JS代码 */ function compressJS(code) { return uglifyJs.minify(code).code; } // 构建时,如果当前env在COMPRESS_ENVS中 const compressedScriptCode = compressJS(scriptCode); 压缩时机:在替换模板中的CSS和JS占位符之后做压缩,而不是直接对index.js和config.$env$.js或其JSX编译结果做压缩,因为模板中的代码也是JS,因此在替换后压缩才能确保压缩到所有JS代码。 压缩CSS 技术选型 使用clean-css作为CSS代码压缩器。 实现 安装依赖clean-css。 通过以下代码实现压缩: const CleanCss = require("clean-css"); const cleanCss = new CleanCss(); /** 压缩CSS代码 */ function compressCSS(code) { return cleanCss.minify(code).styles; } // 构建时,如果当前env在COMPRESS_ENVS中 const compressedCss = compressCSS(css); 压缩时机:在替换模板中的CSS占位符之前做压缩。CSS在模板中是JS中的一个字符串,JS压缩并不会同时压缩CSS,因此要在替换之前先单独压缩CSS,再替换到模板中。
- 泛微Ecology 9代码块开发框架:环境配置
问题 我们有两套环境,开发环境和生产环境。同一个功能,在不同的环境中,业务逻辑一模一样,但有些基础性的值不一样(例如IP地址、数据的ID等)。我们会尽可能从环境中动态获取这些值(例如字段ID),使代码能直接跨环境运行,但总有些值无法动态获取(例如后端IP地址),必须对不同环境分别指定。因此,开发框架需要支持环境配置功能。 初步实现 规定:开发环境的标识为dev,生产环境的标识为prod。 目录结构 将脚本目录的结构扩展为如下形式: config.dev.js:开发环境的配置代码。 config.prod.js:生产环境的配置代码。 index.js:业务逻辑代码,对所有环境都相同。 index.scss/index.css:样式代码,对所有环境都相同。 index.js应尽可能从环境中动态获取所需的值,只有不得不分环境配置时,才使用config.dev.js和config.prod.js。 在config.dev.js和config.prod.js中,通常只有形如const NAME = value;的常量定义,常量名完全相同,只是值不同。 config.$env$.js和index.js在构建时会直接拼接在一起,因此它们的代码处在同一个作用域中,index.js中直接使用config.$env$.js中的常量名。 构建程序 构建时必须指定对哪个环境构建。让构建程序接收参数--dev或--prod(有且只能有一个),得到当前环境的标识$env$(dev或prod)。 构建脚本目录时,先读取对应环境的配置文件config.$env$.js(如果存在),再读取index.js,然后拼接它们得到JSX代码,最后统一做JSX编译得到JS代码。 启动命令 修改package.json: "scripts": { "build-dev": "node index.js --dev", "build-prod": "node index.js --prod", "watch-dev": "node index.js --watch --dev", "watch-prod": "node index.js --watch --prod" } 运行对应的命令执行不同的构建模式和环境配置: # 一次性构建,开发环境 npm run build-dev # 一次性构建,生产环境 npm run build-prod # 持续构建,开发环境 npm run watch-dev # 持续构建,生产环境 npm run watch-prod 同时构建所有环境 问题 目前dist/的构建结果是只针对一个环境的,但实际开发时,我们经常有如下场景: 在生产环境上做一个仅开发人员可见的页面,然后直接在生产环境上开发,因为这样可以使用生产环境的数据直接看到最终效果。 生产环境中出了问题,要在生产环境上调试业务代码。 在这些场景中,经常手上同时还有要在开发环境上做的工作,因此,如果dist/中只有一个环境的构建结果,我们就需要来回切换环境重新构建,很不方便。 解决思路 在dist/下设立dev/和prod/目录。 对每个脚本目录,框架总是同时构建两个环境的脚本文件。绝大多数业务代码都是很小的、独立的,与复杂的、整体性的前端工程项目(例如基于Create React App框架的项目)不同,同时构建两个环境并不会慢。 构建程序 构建程序不再接收--dev和--prod参数,而是定义一个环境标识列表const ENVS = ["dev", "prod"];,这也便于未来再增加新的环境。构建脚本目录时,遍历ENVS,对每个$env$都构建一份JS代码。 为了提高性能,先对index.js做JSX编译,再分别对每个config.$env$.js做JSX编译,然后再拼接。因为index.js最复杂,JSX编译最耗时,且只需要编译一次。 内置Web服务器 每个环境都需要运行一个内置Web服务器。 遍历ENVS,为每个$env$创建一个Express应用,响应dist/$env$/目录的静态文件,端口号从8000依次顺延。 这样,访问脚本文件的URL格式仍然不变,只是不同环境的端口号不同,开发环境为8000端口,生产环境为8001端口。 启动命令 修改package.json: "scripts": { "build": "node index.js", "watch": "node index.js --watch" } 运行对应的命令执行不同的构建模式,每个构建模式都对所有环境构建: # 一次性构建,所有环境 npm run build # 持续构建,所有环境 npm run watch 部署 部署时,将dist/$env$/中的脚本文件上传到$env$环境的OA服务器。
- 泛微Ecology 9代码块开发框架:内置Web服务器
问题 通过Web访问本地构建出来的脚本文件,是我们这套开发框架的核心组成部分之一。目前是通过外置Web服务器nginx来实现的。 新开发人员加入团队时,除了要拉取和启动代码库本身,还要去安装、配置、启动nginx,比较麻烦,也容易出错,因为大家对nginx都不怎么熟悉。 因此,希望将Web服务器内置到开发框架中,只需要启动开发框架这一处就能开始开发,就像Create React App框架的npm run start命令那样。 技术选型 使用基于Node.js的后端框架Express实现内置Web服务器。 实现 安装依赖express。 本地Web访问只在开发时需要,因此只在持续构建模式下运行内置Web服务器。 在持续构建模式下,创建Express应用,响应dist/中的静态文件,然后监听8000端口。 // Express应用 const app = express(); // 响应dist/中的静态文件 app.use(express.static(DIST_DIR_PATH)); // 监听8000端口 app.listen(8000, () => console.log("Web服务器已启动")); 注意:要先停掉nginx服务器,并移除nginx中的配置,避免端口号冲突。
- 泛微Ecology 9代码块开发框架:编译JSX和SCSS
编译JSX 问题 OA系统前端使用React开发,我们的业务代码中也会写React组件。React组件通常用JSX语法编写(形如<Component/>),然后编译成原生JS语法(形如React.createElement(Component, ...))。我们的开发框架目前对JS代码只是简单地组装,浏览器运行的就是我们直接写出来的代码,因此在JS源文件中只能写原生JS语法,这对编写React组件很不方便。因此,开发框架需要支持JSX语法的编译。 技术选型 使用Babel实现将JSX语法编译为原生JS语法。 兼容性 JSX语法是原生JS语法的超集,IDE支持在.js扩展名的源文件中使用JSX语法,因此,我们直接将已有的index.js统一看作JSX语法,统一将其编译为原生JS语法。 实现 安装依赖@babel/core、@babel/preset-react。 修改构建程序: const babel = require("@babel/core"); /** 编译JSX语法为原生JS语法 */ function compileJSX(code) { const OPTIONS = { presets: ["@babel/preset-react"], }; return babel.transformSync(code, OPTIONS).code; } /** 获取JS代码 */ function getJS(scriptDirPath) { const jsxFilePath = path.resolve(scriptDirPath, "index.js"); const jsx = fs.readFileSync(jsxFilePath, {encoding: "UTF-8"}); return compileJSX(jsx); } // 构建时 const js = getJS(scriptDirPath); 编译SCSS 问题 用原生CSS语法写复杂样式时很麻烦,因此我们希望通过CSS预处理器(CSS preprocesssor)来降低复杂性。 技术选型 使用Sass的SCSS语法,它是原生CSS语法的超集,增加了嵌套、变量、单行注释等方便的特性,易学易用易读。 使用sass库将SCSS语法编译为原生CSS语法。 兼容性 之前已经写了不少index.css,但这里不能像JS那样,直接将.css的内容看作SCSS语法,因为IDE不支持,会报错。 我们有两种解决办法: 将所有index.css全部改名为index.scss,然后开发框架只支持index.scss,并要求团队以后必须使用index.scss这个名称,这样更简单。 同时支持index.css(原生CSS语法)和index.scss(SCSS语法),并规定index.scss优先于index.css,这样两种名称都可以用。 考虑到团队的实际情况,考虑到所有人都熟悉CSS但并非所有人都熟悉SCSS,考虑到SCSS只在少数复杂场景下使用才有必要,那么,应该保留对index.css的支持。 实现 安装依赖sass。 修改构建程序: const sass = require("sass"); /** 编译SCSS语法为原生CSS语法 */ function compileSCSS(code) { return sass.compileString(code).css; } /** 获取CSS代码 */ function getCSS(scriptDirPath) { const scssFilePath = path.resolve(scriptDirPath, "index.scss"); const cssFilePath = path.resolve(scriptDirPath, "index.css"); if (fs.existsSync(scssFilePath)) { const scss = fs.readFileSync(scssFilePath, {encoding: "UTF-8"}); return compileSCSS(scss); } else if (fs.existsSync(cssFilePath)) { return fs.readFileSync(cssFilePath, {encoding: "UTF-8"}); } else { return ""; } } // 构建时 const css = getCSS(scriptDirPath); 异常处理 之前的构建只是简单地读取、组装,所以一直没有遇到构建失败的情况。但在支持JSX和SCSS的编译后,构建就不一定能成功了,比如SCSS代码中有语法错误,编译时就会抛异常,导致整个构建程序退出。 因此,要在构建脚本目录时做异常处理,在控制台中报告构建失败,并打印异常。
- 泛微Ecology 9代码块开发框架:持续构建
问题 上一篇文章我们实现了基本构建功能,做到了在业务代码开发完成之后构建为脚本文件,但是在开发过程中还是不方便,因为每次修改都要重新手动运行构建。因此,我们需要实现持续构建,即每当源文件发生修改时,就自动重新构建所在的脚本目录。 初步实现 在构建程序中,创建全局变量watched,存放已监听的源文件路径列表,用于防止重复监听。 每当构建一个脚本目录时,除了构建脚本文件,还对源文件做监听。对于每个涉及的源文件(index.js、index.css(如果有);不监听template.js,因为不变),如果不在watched中,则: 调用fs.watchFile(),注册回调函数,监听源文件的改变。当源文件内容改变时(前后的mtimeMs不一致),则重新构建源文件所在的脚本目录(不递归)。 将源文件加入watched。 启动构建程序时: 删除dist/。 对src/执行构建(递归)。 在对src/递归构建一遍后,此时所有源文件都已被监听,每当源文件修改都会自动重新构建。 构建模式 构建有两种场景: 开发时,需要持续构建。 部署时,需要一次性构建。 因此,我们让构建程序接收一个可选的命令行参数--watch,决定是否对源文件监听,即是否开启持续构建。 将package.json改为: "scripts": { "build": "node index.js", "watch": "node index.js --watch" } 那么,可通过如下命令执行不同的构建模式: # 一次性构建模式 npm run build # 持续构建模式 npm run watch 优化监听性能 实测发现,通过fs.watchFile()实现监听,在发生修改到执行回调之间,有很大的延迟,这导致修改代码后总是不能立即重新构建,刷新页面时加载的还是旧代码,要等一会儿刷新,新代码才生效。 查阅Node.js API文档发现,fs.watchFile()是通过轮询文件的状态信息(stats)来检测文件变化的,效率很低。文档推荐使用fs.watch(),因为它是直接通过操作系统机制(通知、事件等)实现的,效率要高得多。 因此,将fs.watchFile()改为fs.watch(),将判断文件内容变化的条件改为eventType === "change"。 实测,及时性有了显著提高,可以做到立即重新构建。 支持源文件增删 目前只实现了对已有源文件修改的监听,但实际开发过程中还有增加、删除源文件的场景,例如: 新建一个脚本目录,新建index.js,开始开发新功能。 编写代码时发现需要写样式,因此新建index.css。 编写代码时发现不需要样式了,因此删除index.css。 目前只能重新启动构建程序,否则增、删源文件不会触发重新构建。 查阅fs.watch()的文档发现,它支持递归监听整个目录,目录下任何层级的文件变化都会触发回调,通过回调的filename参数可以知道是哪个文件/目录发生了变化。这就类似于在window上集中监听DOM树中所有节点的事件。 那么,现在不需要watched变量了,只需要在src/上注册一次递归监听。当src/中的任何文件/目录发生变化时,不必区分变化类型(增、删、改),直接将变化文件所属的目录作为脚本目录,对其调用构建函数(非递归)。在构建函数中,如果脚本目录存在,且index.js存在,则构建,否则不构建,即可。
- 泛微Ecology 9代码块开发框架:初步实现
目标 开发框架的初版仅实现以下最基本的特性: 版本管理。 规范化目录结构。 基本构建功能:将CSS和JS源文件合并在一起构建为成品JS文件。 可通过Web访问本地的成品JS文件。 技术选型 Git:实现版本管理。 JavaScript语言:开发框架使用JS编写,与业务代码的编程语言保持一致。 Node.js:开发框架运行在Node.js环境上,因为涉及文件操作。 NPM:管理三方库的依赖。 nginx:实现Web服务器(实现初版开发框架时,我还没想到内置Web服务器)。 实现 1. 版本管理 建立Git仓库。 业务上各个功能独立演进,因此不做版本号管理。 2. 规范化目录结构 package.json(NPM项目配置文件) index.js(构建程序,是开发框架的核心) template.js(构建所需的模板JS代码文件) src/(业务代码目录) 流程布局/(存放用于流程布局的代码块的业务代码) 流程名称/(存放指定流程的业务代码) 功能名称/(存放指定功能的业务代码,通常功能名称中说明适用于哪个/哪些流程节点) index.js(业务代码的JS源文件,必须有,一个目录中有index.js,就说明要将该目录构建为成品JS文件) index.css(业务代码的CSS源文件,可有可无) 建模布局/(存放用于建模布局的代码块的业务代码) 模块名称/(存放指定模块的业务代码) 功能名称/ index.js index.css 建模查询/(存放用于建模查询的代码块的业务代码) 模块名称/(存放指定模块的业务代码。建模查询通常隶属于建模模块,且一个功能通常在同属于一个模块的多个查询中复用,因此通常按模块组织目录。) 功能名称/ index.js index.css dist/(成品JS文件输出目录,src/A/B/C/(有index.js)将会构建为dist/A/B/C.js。Git忽略此目录。) 在src/中,流程布局/、建模布局/、建模查询/这3个子目录是固定的,因为OA系统中只有这3个地方可以写代码块。但它们下面的目录结构是不固定的,上述结构只是一个参考,具体要按实际情况合理划分。 为了方便叙述,我们做如下定义: 脚本文件:dist/中的成品JS文件。由脚本目录构建得到,最终会作为一个独立的脚本部署到OA系统中。注意与JS文件区分。 脚本目录:src/中任意层级的包含index.js的目录。有index.js就说明要将该目录构建为一个脚本文件。 JS源文件:脚本目录中的JS源文件。注意与脚本文件区分。 CSS源文件:脚本目录中的CSS源文件。 3. 基本构建功能 模板 template.js:封装jQuery.ready回调(OA系统给的规范)、作用域隔离(通过IIFE(立即调用函数表达式)实现)、设置CSS等基础代码,并预留CSS和JS的占位符。代码如下: jQuery(document).ready(function () { // 设置CSS (function () { let css = `/*[[CSS]]*/`; css = css.trim(); if (css) { let style = document.createElement("style"); style.innerHTML = css; document.head.append(style); } })(); // 执行JS (function () { /*[[JS]]*/ })(); }); 构建程序 index.js(构建程序): 删除dist/。 递归遍历src/中的每个目录,如果目录中有index.js,则为脚本目录,需要构建。 读取index.js,得到JS代码。 如果index.css存在,则读取之,得到CSS代码,否则CSS代码为空。 读取template.js,得到模板代码。 用JS代码、CSS代码分别替换模板代码中的JS占位符、CSS占位符,得到脚本文件代码。 根据src/A/B/C/→dist/A/B/C.js的对应规则,得到脚本文件的路径。 将脚本文件代码输出为脚本文件。 启动命令 在package.json中配置: "scripts": { "build": "node index.js" } 那么,在编写完业务代码后,就可以运行如下命令将整个src/构建为dist/: npm run build 4. 通过Web访问 使用nginx做静态Web服务器,配置如下: server { listen 8000; server_name localhost; root "<项目目录>/dist"; charset utf-8; } 启动nginx后,即可通过形如http://localhost:8000/流程布局/流程名称/功能名称.js的URL访问脚本文件。
- 泛微Ecology 9代码块开发框架
背景 公司使用泛微的Ecology 9系统作为OA系统,以满足公司内部的流程审批、业务数据存储与使用等内部管理需求。 代码块,是OA系统提供的自定义Web前端代码的功能,可以在流程布局、建模布局、建模查询页面中编写自定义的HTML代码(在HTML中又可以通过<style>和<script>编写CSS和JS代码),实现自定义功能。即,这是一种二次开发机制。 原始开发模式 刚起步时,我们的开发模式非常原始: 在OA后台中,打开代码块编辑界面(需多次点击)。 编辑代码块,编写CSS和JS代码(一般不写HTML,而是在JS中动态控制DOM)。 保存代码块。 打开/刷新代码块所在的页面。 运行代码,查看效果。 因为代码总是要多次修改才能写好,所以上述整个过程都要不断循环。开发过程图示如下: 缺点: 除了编写代码本身,还必须做很多步骤的额外操作,非常繁琐,开发效率很低。 没有版本管理,容易改乱。 CSS和JS写在一起,关注点未分离,心智负担较重。 多个功能写在一起,功能复杂时,心智负担很重。 代码复用只能靠手动复制,同步修改困难。 同一个页面只有一个代码块,多个开发人员不能同时开发,因为保存时会互相覆盖。 OA的在线代码编辑器只有有限的代码提示,不如IDE好用、高效。 可见,在这种原始开发模式下,我们很难有效地完成开发任务。 本质上,这些问题跟在Web古早时期直接手写一个个HTML文件是一样的,Web后来发展出了前端工程,通过前端开发框架解决种种开发时的问题。受此启发,我设计出了如下开发框架。 开发框架 目标 解决开发模式的问题,简化代码的开发、部署、管理过程,提高开发效率。 思路 建立Git仓库做版本管理,将开发框架和业务代码都放入其中。 规范化代码组织结构。 支持分开编写CSS和JS源文件,然后合并在一起构建为成品JS文件。 支持Sass、JSX等高级语法的编译。 支持持续构建,源文件修改后,自动重新构建。 支持将复杂的功能拆分为多个源文件。 支持源文件之间代码复用。 支持多个开发人员在同一个页面上各自开发不同的功能。 支持开发环境、生产环境的环境配置。 内置Web服务器,提供开发时的成品JS文件访问。 增强IDE的代码提示,支持提示OA系统API。 将OA系统API二次封装成公共库,屏蔽细节、增强功能、简化使用,提高开发效率。 业务代码的开发过程 启动开发框架,框架会在本地运行起Web服务器,并持续构建代码。 在开发环境的OA系统的代码块中不直接编写CSS和JS代码,而是通过<script src="..."></script>引用本地的成品JS文件。 在开发框架内编写代码(可用IDE),每次保存都会自动重新构建成品JS代码。 打开/刷新代码块所在的页面。 运行代码,查看效果。 改进后的开发过程图示如下: 可见,循环缩短了,繁琐的OA后台操作变成了一次性工作,循环中只剩下了开发本身最必不可少的编写和运行操作。 业务代码的部署过程 将成品JS文件上传至生产环境的OA服务器,然后在OA系统中将<script>的src改为服务器上的地址即可。 实践成果 实践表明,此开发框架显著提高了开发效率。通过不断解决实际遇到的问题,开发框架已经能够支持复杂、大型的业务需求的开发。