嘛,如果你已经看到了这一行,我暂且假定你对 [KJS] KubeJS 的各种奇技淫巧有点兴趣。但是这篇文章并不是为了介绍 KubeJS 能够实现的东西而作,而是要介绍在实现功能时,有什么样的方法可以简化你的开发

本文如无特别说明,使用的 KubeJS 均为 kubejs-forge-1605.3.19-build.299,犀牛 (Rhino) 为 rhino-forge-1605.1.5-build.75,对应 Minecraft 版本为 1.16。更高版本的 KubeJS 与 Rhino 功能可谓只多不少,选这个稍旧的版本就当是我在找刺激照顾下习惯于1.16的脚本开发者。

一些下文可能使用的简写:KubeJS==kjs,JavaScript==JS,TypeScript==TS

函数的“类”式用法

有人管这叫“看看你的new new”。

基础

如果你写了一个函数,并且“不慎”脑补了一个名为 this 的对象,并并且给它的某(几)个属性赋了值,并并并且在VSCode内看这一段代码,比如:

function Who(the, f, is, zzzank) {
    this.name = zzzank
}

你可能会发现函数名(例子里为“Who”)从常见的黄色变成了绿色,鼠标指针悬停在函数名上时还能发现一句话:

new与函数与类型——KubeJS开发的奇巧方法-第1张图片图中的Who首字母为小写,但不会对功能有影响

“此构造函数可能会转换成类声明”。

这句话听起来有些别扭,因为“可能会”原文其实是“may”,这里翻译成“可以”或许你会更明了一点。

注意,虽然它说可以转换,但 Rhino 实际上支持传统意义上的类声明语法,只支持我们正在介绍的这种替代性的等价语法,所以可别急着点那“快速修复”。

使用刚刚那三行代码,我们就已经创建了一个类,其用法与 Java 的类一致,都是使用 new 来构造一个符合该类特性的对象。创造出来的对象可以像普通的对象一样获取数据:

const exactlyWho = new Who(null, null, null, "Zank")
console.info(exactlyWho.name) //输出为 Zank,也就是刚刚提供的"Zank"

“类”式函数里的函数

如果这样一个类只能拿来组织数据,那还不如直接用JS自己的对象。类的精髓在于类可以有独属于自己的函数,只有属于同一个类的对象才可以简单地调用这个/些函数,而其他对象想要调用则要费些周折,这样就很好地把设计之初就不是面向所有对象的函数与那些本来就不需要这个函数的对象分隔开来。

这并不是因为什么数据安全之类的原因(你都用JS了),而是避免不慎调用错时痛苦的排查。

话似乎说得有点多了,不妨直接先看看语法:

Who.prototype = {
    say: function (words) {
        return `${this.name} says: ${words}`
    },
    run: () => {
        return "不"
    }
}

这就是为Who这个类添加了两个函数成员。这两个函数都是可以调用的,使用的函数名为给prototype赋值的对象里与函数对应的键名,其实也就是前面的say与run。让我们试试:

console.info(exactlyWho.say("WOW")) //输出为: Zank says: WOW
console.info(exactlyWho.run()) //输出为: 不

函数成员同样支持已经定义过的函数,只要把定义过的函数名代替我们自己现场构造的函数即可:

function max3(a, b, c) { return Math.max(Math.max(a, b), c) }

Who.prototype = {
    say: function (words) { return `${this.zzzank} says: ${words}` },
    run: () => "不",
    max3: max3
}

给prototype赋值的对象里同样可以填入普通的成员,比如数字与字符串,能起到建立默认值的效果。但我更建议在类的构造函数(Rhino没有,这里等价的函数是那个被视作类声明的函数,比如这里的Who)那里设定默认值,因为数据的初始化最好只放在同一个地方,方便后续的查找与标记。

this

现在我们再看一遍say函数,你可能会发现其中凭空使用了this这个本不应该存在的对象。并且明明叫this而非exactlyWho,但在say函数被exactlyWho调用时,this.name与exactlyWho.name的结果完全一致。(如果你不介意多做点研究,你会发现真的是“完全”一致,根本就是指向同一个东西)

这是因为this本质上就不是一个对象,而类似于一个代号,代表调用this所属函数的对象。在我们的例子里,this所属函数是say,调用say函数的对象是exactlyWho,因此this此时代表exactlyWho。

