1. 这一讲到底在解决什么问题
先说个现象。很多人在WPS里用JS宏,一开始挺顺利,录一段宏、改一改、跑一次,把重复操作替换掉,觉得已经“入门”了。但一旦你开始做正经的项目,比如做一套完整的考勤汇总、合同台账、多表数据核对的宏,问题立刻冒出来:代码越来越长,函数越写越多,A地方的变量和B地方的循环纠缠在一起,改一个地方牵一发动全身。
这时候你需要的不再是“能跑”,而是“能撑住复杂业务”的代码结构。对象、构造函数、this、return就是从这里开始有价值的。
这一讲,也就是这个系列里面向对象这一段的第一个部分,核心目标只有一个:用构造函数把自己的数据和处理逻辑封装成一个“类”,让对象内部能写函数,并且能从外部干净地调用这些函数。
先给个小结论:WPS JS宏本质上就是JavaScript,你平时在其他JS环境里写的面向对象写法,在这里基本都能用。但WPS的宏宿主环境有自己的对象模型,同时又是嵌入式脚本场景,调试方式更原始,所以写法和浏览器项目有一些讲究。这正是我写这篇东西的原因——网上聊“构造函数”的文章一抓一大把,但针对WPS JS宏场景、还结合“内部函数、外部调用”这个具体落点的,其实很少,很多人就是卡在这一步不知道怎么往前迈。
这个内容适合两类人。一是已经在WPS里写过不少宏,但对“自定义类”一直似懂非懂的;二是从VBA转JS宏,知道VBA里的类模块怎么写,但搞不太清楚JS里没有“类模块”这个概念该怎么落地。看完这讲,你能做出一件挺有用的事:把你的宏拆成一个一个独立的小类,每个类管一块业务,外部只需要两三行就能调用,以后维护起来舒服很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先理解this和return在构造函数里的底层逻辑
很多教程直接丢代码上来,“你看,这样就是一个类了”,看得人一头雾水。所以我们先不急着上完整案例,我把this和return在JS里的行为掰开讲清楚,这两个东西搞懂了,后面的代码你一眼就能看穿。
2.1 this在构造函数里到底指向谁
在构造函数内部,this指向的是“将来用new创建出来的那个新对象”。
打个比方。构造函数本身是一张“模具图纸”,当你写:
javascript复制function Person(name) {
this.name = name;
this.sayName = function() {
alert("我的名字是" + this.name);
};
}
这时候其实什么都没发生,直到你写:
javascript复制var p = new Person("张三");
new关键字做了一连串事情:先创建一个新的空对象,然后把构造函数里的this绑定到这个空对象上,接着执行构造函数体。所以this.name = name就把参数name挂在了新对象p上。你可以理解成new是“按下生产按钮”,构造函数是“生产流程”,this是流水线上正在装配的那台产品。
在WPS JS宏里,这个逻辑和浏览器完全一致。唯一要注意的是,如果你在构造函数内部再嵌套普通函数,嵌套函数里的this不会再指向新对象,这是后面要重点排查的坑。
2.2 return在构造函数里有个“冷门”行为
函数执行到return就结束,并且把值返回给调用者,这是所有语言通识。但构造函数里的return有一个特殊情况:
- 如果你return一个原始值,比如数字、字符串、布尔值,new操作符会直接忽略这个返回值,仍然返回那个新创建的对象。
- 如果你return一个对象,那这个对象会覆盖默认返回的新对象。
这个冷门行为平时几乎用不上,但你要知道。最典型的场景是单例,你想让多次new都返回同一个对象,就可以在构造函数里return一个提前创建好的对象。
javascript复制var singleObj = null;
function Singleton() {
if (singleObj) {
return singleObj; // 直接返回已有对象
}
this.createTime = new Date();
singleObj = this;
}
var a = new Singleton();
var b = new Singleton();
alert(a === b); // true,说明a和b是同一个对象
在宏编程里单例不常用,但这个例子能帮你准确理解return在构造过程中的位置。
2.3 用一个最小例子把它们串起来
我们不用WPS对象模型,先用纯逻辑写个示例:
javascript复制function Counter() {
var count = 0; // 外部无法直接访问的变量
this.add = function() {
count++;
};
this.getCount = function() {
return count;
};
}
var c = new Counter();
c.add();
c.add();
alert(c.getCount()); // 输出2
这里有个特别关键的现象:count只是构造函数里的局部变量,外部拿不到,但add和getCount这两个通过this挂到新对象上的内部函数,却能一直访问count。这就是闭包——函数“记住”了创建它的作用域。闭包是JS里非常重要的机制,在WPS宏里尤其好用,你可以用它做出真正的“私有属性”,外部想改都改不了。
以前有VBA经验的朋友可能会把它理解成“模块级变量”,但其实原理更接近“类私有字段”。这个区别要记得:this.count = 0是公开属性,外部能直接改;var count = 0是私有字段,外部只能通过内部函数间接操作。
3. 内部函数的三种写法与外部调用的标准姿势
理论搞清楚了,现在进入正题:构造函数里怎么放函数,函数放好之后外部怎么调用。这节的标题是你的原话——“对象内部函数,外部调用的写法”,我用三种写法把这个问题拆烂。
3.1 写法一:this.函数名 = function(){},最直接
最直白的方式是在构造函数体内部直接给this挂函数:
javascript复制function MyClass(param1) {
this.param1 = param1;
this.show = function() {
alert("参数是:" + this.param1);
};
}
var obj = new MyClass("你好");
obj.show();
这段代码的调用链路是:new MyClass("你好")得到一个实例,实例身上有show函数,外部通过obj.show()触发。这里的show就是“对象内部函数”,而obj.show()就是“外部调用”。
这种方式优点是简单、直观,缺点是每个实例都会重新创建一个函数副本。如果实例很多或者函数很复杂,内存开销会大一点,但在WPS宏的使用场景里,几十个实例完全不是问题,不用过度优化。
3.2 写法二:用var/let定义私有函数,用this暴露公开方法
实际项目里这个方法特别实用,因为它能控制“哪些函数对外可见”。
举一个贴近WPS的场景:假设你要做一个处理表格数据的工具类,内部有个辅助函数专门负责格式化日期,你并不想让外部直接调用它,只想对外开放一个“获取统计结果”的方法。代码可以这样写:
javascript复制function ReportProcessor(tableRange) {
// 私有属性
var source = tableRange;
// 私有函数,外部无法直接访问
function formatDate(d) {
var y = d.getFullYear();
var m = d.getMonth() + 1;
var day = d.getDate();
return y + "-" + m + "-" + day;
}
// 公开方法
this.getDateList = function() {
var result = [];
for (var i = 1; i <= source.Rows.Count; i++) {
var cellValue = source.Cells(i, 1).Value2;
if (cellValue instanceof Date) {
result.push(formatDate(cellValue));
}
}
return result;
};
}
var processor = new ReportProcessor(activeSheet.Range("A1:A10"));
var list = processor.getDateList();
你从外部只能看到getDateList,formatDate完全藏在内部,这就是“内部函数”和“外部调用”的经典分工。外部根本不需要关心日期格式是怎么处理的,只需要传入范围、拿结果。
3.3 写法三:把函数放到原型上,进阶但不算难
如果你想让所有实例共享同一个函数,而不是每个实例复制一份,可以用prototype:
javascript复制function Device(name) {
this.name = name;
}
Device.prototype.getName = function() {
return this.name;
};
var d1 = new Device("打印机");
var d2 = new Device("扫描仪");
alert(d1.getName()); // 打印机
alert(d2.getName()); // 扫描仪
这种方式的好处是内存更省,而且如果想给已有类追加方法,直接往原型上挂就行,不用改原来的构造函数。很多WPS官方示例的封装库就是这么定义的。
但对刚上手的朋友,我个人建议先不要纠结原型,照着前两种写法能把业务跑起来最重要。等你写了几个类、对this彻底不迷糊了,再来优化原型的部分。
3.4 外部调用的标准姿势,汇总一下
外部调用对象内部函数,基本就这几句话:
- 先new,拿到实例对象。
- 用实例对象点出要调用的函数名,后面加括号。
- 如果这个函数需要参数,括号里填参数;如果这个函数有返回结果,用一个变量接收返回值。
- 调用链上要保持this不变,这个下一节重点说。
一个容易犯的低级错误:在构造函数里定义一个函数,但没用this挂载,外部是拿不到的。
javascript复制function Foo() {
function bar() {
alert("bar");
}
}
var f = new Foo();
f.bar(); // 报错:f.bar is not a function
为什么?因为bar只是构造函数内的局部变量,它没有挂到新对象上,所以new完之后,f身上根本没有bar这个属性。你如果想让它能被外部调用,就得用this.bar = bar把它暴露出去。这个点特别像VBA类模块里“Private Sub”和“Public Sub”的区别,本质就是控制可见性。
4. 实操案例:做一个带状态的计算类
光有片段还不够,我拿一个能直接用进WPS宏里的例子,带你完完整整把“对象内部函数、外部调用”跑通。这个例子是一个求和统计类,它可以接收一个Range区域,然后计算总和和平均值。
4.1 场景设计:为什么值得封装成类
假设你每个月要统计多个明细表的金额,原来的做法是写一个宏,循环每个sheet,找到目标列,累加。代码写一次还好,问题是你可能有五六个统计口径,每个口径逻辑还不一样,全堆在同一个宏里,看一个月之后自己都想删掉重写。
封装成类之后,逻辑变成:创建一个统计器对象,把数据区域传给它,它内部负责读取和计算,外部只需要要结果。以后想增加统计口径,就再写一个新类,互不影响。
4.2 完整代码与逐行说明
javascript复制function StatsCalculator(range) {
// 内部保存区域对象
var targetRange = range;
// 私有方法:拿到一列数值,跳过非数字单元格
function getNumbers(colIndex) {
var values = [];
for (var i = 1; i <= targetRange.Rows.Count; i++) {
var v = targetRange.Cells(i, colIndex).Value2;
if (typeof v === "number") {
values.push(v);
}
}
return values;
}
// 公开方法:计算某列总和
this.sum = function(colIndex) {
var numbers = getNumbers(colIndex);
var total = 0;
for (var j = 0; j < numbers.length; j++) {
total += numbers[j];
}
return total;
};
// 公开方法:计算某列平均值
this.average = function(colIndex) {
var numbers = getNumbers(colIndex);
if (numbers.length === 0) {
return 0;
}
var total = 0;
for (var k = 0; k < numbers.length; k++) {
total += numbers[k];
}
return total / numbers.length;
};
// 公开方法:以数组形式返回某列全部数值
this.getColumnValues = function(colIndex) {
return getNumbers(colIndex);
};
}
外部调用是这个风格:
javascript复制function testStats() {
var sheet = ActiveSheet;
var range = sheet.Range("B2:B20");
var calc = new StatsCalculator(range);
var total = calc.sum(1); // 求B列总和
var avg = calc.average(1); // 求B列平均值
var values = calc.getColumnValues(1); // 拿原始数据
MsgBox("总和:" + total + ",平均:" + avg);
}
这里有个细节值得注意:getNumbers函数没有使用this,它就是一个普通私有函数,通过闭包访问targetRange。sum、average、getColumnValues都调用了它。外部调用getColumnValues时,拿到的数组是getNumbers的返回值,中间只是转了个手。如果getNumbers不return结果,那么getColumnValues就什么都拿不到,这就是return和外部调用之间的关键配合。
4.3 链式调用的进阶思路
聊到外部调用,有个很有意思的进阶写法,利用return this实现链式调用:
javascript复制function Logger() {
this.logs = [];
this.add = function(msg) {
this.logs.push(msg);
return this; // 关键:返回对象本身
};
this.show = function() {
MsgBox(this.logs.join("\n"));
};
}
var logger = new Logger();
logger.add("第一条").add("第二条").add("第三条").show();
这里的add执行完之后返回this,也就是logger对象自己,所以可以在后面继续接另一个方法调用。做WPS宏的时候,如果你要连续对一个对象执行多个操作,链式调用能让代码读起来非常顺畅,而且避免了重复写“logger”这个对象名。
5. 高频报错与排查思路
写自定义类最痛苦的是遇到报错但不知道错在哪。这节我盘点几个实际运行中最常见的问题,每一个都是我身边同事和群里朋友踩过的坑。
5.1 报错“this.xxx is not a function”
这个报错基本上可以断定:你要调用的函数并不在对象身上。原因通常有三个。
第一,你定义函数时忘了加this。比如直接在构造函数内部写function show(){},而不是this.show = function(){},那么外部调用obj.show()一定报错。
第二,函数名写错了,尤其是大小写。JS对大小写极其敏感,show和Show是两个完全不同的东西。WPS宏编辑器没有浏览器那种很强的自动补全提示,写错了一个字母就只能靠肉眼找。
第三,this丢失。这种情况更隐蔽。假设你这么写:
javascript复制function User(name) {
this.name = name;
this.say = function() {
alert(this.name);
};
}
var obj = new User("李四");
var fn = obj.say;
fn(); // 报错或弹出undefined
问题出在最后一行。把obj.say赋值给fn之后,调用fn()时,fn内部的this已经不再指向obj,而是指向全局对象。方法在“普通调用”和“对象调用”两种方式下,this完全不同。解决办法是从一开始就用obj.say()这种点号调用,或者在绑定事件/回调函数时,提前把this存到变量里。
5.2 内部函数拿不到构造函数的变量
看这段代码:
javascript复制function Test() {
var privateValue = "hello";
this.run = function() {
var callback = function() {
alert(privateValue); // 这里还能拿到吗?
};
callback();
};
}
能拿到,因为普通函数和箭头函数在闭包这块是一致的。但如果你在回调函数里写this.privateValue,那大概率拿到的是undefined,因为privateValue只是局部变量,不是this的属性。正确的做法是直接使用变量名,而不是通过this。
如果确实想通过this来引用,就把私有变量也挂成this属性:this.privateValue = "hello"。但这样外部也能改了,有点丧失封装的意义。我的建议是:私有数据用普通变量,内部函数用普通变量引用;需要对外暴露的,再通过this挂出去。一套规则用到底,不会混。
5.3 外部调用没return,结果老是丢
这个也是最常见的“没反应”类问题。写了一个方法,明明里面算出了结果,但外部调用后变量是undefined。原因就是函数缺少return语句。
javascript复制function Calc(init) {
this.initNum = init;
this.getDouble = function() {
var result = this.initNum * 2;
// 少了 return result;
};
}
var c = new Calc(5);
var d = c.getDouble();
alert(d); // undefined
这种问题在代码编辑器里通常不会报错,因为语法完全合法,只是结果没交出来。排查方法特别简单:想一下“外部需要的是动作还是结果”。如果外部需要结果,函数末尾必须有return;如果只是让它执行某些操作,比如弹窗、写入单元格,那可以没有return。
5.4 不用new直接当普通函数调用
这个坑对新手来说非常容易掉进去:
javascript复制function Person(name) {
this.name = name;
}
var p = Person("张三"); // 忘记写new
此时不会报错,但Person内部的this指向了全局对象,结果就是你在全局对象上挂了一个name属性,而p本身是undefined。后面再使用p.name就会报错。
一个小经验:凡是构造函数,函数名首字母大写,从命名上提醒自己必须用new调用。同时,在代码里如果看到某个变量后面没有new却想当对象用,第一反应就是回去检查那一行。
我再补一个比较实用的排查技巧。在自定义类比较多的时候,你可以在每个方法入口处临时加一行Debug.Print("进入方法xxx"),尤其当外部调用的结果不符合预期时,用这种埋点方式一步一步看代码到底执行到哪里。WPS宏编辑器本身断点调试能力比较弱,实际排错很多时候靠的就是这种笨办法,但很管用。
6. 一些实际操作中的个人体会
最后分享一点我自己的折腾心得。
刚开始我在WPS里写宏,总觉得“能跑就行”,后来有一次做一个跨sheet的汇总工具,逻辑越加越多,改一个函数要翻半天代码,我才意识到封装的意义。用构造函数自定义类,不是炫技,是在给你自己降低后期维护成本。
从VBA转过来的朋友可能会觉得,JS宏没有“类模块”那么规整,没有现成的“新建类”按钮,也看不到VBEditor那种工程树结构,总感觉不正规。但其实习惯了就好,一个构造函数放在模块顶部,后面对应的所有方法都围着它转,结构反而更轻量。
在这里再分享一个具体经验:如果你发现自己天天在那复制粘贴一大段相同逻辑,那这个逻辑就值得塞进一个类里去了。我第一次把表格格式调整封装成类的时候,原来两百行平铺的代码压缩到几十行,调用处就三四行。那感觉,是真的不一样。
对你来说,现在可以做的第一件事,是挑一个你最近经常手写重复逻辑的宏,把其中一段改造成构造函数加内部函数的形式,然后在外部调用测试。不用一步到位,甚至不用管原型、不用管继承,先把this、return和外部调用这三个点用顺。下一步在这个基础上再学扩展套路,就会轻松很多。
