JavaScript有著資源豐富的生態圈,但同時也令人在面對這一堆套件、工具以及設定時眼花撩亂、不知所措。TypeScript能用比較嚴謹的方式來開發JavaScript程式,可以大大地提升程式碼的可維護性,也可以增加多人協作時的效率。筆者甚至覺得我們都應該用TypeScript來編寫JavaScript程式會比較好。然而,要建立出一個完整TypeScript專案是一件繁瑣的事情,我們會需要安裝多種套件及工具並撰寫設定檔和程式碼,來使專案能符合我們自己的開發習慣。在這篇文章中,筆者要分享自己建立TypeScript專案的方式。



如果您還不熟悉TypeScript的話,可以先參考《TypeScript學習之路》系列文章

全域工具

撰寫TypeScript工具除了要安裝typescript套件來取得tsc工具外,筆者建議也把套件管理工具改成pnpm(預設是npm)。

pnpm的好處是它的速度很快,且會用連結(link)的方式來存放Node.js套件,可以省下大量的時間和硬碟空間。pnpm還有一個特性是,在package.jsondependencies相關屬性中,必須要把有依賴到(有import)的套件都明確寫出來,要引入套件B時不能因為dependencies相關屬性中的套件A已經有依賴套件B了就不把套件B寫在dependencies的相關屬性中。

搭配depcheck工具可以檢查dependencies相關屬性中有無沒用到的套件被寫進來了。

執行以下指令,可以安裝上述的這些全域工具:

npm i -g typescript pnpm depcheck

IDE 的選用

IDE當然就是用VS Code了,最多人使用也最方便。如果您還沒有用過VS Code,可以參考以下這篇文章:

要用VS Code來開發JavaScript程式,筆者會另外安裝以下的延伸模組:

  • ESLint:ESLint是一個JavaScript Linter,可以用來檢查程式碼語法風格以及格式化程式碼。這個延伸模組可以把ESLint的功能整合進VS Code中,就不用每次都打指令來操作ESLint了。
  • Docker:Docker是一種輕量級的作業系統虛擬化解決方案,可以用來製作開發或是執行JavaScript程式的所需環境。這個延伸模組除了可以讓VS Code支援Dockerfile和Docker Compose的設定檔之外,也有在VS Code內加入管理Docker映像和容器的功能,這樣就不用每次都打指令來操作Docker了。
  • Remote Development:這個延伸模組可以透過SSH開啟遠端的程式專案,也可以開啟Docker容器或是WSL中的程式專案。
  • Live Server:如果您是要開發Web前端相關的專案,這個延伸模組可以快速在本地端啟動一個簡單的HTTP伺服器來幫助您查看您輸出的檔案(例如HTML、CSS、JS)經由HTTP伺服器提供後,在網頁瀏覽器上運行的結果。

執行以下指令可以快速安裝上面提到的VS Code延伸模組:

code --install-extension dbaeumer.vscode-eslint
code --install-extension ms-azuretools.vscode-docker
code --install-extension ms-vscode-remote.vscode-remote-extensionpack
code --install-extension ritwickdey.LiveServer

接著在VS Code的使用者設定檔(JSON格式)中,加上以下設定:

{
    ...

    "[json]": {
        "editor.detectIndentation": false,
        "editor.formatOnSave": true,
    },

    "[jsonc]": {
        "editor.detectIndentation": false,
        "editor.formatOnSave": false,
    },

    "[typescript]": {
        "editor.codeActionsOnSave": {
            "source.fixAll.eslint": "always"
        },
        "editor.detectIndentation": false,
        "editor.formatOnSave": false,
    },

    ...
}

加入以上設定後,VS Code會在手動儲存(非自動儲存)JSON檔案的時候,如果它是標準的JSON格式,就會進行格式化(自動排版)。同樣地,手動儲存TS檔案的時候,對該檔案執行eslint --fix指令,自動修正能修正的程式碼語法,並進行程式碼格式化。禁用editor.detectIndentation可以使VS Code的格式化功能嚴格遵守VS Code設定的editor.tabSize數值(預設值是4),才不會不同檔案的TAB字元的空格數量參差不齊。