使用this可以让js知道你想使用的是来自于对象的成员(无论是值还是函数),而非函数参数或者自己定义的同名变量。更重要的是,这样成员函数可以借此处理调用者的数据,类就不会只是一个存放函数的新地方。

如果不用对象而直接调用使用了this的函数呢?

此时this一般会指向一个名为globalThis的共享对象。但是这是浏览器所带有的JS引擎的行为,至于MC的Rhino?Rhino的github历史记录没有记录涉及到globalThis的代码变动,但实际如何不妨自己试试。


this应当处在通常书写格式的函数里,而 不 应 该 处在粗箭头形式的函数里,因为处在粗箭头函数里的this只会代表globalThis。或者说,以下两行代码中,第一行可以放到类的prototype,第二行则不应该放在类的prototype内:

function readX() { return this.x }
let readY = (() => { return this.y })

Who.prototype = {
    read_x: readX,    //可行
    read_y: readY     //不建议
}

我个人则提倡在粗箭头形式的函数里绝不使用this,因为此处的this只能代表globalThis,但rhino已经提供了global,使得globalThis无用武之地,那么此处的this除了混淆视听之外也就没什么用了。

链式调用与延迟求值

或许你已经见过这样的用法了:

generalFluids.forEach((fluid) => {
    event
        .create(fluid.id)
        .textureStill(fluid.still)
        .textureFlowing(fluid.flowing)
        .bucketColor(fluid.color);
});

写起来非常舒适,因为不需要在创建(create)时专门为赋值给一个变量,也不需要重复 写变量名->考虑使用哪个函数->新开一行准备写变量名 的过程。我们不妨做个对比,看看链式调用简短易读了多少:

event.create(fluid.id)                  const builder = event.create(fluid.id);
    .textureStill(fluid.still)          builder.textureStill(fluid.still);
    .textureFlowing(fluid.flowing)      builder.textureFlowing(fluid.flowing);
    .bucketColor(fluid.color);          builder.bucketColor(fluid.color);

实现

链式调用的每一个被调用的函数都是在修改最初的创造出来的对象的属性,意味着链式调用与延迟求值通常需要一起使用,因为如果不延迟求值,链式调用上每一环都会给出一个输出,而下一环又会需要将输出解释回输入,而后又输出,太麻烦了。

为了真的实现链式调用,每一个在链上被调用的函数的返回值应该是被修改过的对象,这样返回值又可以调用下一个函数,比如:

Who.prototype = {
    addName: function (name) {
        this.name= this.name + ' ' + name
        return this
    }
}

这里返回的是this,也就是调用者自身,任何Who类的对象在调用之后返回的都是Who类的,因此可以接着调用Who类下的函数,这样就实现了链式调用:

console.log(exactlyWho.addName("Mark").addName("John").addName("DaMing").name) //输出是: Zank Mark John DaMing

举个例子

function Grid(idPrefix) {
    this._ = {
        rows: -1,
        columns: -1,
        idPrefix: idPrefix,
        dx: 0,
        dy: 0
    }
}

Grid.prototype = {
    rows: function (rows) {
        this._.rows = rows
        return this
    },
    columns: function (col) {
        this._.columns = col
        return this
    },
    moveX: function (dx) {
        this._.dx += dx
        return this
    },
    moveY: function (dy) {
        this._.dy += dy
        return this
    },
    build: function () {
        if (this._.rows < 0 || this._.columns < 0) {
            throw "Invalid size"
        }
        const grid = [];
        const { dx, dy, columns, rows, idPrefix } = this._;
        for (let i = 0; i < columns; i++) {
            for (let j = 0; j < rows; j++) {
                grid.push({
                    x: dx + i * 18,
                    y: dy + j * 18,
                    id: `${idPrefix}_${i + j * rows}`
                });
            }
        }
        return grid;
    }
}

这是一个简化版本的 CMGrid ,虽然简化了,但仍能体现链式调用与延迟求值的实现细节。

每一个不是用来最终求值的函数都会返回调用者自身(this),因此可以不断链式调用,直到调用build求值。build以外的函数都不会计算输出,只是在修改届时用于计算结果的数据。数据在build以外的函数只进行修改,不会被用于修改原数据之外的用途,只会在build函数里被真正意义地被使用。

