使用GEE(Google Earth Engine)做开发的人,大概都经历过脚本从“能跑”到“失控”的过程:一开始只有一个测试脚本,后来功能多了、团队人多了,同样的函数在十几个脚本里复制粘贴,改一处漏一处,最后连自己都分不清哪个版本是新的。GEE的代码编辑器其实内置了一套模块化机制,核心就是两个关键字:exports和require。用它们可以把公共逻辑抽到单独的脚本文件里,按需加载,像搭积木一样组织项目。这篇文章我把这套机制的用法、原理和踩过的坑完整讲一遍,适合脚本已经开始变多、想提升复用水平的开发者,也适合准备给团队搭建公共工具库的人。
1. 模块化背后的思路:没有它,脚本会变成什么样
1.1 不模块化的三个典型痛点
我先说说自己早期在GEE上写代码的经历。最开始我习惯把所有东西放进一个脚本,变量越来越多,函数越写越长。为了在另一个项目里复用一段Landsat云掩膜代码,我直接把整个函数体复制过去,改个变量名接着用。这种方式的麻烦是渐进式的:
- 一是一致性极差。同一个函数在A脚本里加了新参数,B脚本还是旧版本,跑出来的结果对不上。
- 二是文件体积失控。一个脚本上千行,编辑器卡顿不说,滚动找函数都费劲。
- 三是团队协作基本靠“口头同步”。同事想用我的工具函数,得先找我要脚本,再手动导入,流程繁琐。
这些问题本质上都是因为GEE脚本缺少隔离和引用机制:所有代码在一个命名空间里,互相干扰,又无法直接调用另一个脚本的内容。exports和require就是为了解决这些痛点设计的。
1.2 GEE模块化的核心原理
GEE的代码编辑器基于一个特殊的脚本解释环境。你可以把每个脚本文件想象成一个“工作间”:exports是工作间门口贴的“对外供应清单”,只有列在清单上的东西,别人才能取走;require则是拿着清单去指定工作间取货。
具体来说,当你编写一个模块脚本时,脚本内部默认有私有作用域。普通用var定义的变量、用function定义的函数,都不会自动对外可见。想要暴露给其他脚本,就得挂到exports对象上,例如exports.myFunction = function() {...}。而加载方使用require('用户路径/脚本路径')时,引擎会去找到那个脚本,完整执行一遍,收集所有exports挂载的内容,返回成一个对象。
这里要特别强调:GEE里的require是同步加载,主脚本执行到require那一行,会先等模块脚本执行完毕,再继续往下走。这意味着你在主脚本的任意位置写require都能拿到结果。不过我还是建议把所有require放在文件头部,一是符合阅读习惯,二是避免模块加载失败时后边代码白跑。
1.3 模块化带来的直接影响
用上这套机制之后,我最大的感受是“脚本终于可以被拆开思考了”。公共算法单独放一个文件,参数配置单独放一个文件,主流程只负责串联。改动范围被隔离在模块内部,调用方只要函数签名不变,就完全不用动。同时,因为模块天然就是一个脚本文件,Git历史、代码审查、版本回退这些常规工程化手段都能用上了。这也是我认为GEE脚本值得在早期就引入模块化的核心理由。
2. 核心语法:exports怎么用、require怎么用、路径怎么写
2.1 导出内容的正确姿势:exports对象
先看最简单的模块导出。假设你有一个脚本文件,路径是users/yourname/tools/calc,注意这个路径不带.js后缀,它是代码编辑器里显示完整路径时去掉扩展名的形式。在这个文件里,你想提供两个数学函数:
// 文件名:calc exports.sum = function(a, b) { return a + b; }; exports.double = function(x) { return x * 2; };把这段代码保存到代码编辑器里,exports.sum就是给外部提供的接口。调用脚本里这样使用:
var calc = require('users/yourname/tools/calc'); print(calc.sum(3, 4)); // 7 print(calc.double(10)); // 20这里有几个细节要特别注意:
exports是GEE模块环境提供的全局对象,不要自己声明,也不要用this代替。我在早期某个项目里试过this.sum = ...,结果加载后返回的对象是空的。- 你可以在模块里写很多私有函数,比如
function helper() {...},但如果没有通过exports挂载,外部永远拿不到。这不是坏事,它能强制你设计清晰的公共API。 exports除了挂函数,还能挂常量、配置对象。比如exports.MY_CONST = 42;,调用方直接calc.MY_CONST就能用。
2.2 加载模块:require的路径规则与执行顺序
require的完整语法就一行:
var 别名 = require('users/你的用户名/文件夹/脚本名');路径的规则是:
- 必须以
users/你的用户名/开头。 - 后续是脚本在代码编辑器中的文件夹路径和文件名,不要带
.js后缀。 - 路径区分大小写,空格和特殊符号尽量避免。
- 如果你要引用别人分享给你的脚本,前缀就是对方的用户名,例如
users/othername/repo/landsat_utils。
执行顺序上,模块脚本在第一次被require时会完整执行一遍,之后同一运行周期内再require同一个路径,直接返回缓存的exports对象,不会重新执行。这个缓存机制能提高性能,但也带来一个副作用:模块中的顶层代码如果带有print、Map.addLayer这些副作用,即使你只是想require里面的函数,这些副作用也会在每次运行主脚本时执行一次。所以,好模块的顶层应该只做定义和导出,所有实际的操作都放进函数里。
2.3 一个最小可运行示例
我整理一个完整的最小示例,方便你立刻上手。首先在代码编辑器左侧Scripts面板里,新建一个文件夹叫tools,再新建一个脚本叫hello。注意,GEE里新建脚本默认会带一行注释,不用管。
tools/hello脚本内容:
// 模块:hello exports.greet = function(name) { return 'Hello, ' + name + '!'; }; exports.PI = 3.14159;然后在另一个脚本main中:
// 主脚本 var hello = require('users/yourname/tools/hello'); print(hello.greet('GEE')); // Hello, GEE! print(hello.PI); // 3.14159点击Run,控制台输出两条内容,就说明模块化调用成功了。如果报错,最可能是用户名填错或者文件名没保存。
3. 实操案例:把Landsat预处理封装成模块
3.1 模块设计:哪些函数应该抽出来
光看语法还不够,我以一个典型的Landsat 8预处理场景,带你完整走一遍模块设计和调用流程。假设你经常处理Landsat 8集合,需要做三件事:云掩膜、NDVI计算、以及把DN值缩放到反射率。这三步几乎每个项目都要用,但不同项目的细节略有差异。我选择把它们设计成一个模块landsat_utils,对外提供三个函数和一个配置常量。
设计原则是:
- 单一职责:每个函数只做一件事,方便单独测试。
- 参数灵活:预处理函数允许调用方控制是否乘缩放因子,避免硬编码。
- 返回类型统一:函数返回的都是
ee.Image,方便在主脚本中继续链式调用。
3.2 模块代码实现
先新建脚本,路径假设为users/yourname/gis/landsat_utils。完整代码如下:
// 模块:landsat_utils // 提供Landsat 8 SR数据的常用预处理函数 // 使用像元质量波段BQA做云掩膜 exports.maskL8Clouds = function(image) { var qa = image.select('BQA'); // Landsat 8 BQA中,bit 4和bit 8分别表示云和卷云 var cloudBitMask = (1 << 4); var cirrusBitMask = (1 << 8); var mask = qa.bitwiseAnd(cloudBitMask).eq(0) .and(qa.bitwiseAnd(cirrusBitMask).eq(0)); return image.updateMask(mask); }; // 计算NDVI并作为波段加到影像上 exports.computeNDVI = function(image) { var ndvi = image.normalizedDifference(['B5', 'B4']).rename('NDVI'); return image.addBands(ndvi); }; // 一键预处理:云掩膜 + 缩放 + NDVI exports.preprocess = function(image, scaleFlag) { var masked = exports.maskL8Clouds(image); var scaled = masked; if (scaleFlag !== false) { scaled = scaled.multiply(0.0001).copyProperties(masked, ['system:time_start']); } return exports.computeNDVI(scaled); }; // 常用参数配置 exports.NDVI_PALETTE = ['blue', 'white', 'green']; exports.SCALE_FACTOR = 0.0001;这个模块有几个值得注意的点:
exports.maskL8Clouds里用BQA波段判断云和卷云,这是史前Landsat 8 Collection 1 SR数据的常见做法。如果你用Collection 2,SR数据自带QA_PIXEL波段,判断逻辑不同,但模块化的思路完全一致。preprocess函数内部直接调用同一个模块里的maskL8Clouds和computeNDVI。因为这些都是挂载在exports上的属性,所以内部可以用exports.maskL8Clouds来调用,也可以先把函数提取成私有函数再导出。我更推荐定义独立函数然后赋值给exports,这样内部调用和使用私有变量一样自然。- 缩放流程里我用了
copyProperties(masked, ['system:time_start']),如果你调用了multiply(),影像属性会丢失,所以需要把关键的时间戳属性拷贝回来。这个坑很多人踩过。
3.3 主脚本怎么调用这个模块
在另一个脚本里,你需要先require这个模块,然后就能用模块里的函数处理影像集合了。示例:
var utils = require('users/yourname/gis/landsat_utils'); var roi = /* 定义你的研究区域 */ ee.Geometry.Point([-122.41, 37.77]); var collection = ee.ImageCollection('LANDSAT/LC08/C01/T1_SR') .filterBounds(roi) .filterDate('2020-01-01', '2020-12-31') .map(function(img) { return utils.preprocess(img, true); }); var medianNdvi = collection.select('NDVI').median().clip(roi); Map.addLayer(medianNdvi, { min: -0.2, max: 0.8, palette: utils.NDVI_PALETTE }, 'Landsat8 Median NDVI');这里我特别说明一下map函数内部的调用:GEE的collection.map会把你提供的客户端函数序列化发送到服务器端,在服务器上每个影像对象都会执行一次。因为utils.preprocess是在客户端定义的函数,它内部包含的ee.Image操作会被转换为服务端指令。这是GEE支持的一种模式:使用模块函数在map里处理每一景影像,只要函数返回的是ee.Image即可。
3.4 同时导出多个函数和常量的场景
实际使用中,一个模块往往会同时导出函数和常量。比如我们上面的utils.NDVI_PALETTE,就是导出字符串数组。你还可以导出环境配置、波段列表,例如:
exports.L8_OPTICAL_BANDS = ['B1','B2','B3','B4','B5','B6','B7']; exports.L8_THERMAL_BANDS = ['B10','B11'];调用方可以直接引用这些常量,避免魔法数字。如果常量很多,你甚至可以写一个config模块,专门放所有项目的公共参数。这种方式特别适合团队统一标准。
4. 常见问题与排查:从“Module not found”到循环依赖
4.1 找不到模块:路径、权限和命名问题
使用require最常见的报错是:
Exception: Module not found: users/xxx/tools/hello我排查这类问题的顺序一般是:
- 先看代码编辑器左侧的脚本列表,点击你想引用的脚本,确认编辑区顶部显示的正确路径。注意GEE会显示完整路径,通常是
users/你的用户名/文件夹/文件名,复制的时候去掉最前面的users/?不,require里要保留users/,但复制时不要带users/前面的任何前缀。实际上,编辑器顶部显示的是完整用户路径,直接复制即可。 - 检查文件是否已经保存。如果你新建了一个脚本但还没保存,路径可能不生效。
- 检查路径里是否带有
.js后缀。很多人习惯写require('.../hello.js'),GEE会报找不到,必须去掉后缀。 - 检查权限。默认情况下,你只能
require自己账户下并且你有权限访问的脚本。如果你尝试引用别人的脚本,对方必须把脚本共享给你(或设为公开),否则即使路径正确也会报权限错误。
一个快速验证权限的方法:在浏览器地址栏输入https://developers.google.com/earth-engine/datasets/...?不对,测试GEE脚本路径的正确方法是直接尝试打开完整脚本链接。你可以把路径改成https://code.earthengine.google.com/?scriptPath=users/xxx/tools/hello来验证能不能访问。但更稳妥的方法是直接在代码编辑器里,让对方把脚本分享链接发给你。
4.2 共享与发布:让别人也能用你的模块
GEE的脚本默认只有你自己可见。如果你要把模块分享给同事,需要做两步:
- 在代码编辑器左侧的脚本列表中,右键点击脚本或文件夹,选择“Share...”,进入分享对话框。
- 添加对方邮箱或选择“Anyone with this link can view”,点击保存。最省事的是设为“Public on the internet”(公开),这样所有人只要知道路径就能
require。如果只需要内部用,就只给特定邮箱加权限。
分享后,对方在脚本里就可以正常require你的模块。这里有个经验:不要随意把包含敏感逻辑的脚本设为公开。GEE脚本一旦公开,任何知道路径的人都能调用你的函数,虽然不能直接看到源码?实际上,代码编辑器里如果脚本公开,用户可以通过脚本链接看到源码。所以请根据内容决定公开范围。
4.3 隐藏陷阱:缓存、副作用和循环依赖
先说缓存。GEE在一次执行周期内,对同一个require路径只执行一次模块脚本。这个设计通常很好,但如果你在调试中修改了模块,需要重新点击“Run”而不是只在主脚本里再次require,因为重新Run会重新加载所有模块。
再说副作用。我见过有人把Map.addLayer写在模块顶层,结果每次require任何函数,地图上都会多一层。模块应该像纯函数库一样,顶层不执行任何输出和可视化。如果你真想测试模块,可以在模块脚本里单独写一个“仅当直接运行该模块时”的测试段。GEE没有if (module === main)这样的概念,所以最安全的方式是另建一个测试脚本,专门require模块并打印结果。
最后是循环依赖。比如A脚本require了B脚本,B脚本又require了A脚本。GEE可能直接报解析错误,或者导致其中一个对象为空。我在团队协作中遇到过一次:两个开发者各自封装了工具,然后互相引用对方,最后谁也加载不出来。解决方法是打破环:把公共功能下沉到第三层,让A和B都去require那个公共模块。环的存在往往意味着模块边界划分不合理,这时候重新梳理模块粒度比强行用“懒加载”更重要。
4.4 调试技巧:确认模块里到底导出了什么
有时候你出于习惯复制了一个模块路径,但不确定里面有哪些导出项。可以在任何一个临时脚本里这样打印:
var mod = require('users/yourname/tools/hello'); print(Object.keys(mod));然后把模块变量打印出来,查看它的键列表。如果打印出来是空的,说明模块里没有用exports挂载任何内容。这个方法能快速判断是路径问题还是导出语法问题。
另外,如果模块内部报错,GEE控制台会显示出错的模块路径和行号,但有时行号对应的是压缩后的代码。建议在模块内部添加关键位置的print或throw,缩小问题范围,调试完删除。
5. 进阶经验:面向团队的模块组织与小技巧
5.1 用文件夹搭建工具库
当模块数量超过五六个,就要开始规划目录结构了。我的习惯是:
- 顶层建一个
modules文件夹,专门放可复用的工具脚本。 - 下面按主题分子文件夹,比如
modules/landsat、modules/sentinel2、modules/geometry。 - 每个模块文件控制在100行以内,如果超过,考虑是否该拆成更细粒度的模块。
例如:
modules/ landsat/ cloudmask // Landsat云掩膜函数 indices // NDVI、NDWI等指数 sentinel2/ cloudmask utils/ dateRange clipToRegion主脚本只负责流程编排和业务逻辑,所有具体算法都从require获取。这样文件树就是模块地图,新同事接手项目时,看目录就能明白项目里有哪些能力。
5.2 为模块写“测试脚本”
GEE没有内置的单测框架,但不妨碍我们给自己写一个轻量的测试脚本。做法如下:
- 建一个
tests文件夹,里面每个测试脚本对应一个模块。 - 测试脚本里
require模块,输入已知数据,打印输出,并用assert风格表达式判断是否符合预期。
举一个简单例子,为calc模块写测试:
var calc = require('users/yourname/tools/calc'); var assert = { isTrue: function(condition, msg) { if (!condition) { throw new Error(msg || 'Assertion failed'); } } }; assert.isTrue(calc.sum(2, 3) === 5, 'sum failed'); assert.isTrue(calc.double(21) === 42, 'double failed'); print('All tests passed');手动点击“Run”这个测试脚本,如果控制台出现“All tests passed”,就说明模块通过测试。以后修改模块代码后再跑一遍,能有效防止回归问题。
5.3 版本化与变更日志
GEE的代码编辑器支持通过Git仓库同步脚本。如果你用的是标准仓库模式,每次修改模块都能提交commit,回滚也有据可查。我强烈建议在模块文件头部写一个变更记录注释:
// 模块:landsat_utils // 变更记录: // 2024-05-01:增加preprocess函数,统一缩放逻辑 // 2024-04-20:修复BQA云掩膜判断这样即使多人维护,也能快速定位改动历史。当然,更严谨的方法是配合Git commit message记录,但脚本内注释对阅读者更友好。
5.4 多项目复用:把模块仓库当“内部软件包”
当模块库稳定后,你会在不同项目里反复require同一批脚本。这时可以把脚本库看成你自己维护的“软件包”:只通过exports暴露稳定的接口,内部实现随便改。只要接口不破坏,所有调用方都不用迁代码。一旦接口要变更,先通知所有使用者,或者使用带版本后缀的路径,例如users/yourname/modules/landsat_utils_v2,老项目继续用v1,新项目用v2。这种粗糙但有效的版本方案,确实避免过不少意外事故。
结尾:一点个人经验
我实际用下来,exports和require最舒服的使用方式,不是把所有代码塞进一个模块,而是很果断地拆分。刚开始时,我把自己常用的十几个函数全部塞在utils脚本里,结果这个文件越来越大,最后连找一个函数都要滚动好几屏。后来我把它们拆成按主题的小模块,每个文件只做一件事,再配合测试脚本,维护成本显著下降。如果你已经发现脚本里重复代码越来越多,或者团队里总有人问“这个工具函数在你哪个脚本里”,那就是把模块化真正用起来的时候。从一个小工具模块开始,很快就回不去了。