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

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

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.java

But where do you put them?react

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

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

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

通常的作法

展开原文

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.typescript

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 文件中导入所需函数便可。promise

展开原文

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
相关文章
相关标签/搜索