除了JSON外禁用editor.formatOnSave是因為JSON是單純的資料傳輸格式,自動在儲存檔案時格式化並不會有什麼影響。但是JSON-C可能有註解排版的問題,所以對其禁用自動格式化。TS檔案則因為ESLint也會進行格式化的關係,所以不需要再多執行一次格式化。

如果是在Windows作業系統上運行VS Code,可以再加入如下的設定來讓TypeScript、JSON和使用Unix系統的換行字元LF(\n),而不是Windows系統的換行字元CRLF(\r\n),以減少一些問題。

{
    ...

    "files.eol": "\n",

    ...
}

建立 TypeScript 專案

不管是什麼類型的TypeScript專案,都可以透過這個小節提供的方式來建立出最基本的雛型。

建立 Node.js 專案

在專案根目錄中執行以下指令來產生出一個Node.js專案必定會有的package.json檔案:

npm init

在這個指令動作的過程中,您可以順便設定package.json中的部份欄位值,像是版本號碼,專案描述等等。

typescript-start-new-project

package.json檔案產生出來後,移除其中的main欄位,再進行如下的修改:

{
    ...

    "type": "module",
    "exports": "./index.js",
    "engines": {
        "node": ">=20"
    },

    ...
}

type欄位設為module,可以使這個Node.js專案成為ESM。最大的好處是我們可以直接使用await,不需要先定義出一個async的函數再在裡面寫awaitexports欄位可以設定要用哪個檔案./index.js作為此ESM預設暴露出來的檔案。engines.node欄位可以設定這個Node.js專案支援的Node.js版本範圍。

加入 TypeScript

在專案根目錄中執行以下指令來產生出一個TypeScript專案必定會有的tsconfig.json檔案,此為TypeScript的設定檔。

npx tsc --init

typescript-start-new-project

tsconfig.json檔案產生出來後,進行如下的修改:

{
    "compilerOptions": {
        ...

        "target": "ES2023",
        "module": "Node16",
        "moduleResolution": "node16",
        "outDir": "./dist",

        ...
    }
}

compilerOptions.target的欄位值可以照著Node.js當前的LTS版本所能支援的最新JavaScript版本來設定(Node.js 20就有支援到ES2023),TypeScript預設會用比較舊的版本。

compilerOptions.module欄位值設為Node16會依照其它設定來決定編譯出來的模組要不要支援CommonJS,抑或只是純ESM。當package.json中的type欄位為module,且tsconfig.json中的compilerOptions.moduleResolution欄位為node16,TypeScript就會編譯出純ESM。

compilerOptions.outDir用來設定TypeScript所編譯出來的JavaScript檔案要放置哪個目錄下,其底下的JS檔案路徑會相對於TypeScript編譯器所編譯的所有TS檔案的共同上層目錄(因為沒設定compilerOptions.rootDir)。筆者習慣把TypeScript原始碼都放在src目錄下,測試腳本都放在tests目錄下,編譯好的JavaScript檔案都放在dist目錄或lib目錄下。

compilerOptions.moduleResolution要設為node16,TypeScript編譯器才可以用Node.js慣用的套件路徑寫法來引用到node_modules目錄中的套件,且import的路徑中需要包含副檔名,如此才符合ESM的規範。

接著要建立另外一個專門用來建置專案的TypeScript設定檔,命名為tsconfig.build.json。檔案內容如下:

{
    "extends": "./tsconfig.json",
    "include": [
        "src/**/*",
    ]
}

extends欄位可以用來繼承其它的TypeScript設定檔。此處我們替原先的設定檔添加include欄位,用來限制TypeScript在建置專案時只去編譯src目錄下的檔案。

之所以要分兩個TypeScript設定檔,是因為我們希望預設的tsconfig.json可以直接和ESLint一同使用,讓VS Code可以對整個專案目錄下的JavaScript/TypeScript檔案作型別檢查,而不是只能對src目錄下的檔案有作用。

然後還要修改package.json內的scripts欄位,如下:

{
    ...

    "scripts": {
        "clean": "rimraf dist",
        "build": "npm run clean && tsc -p tsconfig.build.json",
        "lint": "eslint src",
        "lint:fix": "npm run lint -- --fix"
    }

    ...
}

