如何將 Elixir 模組風格應用在 JS 中

西樓聽雨發表於2019-02-21

原文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

相關文章