TypeScript 是 JS 类型的超集,并支持了泛型、类型、命名空间、枚举等特性,弥补了 JS 在大型应用开发中的不足,那么当 TypeScript 与 React 一起使用会碰撞出怎样的火花呢?接下来让我们一起探索在 TypeScript2.8+ 版本中编写 React 组件的姿势。
前言
近几年前端对 TypeScript 的呼声越来越高,Ryan Dahl 的新项目 Deno 中 TypeScript 也变成了一个必须要会的技能,知乎上经常见到像『自从用了 TypeScript 之后,再也不想用 JavaScript 了』、『只要你用过 ES6,TypeScript 可以几乎无门槛接入』、『TypeScript可以在任何场景代替 JS』这些类似的回答,抱着听别人说不如自己用的心态逐渐尝试在团队内的一些底层支持的项目中使用 TypeScript。
使用 TypeScript 的编程体验真的是爽到爆,当在键盘上敲下 .
时,后面这一大串的提示真的是满屏幕的幸福,代码质量和效率提升十分明显,再也不想用 JavaScript 了。
在单独使用 TypeScript 时没有太大的坑,但是和一些框架结合使用的话坑还是比较多的,例如使用 React、Vue 这些框架的时候与 TypeScript 的结合会成为一大障碍,需要去查看框架提供的 .d.ts 的声明文件中一些复杂类型的定义。本文主要聊一聊与 React 结合时经常遇到的一些类型定义问题,阅读本文建议对 TypeScript 有一定了解,因为文中对于一些 TypeScript 的基础的知识不会有太过于详细的讲解。
编写第一个 TSX 组件
import React from 'react'
import ReactDOM from 'react-dom'
const App = () => {
return (
<div>Hello world</div>
)
}
ReactDOM.render(<App />, document.getElementById('root')
上述代码运行时会出现以下错误
Cannot find module 'react'
Cannot find module 'react-dom'
错误原因是由于 React
和 React-dom
并不是使用 TS 进行开发的,所以 TS 不知道 React
、 React-dom
的类型,以及该模块导出了什么,此时需要引入 .d.ts 的声明文件,比较幸运的是在社区中已经发布了这些常用模块的声明文件 DefinitelyTyped 。
安装 React
、 React-dom
类型定义文件
使用 yarn 安装
yarn add @types/react
yarn add @types/react-dom
使用 npm 安装
npm i @types/react -s
npm i @types/react-dom -s
有状态组件开发
我们定义一个 App 有状态组件,props
、 state
如下。
Props
props | 类型 | 是否必传 |
---|---|---|
color |
string | 是 |
size |
string | 否 |
State
props | 类型 |
---|---|
count |
string |
使用 TSX 我们可以这样写
import * as React from 'react';
interface IProps {
color: string,
size?: string,
}
interface IState {
count: number,
}
class App extends React.Component<IProps, IState> {
public state = {
count: 1,
}
public render () {
return (
<div>Hello world</div>
)
}
}
TypeScript 可以对 JSX 进行解析,充分利用其本身的静态检查功能,使用泛型进行 Props
、 State
的类型定义。定义后在使用 this.state
和 this.props
时可以在编辑器中获得更好的智能提示,并且会对类型进行检查。
那么 Component 的泛型是如何实现的呢,我们可以参考下 React 的类型定义文件 node_modules/@types/react/index.d.ts
。
在这里可以看到 Component
这个泛型类, P
代表 Props
的类型, S
代表 State
的类型。
class Component<P, S> {
readonly props: Readonly<{ children?: ReactNode }> & Readonly<P>;
state: Readonly<S>;
}
Component 泛型类在接收到 P
, S
这两个泛型变量后,将只读属性 props
的类型声明为交叉类型 Readonly<{ children?: ReactNode }> & Readonly<P>;
使其支持 children
以及我们声明的 color
、 size
。
通过泛型的类型别名 Readonly
将 props
的所有属性都设置为只读属性。
Readonly 实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
由于 props
属性被设置为只读,所以通过 this.props.size = 'sm'
进行更新时候 TS 检查器会进行错误提示,Error:(23, 16) TS2540: Cannot assign to 'size' because it is a constant or a read-only property
防止直接更新 state
React的 state
更新需要使用 setState
方法,但是我们经常误操作,直接对 state
的属性进行更新。
this.state.count = 2
开发中有时候会不小心就会写出上面这种代码,执行后 state
并没有更新,我们此时会特别抓狂,心里想着我哪里又错了?
现在有了 TypeScript 我们可以通过将 state
,以及 state
下面的属性都设置为只读类型,从而防止直接更新 state
。
import * as React from 'react'
interface IProps {
color: string,
size?: string,
}
interface IState {
count: number,
}
class App extends React.PureComponent<IProps, IState> {
public readonly state: Readonly<IState> = {
count: 1,
}
public render () {
return (
<div>Hello world</div>
)
}
public componentDidMount () {
this.state.count = 2
}
}
export default App
此时我们直接修改 state
值的时候 TypeScript 会立刻告诉我们错误,Error:(23, 16) TS2540: Cannot assign to 'count' because it is a constant or a read-only property.
。
无状态组件开发
Props
props | 类型 | 是否必传 |
---|---|---|
children |
ReactNode | 否 |
onClick |
function | 是 |
SFC
类型
在 React 的声明文件中 已经定义了一个 SFC
类型,使用这个类型可以避免我们重复定义 children
、 propTypes
、 contextTypes
、 defaultProps
、displayName
的类型。
实现源码 node_modules/@types/react/index.d.ts
。
type SFC<P = {}> = StatelessComponent<P>;
interface StatelessComponent<P = {}> {
(props: P & { children?: ReactNode }, context?: any): ReactElement<any> | null;
propTypes?: ValidationMap<P>;
contextTypes?: ValidationMap<any>;
defaultProps?: Partial<P>;
displayName?: string;
}
使用 SFC
进行无状态组件开发。
import { SFC } from 'react'
import { MouseEvent } from 'react'
import * as React from 'react'
interface IProps {
onClick (event: MouseEvent<HTMLDivElement>): void,
}
const Button: SFC<IProps> = ({onClick, children}) => {
return (
<div onClick={onClick}>
{ children }
</div>
)
}
export default Button
事件处理
我们在进行事件注册时经常会在事件处理函数中使用 event
事件对象,例如当使用鼠标事件时我们通过 clientX
、clientY
去获取指针的坐标。
大家可以想到直接把 event
设置为 any
类型,但是这样就失去了我们对代码进行静态检查的意义。
function handleEvent (event: any) {
console.log(event.clientY)
}
试想下当我们注册一个 Touch
事件,然后错误的通过事件处理函数中的 event
对象去获取其 clientY
属性的值,在这里我们已经将 event
设置为 any
类型,导致 TypeScript 在编译时并不会提示我们错误, 当我们通过 event.clientY
访问时就有问题了,因为 Touch
事件的 event
对象并没有 clientY
这个属性。
通过 interface
对 event
对象进行类型声明编写的话又十分浪费时间,幸运的是 React 的声明文件提供了 Event
对象的类型声明。
Event 事件对象类型
常用 Event 事件对象类型:
ClipboardEvent<T = Element>
剪贴板事件对象DragEvent<T = Element>
拖拽事件对象ChangeEvent<T = Element>
Change 事件对象KeyboardEvent<T = Element>
键盘事件对象MouseEvent<T = Element>
鼠标事件对象TouchEvent<T = Element>
触摸事件对象WheelEvent<T = Element>
滚轮事件对象AnimationEvent<T = Element>
动画事件对象TransitionEvent<T = Element>
过渡事件对象
实例:
import { MouseEvent } from 'react'
interface IProps {
onClick (event: MouseEvent<HTMLDivElement>): void,
}
MouseEvent
类型实现源码 node_modules/@types/react/index.d.ts
。
interface SyntheticEvent<T = Element> {
bubbles: boolean;
/**
* A reference to the element on which the event listener is registered.
*/
currentTarget: EventTarget & T;
cancelable: boolean;
defaultPrevented: boolean;
eventPhase: number;
isTrusted: boolean;
nativeEvent: Event;
preventDefault(): void;
isDefaultPrevented(): boolean;
stopPropagation(): void;
isPropagationStopped(): boolean;
persist(): void;
// If you thought this should be `EventTarget & T`, see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239
/**
* A reference to the element from which the event was originally dispatched.
* This might be a child element to the element on which the event listener is registered.
*
* @see currentTarget
*/
target: EventTarget;
timeStamp: number;
type: string;
}
interface MouseEvent<T = Element> extends SyntheticEvent<T> {
altKey: boolean;
button: number;
buttons: number;
clientX: number;
clientY: number;
ctrlKey: boolean;
/**
* See [DOM Level 3 Events spec](https://www.w3.org/TR/uievents-key/#keys-modifier). for a list of valid (case-sensitive) arguments to this method.
*/
getModifierState(key: string): boolean;
metaKey: boolean;
nativeEvent: NativeMouseEvent;
pageX: number;
pageY: number;
relatedTarget: EventTarget;
screenX: number;
screenY: number;
shiftKey: boolean;
}
EventTarget
类型实现源码 node_modules/typescript/lib/lib.dom.d.ts
。
interface EventTarget {
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
dispatchEvent(evt: Event): boolean;
removeEventListener(type: string, listener?: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean): void;
}
通过源码我们可以看到 MouseEvent<T = Element>
继承 SyntheticEvent<T>
,并且通过 T
接收一个 DOM
元素的类型,currentTarget
的类型由 EventTarget & T
组成交叉类型。
事件处理函数类型
当我们定义事件处理函数时有没有更方便定义其函数类型的方式呢?答案是使用 React 声明文件所提供的 EventHandler
类型别名,通过不同事件的 EventHandler
的类型别名来定义事件处理函数的类型。
EventHandler
类型实现源码 node_modules/@types/react/index.d.ts
。
type EventHandler<E extends SyntheticEvent<any>> = { bivarianceHack(event: E): void }["bivarianceHack"];
type ReactEventHandler<T = Element> = EventHandler<SyntheticEvent<T>>;
type ClipboardEventHandler<T = Element> = EventHandler<ClipboardEvent<T>>;
type DragEventHandler<T = Element> = EventHandler<DragEvent<T>>;
type FocusEventHandler<T = Element> = EventHandler<FocusEvent<T>>;
type FormEventHandler<T = Element> = EventHandler<FormEvent<T>>;
type ChangeEventHandler<T = Element> = EventHandler<ChangeEvent<T>>;
type KeyboardEventHandler<T = Element> = EventHandler<KeyboardEvent<T>>;
type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;
type TouchEventHandler<T = Element> = EventHandler<TouchEvent<T>>;
type PointerEventHandler<T = Element> = EventHandler<PointerEvent<T>>;
type UIEventHandler<T = Element> = EventHandler<UIEvent<T>>;
type WheelEventHandler<T = Element> = EventHandler<WheelEvent<T>>;
type AnimationEventHandler<T = Element> = EventHandler<AnimationEvent<T>>;
type TransitionEventHandler<T = Element> = EventHandler<TransitionEvent<T>>;
EventHandler
接收 E
,其代表事件处理函数中 event
对象的类型。
bivarianceHack
为事件处理函数的类型定义,函数接收一个 event
对象,并且其类型为接收到的泛型变量 E
的类型, 返回值为 void
。
实例:
interface IProps {
onClick : MouseEventHandler<HTMLDivElement>,
}
Promise 类型
在做异步操作时我们经常使用 async
函数,函数调用时会 return
一个 Promise
对象,可以使用 then
方法添加回调函数。
Promise<T>
是一个泛型类型,T
泛型变量用于确定使用 then
方法时接收的第一个回调函数(onfulfilled)的参数类型。
实例:
interface IResponse<T> {
message: string,
result: T,
success: boolean,
}
async function getResult (): Promise<IResponse<number[]>> {
return {
message: '获取成功',
result: [1, 2, 3],
success: true,
}
}
getResult()
.then(result => {
console.log(result.result)
})
我们首先声明 IResponse
的泛型接口用于定义 response
的类型,通过 T
泛型变量来确定 result
的类型。
然后声明了一个 异步函数 getResult
并且将函数返回值的类型定义为 Promise<IResponse<number[]>>
。
最后调用 getResult
方法会返回一个 promise
类型,通过 .then
调用,此时 .then
方法接收的第一个回调函数的参数 result
的类型为,{ message: string, result: number[], success: boolean}
。
Promise<T>
实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
interface Promise<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;
/**
* Attaches a callback for only the rejection of the Promise.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of the callback.
*/
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}
工具泛型使用技巧
typeof
一般我们都是先定义类型,再去赋值使用,但是使用 typeof
我们可以把使用顺序倒过来。
const options = {
a: 1
}
type Options = typeof options
使用字符串字面量类型限制值为固定的字符串参数
限制 props.color
的值只可以是字符串 red
、blue
、yellow
。
interface IProps {
color: 'red' | 'blue' | 'yellow',
}
使用数字字面量类型限制值为固定的数值参数
限制 props.index
的值只可以是数字 0
、 1
、 2
。
interface IProps {
index: 0 | 1 | 2,
}
使用 Partial
将所有的 props
属性都变为可选值
Partial
实现源码 node_modules/typescript/lib/lib.dom.d.ts
type Partial<T> = { [P in keyof T]?: T[P] };
上面代码的意思是 keyof T
拿到 T
所有属性名, 然后 in
进行遍历, 将值赋给 P
, 最后 T[P]
取得相应属性的值,中间的 ?
用来进行设置为可选值。
如果 props
所有的属性值都是可选的我们可以借助 Partial
这样实现。
import { MouseEvent } from 'react'
import * as React from 'react'
interface IProps {
color: 'red' | 'blue' | 'yellow',
onClick (event: MouseEvent<HTMLDivElement>): void,
}
const Button: SFC<Partial<IProps>> = ({onClick, children, color}) => {
return (
<div onClick={onClick}>
{ children }
</div>
)
使用 Required
将所有 props
属性都设为必填项
Required
实现源码 node_modules/typescript/lib/lib.dom.d.ts
。
type Required<T> = { [P in keyof T]-?: T[P] };
看到这里,小伙伴们可能有些疑惑, -?
是做什么的,其实 -?
的功能就是把 ?
去掉变成可选项,对应的还有 +?
,作用与 -?
相反,是把属性变为可选项。
条件类型
TypeScript2.8引入了条件类型,条件类型可以根据其他类型的特性做出类型的判断。
T extends U ? X : Y
原先
interface Id { id: number, /* other fields */ }
interface Name { name: string, /* other fields */ }
declare function createLabel(id: number): Id;
declare function createLabel(name: string): Name;
declare function createLabel(name: string | number): Id | Name;
使用条件类型
type IdOrName<T extends number | string> = T extends number ? Id : Name;
declare function createLabel<T extends number | string>(idOrName: T): T extends number ? Id : Name;
Exclude<T,U>
从 T
中排除那些可以赋值给 U
的类型。
Exclude
实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
type Exclude<T, U> = T extends U ? never : T;
实例:
type T = Exclude<1|2|3|4|5, 3|4> // T = 1|2|5
此时 T
类型的值只可以为 1
、2
、 5
,当使用其他值是 TS 会进行错误提示。
Error:(8, 5) TS2322: Type '3' is not assignable to type '1 | 2 | 5'.
Extract<T,U>
从 T
中提取那些可以赋值给 U
的类型。
Extract实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
type Extract<T, U> = T extends U ? T : never;
实例:
type T = Exclude<1|2|3|4|5, 3|4> // T = 3|4
此时T类型的值只可以为 3
、4
,当使用其他值时 TS 会进行错误提示:
Error:(8, 5) TS2322: Type '5' is not assignable to type '3 | 4'.
Pick<T,K>
从 T
中取出一系列 K
的属性。
Pick
实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
实例:
假如我们现在有一个类型其拥有 name
、 age
、 sex
属性,当我们想生成一个新的类型只支持 name
、age
时可以像下面这样:
interface Person {
name: string,
age: number,
sex: string,
}
let person: Pick<Person, 'name' | 'age'> = {
name: '小王',
age: 21,
}
Record<K,T>
将 K
中所有的属性的值转化为 T
类型。
Record
实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
type Record<K extends keyof any, T> = {
[P in K]: T;
};
实例:
将 name
、 age
属性全部设为 string
类型。
let person: Record<'name' | 'age', string> = {
name: '小王',
age: '12',
}
Omit<T,K>(没有内置)
从对象 T
中排除 key
是 K
的属性。
由于 TS 中没有内置,所以需要我们使用 Pick
和 Exclude
进行实现。
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
实例:
排除 name
属性。
interface Person {
name: string,
age: number,
sex: string,
}
let person: Omit<Person, 'name'> = {
age: 1,
sex: '男'
}
NonNullable <T>
排除 T
为 null
、undefined
。
NonNullable
实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
type NonNullable<T> = T extends null | undefined ? never : T;
实例:
type T = NonNullable<string | string[] | null | undefined>; // string | string[]
ReturnType<T>
获取函数 T
返回值的类型。。
ReturnType
实现源码 node_modules/typescript/lib/lib.es5.d.ts
。
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;
infer R
相当于声明一个变量,接收传入函数的返回值类型。
实例:
type T1 = ReturnType<() => string>; // string
type T2 = ReturnType<(s: string) => void>; // void
大牛写过一个TypeScript在node项目中的实践。
在里边有解释了为什么要使用TS
,以及在Node
中的一个项目结构是怎样的。
但是那仅仅是一个纯接口项目,碰巧赶上近期的另一个项目重构也由我来主持,经过上次的实践以后,尝到了TS
所带来的甜头,毫不犹豫的选择用TS
+React
来重构这个项目。
这次的重构不仅包括Node
的重构(之前是Express
的项目),同时还包括前端的重构(之前是由jQuery
驱动的多页应用)。
项目结构
因为目前项目是没有做前后分离的打算的(一个内部工具平台类的项目),所以大致结构就是基于上次Node
项目的结构,在其之上添加了一些FrontEnd
的目录结构:
.
├── README.md
├── copy-static-assets.ts
├── nodemon.json
├── package.json
+ ├── client-dist
+ │ ├── bundle.js
+ │ ├── bundle.js.map
+ │ ├── logo.png
+ │ └── vendors.dll.js
├── dist
├── src
│ ├── config
│ ├── controllers
│ ├── entity
│ ├── models
│ ├── middleware
│ ├── public
│ ├── app.ts
│ ├── server.ts
│ ├── types
+ │ ├── common
│ └── utils
+ ├── client-src
+ │ ├── components
+ │ │ └── Header.tsx
+ │ ├── conf
+ │ │ └── host.ts
+ │ ├── dist
+ │ ├── utils
+ │ ├── index.ejs
+ │ ├── index.tsx
+ │ ├── webpack
+ │ ├── package.json
+ │ └── tsconfig.json
+ ├── views
+ │ └── index.ejs
├── tsconfig.json
└── tslint.json
其中标绿(也可能是一个+
号显示)的文件为本次新增的。
其中client-dist
与views
都是通过webpack
生成的,实际的源码文件都在client-src
下。_就这个结构拆分前后分离其实没有什么成本_
在下边分了大概这样的一些文件夹:
dir/file | desc |
---|---|
index.ejs |
项目的入口html 文件,采用ejs 作为渲染引擎 |
index.tsx |
项目的入口js 文件,后缀使用tsx ,原因有二:<br/>1. 我们会使用ts 进行React 程序的开发 <br/>2. .tsx 文件在vs code上的icon 比较好看 :p |
tsconfig.json |
是用于tsc 编译执行的一些配置文件 |
components |
组件存放的目录 |
config |
各种配置项存放的位置,类似请求接口的host 或者各种状态的map 映射之类的(可以理解为枚举对象们都在这里) |
utils |
一些公共函数存放的位置,各种可复用的代码都应该放在这里 |
dist |
各种静态资源的存放位置,图片之类文件 |
webpack |
里边存放了各种环境的webpack 脚本命令以及dll 的生成 |
前后端复用代码的一个尝试
实际上边还漏掉了一个新增的文件夹,我们在src
目录下新增了一个common
目录,这个目录是存放一些公共的函数和公共的config
,不同于utils
或者config
的是,这里的代码是前后端共享的,所以这里边的函数一定要是完全的不包含任何环境依赖,不包含任何业务逻辑的。
类似的数字千分位,日期格式化,抑或是服务监听的端口号,这些不包含任何逻辑,也对环境没有强依赖的代码,我们都可以放在这里。
这也是没有做前后分离带来的一个小甜头吧,前后可以共享一部分代码。
要实现这样的配置,基于上述项目需要修改如下几处:
- 1
src
下的utils
和config
部分代码迁移到common
文件夹下,主要是用于区分是否可前后通用 - 2 为了将对之前
node
结构方面的影响降至最低,我们需要在common
文件夹下新增一个index.ts
索引文件,并在utils/index.ts
下引用它,这样对于node
方面使用来讲,并不需要关心这个文件是来自utils
还是common
// src/common/utils/comma.ts
export default (num: number): string => String(num).replace(/B(?=(d{3})+$)/g, ',')
// src/common/utils/index.ts
export { default as comma } from './comma'
// src/utils.index.ts
export * from '../common/utils'
// src/app.ts
import { comma } from './utils' // 并不需要关心是来自common还是来自utils
console.log(comma(1234567)) // 1,234,567
- 3 然后是配置
webpack
的alias
属性,用于webpack
能够正确的找到其路径
// client-src/webpack/base.js
module.exports = {
resolve: {
alias: {
'@Common': path.resolve(__dirname, '../../src/common'),
}
}
}
- 4 同时我们还需要配置
tsconfig.json
用于vs code
可以找到对应的目录,不然会在编辑器中提示can't find module XXX
// client-src/tsconfig.json
{
"compilerOptions": {
"paths": {
// 用于引入某个`module`
"@Common/*": [
"../src/common/*"
]
}
}
}
- 5 最后在
client-src/utils/index.ts
写上类似server
端的处理就可以了
// client-src/utils/index.ts
export * from '@Common/utils'
// client-src/index.tsx
import { comma } from './utils'
console.log(comma(1234567)) // 1,234,567
环境的搭建
如果使用vs code
进行开发,而且使用了ESLint
的话,需要修改TS
语法支持的后缀,添加typescriptreact
的一些处理,这样才会自动修复一些ESLint
的规则:
"eslint.validate": [
"javascript",
"javascriptreact",
{
"language": "typescript",
"autoFix": true
},
{
"language": "typescriptreact",
"autoFix": true
}
]
webpack的配置
因为在前端使用了React
,按照目前的主流,webpack
肯定是必不可少的。
并没有选择成熟的cra
(create-react-app)来进行环境搭建,原因有下:
webpack
更新到4以后并没有尝试过,想自己耍一耍- 结合着
TS
以及公司内部的东西,会有一些自定义配置情况的出现,担心二次开发太繁琐
但是其实也没有太多的配置,本次重构选用的UI框架为Google Material的实现:material-ui
而他们采用的是jss 来进行样式的编写,所以也不会涉及到之前惯用的scss
的那一套loader
了。
webpack
分了大概如下几个文件:
file | desc |
---|---|
common.js |
公共的webpack 配置,类似env 之类的选项 |
dll.js |
用于将一些不会修改的第三方库进行提前打包,加快开发时编译效率 |
base.js |
可以理解为是webpack 的基础配置文件,通用的loader 以及plugins 在这里 |
pro.js |
生产环境的特殊配置(代码压缩、资源上传) |
dev.js |
开发环境的特殊配置(source-map ) |
dll
是一个很早之前的套路了,大概需要修改这么几处:
- 创建一个单独的
webpack
文件,用于生成dll
文件 - 在普通的
webpack
文件中进行引用生成的dll
文件
// dll.js
{
entry: {
// 需要提前打包的库
vendors: [
'react',
'react-dom',
'react-router-dom',
'babel-polyfill',
],
},
output: {
filename: 'vendors.dll.js',
path: path.resolve(__dirname, '../../client-dist'),
// 输出时不要少了这个option
library: 'vendors_lib',
},
plugins: [
new webpack.DllPlugin({
context: __dirname,
// 向外抛出的`vendors.dll.js`代码的具体映射,引用`dll`文件的时候通过它来做映射关系的
path: path.join(__dirname, '../dist/vendors-manifest.json'),
name: 'vendors_lib',
})
]
}
// base.js
{
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('../dist/vendors-manifest.json'),
}),
]
}
这样在watch
文件时,打包就会跳过verdors
中存在的那些包了。
有一点要注意的,如果最终需要上传这些静态资源,记得连带着verdors.dll.js
一并上传
在本地开发时,vendors
文件并不会自动注入到html
模版中去,所以我们有用到了另一个插件,add-asset-html-webpack-plugin。
同时在使用中可能还会遇到webpack
无限次数的重新打包,这个需要配置ignore
来解决-.-:
// dev.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin')
{
plugins: [
// 将`ejs`模版文件放到目标文件夹,并注入入口`js`文件
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../index.ejs'),
filename: path.resolve(__dirname, '../../views/index.ejs'),
}),
// 将`vendors`文件注入到`ejs`模版中
new AddAssetHtmlPlugin({
filepath: path.resolve(__dirname, '../../client-dist/vendors.dll.js'),
includeSourcemap: false,
}),
// 忽略`ejs`和`js`的文件变化,避免`webpack`无限重新打包的问题
new webpack.WatchIgnorePlugin([
/.ejs$/,
/.js$/,
]),
]
}
TypeScript相关的配置
TS
的配置分了两块,一个是webpack
的配置,另一个是tsconfig
的配置。
首先是webpack
,针对ts
、tsx
文件我们使用了两个loader
:
{
rules: [
{
test: /.tsx?$/,
use: ['babel-loader', 'ts-loader'],
exclude: /node_modules/,
}
],
resolve: {
// 一定不要忘记配置ts tsx后缀
extensions: ['.tsx', '.ts', '.js'],
}
}
ts-loader
用于将TS
的一些特性转换为JS
兼容的语法,然后执行babel
进行处理react/jsx
相关的代码,最终生成可执行的JS
代码。
然后是tsconfig
的配置,ts-loader
的执行是依托于这里的配置的,大致的配置如下:
{
"compilerOptions": {
"module": "esnext",
"target": "es6",
"allowSyntheticDefaultImports": true,
// import的相对起始路径
"baseUrl": ".",
"sourceMap": true,
// 构建输出目录,但因为使用了`webpack`,所以这个配置并没有什么卵用
"outDir": "../client-dist",
// 开启`JSX`模式,
// `preserve`的配置让`tsc`不会去处理它,而是使用后续的`babel-loader`进行处理
"jsx": "preserve",
"strict": true,
"moduleResolution": "node",
// 开启装饰器的使用
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// `vs code`所需要的,在开发时找到对应的路径,真实的引用是在`webpack`中配置的`alias`
"paths": {
"@Common": [
"../src/common"
],
"@Common/*": [
"../src/common/*"
]
}
},
"exclude": [
"node_modules"
]
}
ESLint的配置
最近这段时间,我们团队基于airbnb
的ESLint
规则进行了一些自定义,创建了自家的eslint-config-blued
同时还存在了react和typescript的两个衍生版本。
关于ESLint
的配置文件.eslintrc
,在本项目中存在两份。一个是根目录的blued-typescript
,另一个是client-src
下的blued-react
+ blued-typescript
。
因为根目录的更多用于node
项目,所以没必要把react
什么的依赖也装进来。
# .eslintrc
extends: blued-typescript
# client-src/.eslintrc
extends:
- blued-react
- blued-typescript
一个需要注意的小细节
因为我们的react
与typescript
实现版本中都用到了parser
。 react
使用的是babel-eslint,typescript
使用的是typescript-eslint-parser。
但是parser
只能有一个,从option
的命名中就可以看出extends
、plugins
、rules
,到了parser
就没有复数了。
所以这两个插件在extends
中的顺序就变得很关键,babel
现在并不能理解TS
的语法,但好像babel
开发者有支持TS
的意愿。
但就目前来说,一定要保证react
在前,typescript
在后,这样parser
才会使用typescript-eslint-parser
来进行覆盖。
node层的修改
除了上边提到的两端公用代码以外,还需要添加一个controller
用于吐页面,因为使用的是routing-controllers
这个库,渲染一个静态页面被封装的非常棒,仅仅需要修改两个页面,一个用于设置render
模版的根目录,另一个用来设置要吐出来的模版名称:
// controller/index.ts
import {
Get,
Controller,
Render,
} from 'routing-controllers'
@Controller('/')
export default class {
@Get('/')
@Render('index') // 指定一个模版的名字
async router() {
// 渲染页面时的一些变量
// 类似之前的 ctx.state = XXX
return {
title: 'First TypeScript React App',
}
}
}
// app.ts
import koaViews from 'koa-views'
// 添加模版所在的目录
// 以及使用的渲染引擎、文件后缀
app.use(koaViews(path.join(__dirname, '../views'), {
options: {
ext: 'ejs',
},
extension: 'ejs',
}))
如果是多个页面,那就创建多个用来Render
的ts
文件就好了
深坑,注意
目前的routing-controller
对于Koa
的支持还不是很好,(原作者对Koa
并不是很了解,导致Render
对应的接口被请求一次以后,后续所有的其他的接口都会直接返回该模版文件,原因是在负责模版渲染的URL
触发时,本应返回数据,但是目前的处理却是添加了一个中间件到Koa
中,所以任何请求都会将该模版文件作为数据来返回)所以@Render
并不能适用于Koa
驱动。
资料:
https://github.com/Microsoft/TypeScript
https://github.com/semlinker/awesome-typescript