WPS JS宏玩到一定阶段光靠录操作和堆顺序代码肯定是不够的。这阵子我在整理一套跨表格数据处理宏反复用了构造函数来自定义类途中踩得最深的一个坑就是this。在自定义类里调用内部对象this不能省、也省不得随手漏一个轻则读不到预期值重则把全局环境弄脏。这篇是自定义类系列的第二部分把this在构造函数和类方法里到底怎么指向、怎么用完整拆开讲一遍顺便附上一段可以直接复制跑的代码例子。1. 为什么自定义类要选构造函数而不是对象字面量1.1 对象字面量和构造函数的本质差别先给结论对象字面量适合创建一个孤立的“单例”对象构造函数适合按模板批量创建、带初始化逻辑、带独立状态的对象。两者都能做自定义类但使用场景完全不同。举个例子。如果你只需要一个全局日志对象用来记录宏运行过程中产生的几条消息那直接写var logger { logs: [], add: function (msg) { this.logs.push(msg); } };这完全够用一个地方定义、一个地方用简单直接。但如果你需要同时处理多个工作表每个表都得有独立的扫描状态、独立的行数、独立的统计结果再用对象字面量复制三四份代码立刻变得笨重每个方法都要手写一遍改一个逻辑要同步改好几处非常容易漏。构造函数解决的就是这种“同模板多实例”的需求。写一次构造函数每次new都返回一个独立的新对象互不干扰。这也是我在WPS宏里推荐用构造函数自定义类的原因。真正上规模的数据处理宏基本都要创建多个相似对象而不是只用一个全局单例。1.2 new关键字到底帮我们做了什么很多人写构造函数就是照猫画虎但并不知道new背后发生了什么。明确这一点对理解this至关重要。当你执行function SheetScanner(sheetName) { this.sheetName sheetName; this.rowCount 0; this.foundStrings []; } var scanner new SheetScanner(数据表);new做了四件事先创建一个全新的空对象然后把构造函数内部的this绑定到这个空对象上接着执行构造函数函数体给this挂上属性和初始值最后如果函数体内没有显式返回一个对象就把这个被填好的对象作为表达式结果返回。关键在于第二步和第四步。第二步让this.sheetName sheetName这行代码准确作用在当前这个新对象上而不是别的对象上。第四步则意味着只要构造函数里没有写return 某个对象你拿到的就一定是用this初始化的那个实例。function WrongConstructor() { this.name 张三; return { name: 李四 }; } var obj new WrongConstructor(); console.log(obj.name); // 输出“李四”this白干了构造函数里一旦return了一个对象这个对象会直接覆盖掉new创建的实例。这是新手最容易踩的坑之一也是理解“为什么必须通过this来操作内部状态”的绝佳反面教材。1.3 数据与行为捆绑是自定义类最大的价值自定义类说到底做的事就是数据加行为捆绑。一个对象内部既有状态属性又有操作这些状态的方法。写散装函数加全局变量也能完成但宏一复杂全局变量互相踩踏、函数调用关系混乱的问题就来了。用生活类比解释构造函数相当于一张空白登记表this相当于当前正在填写的这张表。this.姓名 张三是把信息填到当前这张表上而不是改墙上贴的公告。每new一次就是拿一张新空白表。等到对象多了每个对象都带着自己的数据和方法调用scanner1.scan()只会影响scanner1自己不会把scanner2的数据改乱。这也是面向对象编程在宏领域里依然适用的原因——它治理的是复杂度不是语法炫技。2. this的指向规则以及内部调用必须写this的底层原因2.1 WPS JS宏里this的四条基本规则搞清楚了new机制再看this的通用规则就轻松了。在WPS JS宏环境下this的指向逻辑和普通JavaScript基本一致可以归纳为四条第一条普通函数调用时this指向调用它的那个对象没有对象调用时this指向全局对象。比如把一个方法单独拿出来调用this就“掉”到全局去了后面会详细讲这个现场。第二条以obj.method()形式调用方法时this指向obj。这是最常见的调用形式也是正常情况下this不出错的场景。第三条new调用构造函数时this指向新建出来的实例对象。这条和上一章说的流程完全对应。第四条通过call、apply、bind可以手动指定this指向。这是修复this丢失问题的核心工具。另外补充一句如果你的WPS宏编辑器版本较新箭头函数也可以用它会继承外层作用域的this没有自己的this绑定。只是不要默认所有版本都支持写之前先确认一下环境再决定风格。2.2 不写this会发生什么作用域链搜索与全局变量污染这一节是整个主题的核心。为什么强调“调用内部对象必须用this”因为JavaScript不存在隐式的“当前对象成员”查找机制。如果你在方法里写下function Counter() { this.total 0; } Counter.prototype.add function (n) { total total n; // 故意漏掉this this.total this.total n; };第一行total total n里的total会按照作用域链一层一层往外找当前函数作用域里没有局部变量total就往外层找直到找到全局。如果全局刚好有个total变量它会被直接修改而不是当前这个实例的total。如果全局也没有在非严格模式下会自动创建一个全局变量所有实例共用这一个变量互相污染。这种Bug非常隐蔽因为代码不报错结果却完全不对。比如你new了两个计数器计数器A执行add(5)计数器B的total也可能跟着变因为改的是同一个全局变量。在严格模式下这种情况会直接抛出ReferenceError告诉你变量未定义排查还容易一点。但WPS宏环境对严格模式的支持并不统一所以最稳妥的做法就是所有内部对象一律写清楚this.前缀不要碰运气。再打个比方这就好比你在教室点名必须明确说“张三起来回答”不能说“起来回答”否则全班都不知道你在点谁。this就是那个点名对象不写它就等于不指明对象。2.3 局部变量与this属性的分界线构造函数内部很多人混淆var temp 1和this.temp 1的区别。var temp 1只是构造函数执行过程中的一个局部变量函数执行完就销毁了new出来的对象上并没有名为temp的属性。外部代码访问obj.temp会得到undefined。this.temp 1才是把temp挂在实例对象上这个实例只要不被释放temp就一直在。function Monkey(name) { var internalName name; // 私有变量外部拿不到 this.name name; // 公开属性 this.getName function () { return internalName; // 闭包访问私有变量 }; } var m new Monkey(悟空); alert(m.internalName); // undefined alert(m.getName()); // 悟空这种写法把一个变量变成了真正的私有内部变量外部直接访问不到只能通过暴露的方法读取。如果你想把某些状态做成只读或私有就用局部变量加闭包如果你想做成可公开访问的属性就用this挂载。分清这两点构造函数的设计会清晰很多。所以说“调用内部对象必须用this”这句话完整的解读应该是所有打算暴露给外部、或跨方法共享的实例状态都必须通过this来挂载和访问。该私有化的用局部变量该公开化的用this两条线泾渭分明。3. 实操用构造函数定义一个报告构建类全程使用this3.1 打开宏编辑器并建立一个函数模块先解决最基础的问题怎么进入WPS的JS宏编辑器。在你本机的WPS表格中找到“开发工具”选项卡点击“JS宏”就能打开宏编辑器。如果你的版本没有“开发工具”选项卡通常可以去自定义功能区或选项设置里把它勾选出来。WPS个人版、教育版、专业版在菜单上略有差异但大方向一致都是在开发工具这一块进入JS宏。进入编辑器以后左侧是工程资源管理器会列出当前打开的工作簿、加载项等模块。双击当前工作簿对应的模块或者右键插入模块建立一个新的代码区域。WPS JS宏和传统VBA编辑器不是一回事不要拿VBA的“插入模块”概念硬套界面虽然是树状结构但代码组织更接近脚本文件。这里有一个实用习惯每个业务类单独放一个模块比如报告构建类的代码放一个模块运行入口函数放另一个模块方便后续维护。3.2 完整代码构造函数、原型方法与调用示例下面这段代码可以直接复制到WPS JS宏编辑器里。功能是做一个简单的“任务报告构建器”你往里面加文本行它把这些行整理成一段带编号的报告文本然后写入当前工作簿的第一个工作表。function ReportBuilder(reportTitle) { this.reportTitle reportTitle || 未命名报告; this.dataRows []; } ReportBuilder.prototype.addRow function (rowContent) { if (!rowContent) { throw new Error(addRow 不能传入空内容); } this.dataRows.push(rowContent); }; ReportBuilder.prototype.buildText function () { var text 【 this.reportTitle 】\n; for (var i 0; i this.dataRows.length; i) { text text (i 1) . this.dataRows[i] \n; } return text; }; ReportBuilder.prototype.exportToSheet function () { var ws ActiveWorkbook.Worksheets.Item(1); ws.Cells(1, 1).Value2 this.buildText(); }; function runReportDemo() { var builder new ReportBuilder(本周任务汇总); builder.addRow(张三完成A模块开发); builder.addRow(李四完成B模块测试); builder.exportToSheet(); }运行runReportDemo()之后打开第一个工作表A1单元格里就会出现格式化的报告文本。代码里用到的ActiveWorkbook、Worksheets、Range这类API不同WPS版本可能有命名差异如果报错按自己环境里的对象模型替换即可核心逻辑不受影响。3.3 代码逐段讲解每一处this为什么不能少这段代码里的每一个this都不是摆设。构造函数中的this.reportTitle和this.dataRows是给新实例初始化两个公开属性。如果漏掉this写成reportTitle reportTitle || 未命名报告左边和右边同名等于给参数自己赋值实例上根本没有这个属性后面所有方法都会拿到undefined。addRow方法里的this.dataRows.push(rowContent)是对当前实例的数组执行push。这里不写this引擎会沿着作用域链去找dataRows变量大概率找不到严重点全局污染轻则整段报错。buildText方法里的this.reportTitle和this.dataRows.length构成了整个报告的拼接基础。如果没有thisreportTitle读取不到当前实例的标题多个实例创建的报告会串数据。exportToSheet方法里的this.buildText()是重点中的重点。它调用的是当前实例自己的buildText方法。如果不写this直接写buildText()JavaScript会在当前作用域找这个函数名找不到就报“变量未定义”。这正好印证了本章的标题调用内部对象方法也好属性也好都必须用this指明对象。3.4 运行验证与调试输出技巧写完代码别急着跑大逻辑先做最小验证。调用runReportDemo()如果A1单元格出现完整文本说明流程通了。如果没出现预期结果最朴素的调试方式是把关键中间值写到临时单元格里观察。比如想知道builder.dataRows到底有没有内容可以临时加一行ActiveWorkbook.Worksheets.Item(1).Cells(5, 1).Value2 builder.dataRows.length;然后把运行结果和预期对比。很多WPS宏环境下控制台日志方法不一定存在写单元格是最稳定可靠的调试手段。另外一个技巧是利用异常中断执行在判断条件处主动throw new Error(这里出了预期之外的情况)让程序停下来提醒你。我个人的建议是每设计一个自定义类就单独写一个runXxxDemo的验证函数先构造一个最小实例跑通一条路径再扩展完整业务。宏的调试成本比普通程序高宁可前期多写几行验证代码也不要在几百行代码里大海捞针。4. this丢失的高频现场与修复方案4.1 方法被单独取出调用this悄悄指向了全局这是最常见、也最容易让人懵的this丢失场景。var builder new ReportBuilder(测试报告); var fn builder.addRow; fn(一条新内容); // 这里的this不再指向builder而是全局对象当你把builder.addRow赋给变量fn再执行fn(...)时调用方式已经变成了普通函数调用。按照上一章的四条规则普通函数调用时this指向全局对象于是addRow内部执行this.dataRows.push()时访问的是全局对象上的dataRows属性要么报错要么改错对象。这个坑在实际项目中特别多因为你可能没意识到“把方法当作参数传出去”其实也算把方法单独取出来。比如你写了一个工具函数接收一个回调函数并执行它回调函数内部如果用到了this就很容易丢掉原来的指向。修复方式有两个方向一是保持builder.addRow(...)这种点号调用形式不让方法脱离对象二是用bind把this永久绑定到实例上var fn builder.addRow.bind(builder); fn(一条新内容); // 正常bind会返回一个新函数这个新函数无论被怎么调用this都指向传入的那个对象。这个方法在处理回调时尤其好用。4.2 循环回调和事件回调里的this数组的遍历方法也经常制造this丢失陷阱。比如你在某个类的方法内部用了forEachReportBuilder.prototype.summaryByPrefix function (prefix) { var result []; this.dataRows.forEach(function (row) { if (row.indexOf(prefix) 0) { result.push(row); } }); return result; };这个例子虽然没直接踩this但如果你在forEach回调里尝试访问this.dataRows就不对了。因为forEach回调是数组方法内部调用的普通函数情况下回调函数里的this并不指向外层对象。修复方式很经典在进入forEach之前先把this存到另一个变量里。ReportBuilder.prototype.summaryByPrefix function (prefix) { var self this; var result []; this.dataRows.forEach(function (row) { if (row.indexOf(prefix) 0) { result.push(row); self.dataRows.push(row); // 用self不直接用this } }); return result; };self这个名字在老代码里很常见是典型的“this快照”方案。因为普通函数作用域链可以向外层函数找变量所以内层回调里的self能正确指向外层函数里的this快照。如果环境支持ES6箭头函数还可以直接写this.dataRows.forEach((row) { // 这里的this继承自外层函数 });箭头函数没有自己的this绑定在定义时就捕获了外层作用域的this所以不用担心丢失。前提仍然是你的WPS宏环境支持它不确定就先测试一行再大规模使用。4.3 三种常用的修复姿势把修复方案总结一下核心就是三招。第一招调用时保持“对象点方法”结构不拆开。能直接builder.addRow(...)就不要先赋值再调用。这治标也治本问题是有些场景由框架或工具函数决定调用方式你控制不了。第二招用var self this做快照内层回调全部用self。这是最传统、兼容性最好的办法适合老版本环境。第三招用bind显式绑定。适合把一个方法传给回调函数的场景调用前先.bind(instance)一劳永逸。三招可以混用没有绝对优劣。我的建议是代码库整体风格偏旧就用self快照和现有代码一致新写的模块且确认环境支持ES6优先考虑箭头函数和bind代码更简洁。5. 常见报错与排查速查表5.1 高频报错对照表整理了一张问题速查表方便你排查时报错对照。现象真正原因修复方向提示“变量未定义”方法内部漏写this直接写了不加前缀的属性名或方法名给实例属性和方法加上this前缀this的值为undefined或全局对象方法被单独取出调用或回调函数中this丢失用bind绑定或调用时保持对象点方法形式多个实例数据互相覆盖属性写成了全局变量没有挂到this上在构造函数中用this初始化所有实例属性new出来的对象属性全部为空构造函数里写了return 对象覆盖了this删掉构造函数末尾的return对象语句构造完成后外部访问不到属性属性用的是局部变量var没有用this挂载将需要公开的属性改用this.xxx 值只报错但不定位问题调试时没有输出关键变量把关键值写入临时单元格或用throw中断5.2 排查this问题的三板斧遇到this相关的问题我复盘了一下基本就是三招走天下。第一板斧打印关键this内容。在方法第一行临时写把this.reportTitle或者this.dataRows.length写入一个临时单元格然后运行查看。这样能确认this到底指向了谁。如果输出的内容是空的说明this没指到当前实例。第二板斧缩小范围。当一段代码逻辑复杂时不要盯着整个函数猜。把构造函数、原型方法、调用函数拆成三个层级逐个验证。先确认new出来的对象属性存在再确认方法里的this能正确读取最后确认调用入口没问题。第三板斧主动抛异常。在判断条件处写throw new Error(...)把运行流程卡在你怀疑的位置通过异常信息确认程序走到了哪一步。这个手段在WPS宏环境里屡试不爽因为它的调试能力很有限中断执行比逐行跟踪来得快。5.3 长期好习惯和从VBA转来的注意点这里再说几个实操中总结出来的长期好习惯。一是构造函数参数名尽量和属性名做区分。比如构造函数定义为function ReportBuilder(reportTitle)属性又是this.reportTitle两者同名写代码时容易漏写一边。我习惯给参数加个前缀或者用更鲜明的命名比如function ReportBuilder(title)内部再this.reportTitle title。一眼就能看出哪个是参数、哪个是属性。二是写方法之前先问自己一句这个函数会被谁调用调用它时this应该指向谁只要这个问题能回答清楚八成的this问题都能提前避免。三是从VBA转过来的人要特别注意VBA类模块里用的是MeJS宏里用的是this概念相似但机制有区别。VBA的默认引用方式在JS里不存在JS必须显式写this才能访问实例成员没有“不写也能自动匹配当前对象”的便利。习惯VBA的人初转JS宏最容易把这一层漏掉。四是写完类之后故意删掉一个方法的this跑一遍测试。我试过好多次这比反复看文档管用得多。你会直观地看到报错、全局污染、数据串号等现象长什么样。踩过几次之后再写代码时就会自然带上this形成肌肉记忆。调试this问题的时候我最深的体会是不要慌核心思路永远只有一句话——先搞清楚这个方法是被谁调用的调用的那一刻this应该指向谁想清楚了再动手改。自定义类的单测阶段多花十分钟打日志比后期在几十个方法里排查要省出好几个小时。如果你也正在写WPS JS宏的自定义类建议把上面这段代码跑通再故意改错两处this体会一下报错的脾气比看多少篇文章都记得牢。