发布于 2026-01-06 5 阅读
0

从 TypeScript 转到 Elixir

从 TypeScript 转到 Elixir

我使用 Elixir 已经大约两个月了,感觉非常有趣。我之前主要使用 TypeScript/JavaScript 和 Ruby,所以一开始并不确定 Elixir 是否容易上手。

我读过的很多文章都说大多数 Ruby 开发者都能轻松上手 Elixir,但我对此持保留意见。除了表面上的相似之处,Elixir 实际上会迫使你用一种略微不同的方式思考问题。

在我的职业生涯中,我曾涉猎过一些与我工作无关的编程语言,但这次是我第一次真正意义上通过直接上手构建全栈应用程序来学习一门语言。说实话,我花在 Elixir 书籍上的时间相对较少,大部分时间都直接投入到产品开发中。因此,以下很多观点都来自一个可能没有编写过太多高质量 Elixir 生产代码的人的视角。😬

目前为止我喜欢的部分

以下几点让我对使用 Elixir 感到兴奋。😊

社区

这很简单。我刚开始接触 Elixir 时,做的第一件事就是加入Elixir Slack群组,它一直是我作为初学者最有帮助的资源之一。这个社区非常友好、耐心和乐于助人。当我错误地使用with语句时,他们会指导我如何重构。当我开始设置身份验证时,他们向我推荐了Pow。当我需要设置 worker 时,他们向我推荐了Oban 。甚至有人非常热心地帮我审查了GitHub上一些糟糕的代码。这真是太棒了。

丰富的内置功能

语言内置了这么多实用函数,真是太棒了。想扁平化数组?List.flatten()没问题,轻松搞定import {flatten} from 'lodash'。想按给定键对记录列表进行分组?没问题,轻松搞定Enum.group_by()。我可以一直说下去!

我特别喜欢列表、映射和范围都实现了枚举协议这一点。例如,如果我想在 JavaScript 中遍历一个对象/映射并将每个值翻倍,我需要这样做:

const obj = {a: 1, b: 2, c: 3};

const result = Object.keys(obj).reduce((acc, key) => {
  return {...acc, [key]: obj[key] * 2};
}, {});

// {a: 2, b: 4, c: 6}
Enter fullscreen mode Exit fullscreen mode

而在 Elixir 中,我可以直接这样做:

map = %{a: 1, b: 2, c: 3}

result = map |> Enum.map(fn {k, v} -> {k, v * 2} end) |> Map.new()

# %{a: 2, b: 4, c: 6}
Enter fullscreen mode Exit fullscreen mode

编辑:原来还有更简单的办法来处理这个问题Map.new/2!(感谢Reddit用户/u/metis_seeker提供的技巧😊)

Map.new(map, fn {k, v} -> {k, v * 2} end)

# %{a: 2, b: 4, c: 6}
Enter fullscreen mode Exit fullscreen mode

最后,我很喜欢像这样的方法String.jaro_distance/2,它可以计算两个字符串之间的距离/相似度。我目前还没用到它,但我能看出它在验证电子邮件地址域名方面可能很有用(例如foo@gmial.com-> “您是不是要找foo@gmail.com?”)。

模式匹配

模式匹配感觉是 Elixir 语言最强大的特性之一。虽然确实需要一些时间来适应,但我发现它迫使我编写更简洁的代码。(它也让我编写的语句比以前多得多case,而子句则少得多!)if

例如,如果我想在 Elixir 中编写一个方法来确定用户是否具有给定的角色(例如,为了限制对某些功能的访问),我可能会这样做

defp has_role?(nil, _roles), do: false

defp has_role?(user, roles) when is_list(roles),
  do: Enum.any?(roles, &has_role?(user, &1))

defp has_role?(%{role: role}, role), do: true

defp has_role?(_user, _role), do: false
Enter fullscreen mode Exit fullscreen mode

(注意第三种变体中额外使用了模式匹配,以has_role?/2检查第一个参数中的值是否与第二个参数中提供的user.role值相同!)role

在 TypeScript 中,上述代码的(非常粗略的)等效形式可能如下所示:

const hasRole = (user: User, roleOrRoles: string | Array<string>) => {
  if (!user) {
    return false;
  }

  // This is probably not the most idiomatic TS/JS code :/
  const roles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];

  return roles.some((role) => user.role === role);
};
Enter fullscreen mode Exit fullscreen mode

还是不明白?这很正常。这里再贴一遍 Elixir 代码,并添加了一些注释:

# If the user is `nil`, return false
defp has_role?(nil, _roles), do: false

# Allow 2nd argument to be list or string; if it is a list, check
# if any of the values match by applying method recursively to each one
defp has_role?(user, roles) when is_list(roles),
  do: Enum.any?(roles, &has_role?(user, &1))

# Use pattern matching to check if the `user.role` matches the `role`
defp has_role?(%{role: role}, role), do: true

# If none of the patterns match above, fall back to return false
defp has_role?(_user, _role), do: false
Enter fullscreen mode Exit fullscreen mode

这种方法需要一些时间来适应,但我越来越喜欢它了。例如,我开始使用的一种推出新功能(例如 Slack 通知)的模式是这样的:

def notify(msg), do: notify(msg, slack_enabled?())

# If Slack is not enabled, do nothing
def notify(msg, false), do: {:ok, nil}

# If it _is_ enabled, send the message
def notify(msg, true), do: Slack.post("/chat.postMessage", msg)
Enter fullscreen mode Exit fullscreen mode

不太确定这种说法是否地道,但这确实是避免if阻塞的好方法!

异步处理

很多 JavaScript 操作默认都是异步(非阻塞)处理的。这对于新手程序员来说可能有点棘手,但一旦掌握了它,就会发现它非常强大(例如,`async`Promise.all是并发执行多个异步进程的好方法)。

Elixir 默认以同步(阻塞)方式处理——在我看来,这让事情变得容易得多——但 Elixir 也恰好让异步处理进程变得极其容易(如果你愿意的话)。

举个可能有点简单的例子,我在设置消息 API 时注意到,随着我们添加越来越多的通知副作用(例如 Slack、Webhooks),每次创建消息时 API 的速度都会变慢。我很高兴能够通过简单地将逻辑放入异步进程中来暂时解决这个问题Task

Task.start(fn -> Papercups.Webhooks.notify(message))
Enter fullscreen mode Exit fullscreen mode

当然,这绝对不是处理这个问题的最佳方法。(或许把它放到队列里会更合理,比如用 Oban 处理。)但我很喜欢它能如此轻松地帮我解决问题。

如果我们想要实现类似 JavaScript 的功能Promise.all,Elixir 为我们提供了更好的选择:对超时时间的控制!

tasks = [
  Task.async(fn -> Process.sleep(1000) end), # Sleep 1s
  Task.async(fn -> Process.sleep(4000) end), # Sleep 4s
  Task.async(fn -> Process.sleep(7000) end)  # Sleep 7s, will timeout
]

tasks
|> Task.yield_many(5000) # Set timeout limit to 5s
|> Enum.map(fn {t, res} -> res || Task.shutdown(t, :brutal_kill) end)
Enter fullscreen mode Exit fullscreen mode

这样我们就可以关闭任何耗时过长的进程。🔥

管道操作员

几乎任何介绍 Elixir 的博客文章都必须提及这一点,所以我们就来谈谈这个。

我们直接以Papercups 代码库中的例子为例。在我们的一个模块中,我们通过检查给定域名的 MX 记录来进行电子邮件验证。以下是 Elixir 中的代码:

defp lookup_all_mx_records(domain_name) do
  domain_name
  |> String.to_charlist()
  |> :inet_res.lookup(:in, :mx, [], max_timeout())
  |> normalize_mx_records_to_string()
end
Enter fullscreen mode Exit fullscreen mode

如果我想用 TypeScript 编写这段代码,我可能会这样做:

const lookupAllMxRecords = async (domain: string) => {
  const charlist = domain.split('');
  const records = await InetRes.lookup(charlist, opts);
  const normalized = normalizeMxRecords(records);

  return normalized;
};
Enter fullscreen mode Exit fullscreen mode

这本身并没有什么错,但是管道可以避免一些无用的变量声明,并且生成的代码可以说同样可读!

我觉得大家最喜欢管道运算符的地方在于它既看起来很酷,又能提高(或者至少不会降低)文本的易读性。但最主要的还是它看起来很酷。🤓

由于我没能就管道问题写出什么特别有见地的文章,所以我将以萨沙·尤里奇的《行动中的灵丹妙药》中的一段话来结束本节:

管道运算符突显了函数式编程的强大之处。你可以将函数视为数据转换,然后以不同的方式组合它们,从而获得所需的效果。

不变性

我数不清有多少次在编写 JavaScript 代码时忘记了对数组调用.reverse()`or`.sort()方法实际上会改变原始值。(说来惭愧,这差点害我上次技术面试失败。)

例如:

> const arr = [1, 6, 2, 5, 3, 4];
> arr.sort().reverse()
[ 6, 5, 4, 3, 2, 1 ]
> arr
[ 6, 5, 4, 3, 2, 1 ] // arr was mutated 👎
Enter fullscreen mode Exit fullscreen mode

我喜欢 Elixir 的一点是,默认情况下一切都是不可变的。所以如果我定义了一个列表,然后想反转或排序它,原始列表永远不会改变:

iex(12)> arr = [1, 6, 2, 5, 3, 4]
[1, 6, 2, 5, 3, 4]
iex(13)> arr |> Enum.sort() |> Enum.reverse()
[6, 5, 4, 3, 2, 1]
iex(14)> arr
[1, 6, 2, 5, 3, 4] # nothing has changed 👌
Enter fullscreen mode Exit fullscreen mode

太好了!这使得代码更具可预测性。

处理字符串

我非常喜欢 Elixir 中提供了如此多的字符串格式化和插值方式。这可能有点小众,但三引号"""方法对于电子邮件文本模板来说非常有用,因为它会删除每行前面的所有空格:

def welcome_email_text(name) do
  """
  Hi #{name}!

  Thanks for signing up for Papercups :)

  Best,
  Alex
  """
end
Enter fullscreen mode Exit fullscreen mode

如果我想用 TypeScript 实现这个功能,我需要这样做:

const welcomeEmailText = (name: string) => {
  return `
Hi ${name}!

Thanks for signing up for Papercups :)

Best,
Alex
  `.trim();
};
Enter fullscreen mode Exit fullscreen mode

看起来……很尴尬。

我……还在慢慢习惯。

我差点把这部分命名为“目前为止我不喜欢的地方”,但我觉得那样有点不公平。仅仅因为我不习惯某些思维方式,并不意味着我就必须讨厌它。

那么,废话不多说,以下是我在使用 Elixir 时还在适应的一些事情。😬

错误处理

我刚开始接触 Elixir 时,最先注意到的一点就是返回元组的方法非常普遍{:ok, result}{:error, reason}起初我并没有太在意,结果写出了很多类似这样的代码:

{:ok, foo} = Foo.retrieve(foo_id)
{:ok, bar} = Bar.retrieve(bar_id)
{:ok, baz} = Baz.retrieve(baz_id)
Enter fullscreen mode Exit fullscreen mode

然后就被一堆MatchErrors击中了。

如果你写过 Elixir 代码,你可能已经猜到了,这让我对这个声明有点过于热情了with。如果你没写过 Elixir 代码,它看起来大概是这样的:

with {:ok, foo} <- Foo.retrieve(foo_id),
     {:ok, bar} <- Bar.retrieve(bar_id),
     {:ok, baz} <- Baz.retrieve(baz_id) do
  # Do whatever, as long as all 3 methods above execute without error
else
  error -> handle_error(error)
end
Enter fullscreen mode Exit fullscreen mode

这本身并没有什么特别的问题,但我发现自己也写了一些方法,这些方法基本上只是提取元组result的一部分{:ok, result},这感觉有点傻:

case Foo.retrieve(foo_id) do
  {:ok, foo} -> foo
  error -> error
end
Enter fullscreen mode Exit fullscreen mode

(上面的代码很可能是一种反模式,而我只是处理得不够正确。)

总之,一方面,我觉得这种语言约定很好,因为它迫使程序员更加重视错误处理。但另一方面,确实需要一段时间才能适应。

隐式返回(且无return关键字)

虽然模式匹配很棒,但 Elixir 无法提前跳出函数这一事实可能会让初学者感到有些沮丧。

例如,如果我想用 TypeScript 编写一个函数来计算账单的总费用,我可能会这样做:

const calculateTotalPrice = (bill: Bill) => {
  if (!bill) {
    return 0;
  }

  const {prices = []} = bill;

  // This is a little unnecessary, but illustrates the point of
  // a second reason we may want to return early in a function
  if (prices.length === 0) {
    return 0;
  }

  return prices.reduce((total, price) => total + price, 0);
};
Enter fullscreen mode Exit fullscreen mode

上面的代码允许我return 0在某些情况下提前终止(例如bill为空列表时)。nullprices

Elixir 通过模式匹配解决了这个问题(我们在上面已经详细讨论过了)。

def calculate_total_price(nil), do: 0

def calculate_total_price(%{prices: prices}) when is_list(prices),
  do: Enum.sum(prices)

def calculate_total_price(_bill), do: 0
Enter fullscreen mode Exit fullscreen mode

对于像我这样刚接触 Elixir 的新手来说,这需要一些时间来适应,因为它迫使你退后一步,重新思考你通常会如何设计你的函数。

透析器及其开发经验

除了 Dialyzer 有时用起来很让人抓狂之外,也没什么好说的了。有时候它运行很慢,警告信息要过几秒钟才会弹出……这很烦人,因为我修改了一些代码来修复一个警告;警告消失了几秒钟;我感觉自己修复成功了,然后突然,另一个警告又冒出来了。

有时,这些警告晦涩难懂,令人困惑:

神秘透析器警告

(我完全不知道这是什么意思……)

调试宏

当我开始使用Pow 库实现身份验证时,我第一次接触到了 Elixir 宏。我当时觉得自己像个傻瓜,费尽心思地寻找pow_password_changeset方法的定义,直到最后找到了这段代码

@changeset_methods [:user_id_field_changeset, :password_changeset, :current_password_changeset]

# ...

for method <- @changeset_methods do
  pow_method_name = String.to_atom("pow_#{method}")

  quote do
    @spec unquote(pow_method_name)(Ecto.Schema.t() | Changeset.t(), map()) :: Changeset.t()
    def unquote(pow_method_name)(user_or_changeset, attrs) do
      unquote(__MODULE__).Changeset.unquote(method)(user_or_changeset, attrs, @pow_config)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Elixir 支持宏真是太棒了,不过它的语法和动态生成方法的概念我以前从来没接触过。但我很期待尝试一下!

处理 JSON

说实话,我觉得这种情况适用于大多数语言(除了 JavaScript/TypeScript)。由于 Elixir 中的大多数 map 都使用原子作为键,我发现自己在不知情的情况下处理从 JSON 解码而来的 map 时,经常会不小心混用原子键和字符串键。

语言发展轨迹不明朗

说实话,我不知道 Elixir 的受欢迎程度是在增长、停滞还是下降,但到目前为止,它的表现似乎比我预期的要令人愉快得多,也少得多痛苦。

当我们最初用 Elixir 开发Papercups时,一些人就警告我们,由于缺乏库和支持,开发速度会大大降低。虽然 Elixir 的开源库数量远少于 JavaScript、Ruby、Python 和 Go 等语言,但到目前为止,这还没有造成太大的问题。

随着越来越多知名公司(例如 WhatsApp、Discord、Brex)开始在生产环境中使用 Elixir,我希望开发者对 Elixir 的接受度能够持续增长。我很乐观!😊

今天就到这里!

如果您有兴趣为开源 Elixir 项目做贡献,欢迎访问Github 查看 Papercups

文章来源:https://dev.to/_areichert/coming-to-elixir-from-typescript-2kg1