本篇教程由作者设定使用 CC BY-NC-SA 协议。

前言

JavaScript (简称 JS)是现代浏览器通用的脚本语言,在 LatvianModder 的 KubeJS 模组中,JS 还能用来魔改 Minecraft!如果你想使用这个模组,你就需要了解 JavaScript。

第一行代码

安装 KJS 启动一次游戏之后你会在游戏目录发现一个叫做 kubejs 的文件夹,现在进入 kubejs/server_scripts/,里面有一个 script.js 文件,你可以用编辑器(个人推荐使用 Visual Studio Code)打开它然后清空所有内容来写你的代码,或者自己新建一个 js 文件。

现在把下面的代码写进这个 js 文件:

console.log('Hello World!')

保存,然后启动游戏,如果你已经在游戏中就使用 /reload 指令,如果没有报错,不出意外你会在日志文件中看到一行 "Hello World!"(当然它可能淹没在了众多其他信息中)。

JS 的代码不必写分号,这很厉害!

基本数据类型

JavaScript 有 8 种基本数据类型,其中有 7 种原始类型:

- 数字(Number)

- 字符串(String)

- 空(Null)

- 未定义(Undefined)

- 布尔(Boolean)

- BigInt

- Symbol

以及一种引用类型:

- 对象(Object)

BigInt 和 Symbol 类型在 KJS 中几乎不可能会用到,本文不作介绍,有兴趣的读者可以自己查询相关信息。

Number 类型不像有些其他语言那样分整数和浮点数,JS 中的 Number 是符合 IEEE 754 标准的 64 位双精度数字。

JavaScript 变量

以下的代码定义一个变量。

var a = 1
// 或者
let a = 1

JS 是一个动态类型语言,你不需要在定义变量时指定数据类型,解释器会自动推断类型(有时候还会进行隐式转换)。

var 和 let 的区别在于,var 定义的变量可以跨块访问,而 let 定义的变量只能在当前的块访问。相同点是它们都不能跨函数访问。

此外你还可以使用 const 来声明一个常量。

const a = 1

const 和 let 的作用域相同,但是 const 声明的常量的值不能通过重新赋值改变,在声明时也必须指定初始值,也不能重新使用 const 声明已有的常量。

JavaScript 运算符

数学运算符:

运算符作用运算符作用
+加法+=加法并赋值
-减法-=减法并赋值
*乘法*=乘法并赋值
**幂运算**=幂运算并赋值
/除法/=除法并赋值
%取余%=取余并赋值

逻辑运算符:

运算符作用
&&
||
!

其他运算符(我不知道怎么归类):

运算符作用
=赋值
==相等
!=不相等
===严格相等
!==不严格相等
5 == '5' // true
5 === '5' // false

JavaScript 对象

以下代码创建一个对象:

let obj = {
    a: 1,
    b: 2
}

对象是一种数据类型,以键值对的方式存储数据,在上面的代码中,a、b是键,1、2 是值。

你可以使用如下方式访问对象中的属性:

let a = obj.a
// 或者
let a = obj['a']

对象除了属性也可以有方法,方法简单来说就是对象中的函数。

以下代码在 obj 中声明一个名为 hello 的方法:

let obj = {
    a: 1,
    hello: () => {
        console.log('Hello!')
    }
}
// 或者
let obj = {
    a: 1,
    hello(){
        console.log('Hello!')
    }
}
// 或者
let obj = {
    a: 1,
    hello: function(){
        console.log('Hello!')
    }
}
// 或者
function helloWorld() {
    console.log('Hello!')
}
let obj = {
    a: 1,
    hello: helloWorld
}

容易看出 JavaScript 是一个非常灵活的语言。

要调用方法,就这样做:

obj.hello() //要有括号,否则只是访问这个方法的内容而不是调用方法
// 或者
obj['hello']() // 这样也行,不过我从没见过有人这样做

显然方法也只是一种特殊的属性,毕竟函数也只是特殊的对象(后面会提到)。

JavaScript 数组

数组(Array)是一种特殊的对象。

以下代码创建一个数组:

let arr = [1, 2, 3]

这样做本质上是创建了一个 Array 对象,和以下的代码等价:

let arr = new Array(1, 2, 3)

访问一个数组中的某个元素:

let a = arr[0]

数组的下标从 0 开始,注意,虽然数组也是对象,但不能使用 . 访问数组中的元素。