如此一來,在專案根目錄下執行npm run clean就可以清除輸出目錄;執行npm run build就可以在清空輸出目錄後,編譯TypeScript程式碼;執行npm run eslint就可以用eslint來檢查src目錄底下的TypeScript程式碼;執行npm run eslint:fix就可以用eslint在檢查程式碼的同時順便進行修正。

當然,現在我們還沒有把tsc、ESLint、rimraf加進專案。所以還要在專案根目錄中執行以下指令,來安裝相關的套件:

pnpm i -D eslint@^9 @eslint/js@^9 globals typescript@~5.6 @types/eslint__js@^8 typescript-eslint@^8 @stylistic/eslint-plugin@^2 eslint-plugin-import@^2 eslint-import-resolver-typescript@^3 rimraf

接著在專案根目錄中新增eslint.config.mjs檔案,內容如下:

import eslint from '@eslint/js';
import stylistic from "@stylistic/eslint-plugin";

import * as importPlugin from "eslint-plugin-import";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
    eslint.configs.recommended,
    ...tseslint.configs.strictTypeChecked,
    ...tseslint.configs.stylisticTypeChecked,
    {
        languageOptions: {
            ecmaVersion: 2023,
            sourceType: "module",
            globals: {
                ...globals.browser,
                ...globals.node,
            },
            parserOptions: {
                projectService: true,
                tsconfigRootDir: import.meta.dirname,
            },
        },
        rules: {
            // These rules relate to possible logic errors in code:

            "array-callback-return": "error",
            "no-constructor-return": "error",
            "no-inner-declarations": ["error", "both"],
            "no-promise-executor-return": "error",
            "no-self-compare": "error",
            "no-template-curly-in-string": "warn",
            "no-unmodified-loop-condition": "warn",
            "no-unreachable-loop": "error",
            "require-atomic-updates": "error",

            // These rules suggest alternate ways of doing things:

            "accessor-pairs": "error",
            "arrow-body-style": ["error", "as-needed"],
            "block-scoped-var": "error",

            "camelcase": ["error", {
                properties: "never",
            }],

            "consistent-this": ["error", "self"],
            "curly": "error",
            "default-case-last": "error",

            "eqeqeq": "error",
            "func-names": ["error", "as-needed"],
            "func-style": ["error", "expression"],

            "grouped-accessor-pairs": "error",
            "guard-for-in": "error",
            
            "logical-assignment-operators": ["error", "always", {
                enforceForIfStatements: true
            }],

            "new-cap": ["error", {
                newIsCap: true,
                capIsNew: true,
            }],

            "no-bitwise": ["error", {
                int32Hint: true,
            }],

            "no-caller": "error",
            "no-div-regex": "error",
            "no-eq-null": "error",
            "no-eval": "error",
            "no-extend-native": "error",
            "no-extra-bind": "error",
            "no-extra-label": "error",
            "no-implicit-coercion": "error",
            "no-implicit-globals": "error",
            "no-invalid-this": "error",
            "no-iterator": "error",
            "no-label-var": "error",
            "no-lone-blocks": "error",
            "no-lonely-if": "error",
            "no-multi-assign": "error",
            "no-nested-ternary": "error",
            "no-new": "error",
            "no-new-func": "error",
            "no-new-wrappers": "error",
            "no-object-constructor": "error",
            "no-octal-escape": "error",

            "no-plusplus": ["error", {
                allowForLoopAfterthoughts: true,
            }],

            "no-proto": "error",
            "no-return-assign": "error",
            "no-sequences": "error",
            "no-unneeded-ternary": "error",
            "one-var": ["error", "never"],
            "operator-assignment": "error",
            "prefer-arrow-callback": "error",
            "prefer-const": "error",
            "prefer-exponentiation-operator": "error",
            "prefer-numeric-literals": "error",
            "prefer-object-has-own": "error",
            "prefer-object-spread": "error",
            "prefer-regex-literals": "error",
            "prefer-rest-params": "error",
            "prefer-spread": "error",
            "prefer-template": "error",
            "radix": ["error", "as-needed"],
            "require-unicode-regexp": "error",

            "sort-imports": ["error", {
                ignoreDeclarationSort: true,
            }],

            "symbol-description": "error",
            "vars-on-top": "error",

            // These rules care about how the code looks rather than how it executes:

            "unicode-bom": "error",
        }
    },
    {
        rules: {
            // override
            "@typescript-eslint/no-require-imports": ["error", {
                allowAsImport: true,
            }],

            "@typescript-eslint/no-unused-vars": ["error", {
                args: "all",
                argsIgnorePattern: "^_",
                caughtErrors: "all",
                caughtErrorsIgnorePattern: "^_",
                destructuredArrayIgnorePattern: "^_",
                varsIgnorePattern: "^_",
                ignoreRestSiblings: true,
            }],

            "@typescript-eslint/restrict-template-expressions": ["error", {
                allowBoolean: true,
                allowNullish: true,
                allowNumber: true,
                allowRegExp: true,
            }],

            // switch on

            "@typescript-eslint/consistent-type-exports": "error",

            "@typescript-eslint/consistent-type-imports": ["error", {
                disallowTypeAnnotations: false,
                fixStyle: "separate-type-imports",
                prefer: "type-imports",
            }],

            "@typescript-eslint/default-param-last": "error",
            "@typescript-eslint/explicit-function-return-type": "error",

            "@typescript-eslint/explicit-member-accessibility": ["error", {
                accessibility: "no-public",
            }],

            "@typescript-eslint/explicit-module-boundary-types": "error",
            "@typescript-eslint/method-signature-style": "error",
            "@typescript-eslint/no-import-type-side-effects": "error",
            "@typescript-eslint/no-loop-func": "error",
            "@typescript-eslint/no-unnecessary-parameter-property-assignment": "error",
            "@typescript-eslint/no-unnecessary-qualifier": "error",
            "@typescript-eslint/no-use-before-define": "error",
            "@typescript-eslint/no-useless-empty-export": "error",
        },
    },
    stylistic.configs['recommended-flat'],
    {
        rules: {
            // override

            "@stylistic/arrow-parens": ["error", "always"],
            "@stylistic/brace-style": ["error", "1tbs", {}],

            "@stylistic/indent": ["error", 4, {
                SwitchCase: 1,
            }],

            "@stylistic/indent-binary-ops": ["error", 4],
            "@stylistic/lines-between-class-members": "off",
            "@stylistic/member-delimiter-style": ["error", {}],
            "@stylistic/multiline-ternary": ["error", "never"],

            "@stylistic/no-extra-parens": ["error", "all", {
                nestedBinaryExpressions: false,
            }],

            "@stylistic/no-mixed-operators": ["error", {}],

            "@stylistic/no-multiple-empty-lines": ["error", {
                max: 2,
                maxEOF: 0,
                maxBOF: 0,
            }],

            "@stylistic/no-trailing-spaces": ["error", {
                skipBlankLines: true,
            }],

            "@stylistic/object-curly-spacing": ["error", "always", {
                arraysInObjects: true,
                objectsInObjects: true,
            }],

            "@stylistic/quote-props": ["error", "as-needed"],
            "@stylistic/quotes": ["error", "double", {}],
            "@stylistic/semi": ["error", "always"],

            "@stylistic/semi-spacing": ["error", {
                before: false,
                after: false,
            }],

            "@stylistic/spaced-comment": ["error", "always", {}],
            "@stylistic/wrap-iife": ["error", "outside", {}],
            "@stylistic/yield-star-spacing": ["error", "after"],

            // switch on

            "@stylistic/array-bracket-newline": ["error", {
                minItems: 4,
                multiline: true,
            }],

            "@stylistic/array-element-newline": ["error", "consistent"],
            "@stylistic/function-call-argument-newline": ["error", "consistent"],
            "@stylistic/function-call-spacing": "error",
            "@stylistic/function-paren-newline": ["error", "multiline-arguments"],

            "@stylistic/generator-star-spacing": ["error", {
                before: false,
                after: true,
            }],

            "@stylistic/implicit-arrow-linebreak": "error",
            "@stylistic/jsx-quotes": ["error", "prefer-double"],
            "@stylistic/linebreak-style": "error",

            "@stylistic/newline-per-chained-call": ["error", {
                ignoreChainWithDepth: 3,
            }],

            "@stylistic/no-extra-semi": "error",
            "@stylistic/nonblock-statement-body-position": "error",

            "@stylistic/object-curly-newline": ["error", {
                "ObjectExpression": { "multiline": true, "minProperties": 4 },
                "ObjectPattern": { "multiline": true, "minProperties": 4 },
                "ImportDeclaration": { "multiline": true, "minProperties": 4 },
                "ExportDeclaration": { "multiline": true, "minProperties": 4 }
            }],

            "@stylistic/object-property-newline": ["error", {
                allowAllPropertiesOnSameLine: true,
            }],

            "@stylistic/semi-style": "error",
            "@stylistic/switch-colon-spacing": "error",
            "@stylistic/wrap-regex": "error",
        }
    },
    importPlugin.flatConfigs.recommended,
    importPlugin.flatConfigs.typescript,
    {
        settings: {
            "import/resolver": {
                "typescript": true,
                "node": true,
            }
        },
        rules: {
            // override

            // switch on

            "import/no-empty-named-blocks": "error",

            "import/no-extraneous-dependencies": ["error", {
                devDependencies: false,
            }],

            "import/no-mutable-exports": "error",

            "import/no-unused-modules": ["warn", {
                unusedExports: true,
                src: [
                    "src/**/*",
                ],
            }],

            "import/no-absolute-path": "error",
            "import/no-cycle": "error",
            "import/no-self-import": "error",
            "import/consistent-type-specifier-style": ["error", "prefer-top-level"],
            "import/first": "error",
            "import/newline-after-import": "error",
            "import/no-named-default": "error",
            "import/no-namespace": "warn",

            "import/order": ["error", {
                "newlines-between": "always-and-inside-groups",
                alphabetize: {
                    order: "asc",
                },
                warnOnUnassignedImports: true,
            }],
        }
    },
    { ignores: ["eslint.config.mjs", "dist/*"], },
);

