如何将 Elixir 模块风格应用在 JS 中

1,383 阅读15分钟

原文A Proposal: Elixir-Style Modules in JavaScript
作者Will Ockelmann-Wagner 发表时间:13th August 2018
译者:西楼听雨 发表时间: 2018/8/26
(转载请注明出处)

img

展开原文Moving your code towards a more functional style can have a lot of benefits – it can be easier to reason about, easier to test, more declarative, and more. One thing that sometimes comes out worse in the move to FP, though, is organization. By comparison, Object Oriented Programming classes are a pretty useful unit of organization – methods have to be in the same class as the data they work on, so your code is pushed towards being organized in pretty logical ways.

In a modern JavaScript project, however, things are often a little less clear-cut. You’re generally building your application around framework constructs like components, services, and controllers, and this framework code is often a stateful class with a lot of dependencies. Being a good functional programmer, you pull your business logic out into small pure functions, composing them together in your component to transform some state. Now you can test them in isolation, and all is well with the world.

But where do you put them?

将代码切换到函数式风格可以带来很多好处——这样更容易查找原因,更容易进行测试,更具声明化(declarative),等等。不过有时候这会给代码的某个方面带来非常糟糕的效果,那就是代码的“组织性”。通过对比,我们发现面向对象编程中的类是一种很好的代码组织单元——方法必须和与其相关的数据放置在同一个类中,这样你的代码在逻辑上就会变得具有组织性。

(虽然如此,)然而即便在现代化的 JavaScript 项目中,事情通常也不会那么明晰。假设你现在正围绕着你架构的结构——即组件、服务、控制器来构建你的应用,这些架构里的代码大多都是有着许多依赖且有状态的类。作为一名优秀的函数式程序员,你把业务逻辑分割为了多个小型的函数,然后在你的组件中将其编织起来做一些状态转换工作。然这样你就可以对他们进行单独测试,这个世界非常和谐。

但问题是你该怎么安置这些小型函数呢?

一般的做法

展开原文

The first answer is often “at the bottom of the file.” For example, say you’ve got your main component class called UserComponent.js. You can imagine having a couple pure helper functions like fullName(user) at the bottom of the file, and you export them to test them in UserComponent.spec.js.

Then as time goes on, you add a few more functions. Now the component is a few months old, the file is 300 lines long and it’s more pure functions than it is component. It’s clearly time to split things up. So hey, if you’ve got a UserComponent, why not toss those functions into a UserComponentHelpers.js? Now your component file looks a lot cleaner, just importing the functions it needs from the helper.

第一种回答通常是“放在文件的底部”。比如说你现在有一个叫做 UserComponent.js 的组件。你可以想象在这个文件的底部有一对单纯用于辅助的函数——比如 fullName(user)——并将这对函数作为导出,以便在 UserComponent.spec.js 文件中对其进行测试。

但随着时间的推移,你又添加了几个函数进去。这个时候,这个组件的”年纪“已经有几个月,文件也有300多行的长度,它已经不再像是一个组件了,而更像是堆积起来的一堆纯函数。显然,这就是将他们开始进行分割的时候了。所以,你现在已经有一个 UserComponent,为什么不把这些函数放置在一个单独的 UserComponentHelpers.js 文件中呢?这样你的组件就变得整洁了,只需从这个 helper 文件中导入所需函数即可。

展开原文

So far so good – though that UserComponentHelpers.js file is kind of a grab-bag of functions, where you’ve got fullName(user) sitting next to formatDate(date).

And then you get a new story to show users’ full names in the navbar. Okay, so now you’re going to need that fullName function in two places. Maybe toss it in a generic utils file? That’s not great.

目前为止还好——即便 UserComponentHelpers.js 文件就像一个函数杂货袋一样——fullName(user)formatDate(date) 贴在一起 。

之后你又有了一个将用户的全名展示在导航栏中的新需求。好,现在你需要在两个地方用到这个“fullName”函数了。所以,你把它丢到一个 utils 文件中?这样不好!

展开原文

And then, a few months later, you’re looking at the FriendsComponent, and find out someone else had already implemented fullName in there. Oops. So now the next time you need a user-related function, you check to see if there’s one already implemented. But to do that, you have to check at least UserComponent, UserComponentHelpers, and FriendsComponent, and also UserApiService, which is doing some User conversion.