不妨试试:

const big = true
const slots = new Grid('grid_what')
    .moveX(20)
    .moveY(8)
    .rows(big ? 3 : 1)
    .columns(big ? 7 : 4)
    .build()

slots此时会是一个存有3*7=21个对象的数组,每个对象都有三个属性:x, y, id。x都至少为20,y至为8,id都以grid_what开头。其来源在build函数里应该展示的很清楚了。

并且,通过修改big的值,还可以实现生成不同大小的Grid。这是将大量控制变量包装成单个“开关”的良好途径之一。

JS类型系统

有人把类型视作镣铐,但实际上显式类型带来的自动补全以及语法检查带来的便利远甚于带来的束缚。

ProbeJS

如果你在为MC1.18或更高的版本开发,见ProbeJS。如果你在为MC1.16.5Forge开发,见ProbeJS Legacy。如果你在用MC1.16.5Fabric,额,暂且自求多福。

ProbeJS能为JS提供类型提示,比如在调用event.shaped()时指明第一个参数的类型应当为可以为物品的东西,第二个参数是字符串列表,第三个参数是一个JS对象。

此外,输入@可以触发物品等的代码片段。如果安装了ProbeJS对应的VSCode插件,把鼠标悬停在物品/流体/语言键等等的字符串上还可以显示其详细信息。

类型注释

把光标定位到函数/类/变量声明的上一个空行行,没有则自己加一行,输入一个/,输入两个*,此时应该能看到VSCode为你提供了一项显示为/** */的建议:

new与函数与类型——KubeJS开发的奇巧方法-第2张图片回车就能形成图中下方的注释。注释中大括号里最初应当为any。将其改为number,这样a, b, c都会被视为number类型的变量,于是就能接受针对number的自动补全。

你还可以进一步为每一个参数,返回值,函数整体补充注释,使用时会自动调用:

new与函数与类型——KubeJS开发的奇巧方法-第3张图片

类型注释:类型参数

但是JS的类型注释还具有很多限制,比如如果有一个取数组里最大值的函数,无论数组元素的类型是什么都可行,那返回值又应该如何标记?由于JS并没有利用类型参数自动识别输入类型的功能,我们只能标注返回值的类型为any,但这样类型注释又几乎p用没有了。

但是还记得刚刚的ProbeJS吗?借助ProbeJS设定好的工作区设置,我们可以在kubejs文件夹下的probe文件夹下的user文件夹里添加我们自己的文档,只需要新建随便一个后缀为.d.ts的文件,而后就可以在这个文件里使用TypeScript类型注释了。比如

/**
 * Get the "biggest" element among provided entries, "big" is defined by `comparator`
 * @param list provided entries
 * @param comparator If not specified, will use `(a, b) => a - b`
 * @returns the "biggest" entry
 */
declare function maxOf<T>(list: T[], comparator: (a:T,b:T)=>1|-1|0): T

ts自带类型标记的语法,因此类型注释与js相比简单一些,类型标注的部分都转移到了代码之内。

之后我们去实现maxOf函数时不需要再次编写文档,VSCode会自动复用定义好的TS文档。

语法检查

在使用ProbeJS生成文档之后,你应该会在kubejs文件夹下找到一个名为jsconfig的json文件。如果你使用的ProbeJS版本比较新,checkJS选项应该是存在并且默认开启的。但如果没有,打开它,添加一行:

"checkJs": true,

结果应该长这样:

new与函数与类型——KubeJS开发的奇巧方法-第4张图片

之后就可以看到语法检查正在运行,不合类型检查的用法会被标红。比如我们先前实现的max3函数的3个参数都用类型注释标注了类型必须为number,那么输入不同类型就会被标红:

new与函数与类型——KubeJS开发的奇巧方法-第5张图片不过注意:ProbeJS生成的类型文档长期以来都不完美,因此开启checkJS有可能会导致正确的用法被误报成错误的,还请自行斟酌。

如果你只是稍微改改合成表,那以上部分大概并不会有什么用武之地。但如果你希望做一些更大体量更深度的东西,以上方法将能极大地标准化你写出的代码,减少心智负担并方便后续开发。

祝你能用KubeJS把《我的世界》真的变成一个独特的全新的世界。