您也可以自行參考ESLint的官方文件typescript-eslint的官方文件ESLint Stylistic的官方文件eslint-plugin-import的官方文件來設定rules欄位。

另外,由於eslint-plugin-import有Bug存在,所以還需要新增.eslintrc檔案到這個專案下,空檔案即可。

設定好ESLint後,使用VS Code來開啟這個TypeScript專案,就可以比較安心地撰寫TypeScript程式碼了!

加入 Git

在專案根目錄中執行以下指令來產生出一個Git專案必定會有的.git目錄:

git init

接著要在專案根目錄中建立出.gitignore檔案。考慮到Node.js專案常見的IDE有VS Code和WebStorm這兩個,且考慮到也有一些堅持使用Vim來完成一切文字編輯相關事務的人,筆者的.gitignore檔案內容如下:

### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf

# AWS User-specific
.idea/**/aws.xml

# Generated files
.idea/**/contentModel.xml

# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml

# Gradle
.idea/**/gradle.xml
.idea/**/libraries

# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn.  Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr

# CMake
cmake-build-*/

# Mongo Explorer plugin
.idea/**/mongoSettings.xml

# File-based project format
*.iws

# IntelliJ
out/

# mpeltonen/sbt-idea plugin
.idea_modules/

# JIRA plugin
atlassian-ide-plugin.xml

