前言
在工作时,我们往往使用公司脚手架搭建项目或者直接Copy旧项目配置,很少能有机会自己去搭建一个项目。 私底下想做点东西,用creat-react-app或者vite创建一个项目,就发现很不习惯,因为需要装一大堆的配置。
因此为了更好的摸鱼🐟节省时间,顺便接触下工程化的东西,我试着去建一个标准化的React+Ts+vite项目。
这篇文章就是搭建的过程,采用的版本都是最新的,所以不用太担心出现版本相关的问题。
命令行操作单独列出来了,方便不想配置全部只想CV部分的同学😀,欢迎提供意见和建议!
新建一个文件夹,开始项目搭建
mkdir template-ts-react
cd template-ts-react
包管理工具
项目采用pnpm作为包管理工具,pnpm的依赖包被存在在统一的位置,可以节约磁盘空间。即使是不同版本的依赖,也仅有版本之间不同的文件被存储起来。依赖的安装速度也更快。
npm install -g pnpm
pnpm init
(可选) 将pnpm切换成tabao镜像
# 查看pnpm源
pnpm get registry
# 切换tabao镜像
pnpm config set registry https://registry.npmmirror.com
pnpm install
# 还原
pnpm config set registry https://registry.npmjs.org
这样设置源也有一些问题,自己用用到还算好,但如果碰到日常开发使用公司的私有源就有点麻烦。
使用镜像源管理工具nrm(NPM registry manager),能很好解决这个问题。
pnpm i -g nrm
nrm use taobao
# 添加私有源
nrm add <registry> <url>
nrm del <registry>
# 查看源
nrm ls
🐱切换源之后pnpm全局安装会出现 ERR_PNPM_REGISTRIES_MISMATCH问题
pnpm i -g
pnpm i -g pnpm
vite
Vite原生支持ESM和Typescript,支持自动热更新,构建速度也比webpack快。除了陈年老项目实在想不出不用的理由。。。
pnpm i vite -D
在项目根目录新建一个index.html,为作为入口文件。
# 会开启一个本地服务器,就可以直接打开这个index.html页面
pnpm vite
vite v1版本使用Koa开启本地服务器,v2及以上版本使用connect中间件的形式, live-server也是使用connect中间件的形式
根目录下新建下一个vite.config.ts,安装下@vitejs/plugin-react,开启HMR特性.
pnpm i @vitejs/plugin-react -D
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: './',
plugins: [react()],
})
引入React 和 Typescript
React核心代码位于React包中,虚拟DOM相关代码在React的Reconciler包中,使用虚拟DOM对接不同的宿主环境,调用对应的API,就能实现多平台的渲染能力。
在浏览器和Nodejs宿主环境使用ReactDOM。
pnpm i react react-dom
这里以版本的形式列出了部分React的变化,标记红色粗体部分在后续的操作中会接触到
并发模式
在项目中使用React第一步就是需要先创建一个React root,用于在浏览器DOM中显示元素,React v16和v17版本创建的项目有些是使用ReactDOM.render(<App />, document.getElementById('root'));,v18则全面开启并发模式(ConCurrent),实现并发更新.
差异如下:
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
// src/App.tsx
function App() {
return <div>Hello World</div>
}
export default App
新的JSX转换方式
浏览器本身并不支持JSX,v17之前的旧版本JSX需要借助babel-plugin-transform-react-jsx转化成React.createElement,因此即便没使用React也需要显示引入React.
转换后的结果如下:
import React from 'react';
function App() {
return React.createElement('h1', null, 'Hello world');
}
得益于v17版本引入的全新的JSX转换,即使无需引入React也能够使用JSX.
// 由编译器引入(禁止自己引入!)
import {jsx as _jsx} from 'react/jsx-runtime';
function App() {
return _jsx('h1', { children: 'Hello world' });
}
Typescript
继续开发时就会发现ts不支持JSX的语法,还需外要额外配置下ts.
# 安装下typescript和React的类型声明
pnpm i typescript @types/react @types/react-dom -D
# 生成tsconfig.json
tsc -init
设置下tsconfig.json,找到"jsx": "preserve"这一项,修改值为"jsx": "react-jsx" ,
此时TS的报错问题就解决了.
tsconfig.ts中添加下需要编译处理的文件列表:
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"types/*.d.ts",
"vite.config.ts"
],
对根目录的index.html做下修改,设置根结点,以模块形式引入main.tsx,就建立一个最基本的结构了.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
执行pnpm vite就可以启动本地服务器在浏览器上可以看到渲染出的内容了
pnpm vite
为了使用方便,项目需要添加下别名.因为node本身并不支持ts,需要安装下node的类型定义.
pnpm i @types/node -D
在vite.config.ts中添加alias
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
}
实际使用别名,还需要处理ts问题.
tsconfig.json中添加下关于baseUrl和path的配置.
"baseUrl": "./",
"paths": {"@/*": ["src/*"]}
代码格式
📏 没有规范的代码格式会导致:
- 代码格式风格不统一,要花更多的时间看代码
- 没有统一的规范,会导致在多人协作的代码提交中会有很多的格式修改,造成不必要的消耗。非常影响排查问题
ESLint
ESLint 是一个根据方案识别并报告 ECMAScript/JavaScript 代码问题的工具,其目的是使代码风格更加一致并避免错误。
# 初始化eslint配置
npx eslint --init
就直接使用现成的ESlint标准了,不折腾了
根目录新建一个.eslintignore,里面存放Eslint不检测的文件
node_modules
.eslintrc.cjs
dist
Prettier
使用Prettier格式化代码,但是同时启用ESLint+Prettier,ESlint会先执行,Prettier后执行,导致代码格式反复横跳。
pnpm i prettier eslint-plugin-prettier eslint-config-prettier -D
Eslint和prettier的冲突处理可以参考下prettier/eslint-plugin-prettier的配置方式
根目录下新建.prettierrc.cjs,添加下Prettier配置
module.exports = {
printWidth: 100, //单行长度
tabWidth: 2, //缩进长度
useTabs: false, //使用空格代替tab缩进
semi: false, //句末使用分号
singleQuote: true, //使用单引号
quoteProps: 'as-needed', //仅在必需时为对象的key添加引号
jsxSingleQuote: true, // jsx中使用单引号
bracketSpacing: true, //在对象前后添加空格-eg: { foo: bar }
jsxBracketSameLine: true, //多属性html标签的‘>’折行放置
arrowParens: 'always', //单参数箭头函数参数周围使用圆括号-eg: (x) => x
requirePragma: false, //无需顶部注释即可格式化
insertPragma: false, //在已被preitter格式化的文件顶部加上标注
endOfLine: 'auto', //结束行形式
embeddedLanguageFormatting: 'auto', //对引用代码进行格式化
}
.eslintrc.js添加下规则,extends添加plugin:prettier/recommended,
关闭冲突规则。
plugins中添加prettier,使得Eslint可以通过Prettier格式化代码
extends: ['standard-with-typescript','plugin:react/recommended','plugin:prettier/recommended'],
plugins: ['react', 'prettier'],
rules: {
'react/jsx-use-react': 0, // React V17开始JSX已经不再需要引入React
'react/react-in-jsx-scope': 0, // 同上
'import/first': 0, // 消除绝对路径必须要在相对路径前引入,
'no-mixed-spaces-and-tabs': 2, // 禁止空格和 tab 的混合缩进
'no-debugger': 2, // 禁止有debugger
'space-infix-ops': 2, // 要求操作符周围有空格
'space-before-blocks': 2, // 要求语句块之前有空格
'@typescript-eslint/explicit-function-return-type': 0, // 禁止函数必须要定义返回类型
},
Stylelint
使用Stylelint规范化CSS,可以控制下CSS属性的书写顺序,看起来更加工整。
这里以less为例子,使用Stylelint需要安装对应的less相关的库
pnpm i less stylelint stylelint-config-standard-less postcss-less -D
可以根据项目和习惯自定义CSS属性的属性顺序,也可以直接使用社区方案
- 自定义CSS属性的属性顺序
pnpm i stylelint-order -D
.stylelintrc.cjs按照下面代码进行配置,order/properties-order中存放指定属性的前后顺序
module.exports = {
extends: ['stylelint-config-standard-less'],
overrides: [{ files: ['**/*.less'], customSyntax: 'postcss-less' }],
plugins: ['stylelint-order'],
rules: {
'order/order': ['custom-properties', 'declarations'],
'order/properties-order': ['width', 'height'],
},
}
- 使用社区方案 在awesome-stylelint上有其他已经配置好的方案,开盒即用
这里就以stylelint-config-recess-order为例
pnpm i stylelint-config-recess-order -D
module.exports = {
extends: ['stylelint-config-standard-less', 'stylelint-config-recess-order'],
overrides: [{ files: ['**/*.less'], customSyntax: 'postcss-less' }],
plugins: [],
rules: {},
}
环境配置
环境变量
针对开发/生产环境项目需要制定不同的配置,根目录下新建.env,.env.development,.env.production
- .env:所有环境都需要用到的环境变量
- .env.development: 开发环境需要用到的环境
- .env.production: 生产环境需要用到的环境
vite会将对应的环境变量注入到import.meta.env里去。vite会拦截非VITE开头,就不会注入到import中去.因此环境变量的命名必须以VITE_开头,
这里就以.env.development为例子
# 接口路径
VITE_BASE_API = '/api'
# 开发环境地址前缀
VITE_PUBLIC_PATH = '/'
# 密钥
VITE_AK = 'AK1'
在types目录下添加vite-env.d.ts,声明下 vite 环境变量的类型
/// <reference types="vite/client" />
declare interface ImportMetaEnv {
readonly VITE_BASE_API: string
readonly VITE_PUBLIC_PATH: string
readonly VITE_AK: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
调整tsconfig.ts中的属性,否则调用import.meta.env会提示类型问题.
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler"
在APP中打印import.meta.env,就能打印出development的环境变量. DEV字段为true说明运行在开发环境,PROD为true说明运行在生产环境.
请求
工作中最常用的请求库还是axios,配置简单也不复杂.
pnpm i axios
在src/utils目录下新建一个service.ts,进行axios的配置
import axios from 'axios'
const request = axios.create({
baseURL: import.meta.env.VITE_BASE_API, // 域名配置,可添加变量配置文件定义
headers: {
Authorization: `Bearer ${token}`,// token从Cookie中获取
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
},
timeout: 5000, // 请求超时时间
})
//请求拦截
request.interceptors.request.use(
(config) => config,
(err) => Promise.reject(err.response),
)
// 响应拦截
request.interceptors.response.use(
(response) => {
// 有些情况下接口未必是RESTful风格,C相关的接口返回异常时状态码会小于0
if (response.status !== 200) return Promise.reject(response.data)
// 一般会和后端约定一些code,分别进行处理,这里直接返回了不做处理
return response.data
},
(err) => Promise.reject(err.response)
)
export default request
cookie操作一般都是使用js-cookie进行封装.在src/utils目录下新建一个cookie.ts简单封装下token操作.
import Cookies from "js-cookie"
const SYS_TOKEN = 'SYS_TOKEN'
export const getToken = () => {
return Cookies.get(SYS_TOKEN)
}
export const setToken = (token: string) => {
Cookies.set(SYS_TOKEN, token)
}
export const removeToken = () => {
Cookies.remove(SYS_TOKEN)
}
一般来说,同一个项目的接口风格相对固定。在types目录下添加api.d.ts存放请求数据类型声明
interface ApiResData<T> {
code: number
data: T
message: string
}
我个人习惯把请求封装好,这样直接调用也方便维护,存放在src/api目录下,按照不同的模块进行分类.
以登录页面为例子,调用loginApi进行登录,请求获取的数据也能有类型提示.
开发环境下需要配置下proxy处理跨域问题,生产环境下使用nginx.
在vite.config.ts中配置下server相关属性.
server: {
/** 设置 host: true 才可以使用 Network 的形式,以 IP 访问项目 */
host: true, // host: "0.0.0.0"
open: false,
/** 跨域设置允许 */
cors: true,
/** 端口被占用时,是否直接退出 */
strictPort: false,
proxy: {
"/api": {
target: "对应的url",
/** 是否允许跨域 */
changeOrigin: true
}
},
},
规范化提交
Commitizen
没有明确的提交规范会导致commit message非常随意,在查找定位问题和处理合并冲突就会很头疼了😂。 使用Commitizen规范下commit message。
# 初始化下git
git init
# 安装配置Commitizen
pnpm install commitizen -D
commitizen init cz-conventional-changelog --pnpm --save-dev --save-exact
# 后续就可以使用commitizen
git cz
husky
🐶 husky是一个增强的 git hook 工具,可以在 git hook 的各个阶段执行我们在package.json中配置好的script。
# 安装husky
pnpm dlx husky-init
pnpm install
代码检验
在commit之前,执行lint进行代码校验
在package.json中添加下lint指令,使用eslint使用自动修复在src目录下ts和tsx文件
"scripts": {
"lint": "eslint --fix --ext .ts,.tsx src"
},
npx husky add .husky/pre-commit "pnpm run lint"
提交信息检验
(可选) 在husky中添加prepare-commit-msg
npx husky add .husky/prepare-commit-msg "exec < /dev/tty && npx cz --hook || true"
这样使用git commit会自动进入到commitizen,但是我不建议这样操作.
改变 git commit 命令原有的行为,失去像 git commit -m "chore: ..."快速提交的方式.即便输入符合规范的commit message信息,也会跳转到commitizen补全信息,而且这个命令行交互效果体验很差.
但使用git commit提交当前无法限制提交的message,因此额外添加了
commitlint进行判断.使用commitlint工具对git commit提交的message信息进行检验.
pnpm i @commitlint/config-conventional @commitlint/cli -D
在package.json中添加
"scripts": {
"commitlint": "commitlint --config commitlint.config.cjs -e -V"
},
配置下husky的commit-msg
npx husky add .husky/commit-msg 'pnpm run commitlint'
配置下commitlint的配置文件commitlint.config.cjs
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert', 'build'],
],
'type-case': [0],
'type-empty': [0],
'scope-empty': [0],
'scope-case': [0],
'subject-full-stop': [0, 'never'],
'subject-case': [0, 'never'],
'header-max-length': [0, 'always', 72],
},
}
现在既能够支持使用git cz方式提交commit,也能使用git commit按照规范快速提交.
样式检验
"scripts": {
"stylelint": "stylelint \"src/**/*.less\" --fix"
},
npx husky add .husky/pre-commit 'pnpm run stylelint'
原子化CSS
原子化 CSS 是一种将样式表拆分为最小单元的 CSS 设计方法,它倾向于小巧且用途单一的 class,并且会以视觉效果进行命名,将所有样式都分解为一些独立的类名,每个类名只包含一个属性和对应的值,通过组合这些类名来实现样式的复用.
使用原子化CSS可以先看一下大佬的文章重新构想原子化 CSS (antfu.me)
对于一些没有啥历史包袱的项目上,我觉得引入原子化css是一个不错的选择,不需要太纠结class命名和冗杂重复的css属性,也能有效减少了打包体积.
尤其在是想快速搭建一个组件库项目的情况下,使用原子化CSS也能完成样式的定制,而不用额外搭建一套CSS子系统.
这里就选择Anthony Fu大佬的UnoCSS
# 引入unocss和@iconify-json/mdi图标
pnpm i -D unocss @iconify-json/mdi
# rem转换成px
pnpm i -D @unocss/preset-rem-to-px
# 支持在JSX/TSX中valueless attributify写法
pnpm i -D @unocss/transformer-attributify-jsx
#如果项目中没有使用其他的CSS框架,可以引入unocss的样式重置
pnpm i @unocss/reset
在项目入口文件中引入Unocss
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@unocss/reset/normalize.css'
import 'uno.css'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
根目录下新建一个uno.config.ts文件存放Unocss设置
// uno.config.ts
import { defineConfig,presetUno,presetAttributify,presetIcons,presetTypography,presetWebFonts,transformerDirectives,transformerVariantGroup } from 'unocss'
import presetRemToPx from '@unocss/preset-rem-to-px'
import transformerAttributifyJsx from '@unocss/transformer-attributify-jsx'
export default defineConfig({
presets: [
//将rem单位转换成px
presetRemToPx(),
// 默认预设
presetUno(),
// 支持attributify mode,简单说就是为了避免样式写太长难维护,能将py-2 px-2这种相关属性整合起来写成p="y-2 x-4"
presetAttributify(),
// 图标异步导入按需加载
presetIcons({
collections: {
carbon: () => import('@iconify-json/mdi').then((i) => i.icons),
},
}),
presetTypography(),
presetWebFonts(),
],
transformers: [transformerAttributifyJsx(),transformerDirectives(), transformerVariantGroup()],
})
很多情况下组件都根据属性值动态生成样式,但UnoCSS默认是按需生成方式,在 class属性中使用变量是无法分析变量的取值的.还需要额外添加安全列表safelist.
import UnoCSS from 'unocss/vite'
import { presetUno, presetAttributify, presetIcons } from 'unocss'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const colors = ['white','black','gray','red','yellow','green','blue','indigo','purple','pink',]
const icon = ['search', 'edit', 'check', 'message', 'star-off', 'delete', 'add', 'share']
const safelist = [
...colors.map((v) => `bg-${v}-500`),
...colors.map((v) => `hover:bg-${v}-700`),
...icon.map((v) => `i-mdi-${v}`),
]
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
UnoCSS({
safelist,
presets: [presetUno(), presetAttributify(), presetIcons()],
}),
react(),
],
build: {
// 编译时独立输出css
cssCodeSplit: true,
},
})
这里实现一个简易的Button组件,来检验一下效果
import { FC, PropsWithChildren, ButtonHTMLAttributes, AnchorHTMLAttributes } from 'react'
type tColor = 'black' | 'gray' | 'red' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink'
interface IButtonProps {
color: tColor
icon?: string
link?: boolean
}
type NativeButtonProps = IButtonProps & ButtonHTMLAttributes<HTMLButtonElement>
type AnchorButtonProps = IButtonProps & AnchorHTMLAttributes<HTMLAnchorElement>
type ButtonProps = Partial<NativeButtonProps | AnchorButtonProps>
const Button: FC<PropsWithChildren<ButtonProps>> = (props) => {
const { color = 'blue', icon, link, children, ...restProps } = props
if (link) {
return <a {...(restProps as AnchorHTMLAttributes<HTMLAnchorElement>)}>{children}</a>
} else {
return (
<button
className={`
py-2
px-4
font-semibold
rounded-lg
shadow-md
text-white
bg-${color}-500
hover:bg-${color}-700
border-none
cursor-pointer
m-1
`}
{...(restProps as ButtonHTMLAttributes<HTMLButtonElement>)}>
{icon && <i className={`i-mdi-${icon} p-2`}></i>}
{children}
</button>
)
}
}
export default Button
调用一下Button组件,可以看到Button效果已经能正常显示了.
<Button color='blue' icon='search'>12321</Button>
Unocss的1单位为0.25rem,大部分浏览器的html默认font-size为 16px,即 1rem = 16px.
当然其也支持直接写px.如果已经引入了@unocss/preset-rem-to-px会自动将rem转换成px.
代码生成
Snippets
之前使用create-react-app搭建的项目时一直觉得那种只需要在输入几个像crf的字符,就能快速生成一段代码的方式很方便,自己搭建的项目里面肯定也要加上这样的功能。
大部分的IDE都支持自定义代码片段功能,我就以VScode为例建立一个自定义的Snippets
VScode中ctrl+shift+P搜索Snippets就能找到对应的设置。
可选择全局或者项目内设置snippets,设置后就会出现一个后缀名为code-snippets的文件。
Snippets有特定的语法,学习起来还要花时间,所以直接使用代码转Snippet的方案snippet-generator.app/,设置下代码和想使用的prefix前缀,cv下粘到code-snippets
文件里面就好了
hygen
在一些场景下像组件,都是有比较固定的文件结构和代码结构,直接copy旧组件还要删除多余的部分,这可太麻烦了,有时候还会带入一些bug。
既然是固定的结构那直接使用代码生成器根据指定模板生成即可。
使用hygen生成代码
pnpm i hygen -D
hygen init self
初始化之后会创建一个根目录下创建一个_templates文件夹,内部存放模板,接下来我们就创建一个名为component的模板。
hygen generator new component
模板的结构与ejs类似,前面一部分存放生成代码的位置,后面一部分就是ejs的写法,使用尖括号加百分号的标记来执行代码和插值。
---
to: src/components/<%= name %>/index.tsx
---
import React,{ FC } from "react";
interface I<%= name %>Props {
}
const <%= name %>:FC<I<%= name %>Props> = (props) => {
return <div></div>
}
export default <%= name %>
根据这个模板可以在 src/components/指定的目录下根据模板生成指定的代码。
hygen component new Button
文档
TypeDoc
📕项目开发末期后续为了更好的维护,通常要出一份文档,而写文档本身是一个相当无聊的过程。
采用TypeDoc,只要在开发过程中按规范写类型声明。能够将中的注释转换为 HTML 格式的文档或格式化的 JSON 数据。
pnpm i typedoc -D
为了测试功能,现在src/utils目录下先建立几个测试的函数和接口
然后引入到src/utils/index.ts中
npx typedoc src/utils/index.ts
会在根目录下生成一个docs的文件夹
index.html会展示项目的readme.md文件,侧边栏显示组件信息,点击就看查看详细信息.
Storybook
Storybook提供了一种可视化的方式展示组件的使用方法和效果,并支持交互式调试和快速迭代.在线调试,实时查看组件效果.
pnpm dlx storybook@latest init
根据个人的习惯,我倾向于将stories文件放到组件内部,src目录下的stories文件夹就直接删掉了
创建一个Button.stories.ts,内容如下:
import type { Meta, StoryObj } from '@storybook/react'
import Button from './index'
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
const meta = {
title: 'Button',
component: Button,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout
layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
// argTypes: {
// backgroundColor: { control: 'color' },
// },
} satisfies Meta<typeof Button>
export default meta
type ButtonStory = StoryObj<typeof meta>
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
/**
* 默认样式
*/
export const Normal: ButtonStory = {
args: {
children: '默认',
},
}
export const WithLabel: ButtonStory = {
args: {
icon: 'search',
children: '默认',
},
}
/**
* anchor样式
*/
export const Label: ButtonStory = {
args: {
children: '默认',
link: true,
href: 'http://www.baidu.com',
},
}
Tips:如果使用Unocss,需要在preview.ts中引入Unocss的样式文件
执行pnpm run storybook就能显示出渲染的内容的