前言
随着我涉足 FiveM 模组社区,我观察到了编写 Lua 代码的许多不同风格和策略。其中一些导致代码易于维护、阅读和理解;而其他代码则会导致代码令人困惑、错误且性能较低。
我还了解到许多特定于 FiveM 或 Lua 语言本身的行为,这些行为表明更喜欢某些功能而不是其他功能。
我想从这本手册中收集这些最佳实践 Lua 和 FiveM,并结合我从更广泛的软件社区获得的干净代码实践知识。本文档旨在立即有用和具体,并提供大量示例说明该做什么和不该做什么。
但是,免责声明;这项工作代表了我为 GTA 5 FiveM 框架编写有效 Lua 代码的最佳知识。因此,我知识的局限性也反映在这项工作中。如果我犯了错误,请告诉我。
结构/范围
首选有限范围
应将变量和函数的作用域限制在所需的最小可见范围内。优先选择以下顺序:
- 局部函数 (local function) - 最小作用域,仅在当前代码块中可见。
- 普通函数 (function) - 在当前文件或模块中可见。
- 导出函数 (export function) - 函数可供其他资源或脚本调用。
- 事件处理器 (AddEventHandler) - 注册本地事件处理器,在当前资源范围内可见。
- 注册事件/回调 (RegisterNetEvent/Callback) - 注册全局事件或回调,供其他脚本触发和调用。
单独的客户端和服务器文件
客户端和服务器特定的文件应该组织到它们自己的文件夹中
使用逻辑分组
将相同类型的构造放在一个文件中可能是有意义的。例如,文件顶部的所有文件范围变量,后跟所有本地函数,后跟所有全局函数,后跟事件。通过这种分组结构,可以更轻松地在服务器、资源和文件级别理解 API。或者,本地单机函数可以位于调用它们的函数的正上方或尽可能靠近,并且可以基于调用结构而不是构造类型进行分组。
使用文件/模块隐藏本地函数
如果你有一个资源范围的函数,它调用了一些本地的一次性函数,请将它们全部放在它们自己的文件或模块中。
资源命名
资源应使用下划线 “_” 而不是空格命名。应避免使用其他特殊字符,以便导出工作正常。
文件命名
文件的名称应全部小写,不含任何空格。可以使用破折号 “-” 或下划线 “_” 代替空格。
功能
规模和范围¶
- 函数应该只做一件事,并且应该很小。如果函数不小,请将其分解为较小的函数。
- 避免在同一函数中混合使用高级代码和低级代码。
命名¶
- 本地函数应以 camelCase 格式命名,以区别于本机函数和标准 lua 库函数
- 全局函数应以 PascalCase 格式命名
- 函数也应该用前导动词命名。
不推荐的代码
lua:
function player()
function playerDrop()
推荐的代码
lua:
function getPlayerObject()
function dropPlayer()
参数¶
限制参数数量¶
如果单个函数需要 3 个以上的参数,这可能表明参数可以在表中分组并作为对象传递,而不是作为单个参数传递。不推荐
lua:
function createChar(name, age, height, birthday, nationality)
end
lua:
function createChar(char)
end
避免在 API 中使用布尔参数¶
布尔参数是函数正在做两件事的信号。相反,请调用两个不同的函数,每个函数只做一件事。虽然 API 的定义是模棱两可的,但一个好的经验法则是不要在全局函数或导出函数中包含布尔参数。不推荐
lua:
function PrintEmotionalState(isHappy)
if isHappy then
print("happy")
else
print("sad")
end
end
推荐
lua:
function PrintHappy()
print("happy")
end
function PrintSad()
print("sad")
end
避免将隐式函数作为参数传递¶
而是在局部变量中声明函数并将变量作为参数传递。如果多次调用调用函数,这将大大提高性能。某些函数(如 CreateThread)通常只调用一次,因此本地化参数函数不会有任何性能改进。但是,我仍然建议将其作为一种防御措施,以便在将来代码更改时完全避免此问题。不推荐
lua:
someFunction(function()
end)
推荐
代码:
local function myFunction()
end
someFunction(myFunction)
参数重载¶
请小心重载函数。重载可能是一种味道,表明函数正在做不止一件事。重载可用作包装函数,提供调用同一基础函数的不同方法。可选参数¶
必需参数应位于可选参数之前。出口文件¶
导出应具有 lua-language-server 注释以执行 API
lua:
--- Puts a space between a first and last name
---@param first string first name param第一个字符串第一个名称
---@param last string last name param最后一个字符串姓氏
---@return string full name 返回字符串全名
local function formatName(first, last)
return first .. ' ' .. last
end
exports('formatName', formatName)
保持返回值较小¶
由于返回的值是按值传递的,因此返回大型有效负载可能会产生巨大的性能成本。基准测试表明,即使传输的总字节数相同,拥有许多每个返回少量数据的 export 调用比拥有少量返回大量负载的 export 调用的性能更高。提供访问器导出而不是对表的直接访问还使您的 API 更灵活地应对将来的更改。不推荐
lua:
exports('GetTable', function()
return myTable
end)
推荐
代码:
exports('GetValue', function(key)
return myTable[key]
end)
使用 guard 子句¶
通常在执行函数的 “实际” 工作之前,必须满足某些先决条件。Guard 子句是条件语句,提供早期返回以检查某些条件。这允许 reader 提前退出,而不是读取整个函数。此外,使用 guard 子句可以避免嵌套,嵌套会使代码难以阅读。但有时,一个简单的 if 语句读起来还不错。使用你最好的判断。
不推荐
lua:
-- 定义一个函数 getFullName,接受两个参数:first 和 last,分别表示名字和姓氏
local function getFullName(first, last)
-- 检查变量 nameHidden 是否未定义且 first 和 last 参数是否存在
if not nameHidden and first and last then
-- 如果条件满足,返回名字和姓氏的拼接字符串
return first .. last
else
-- 如果条件不满足,返回 nil
return nil
end
end
推荐
lua:
-- 定义一个函数 getFullName,接受两个参数:first 和 last,分别表示名字和姓氏
local function getFullName(first, last)
-- 如果 nameHidden 为 true,则直接返回 nil,表示不显示名字
if nameHidden then return end
-- 如果 first 或 last 为空,则直接返回 nil
if not first or not last then return end
-- 返回名字和姓氏的拼接字符串
return first .. last
end
事件
命名¶
事件名称应采用 '{resourceName}:{client/server}:{eventName}' 格式 这使读者可以一目了然地判断事件是从哪个资源触发的,以及事件应该在客户端还是服务器上处理。过去式¶
事件应该描述已经发生的事情,而不是规定所需的反应。此模式识别同一事件可能存在许多事件处理程序,每个事件处理程序都以不同的方式处理事件。触发事件应被视为原因,而处理事件应被视为结果。效果不应位于事件名称中。虽然事件名称不必严格使用过去时,但使用过去时编写事件名称可以帮助开发人员遵循此原则。不推荐
代码:
-- 定义一个函数 sendMessage,接受一个参数:message
local function sendMessage(message)
-- 触发服务器端事件 'resourceName:server:checkProfanity' 并将 message 作为参数传递
TriggerEvent('resourceName:server:checkProfanity', message)
end
-- 注册服务器端事件 'resourceName:server:checkProfanity'
RegisterNetEvent('resourceName:server:checkProfanity', function(message)
-- 调用 checkProfanity 函数来检查 message 中是否有不当内容
checkProfanity(message)
end)
推荐
lua:
-- 定义一个名为 sendMessage 的本地函数,用于发送消息
local function sendMessage(message)
-- 触发服务器端的事件 'resourceName:server:sentMessage',并将消息作为参数传递
TriggerEvent('resourceName:server:sentMessage', message)
end
-- 注册服务器端事件 'resourceName:server:sentMessage'
-- 当事件触发时,函数会接收源玩家 (source) 和消息 (message)
RegisterNetEvent('resourceName:server:sentMessage', source, message)
-- 检查消息中是否包含不良语言,调用 checkProfanity 函数
checkProfanity(message)
end
当想要通过网络取回数据时使用回调¶
在触发事件时,触发和侦听单独的事件以通过网络取回数据是一种反模式。请改用回调。对单个资源、非网络事件使用函数而不是事件处理程序¶
事件与函数的不同之处在于,一个事件可以有多个处理程序函数,而一个函数调用只执行一个函数。如果事件是非网络的,并且仅打算由一个资源处理,则应改用函数。将 AddEventHandler 用于非网络事件¶
根据限制范围的原则,如果事件仅在客户端或服务器上触发和处理,请不要将其注册为 net 事件。Secure Net 事件¶
如果事件是从注册事件的网络的另一端触发的, (客户端触发服务器事件,或服务器触发客户端事件) ,则 GetInvokingResource 将为 nil。限制调用事件的其他方式以防止漏洞利用,除非事件旨在由客户端和服务器触发。
lua:
-- 注册客户端事件 'resourceName:client:eventName'
-- 当该事件触发时,执行定义的函数
RegisterNetEvent('resourceName:client:eventName', function()
-- 如果存在触发该事件的资源,则退出函数,不执行后续代码
-- GetInvokingResource() 用于获取触发事件的资源名称
if GetInvokingResource() then return end
-- 处理事件的逻辑,具体操作可在此处编写
--- handle the event
end)
表¶
隐含数组索引¶
其他语言不允许声明具有显式索引的数组。除非这些键具有需要向读者阐明的重要含义,否则它们应该是隐含的。不推荐
lua:
坏
local myTable = {
[1] = "first index",
[2] = "second index",
[3] = "third index"
}
推荐
lua:
local myTable = {
"first index",
"second index",
"third index"
}
取消引用¶
常量键首选对象访问,非常量键首选数组访问
lua:
local company = {
boss = "Sam"
}
不推荐
lua:
local boss = company["boss"]
推荐
lua:
local boss = company.boss
避免 table.insert()¶
它的性能很糟糕。仅当需要插入到不是最后一个索引的特定索引处的数组中时,才应使用它。在表的末尾插入¶
不推荐
lua:
table.insert(myTable, "value")
推荐
lua:
myTable[#myTable + 1] = "value"
插入/覆盖给定的键¶
不推荐
lua:
table.insert(myTable, "key", "value")
推荐
lua:
myTable["key"] = "value"
迭代数组时使用数字 for 循环¶
这是性能提升不推荐
lua:
for k, v in pairs(myArray) do
print(k .. ", " .. v)
end
推荐
lua:
for i=1, #myArray do
print(i .. ", " .. myArray[i])
end
维护您自己的数组大小变量¶
大型数组存在显著的性能差异,因为 #array 是 O(n) 操作。请注意,有时遍历整个数组以查找大小是可取的,但从空数组开始并在循环中填充它的常见模式应该使用数组大小变量。不推荐
lua:
for i = 1, 100 do
myArray[#myArray+1] = i
end
推荐
lua:
local myArraySize = 0
for i = 1, 100 do
myArraySize += 1
myArray[myArraySize] = i
end
变量
命名¶
使用 ALL_CAPS (全为大写字母) 命名常量¶
lua:
local MY_CONSTANT = "constant value"
MY_GLOBAL_CONSTANT = "another constant value"
驼峰式命名法非常量局部变量名称¶
lua:
local myVariable = "variable value"
PascalCase (与骆驼命名法类似。只不过骆驼命名法是首字母小写,而帕斯卡命名法是首字母大写) 非常量全局变量名称¶
lua:
MyGlobalVariable = "global variable value"
使用下划线 “_” 作为无法删除但未使用的变量的名称。¶
lua:
local function printValues(map)
for _, v in pairs(map) do
print(v)
end
end
解释:
- local function printValues(map):定义了一个名为 printValues 的本地函数,接收一个参数 map(通常是一个表/字典)。
- for _, v in pairs(map) do:使用 pairs 遍历 map 表中的键值对。在遍历过程中,每一对键和值都会被分别赋给两个变量。
- _:表示键(或索引),因为在这个循环中,键没有被使用,采用 _ 来表示它是一个不使用的变量。
- v:表示表中的值,每次循环时都会将 v 输出。
- print(v):将每次遍历到的值 v 打印出来。
为什么使用 _:
在 Lua 中,通常使用 _ 作为一个未使用的占位符变量,这意味着你并不关心这个值(在这里是键),但是你仍然需要为它提供一个占位符以满足语法的要求。 _ 是一种约定俗成的做法,用来表示这个变量不会被使用。
总结:
这段代码定义了一个函数,它接受一个表作为输入,并打印出表中的所有值,而忽略键。
枚举与布尔值¶
当存在两个以上的选项时,应该使用 Enum 来反映某物的状态。一种常见的反模式是使用多个布尔值来反映状态。这是令人困惑和有问题的,因为代码需要防御不可能的状态,因为布尔值的组合能够表示比所需更多的状态。它还使代码更加不透明,更难推理。如果 isWalking 和 isRunning 都是 false,这意味着什么?我们不知道吗?状态是否空闲?或者也许是游泳?不推荐
lua:
local isWalking = false
local isRunning = false
推荐
lua:
local MOVEMENT = {
UNKNOWN = 1,
WALKING = 2,
RUNNING = 3
}
local movementState = MOVEMENT.UNKNOWN
以这种方式在 enum 中表示状态也使得将来更容易修改以添加更多状态。如怠速、游泳、飞行、跌落等。当枚举不详尽时,添加 UNKNOWN 字段非常有用,因为 catch all 表示任何其他状态。
位置¶
函数中的局部变量应尽可能靠近它们的使用位置进行声明。这限制了开发人员在阅读代码时必须牢记的内容。 在函数外部声明的局部变量应该在文件的顶部声明,并组合在一起。 全局变量应在分组在一起的文件顶部声明。客户端/服务器全局变量只能在单个客户端/服务器文件中声明。这有助于保持井井有条,而不是在资源周围传播随机的 global。条件¶
默认值¶
考虑使用三元运算符 'or' 而不是 nil 检查来提高可读性。不推荐
lua:
if name then
return name
else
return "John Doe"
end
推荐
代码:
return name or "John Doe"
不要写 “if true then return true”¶
当将变量返回或设置为条件语句本身的值时,不要使用 if else 块。不推荐
lua:
if name == "mark" or name == "stacy" then
return true
else
return false
end
推荐
lua:
return name == "mark" or name == "stacy"
首选正布尔表达式¶
这使得代码更易于阅读。不推荐
lua:
if not isHappy then
return "sad"
else
return "happy"
end
推荐
lua:
if isHappy then
return "happy"
else
return "sad"
end
Fivem提供官方函数(直译本地人或当地人,个人认为官网函数更为贴切)
删除 Citizen. 前缀¶
不推荐
代码:
Citizen.Wait()
Citizen.CreateThread()
Citizen.SetTimeout()
推荐
代码:
Wait()
CreateThread()
SetTimeout()
不要使用 GetHashKey()¶
将字符串哈希替换为反引号¶
不推荐
lua:
local hashKey = GetHashKey('hash')
推荐
代码:
local hashKey = `hash`
将 GetHashKey(variable) 替换为 joaat(variable)¶
不推荐
lua:
local hashKey = GetHashKey(myHash)
推荐
代码:
local hashKey = joaat(myHash)
使用 Vector3 数学运算,而不是 GetDistanceBetweenCoords()¶
不推荐
lua:
local distance = GetDistanceBetweenCoords(1, 1, 1, 0, 0, 0)
推荐
lua:
local coord1 = vector3(1, 1, 1)
local coord2 = vector3(0, 0, 0)
local distance = #(coord1 - coord2)
使用 PlayerPedId() 而不是 GetPlayerPed(-1)
不推荐
lua:
local playerPed = GetPlayerPed(-1)
推荐
lua:
local playerPed = PlayerPedId()
错误处理¶
代码中的意外状态或条件可能会导致引发 lua 错误。但是,如果不明确检测和处理这些不良状态,可能会产生其他后果。它们可能会将错误状态传播到系统的其他组件,或引入意外行为。检查和处理意外状态可以使您的代码更健壮地应对可能的故障和漏洞。使用 assert 而不是 'if expression then error()'¶
assert 是一种更简洁、可读性更强的在未满足条件时引发错误的方法不推荐
lua:
if not someVar then error("someVar is nil") end
推荐
lua:
assert(someVar ~= nil, "someVar is nil")
-- 如果 someVar 为 nil,则抛出错误并显示 "someVar is nil" 消息
assert(someVar ~= nil, "someVar is nil")
自由地进行前提检查¶
执行操作时,列出假设,然后编写前提条件检查。意外状态大声失败¶
在编写前提条件检查时,失败通常会导致 lua 错误和消息。仅通过从函数提前返回而无提示失败可能难以调试,并且可能无法检测到。不推荐
lua:
if not isPlayerDead() then return end
推荐
lua:
assert(isPlayerDead(), "player is not dead")
抛出错误 vs 日志记录¶
有些状态可能是意想不到的,但可以恢复。在这些情况下,最好记录状态,但仍允许操作继续进行。这方面的一个例子是玩家向 NPC 出售物品。如果某些商品未能售出,最好记录/打印错误,同时让其余商品通过。请记住,通过抛出错误将取消哪些执行,并使用最佳判断来决定抛出还是日志记录是更好的选择。
断言与错误作为值¶
断言会导致 lua 错误在堆栈中向上传播,从而迫使调用方通过受保护的调用来处理它们。虽然断言应始终用于意外或“不可能”的情况,但作为值的错误对于我们希望 API 调用方处理的预期失败情况可能会有所帮助。这涉及到返回一个成功布尔值,后跟一个可选的错误代码和消息。这样做会使函数的默认行为以静默方式失败,因为调用者有责任决定处理错误。这可以为玩家提供更好的体验,因为在玩家按下错误按钮的情况下,静默失败可能比响亮的错误消息更可取。请注意,API 函数应尽可能是幂等的。如果系统的状态是调用方在函数运行后期望的状态,则无操作结果仍被视为成功。
作为值的示例错误¶
lua:
function add(a, b)
if not a or not b then
return nil, {
code = 'missing_required_params',
message = 'either a or b is nil',
}
end
return a + b
end