# Cursive Clojure plugin
.idea/replstate.xml

# SonarLint plugin
.idea/sonarlint/

# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties

# Editor-based Rest Client
.idea/httpRequests

# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

### Intellij+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.

.idea/*

!.idea/codeStyles
!.idea/runConfigurations

### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/

# Dependency directories
node_modules/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# vuepress v2.x temp and cache directory
.temp

# Docusaurus cache and generated files
.docusaurus

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

### Node Patch ###
# Serverless Webpack directories
.webpack/

# Optional stylelint cache

# SvelteKit build / generate output
.svelte-kit

### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg  # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]

# Session
Session.vim
Sessionx.vim

# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~

### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets

# Local History for Visual Studio Code
.history/

# Built Visual Studio Code Extensions
*.vsix

### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide

以上設定參考自gitignore.io,異動部份已經標注。

再來要替專案加上Git Hooks。有關於Git Hooks的說明可以參考以下這篇文章:

在專案根目錄中新增.githooks目錄,並在該目錄下新增擁有可執行權限的pre-commit檔案,內容如下:

#!/bin/bash

npm run lint

接著修改package.json中的scripts欄位,如下:

{
    ...

    "scripts": {
        ...

        "prepare": "git config core.hooksPath .githooks || exit 0"
    }

    ...
}

prepare腳本會在npm ci或是npm i的時候被執行。當我們clone一個Node.js專案後,通常首先要做的事情就是npm ci或是npm i,所以利用prepare腳本來設定Git Hooks的目錄。

加入 VS Code 設定檔

在專案根目錄中新增.vscode目錄,這個目錄用來存放給VS Code看的設定檔。

.vscode目錄中新增tasks.json檔案,這個檔案可以用來為VS Code添加任務(Task)。內容如下:

{
    "version": "2.0.0",
    "tasks": [
        {
            "type": "npm",
            "script": "build",
            "label": "Build",
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

tasks欄位底下可以設定多個任務。

每個任務都有type欄位,用來設定這個任務的類型,npm表示要執行package.jsonscripts欄位所設定的腳本。script欄位用來設定要執行的腳本名稱。label欄位用來設定這個任務的名稱。group用來設定這個任務屬於建構(build)還是測試(test)類型的任務,group.isDefault用來設定這個任務是否為group.kind這個類型的預設任務。

以上設定,會將npm run build設為這個Node.js專案在VS Code中時的預設建構任務。在VS Code中,按下Ctrl + Shift + b,就可以執行預設的建構任務,也就是說,會去執行到npm run build

在VS Code的命令選擇區中,可以執行Run Task命令來選取一個任務來執行。

函式庫專案

如果我們現在要建立的TypeScript專案是要給其它專案引入來用的話,就屬於函式庫專案。

除非您很清楚您接下來要實作的函式庫都不會用到Node.js的任何模組,不然首先要執行以下指令將Node.js的定義套件加入dependancies中。

pnpm i @types/node

然後要修改package.json,如下:

{
    ...

    "exports": "./lib/lib.js",
    "types": "./lib/lib.d.ts",
    "files": ["lib"],
    ...
    "scripts": {
        "clean": "rimraf lib",
        ...
        "build:watch": "npm run build -- -w",
        ...
        "prepack": "npm run build"
    }

    ...
}

對於函式庫專案,筆者會將編譯出來的JavaScript檔案改放在lib目錄下,這樣在其它Node.js專案引用這個專案中特定JS檔案的時候就會使用foo/lib/bar這樣的路徑,而不是foo/dist/bar,後者看起來怪怪。

以上設定中,types欄位可以用來指定預設的定義檔按路徑,這個檔案等等會說明如何產生;files欄位可以填寫打包(npm pack)這個Node.js專案時要包含進來檔案或是目錄。

build:watch腳本可以讓tsc持續偵測檔案的變化不斷地去進行編譯,方便在撰寫程式碼的過程中即時找出有沒有什麼會編譯錯誤的地方。

prepack腳本會在執行npm pack或是npm publish指令時被自動執行,除了可以在發佈程式之前檢查是否能通過編譯外,還能確保lib目錄下的JavaScript檔案是跟TypeScript原始碼同步的。

再來是編輯.gitignore檔案,加入/lib。如下:

/lib

...

還要編輯eslint.config.mjs檔案,將ignoresdist/*改為lib/*

再來是編輯tsconfig.json檔案,如下:

{
    "compilerOptions": {
        ...

        "outDir": "./lib",
        "declaration": true,

        ...
    },

    ...
}

啟用declaration可以在編譯TypeScript的同時產生出.d.ts定義檔。

接著修改eslint.config.mjs檔案的import/no-unused-modules規則,如下:

{
    ...

    "rules": {
        ...

        "import/no-unused-modules": ["warn", {
            unusedExports: true,
            src: [
                "src/**/*"
            ],
            ignoreExports: [
                "src/lib.ts",
            ],
        }],

        ...
    }
}

