如何將 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
相關文章
相關標籤/搜索