使用 .length 原型属性来获取数组的元素数量,这在写循环时非常有用。

数组有一些原型方法,在 KubeJS 中最常用的是 .foreach():

let items = [
    'minecraft:apple',
    'minecraft:carrot',
    'minecraft:potato'
]

items.foreach(item => {
    console.log('I love ' + item + ' !')
})

输出是这样:

I love minecraft:apple !
I love minecraft:carrot !
I love minecraft:potato !

.foreach() 接受一个回调函数作为参数,并将数组中的每一项都作为参数传给这个回调函数。

如果你想要结合两个或更多数组,使用 .concat() 方法:

let arr1 = [1, 2, 3]
let arr2 = [4, 5, 6]
let arr3 = arr1.concat(arr2) // [1, 2, 3, 4, 5, 6]

以及一些常用的原型方法:

方法名参数作用
.push()一个或多个元素将传入的元素插入数组后
.includes()一个元素如果传入的元素存在,返回 true,否则返回 false
.sort()(可选)排序函数排序数组
.unshift()一个或多个元素将传入的元素插入数组前
.pop()删除数组最后的元素并返回这个元素
.shift()删除数组第一个元素并返回这个元素


JavaScript 字符串

以下代码声明一个字符串:

let str = 'hello'

JS 中的字符串可以使用单引号或者双引号包裹。

字符串和数组有一些相似之处,你可以使用下面的方法访问字符串中的某个字符:

let a = str[0]

或者使用 .charAt() 原型方法:

let a = str.chatAt(0)

如果要拼接两个字符串,只需要使用 + 号:

let str1 = 'hello'
let str2 = ' world'
let str3 = str1 + str2 // 'hello world'

当然也可以使用 .concat() 方法,但是比较麻烦。

还有一种特殊的字符串叫做模板字符串,使用反引号(`)包裹:

let arr = `Hello
 World!`

模板字符串可以是多行的,另外还支持插值:

let a = 1
let b = 2
console.log(`a is ${a}, b is ${b} !`) // a is 1, b is 2 !

在字符串中使用反斜杠(\)来转义字符:

`\${}` == '${}' //true

支持 Unicode 编码:

'\u4f60\u597d' == '你好' //true

JavaScript 循环与条件判断

JS 中有几种循环的方式,最常用的是 for 循环:

for(let i = 0; i < 10; i++){
    console.log(i)
}

可以发现 for 循环的语法和 C 语言一样,以上代码会在控制台中依次输出0-9。

如果你有一个数组,除了 .forEach() 方法外,也有方便的方法遍历数组中的每一个元素:

let arr = [1, 2, 3, 4]
for(let i in arr){
    console.log(i)
}

如果你想中断循环,使用 break:

for(let i = 0; i < 10; i++){
    if(i == 5) break;
    console.log(i)
}

你会发现控制台输出 4 之后就结束了。

if 语句用于条件判断,括号中的表达式如果是真,就执行 if 后面的代码,如果为假,就跳过。

布尔值 false,数字 0,undefined,null 均被视为假,其余任何值(或者表达式)都视为真。

JavaScript 函数

有两种方法声明函数:

// 1
function sayHi1() {
    console.log('Hi')
}
// 2 
let sayHi2 = () => {
    console.log('Hi')
}

后一种方法声明的函数称为箭头函数,如果箭头函数只有一个参数,可以省略括号,如果函数体只有一行代码,可以省略花括号。

两种方法声明的函数直接调用没有什么区别,但是箭头函数没有自己的 this,.bind() 原型方法也对箭头函数无效。

函数名不是必要的,没有函数名的函数叫做匿名函数。KJS 中存在许多需要回调函数的地方,这些回调函数绝大多数都是匿名函数。

JS 中的函数也是对象,你可以轻松地通过 = 来拷贝一个函数。

函数需要一个返回值:

function foo(){
    return 'foo!'
}

如果没有 return 语句,或者 return 后面没有值,则返回 undefined。

return 除了返回值之外,还能跳出函数回到上一级的上下文中:

function foo(arg){
    if(arg == 'bar') return;
    console.log('foo!')
}

Function.prototype.bind()

我在使用 KJS 给物品和方块添加标签的时候发现,类似 forge:ores/iron 这样的标签被添加到目标时,父级标签 forge:ores 不会被同时添加,而且也不支持给一个目标同时添加多个标签,这就意味着我得再写更多代码,我很不高兴,于是写了一个函数:

function splitTag(tag){
    let tagList = []
    let splitedTag = tag.split('/')
    tagList.push(splitedTag[0])
    for(let i = 1; i< splitedTag.length; i++) {
        tagList.push(tagList[i-1] + '/' + splitedTag[i])
    }
    return tagList
}

function advancedAdd(tags, target){
    function temp_addTag(tag){
        if(tag.includes('/')){
            let tagList = splitTag(tag)
            tagList.forEach(singleTag => {
                this.add(singleTag, target)
            })
        }else this.add(tag, target)
    }
    let addTag = temp_addTag.bind(this)
    if(typeof tags == 'string'){
        addTag(tags)
    }else{
        tags.forEach(tag => {
            addTag(tag)
        })
    }
}

在此就不解释这个函数的工作方式了,你只需要知道面对 forge:ores/iron 这样带有斜杠的标签的时候,advancedAdd() 会依次将父级标签添加到目标上(在这个例子中是 forge:ores);面对多个标签的时候,也能依次添加到目标上。

然而问题出现了,.add() 是 KubeJS 中 TagEventJS 对象自己的方法,你不能在这个对象之外的地方使用它,也就是说:

// event 的类型是  TagEventJS
onEvent('item.tags', event => {
    // 我只能把函数声明在这里
})

本来这是一个非常通用的函数,但是现在我要在每一个 js 文件里面都声明一遍这个函数,这很坏,而且很不优雅!

幸运的是,Function.prototype.bind() 原型方法可以创建一个函数的拷贝,这个新函数的 this 被绑定到了 .bind() 的参数上。

具体来说,我可以:

onEvent('item.tags', event => {
    let advAdd = (tags, target) => advancedAdd.bind(event)(tags.target)
})

这样就创建了一个名为 advAdd 的函数,功能与 advancedAdd 完全一致,但是由于 this 被绑定到了 event 上,所以可以正常使用 event.add() 方法,函数的声明也可以放在其他地方供其他 js 文件使用!

JavaScript 面向对象与原型链

我在修改匠魂材料属性的时候为了方便,写了下面的代码:

function Traits(){
    this.default = []
}
Traits.prototype.add = function (name, level){
    level = level || 1
    this.default.push(
        {
            name: name,
            level: level
        }
    )
    return this
}

这种函数叫做构造函数,它创建了一个名为 Traits 的类(本质上还是对象),Traits 类拥有一个叫做 .add() 的原型方法。

接下来试试使用它(可以直接在浏览器控制台里复制粘贴上面的代码和下面的代码,毕竟只是为了演示):

let myTrait = new Traits()
console.log(myTrait)

如果你是在浏览器控制台里面执行的代码,你会看到这个:

Traits {default: Array(0)}

这意味着创建了一个 Traits 对象,其中有个叫 default 的属性,值是一个空数组。

接下来试试 .add() 方法:

myTrait.add('tconstruct:momentum', 2)
console.log(myTrait)

现在可以看到 default 里面的数组不是空的了,从源代码可以看出 .add() 方法是就是往 default 里面装东西。

神奇的是,你可以不止一次地调用 .add():

myTrait.add('tconstruct:overslime', 2).add('tconstruct:insatiable', 1)
console.log(myTrait)

为什么?因为 .add() 除了往 default 里面装东西,还返回了 this。在 Traits 对象中,this 指向的就是它自己,因此 .add() 返回的是一个 Traits 对象,而 Traits 对象拥有 .add() 方法,它又返回一个 Traits 对象……你可以一直这样做,这叫链式调用。

读到这里相信你对 JS 中的原型有了一点感性的认识,JS 中的每个对象都有一个名为 prototype (原型)的属性,原型也有自己的原型,这样的继承关系一直持续下去,最后指向 null,这就是原型链。

在 JS 中可以直接使用 class 来创建类:

class Traits{
    constructor() {
        this.default = []
    }
    add(name, level){
        level = level || 1
        this.default.push(
            {
                name: name,
                level: level
            }
        )
        return this
    }
}

constructor() 方法在类初始化时调用,也就是构造函数。

这种方式本质上就是上文那种方式的语法糖,可惜的是 KJS 不支持 class 声明。