以上設定,可以讓我們在src/lib.ts檔案正常使用export關鍵字。

之後就是建立src/lib.ts檔案,開始製作函式庫啦!

一般來說,函式庫專案輸出的./lib目錄不需要進Git倉庫,所以有在.gitignore檔案中加入/lib來忽略。但如果您並未打算將函式庫專案發佈到npm Registry,而是想要直接透過遠端的Git倉庫來安裝使用的話,./lib目錄就應該要進Git倉庫中。在這樣的情況下,.gitignore檔案中不要忽略/lib,且最好再加上.githooks/pre-push檔案來保證開發端推到遠端Git倉庫上的./lib目錄是和TypeScript原始碼有同步的,該檔案的內容如下:

#!/bin/bash

npm run build
if ! git diff --exit-code -- lib; then
    git add lib && git commit --no-verify -m "generate dist files"
fi

應用程式專案

如果我們現在要建立的TypeScript專案是要直接使用nodepm2等指令來執行的話,就屬於應用程式專案。

首先要執行以下指令將Node.js的定義套件加入devDependancies中。

pnpm i -D @types/node

再來還要加入nodemon作為開發階段用來執行專案的工具,它可以在檔案發生變化的時候自動重新執行JavaScript/TypeScript程式。為了使nodemon能執行TypeScript程式,還需要安裝ts-node。至於在生產階段執行專案的時候,為了方便偵錯,我們要使用SourceMap來將JavaScript程式對應回TypeScript程式,原生的node並不支援SourceMap,還需要安裝source-map-support。所以要執行以下指令來安裝這三個套件:

pnpm i -D nodemon ts-node
pnpm i source-map-support

然後要刪除package.json內的exports欄位,接著做如下的修改:

{
    ...

    "bin": "./dist/app.js",
    ...
    "scripts": {
        ...
        "start": "NODE_ENV=production node -r source-map-support/register dist/app.js",
        "start:dev": "NODE_ENV=development nodemon --exec \"node --loader ts-node/esm src/app.ts\" -e 'ts,json'",
        ...
    }

    ...
}

start腳本是正式環境時透過執行npm start指令來使用,會直接執行JS檔案。start:dev腳本是在開發環境使用,還可以結合VS Code的偵錯工具。而NODE_ENV是Node.js程式中常用來判斷執行環境的環境變數。

這裡要注意的是,如果您的應用程式專案有要在Windows環境上運行的話,NODE_ENV環境變數用以上的方式來設定是會出問題的。解決方法是使用以下指令來安裝run-with-node-env套件:

pnpm i run-with-node-env

然後要再次修改package.json,如下:

{
    ...

    "scripts": {
        ...
        "start": "run-with-node-env production node -r source-map-support/register dist/app.js",
        "start:dev": "run-with-node-env development nodemon --exec \"node --loader ts-node/esm src/app.ts\" -e 'ts,json'",
        ...
    }

    ...
}

tsconfig.json檔案要加上如下的設定,使TypeScript編譯器能產生SourceMap檔案,以及讓ts-node能正常動作。

{
    "compilerOptions": {
        ...

        "sourceMap": true,

        ...
    },

    ...

    "ts-node": {
        "files": true
    }
}

再來要在.vscode目錄下新增launch.json檔案,內容如下:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node-terminal",
            "name": "start:dev",
            "request": "launch",
            "command": "npm run start:dev"
        }
    ]
}

configurations欄位底下可以設定多筆偵錯組態。

每個組態都有type欄位,用來設定這個組態的類型,node-terminal表示要使用VS Code的JavaScript Debug Terminal。name欄位用來設定這個組態的名稱,會顯示在VS Code偵錯工具的組態選單中。request欄位用來設定這個組態是要啟動(launch)新的行程(process),還是要將已有的行程給附加(attach)進來。command用來設定這個組態要執行的指令。

有了這個組態設定,在VS Code的偵錯工具中,就可以選擇start:dev來執行與偵錯。

之後就是建立src/app.ts檔案,開始製作應用程式啦!

加入 Jest 測試框架至 TypeScript 專案

開發JavaScript/TypeScript程式,測試是非常重要的一環。筆者選用的測試工具是Facebook開發的Jest測試框架,比起同樣很多人使用的Mocha,Jest在使用上更為簡易。

執行以下指令來安裝所需的相關套件:

pnpm i -D jest ts-jest @types/jest

然後要修改package.json,如下:

{
    ...

    "scripts": {
        "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
        "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
        "test:inspect-brk": "node --experimental-vm-modules --inspect-brk=0.0.0.0:9230 node_modules/jest/bin/jest.js --testTimeout 0 --runInBand",
        ...
        "lint": "eslint src tests",
        ...
        "prepublishOnly": "npm run lint && npm run test"
    }

    ...
}