So at this point, you may find yourself yearning for the days of classes, where a User would handle figuring out its own fullName. Happily, we can get the best of both worlds by borrowing from functional languages like Elixir.

然后几个月之后,你正在查看 FriendsComponent 时,你发现某个人已经在这里实现过 fullName 。尴尬!(鉴于此)下次你再需要某个用户相关的函数时,你会先检查下是不是已经有一个实现了。不过在执行的时候,你得检查一遍至少 UserComponentUserComponentHelpersFriendsComponents ,还有 UserApiServeice——这个文件负责 User 对象的一些转换工作——这些文件。

Elixir 中的模块

展开原文

Elixir has a concept called structs, which are dictionary-like data structures with pre-defined attributes. They’re not unique to the language, but Elixir sets them up in a particularly useful way. Files generally have a single module, which holds some functions, and can define a single struct. So a User module might look like this:

在 Elixir 中有一个概念叫做 struct (结构体),它是一种类似于事先定义了一些属性的字典数据结构。这虽然不是 Elixir 的独有,不过它却将其发展为一种非常有用的形式。每个文件里通常只有一个模块,每个模块里面可以放置一些函数,也可以定义一个 struct。所以,一个名为 User 的模块通常是这样子的:

defmodule User do
  defstruct [:first_name, :last_name, :email]

  def full_name(user = %User{}) do
    "#{user.first_name} #{user.last_name}
  end
end
展开原文

Even if you’re never seen any Elixir before, that should be pretty easy to follow. A User struct is defined as having a first namelast name, and email. There’s also a related full_namefunction that takes a User and operates on it. The module is organized like a class – we can define the data that makes up a User, and logic that operates on Users, all in one place. But, we get all that without trouble of mutable state.

上面这段代码——即便在这之前你从来没见过 Elixir——也是非常容易看懂的。一个 User 结构体定义为包含 first_namelast nameemail 三个属性;另外还有一个相关函数 full_name,这个函数接收一个 User 并对其进行操作。这个模块的组织形式就类似于一个 class —— 我们在同一个地方可以定义组成 User 的数据,以及和他相关的操作逻辑,同时还不会有“可变状态”问题。

JavaScript 中的模块

展开原文

There’s no reason we can’t use the same pattern in JavaScript-land. Instead of organizing your pure functions around the components they’re used in, you can organize them around the data types (or domain objects in Domain Driven Design parlance) that they work on.

So, you can gather up all the user-related pure functions, from any component, and put them together in a User.js file. That’s helpful, but both a class and an Elixir module define their data structure, as well as their logic.

In JavaScript, there’s no built-in way to do that, but the simplest solution is to just add a comment. JSDoc, a popular specification for writing machine-readable documentation comments, lets you define types with the @typedef tag:

其他语言可以这样,在 JavaScript 中也没有理由不能这样。除了将你的纯函数围绕着被他们使用的组件来组织代码,你可以改为围绕着数据类型(在”领域驱动设计“中的术语叫做领域对象)来组织。

所以,你可以将所有和用户相关的纯函数从所有分散的组件中集中起来,将其放置在一个 User.js 文件中。这样做虽然有用,不过(除了纯函数外) class 和 (上面我们说到的) Elixir 模块都有定义自己的数据结构(User 结构体/类)——包括逻辑一起。

JavaScript 没有内置的方式来实现这点,但是有一种只需添加一些注释就可以实现的最简单的方案。那就是 JSDoc——一套非常流行的用于编写“机器可阅读的”注释文档的规范,它可以让你通过 @typedef 标签来定义一个类型 (type):

/**
 * @typedef {Object} User
 * @property {string} firstName
 * @property {string} lastName
 * @property {string} email
 */

/**
 * @param {User} user
 * @returns {string}
 */
export function fullName(user) {
  return `${user.firstName} ${user.lastName}`;
}
展开原文

With that we’ve replicated all the information in an Elixir module in JavaScript, which will make it easier for future developers to keep track of what a User looks like in your system. But the problem with comments is they get out of date. That’s where something like TypeScript comes in. With TypeScript, you can define an interface, and the compiler will make sure it stays up-to-date:

这样我们就将 Elixir 模块中的所有信息都迁移到了 JavaScript 中来,这有利于未来其他开发人员对你系统中的 User 对象的样子获得理解。不过“注释”有个问题,就是他们会”过期“(译注:即跟不上代码的变动)。这个时候就是像 TypeScript 一类的语言派上用场的时候了。借助于 TypeScript,你可以利用定义接口,编译器会确保它”永不过期“。

export interface User {
  firstName: string;
  lastName: string;
  email: string;
}

export function fullName(user: User): string {
  return `${user.firstName} ${user.lastName}`;
}
展开原文

This also works great with propTypes in react. PropTypes are just objects that can be exported, so you can define your User propType as a PropType.shape in your User module.

React 中的 propTypes 也有同样的效果。 PropType 只是一些对象而已,可以导出,所以你可以在你的模块中通过 PropTyp.shape 定义你的 User 的 PropType,。

export const userType = PropTypes.shape({
  firstName: PropTypes.string;
  lastName: PropTypes.string;
  email: PropTypes.string;
});

export function fullName(user) {
  return `${user.firstName} ${user.lastName}`;
}

Then you can use the User’s type and functions in your components, reducers, and selectors.

然后就可以在你的组件、reducers 和 selectors 中使用这个 User 类型。

译注:

  1. reducer : Array.prototype.reduce 方法的回调函数常被称之为 reducer;

  2. selector 则指数组的 filter、every、find 等这类具有判断性质方法的回调函数。

import React from ‘react’;
import {userType, fullName} from ‘./user’;

const UserComponent = user => (
  <div>Name: {fullName(user)}</div>
);
UserComponent.propTypes = {
  user: userType
};
展开原文

You could do something very similar with Facebook’s Flow, or any other library that lets you define the shape of your data.

However you define your data, the key part is to put a definition of the data next to the logic on the data in the same place. That way it’s clear where your functions should go, and what they’re operating on. Also, since all your user-specific logic is in once place, you’ll probably be able to find some shared logic to pull out that might not have been obvious if it was scattered all over your codebase.

你可以自己做一些类似于 Facebook 的 Flow (译注:一种类型检查框架) ,或其他任何可以让你定义数据轮廓的库所做的事情。

在定义你的数据类型时,关键点在于:要把数据类型的定义和与其相关的逻辑放在一起。这样,你的函数会做什么动作以及他们所操作是哪些数据就会变得清晰。另外,由于所有 User 相关的逻辑都在同一个地方,你会找出一些不容易察觉的分散在代码库各个角落里的相同的逻辑,进而把他们迁移进来.

参数的位置

展开原文

It’s good practice to always put the module’s data type in a consistent position in your functions – either always the first parameter, or always the last if you’re doing a lot of currying. It’s both helpful just to have one less decision to make, and it helps you figure out where things go – if it feels weird to put user in the primary position, then the function probably shouldn’t go into the User module.

Functions that deal with converting between two types – pretty common in functional programming – would generally go into the module of the type being passed in – userToFriend(user, friendData) would go into the User module. In Elixir it would be idiomatic to call that User.to_friend, and if you’re okay with using wildcard imports, that’ll work great:

将模块中的数据类型始终放置在你函数入参列表中特定的位置是一个很好的实践——要么始终位于第一个参数,要么始终在最后一个参数——如你需要进行很多”咖喱化”的话。两种方案任何一种都好,可以帮你找到参数的定位——如果把 user 放在主位让你感到怪异,这就表示这个函数根本就不应该出现在 User 模块中。

那些处理两种数据类型之间的转换的函数——在函数式编程中非常普遍——通常是将其放置在被传入的数据类型所在的模块中,如此,例如 userToFriend(user, friendData) 就将放置在 User 模块中。在 Elixir 中,习惯用 User.to_friend 调用,如果你觉得使用通配符形式的导入对你来说没问题的话,这也是可以的:

import * as User from 'accounts/User';

User.toFriend(user):

On the other hand, if you’re following the currently popular JavaScript practice of doing individual imports, then calling the function userToFriend would be more clear:

不过,如果你遵循的是现在比较流行的“分散导入” JavaScript 实践,那么调用 userToFriend 反而会更加清晰些:

import { userToFriend } from 'accounts/User';

userToFriend(user):

通配符导入形式的思考

展开原文

However, I think that with this functional module pattern, wildcard imports make a lot of sense. They let you prefix your functions with the type they’re working on, and push you to think of the collection of User-related types and functions as one thing like a class.

But if you do that and declare types, one issue is that then in other classes you’d be referring to the type User.User or User.userType. Yuck. There’s another idiom we can borrow from Elixir here – when declaring types in that language, it’s idiomatic to name the module struct’s type t.

We can replicate that with React PropTypes by just naming the propType t, like so:

(虽然有这样的实践) 不过我认为在这种函数式模块模式中,通配符导入的形式反而更具意义。因为它可以让你在函数前加上代表与其目的相关的类型前缀,从而促使你把 User 相关的数据类型和函数作为一个整体的方式来思考——就好像它是一个 class 一样。

不过假如你真的这样做了,就会出现一个问题:在其他类中,你需要用 User.User 或者User.userType 来引用这个类。这真的很讨厌!不过我们可以借用一个来自 Elixir 的“风俗”——当你用这种语言声明一个类型的时候,将这个模块的结构体命名为 t 是一种约定的习惯。

在 React 的 PropType 中,我们也可以通过将 propType 命名为 t 达到同样的效果,就像这样:

export const t = PropTypes.shape({
  firstName: PropTypes.string;
  lastName: PropTypes.string;
  email: PropTypes.string;
});

export function fullName(user) {
  return `${user.firstName} ${user.lastName}`;
}
import React from ‘react’;
import * as User from ‘./user’;

const UserComponent = user => (
  <div>Name: {User.fullName(user)}</div>
);
UserComponent.propTypes = {
  user: User.t
};

It also works just fine in TypeScript, and it’s nice and readable. You use t to describe the type of the current module, and Module.t to describe the type from Module.

这在 TypeScript 中同样有效,并且效果更好、更具可读性。(具体做法就是) 使用 t 来表示当前模块的类型;使用 Module.t 来表示 Module 中的类型。

export interface t {
  firstName: string;
  lastName: string;
  email: string;
}

export function fullName(user: t): string {
  return `${user.firstName} ${user.lastName}`;
}
import * as User from './user';

class UserComponent {
  name(): User.t {
    return User.fullName(this.user);
  }
}
展开原文

Using t in TypesScript does break a popular rule from the TypeScript Coding Guidelines to “use PascalCase for type names.” You could name the type T instead, but then that would conflict with the common TypeScript practice of naming generic types T. Overall, User.tseems like a nice compromise, and the lowercase t feels like it keeps the focus on the module name, which is the real name of the type anyway. This is one for your team to decide on, though.

在 TypeScript 中使用 t 会破坏TypeScript 代码指导中倡导的一个比较流行的原则——“在类型的名称中采用帕斯卡命名规范(译注:PascalCase,即名称中的所有单词的首字母都大写)”。你可以将其命名为 T,不过这又会与 TypeScript 的在将泛型命名为 T 的普遍实践相冲突。综上,User.t 看起来是一个不错的折衷方案,小写的 t 让人感觉它描述的是模块的名字,但实际上是类型的名字。总之,这就要看你们团队如何决定了。

总结

展开原文

Decoupling your business logic from your framework keeps it nicely organized and testable, makes it easier to onboard developers who don’t know your specific framework, and means you don’t have to be thinking about controllers or reducers when you just want to be thinking about users and passwords.

This process doesn’t have to happen all at once. Try pulling all the logic for just one module together, and see how it goes. You may be surprised at how much duplication you find!

将业务逻辑与你的架构进行解耦,可以有效的保持代码的组织性和可测试性,让刚接手对你的架构不熟悉开发人员入门变得容易,也意味着当你考虑的只是用户和密码的时候你不用去思考关于控制器及 reducer 方面的东西。

这个过程不必追求一次完成。先试着仅从一个模块开始集中其所有相关逻辑,然后观察其变化。最后你会惊讶地发现你的代码里有许多重复性的东西。

展开原文

So in summary:

Try organizing your functional code by putting functions in the same modules as the types they work on. Put the module’s data parameter in a consistent position in your function signatures. Consider using import * as Module wildcard imports, and naming the main module type t.

简而言之:

  • 尝试把你的函数式代码放到和其相关的数据类型的同一个模块中。
  • 将模块的数据参数放置在各个函数的固定位置。
  • 考虑采用 import * as Module 形式通配导入,并将模块的主数据类型命名为 t