[譯] 如何編寫全棧 JavaScript 應用

JessicaC發表於2019-09-25

如何編寫全棧 JavaScript 應用

我們的 GitHub 倉庫最近在 GitHub 上獲得了 10,000 顆星。它在 HackerNews、GitHub Trending 上排名第一,並在 Reddit 上獲得了 2 萬個贊。

這篇文章是我這一段時間以來一直想寫的,隨著我們的倉庫快速上升,我認為現在是寫它的最佳時間。

No. 1 Trending on GitHub

我是自由職業者團隊的一員,我們使用 React/React Native、Node.js、GraphQL 等典型專案。這篇文章既是寫給那些有興趣瞭解我們如何構建完整的全棧應用程式的人,也是那些將來打算加入我們的人的入職工具。

以下是我們的核心原則。

保持簡單易讀

說起來容易做起來難。大多數開發人員都明白簡單易讀是一個重要的原則,但是這並不那麼容易就做到的。簡單易讀的程式碼使維護更容易,還使所有團隊成員更容易做出貢獻。它還將幫助您在日後管理自己的程式碼。

我看到的一些錯誤:

  • 過於聰明。複製貼上程式碼有時是挺好的。您不需要抽象每兩段看起來有些相似的程式碼。我自己就犯過這個錯誤。人人都這樣。DRY(Don't Repeat Yourself)是一個很好的原則,但是選擇錯誤的抽象可能會很糟糕,並使程式碼庫複雜化。如果您想了解更多相關內容,我推薦:AHA Programming.
  • 拒絕使用現成的工具。比如放著 mapfilter 不用,反而去用 reduce。當然您可以mapreduce,但它可能會有更多的程式碼行數,而且其他人也更難理解。
    當然,簡單易讀是主觀的。您將看到經驗豐富的開發人員在他們不需要使用 reduce 的地方使用 reduce。 有時您需要使用 reduce,如果您曾經束縛於 mapfilterreduce 可能會有更好的表現,因為您只需要將集合傳遞一次而不是兩次。這是一個效能與簡單易懂性的抉擇。總的來說,我傾向於簡單易讀,避免過早的優化。如果使用兩層的 map/filter 成為了您的瓶頸,您可以將程式碼切換為使用 reduce

下面的許多原則也旨在使程式碼庫儘可能簡單易讀。

物以類聚(主機託管)

這一原則適用於應用程式的許多部分。客戶端和伺服器資料夾結構,以及在每個檔案中的程式碼,保持在相同的倉庫。

倉庫

將客戶端和伺服器資料夾儲存在同一個 Monorepo 中。(譯者注:Monorepo 是用一個倉庫來管理所有的原始碼,Multirepo 是用多個倉庫來管理自己的原始碼)這很簡單。別把事情複雜化。人人都是用這種方式同步的。在使用 Multirepo 的專案中工作,也並不是世界末日,但是使用 Monorepo 會讓生活變得更簡單。您不會意外地擁有不同步的客戶端和伺服器。

客戶端結構

一個常見的客戶端資料夾結構是按檔案型別分組。該結構使用不同的資料夾:components,containers,actions,reducers 和 routes(actions 和 reducers 是使用 redux 才有的,而我會盡量避免用它)。components 資料夾將包含 BlogPostProfile 之類的內容,而 containers 資料夾將包含 BlogPostContainerProfileContainer 檔案。容器將從伺服器獲取資料並將其傳遞給 Dumb 子元件,Dumb 子元件的工作是將資料呈現到螢幕上。(譯者注:React 中可以將元件分為 Smart 和 Dumb 兩類,方便元件複用)

這個結構是可行的。至少它是一致的,這是很重要的,一個新加入程式碼庫的人會明白髮生了什麼,在哪發生的。但這種結構的缺點,也是我個人現在避免使用它的原因是,您必須經常跳轉程式碼庫。比如,ProfileContainerBlogPostContainer 它們之間沒有任何關係,但是檔案就在彼此的旁邊,並且遠離它們實際要使用的地方。

我更喜歡將要一起使用的檔案分為一組 —— 一種基於功能的方法。將 Smart 父元件和 Dumb 子元件放在同一個資料夾中。這會讓您的生活更容易。

我們通常使用 routes / screens 資料夾和 components 資料夾。元件將包含可以在應用程式的任何頁面上使用的 ButtonInput 等內容。route 資料夾中的每個資料夾代表著應用程式的不同頁面,與該路由相關的所有元件和業務邏輯都放在該資料夾中。在多個螢幕上使用的元件放在 components 資料夾中。

在每個 route 資料夾中,您可以在其中建立更多資料夾,對頁面的某些部分進行分組。所以如果 route 資料夾中包含了很多內容,這是可以理解的。但是我要警告的一件事是,不要嵌得太深。這將使我們這個專案在這個專案中更難地跳轉。這是不必要的事情過於複雜的另一個跡象(順便說一句,使用 command-p 和搜尋也是在專案找到所需內容的好方法,但檔案結構會有所影響)。

類似的方法是按功能分組,而不是按路由分組。在一個使用 Mobx State Tree 並且包含許多特性的單頁面的專案中,這種方法對我非常有效。按常規方法分組很簡單,而且不需要花費太多腦力來找出應該分組的內容和在哪裡找到專案。按功能分組的一個麻煩之處在於決定它屬於哪裡。功能的邊界可能很模糊。

更進一步,您甚至可能喜歡將容器和元件放在同一個檔案中。或者更進一步,把兩部分合為一。我知道您在想什麼。“這傢伙在說些什麼?這是褻瀆。”實際上,它並不像聽起來那麼糟糕,實際上非常好,如果您正在使用 React Hook 和/或生成的程式碼,我推薦使用這種方法。

真正的問題是,為什麼要將元件分成 Smart 和 Dumb 元件?對此有幾個答案:

  1. 易於測試
  2. 易於工具的使用,如 Storybook
  3. 可以使用相同的 Dumb 元件 與多個不同的 Smart 元件(反之亦然)。
  4. 可以跨平臺共享 Smart 元件(例如 React 和 React Native)。

這些都是正當的理由,但往往無關緊要。在我們的程式碼庫中,我們經常使用帶有 hook 的 Apollo Client。它用來進行測試,您可以模擬 Apollo 響應,也可以模擬 hook。Storybook 也是如此。至於混合和匹配 Smart 和 Dumb 元件,我從未在實踐中看到過這種情況。至於跨平臺使用,有一個專案我打算這麼做,但最終沒有實踐。那個專案應該是 Lerna 管理的一個 Monorepo。今天,無論如何您都很可能選擇 React Native Web 而不是這種方法。

因此,區分 Smart 元件和 Dumb 元件是有正當理由的。這是一個需要注意的重要概念,但通常不需要像您想象的那樣擔心,特別是最近 React 新增了 hook 新特性。

在同一個元件中組合 Smart 元件和 Dumb 元件的好處是,它加快了開發時間,而且更簡單。

此外,如果將來有需要,您也是可以將元件分成兩個單獨的元件的。

樣式

我們使用 emotion/styled components 進行樣式管理。人們傾向於將樣式拆分為單獨的檔案。我見過有人這樣做,但在嘗試了這兩種方法之後,我認為沒有任何理由將樣式放在不同的檔案中。與這裡列出的其他所有內容一樣,如果您將樣式與它們所關聯的元件放在同一個檔案中,那麼您的生活會更容易。

React 官方文件中包含了一些關於結構的簡明說明,我也推薦大家通讀一遍。其中最大的收穫:

一般來說,將經常更改的檔案放在一起是一個好主意。這一原則被稱為“託管”。

伺服器結構

伺服器也是如此。我個人避免使用的典型結構是這樣的

src
│ app.js # App 入口點
└───api # 表示 app 的所有後端路由控制器
└───config # 環境變數和配置相關的東西
└───jobs # agenda.js 的作業定義
└───loaders # 將啟動過程分成模組
└───models # 資料庫模型
└───services # 所有的業務邏輯都在這裡
└───subscribers # 非同步任務的事件處理程式
└───types # Typescript 的型別宣告檔案(d.ts)

我們通常在我們的專案中使用 GraphQL。有模型、服務和解析器檔案。與其把這三個檔案分散在應用程式中,不如把它們都放在同一個資料夾中。絕大多數情況下,它們會一起使用,如果它們放在一起,您會更容易找到它們。

在這裡看一個示例伺服器結構:elie222/bike-sharing

不重寫型別

我們在專案中使用了很多型別系統:TypeScript,GraphQL,資料庫模式,有時候還有 Mobx State Tree。

您可能會寫同樣的型別 3 或 4 次。避免這種情況。使用自動生成型別的工具。

在伺服器上,您可以使用 TypeORM/Typegoose 和 TypeGraphQL 的組合來覆蓋所有型別。TypeORM/Typegoose 將定義資料庫模式 以及它們的 TypeScript 型別。TypeGraphQL 將生成 GraphQL 型別和 TypeScript 型別。

在一個檔案中定義 TypeORM(MongoDB)和 TypeGraphQL 型別的一個例子:

import { Field, ObjectType, ID } from 'type-graphql'
import {
  Entity,
  ObjectIdColumn,
  ObjectID,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm'

@ObjectType()
@Entity()
export default class Policy {
  @Field(type => ID)
  @ObjectIdColumn()
  _id: ObjectID

  @Field()
  @CreateDateColumn({ type: 'timestamp' })
  createdAt: Date

  @Field({ nullable: true })
  @UpdateDateColumn({ type: 'timestamp', nullable: true })
  updatedAt?: Date

  @Field()
  @Column()
  name: string

  @Field()
  @Column()
  version: number
}
複製程式碼

GraphQL Code Generator 能夠生成許多不同型別。我們使用它在客戶端上生成 TypeScript 型別,並使用 React Hook 呼叫伺服器。

如果您使用 Mobx State Tree,可以通過新增 2 行程式碼自動從中獲取 TypeScript 型別,如果將它與 GraphQL 一起使用,則會有一個名為 MST-GQL 的新包,它將從 GQL 模式中生成狀態樹。

將這些包一起使用將節省您重寫大量程式碼並幫助您避免潛在的 bug。

其他解決方案 PrismaHasuraAWS AppSync 也可以幫助避免型別複製。使用這些工具有利有弊。對於我們所做的專案,這些也不總是一個選項,因為我們需要將程式碼部署到提前預置好的伺服器上。

儘可能地生成程式碼

除了使用上面的程式碼生成工具,您還會發現自己一次又一次地編寫相同的程式碼。我在這裡可以給您的第一個技巧是為您經常使用的所有東西新增 snippet。如果您寫了大量的 console.log,確保您有一個 cl snippet 將 cl 展開為 console.log()。如果您不這樣做,還請我幫忙除錯您的程式碼,我會生氣的。

儘管有很多 snippet 的包,但是您也可以很容易地在這裡生成您自己的:snippet generator

一些我喜歡的 snippet:

  • cl — console.log
  • React component/hooks snippets
  • imes — import emotion/styled
  • sc — emotion/styled component
  • fn — 列印當前所在檔案的檔名。

如果您想手動將它們新增到 VS Code 中,下面是程式碼:

{
  "Export default": {
    "scope": "javascript,typescript,javascriptreact,typescriptreact",
    "prefix": "eid",
    "body": [
      "export { default } from './${TM_DIRECTORY/.*[\\/](.*)$$/$1/}'",
      "$2"
    ],
    "description": "Import and export default in a single line"
  },
  "Filename": {
    "prefix": "fn",
    "body": ["${TM_FILENAME_BASE}"],
    "description": "Print filename"
  },

  "Import emotion styled": {
    "prefix": "imes",
    "body": ["import styled from '@emotion/styled'"],
    "description": "Import Emotion js as styled"
  },
  "Import emotion css only": {
    "prefix": "imec",
    "body": ["import { css } from '@emotion/styled'"],
    "description": "Import Emotion css only"
  },
  "Import emotion styled and css only": {
    "prefix": "imesc",
    "body": ["import styled, { css } from ''@emotion/styled'"],
    "description": "Import Emotion js and css"
  },
  "Styled component": {
    "prefix": "sc",
    "body": ["const ${1} = styled.${2}`", "  ${3}", "`"],
    "description": "Import Emotion js and css"
  },

  "TypeScript React Function Component": {
    "prefix": "rfc",
    "body": [
      "import React from 'react'",
      "",
      "interface ${1:ComponentName}Props {",
      "}",
      "",
      "const ${1:ComponentName}: React.FC<${1:ComponentName}Props> = props => {",
      "  return (",
      "    <div>",
      "      ${1:ComponentName}",
      "    </div>",
      "  )",
      "}",
      "",
      "export default ${1:ComponentName}",
      ""
    ],
    "description": "TypeScript React Function Component"
  },
  
  "console.log": {
    "prefix": "clg",
    "body": [
      "console.log('$1', $1)"
    ],
    "description": "console.log"
  },
  "console.log JSON": {
    "prefix": "clgj",
    "body": [
      "console.log('$1', JSON.stringify($1, null, 2))"
    ],
    "description": "console.log JSON"
  }
}
複製程式碼

除了 snippet,編寫程式碼生成器也可以節省大量時間。我喜歡使用 plop

Angular 有自己的生成器,可以通過命令列建立一個新的元件,每個 Angular 元件都有 4 個檔案。很遺憾 React 沒有這樣開箱即用的功能,但是您可以使用 plop 自己建立它。如果您建立的每個新元件都應該是一個包含元件、測試和 Storybook 檔案的資料夾,那麼生成器可以在一行中為您建立。在很多情況下,這會讓我們的生活變得輕鬆。例如,在伺服器上新增新特性是命令列中的一行,它建立一個實體、服務和解析器檔案,所有核心部分都自動填寫。

生成器的另一個好處是它推動您的團隊以一致的方式工作。如果每個人都使用相同的 plop 生成器,程式碼將具有非常一致的感覺。

看一下在這個專案中我們使用的生成器的例子:elie222/bike-sharing

自動格式化程式碼

這很簡單,但不幸的是並不總是這樣。不要浪費時間在縮排程式碼和新增或刪除分號上。在每次提交時,使用 Prettier 自動格式化程式碼:azz/pretty-quick


總結

我們討論了多年來我們從嘗試不同方法中學到的一些技巧。有很多方法可以構造程式碼庫,但是沒有一種方法是絕對“正確的”。

核心思想是保持事物的簡單、一致、結構化和易於遍歷。這將方便許多人蔘與到專案中工作,而且馬上就有種在讀自己程式碼的感覺。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章