test腳本會執行jest進行測試。test:coverage腳本可以匯出測試覆蓋率。test:inspect-brk腳本可以在執行測試的時候先等待測試行程附加至偵錯工具後才開始動作,之所以監聽0.0.0.0而不是127.0.0.1是為了讓測試即便在Docker容器中運行,也還是可以在Host環境來偵錯。

prepublishOnly腳本會在執行npm publish指令時被自動執行(prepublishOnly腳本執行完後是prepack腳本),可以在發佈程式之前檢查是否能通過程式碼校驗和測試。

然後要修改tsconfig.build.json檔案,排除掉檔名為.spec.ts.test.ts結尾的檔案。這樣的話要寫單元測試也可以在src目錄中的某個.ts檔案旁邊建立出一個.spec.ts.test.ts檔案來專門為該.ts檔案寫單元測試。Jest預設會執行檔名為.spec.ts.test.ts結尾的檔案。

{
    ...

    "exclude": [
        "**/*.spec.ts",
        "**/*.test.ts"
    ]
}

要讓Jest能執行TypeScript程式,還要加上jest.config.json檔案,內容如下:

{
    "extensionsToTreatAsEsm": [
        ".ts"
    ],
    "transform": {
        "^.+\\.[jt]sx?$": [
            "ts-jest",
            {
                "useESM": true
            }
        ]
    },
    "moduleNameMapper": {
        "^(\\.\\.?\\/.+)\\.jsx?$": "$1"
    }
}

以上的moduleNameMapper欄位是要將引用到我們自己專案下的js檔案給去掉副檔名,不然Jest會找不到該檔案。

再來要修改.vscode/tasks.json檔案,添加新任務進去。如下:

{
    ...

    "tasks": [
        ...

        {
            "type": "npm",
            "script": "test",
            "label": "Test",
            "group": {
                "kind": "test",
                "isDefault": true
            }
        }
    ]
}

以上設定,會將npm run test設為這個Node.js專案在VS Code中時的預設測試任務。在VS Code的命令選擇區中,可以執行Run Test Task命令來執行預設的測試任務,當然,您也可以替這個命令設定快速鍵(設成Alt + t還蠻好用的,而Run Build Task則可以改設為Alt + b)。

再來要修改.vscode/launch.json檔案,添加新組態進去。如果您是函式庫專案,並不會有.vscode/launch.json檔案,請自行新增,相關說明可以參考建立應用程式專案的小節。將.vscode/launch.json檔案修改如下:

{
    "version": "0.2.0",
    "configurations": [
        ...

        {
            "type": "node-terminal",
            "name": "test",
            "request": "launch",
            "command": "npm test"
        },
        {
            "type": "node",
            "name": "test:inspect-brk",
            "request": "attach",
            "port": 9230
        }
    ]
}

執行test:inspect-brk組態之前,要先執行npm run test:inspect-brk指令。

接著修改eslint.config.mjs檔案的import/no-extraneous-dependencies規則,如下:

{
    ...

    "rules": {
        ...

        "import/no-extraneous-dependencies": ["error", {
            devDependencies: [
                "tests/**/*",
                "**/*.test.ts",
                "**/*.spec.ts",
            ],
        }],

        ...
    }
}

在撰寫測試的時候,我們可能會需要引用到只有在測試程式中才會使用到的套件,此時應將這類新套件使用pnpm i -D <套件名稱>指令來安裝,將套件安裝至package.json檔案的devDependencies欄位中。如此一來當這個Node.js專案被其它專案引用的時候,就不需要再去相依只有在這個Node.js專案進行測試時才會用到的套件。

eslint.config.mjs檔案中,import/no-extraneous-dependencies規則能設定除了指定的路徑外,其餘的地方都不能引用屬於devDependencies的套件,以免目前這個程式專案被其它專案引用的時候發生缺少套件的問題。而以上設定,可以允許寫在package.json檔案的devDependencies欄位中的套件能在我們的測試程式中引用。

最後,我們可以新增tests/template.test.ts檔案來測試看看Jest框架有沒有加入成功。檔案內容如下:

describe("Hello Jest", () => {
    it("should success", () => {
        expect(true).toBe(true);
    });
});

然後在專案根目錄下執行npm test指令試試吧!

加入 Web 框架至 TypeScript 專案

不同的Web框架有不同的用法,可以參考下列的延伸文章: