React Hook + TypeScript + styled-component 建站
Posted 刻刻帝丶
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了React Hook + TypeScript + styled-component 建站相关的知识,希望对你有一定的参考价值。
技术选型
Vue 与 React 的对比
- 组件化
Vue 的组件化是将 UI 结构(template)、UI 样式(style)、数据与业务逻辑(script)都放在一个 .vue 的文件中,运行前 .vue 文件会被编译成真正的组件;
React 的组件化是直接通过 JS 代码的形式实现组件 - 模板引擎
Vue的视图模板使用类 html 的写法加上属性与指令,多数情况下要比 React 的 JSX 写法清晰且开发效率高,但是在复杂场景下,Vue 的写法有时会比 React 写起来更麻烦 - 数据监听
Vue 使用代理/拦截的方式使得我们直接修改 data 就可以,但 React 需要使用 setState API 改变数据
项目构建
目录结构
├─ mock #数据模拟
├─ public #静态
├─ scripts #脚本
└─ src
├─ common #工具库
├─ components #组件
├─ hooks #钩子
├─ pages #页面
├─ styles #样式
└─ router #路由
技术栈
- 开发框架:
React
- 构建工具:
Webpack
- 类型检查:
TypeScript
- 日志埋点:
@baidu/bend-sdk
- 视图样式:
styled-components
- 状态管理:
React-hook/useReducer
- 数据请求:
Umi-hook/useRequest + axios
- 规范检测:
Eslint + prettier + husky + lint-staged + commitlint
代码规范化提交
husky
注册 git 的钩子函数保证在 git 执行 commit 时调用代码扫描的动作lint-staged
保证只对当前 add 到 stage 区的文件进行扫描prettier
自动格式化代码eslint
按照配置扫描代码@commitlint/cli
规范 commit 提交@commitlint/config-conventional
commtlint 通用配置
工作流
- 待提交的代码
git add
添加到暂存区 - 执行
git commit
husky
注册在git pre-commit
的钩子函数被调用,执行lint-staged
lint-staged
取得所有被提交的文件依次执行写好的任务(ESLint 和 Prettier)- 如果有错误(没通过ESlint检查)则停止任务,同时打印错误信息,等待修复后再执行
git commit
- 成功 commit,可 push 到远程
package.json配置如下:
{
...
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"src/**/*.{jsx,js,tsx,ts}": [
"prettier --write",
"eslint --fix",
"git add"
]
}
}
.prettierrc.js
module.exports = {
"printWidth": 100, // 一行的字符数,如果超过会进行换行,默认为80
"tabWidth": 4,
"useTabs": false, // 注意:makefile文件必须使用tab
"singleQuote": true,
"semi": true,
"trailingComma": "es5", //是否使用尾逗号,有三个可选值"<none|es5|all>"
"bracketSpacing": true, //对象大括号之间是否有空格,默认为true,效果:{ foo: bar }
"endOfLine": "auto",
"arrowParens": "avoid"
};
.eslintrc.js
module.exports = {
"root": true,
"env": {
"browser": true,
"node": true,
"es6": true,
"jest": true,
"jsx-control-statements/jsx-control-statements": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": 'module',
"ecmaFeatures": {
"jsx": true,
"experimentalObjectRestSpread": true
}
},
"globals": {
// "wx": "readonly",
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:jsx-control-statements/recommended", // 需要另外配合babel插件使用
"prettier"
],
"overrides": [
{
"files": ["**/*.tsx"],
"rules": {
"react/prop-types": "off"
}
}
],
"settings": {
"react": {
"version": "detect"
}
},
"plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-control-statements", "prettier"],
"rules": {
"prettier/prettier": 1,
"no-extra-semi": 2, // 禁止不必要的分号
"quotes": ['error', 'single'], // 强制使用单引号
"no-unused-vars": 0, // 不允许未定义的变量
"jsx-control-statements/jsx-use-if-tag": 0,
"react-hooks/rules-of-hooks": "error", // 检查 Hook 的规则
"react-hooks/exhaustive-deps": "warn" // 检查 effect 的依赖
}
};
.eslintignore/.prettierignore
**/*.js
!src/**/*.js
.commitlintrc.js
module.exports = {
extends: ['@commitlint/config-conventional']
};
提交需遵循 conventional commit 格式,即:
type(scope?): subject
e.g. feat: 教培PC框架搭建(cvi-3000)
type 可以是:
feat
:新功能(feature)upd
:更新某功能fix
:修补bugdocs
:文档(documentation)style
: 格式(不影响代码运行的变动)refactor
:重构(即不是新增功能,也不是修改bug的代码变动)test
:增加测试chore
:构建过程或辅助工具的变动
样式方案
styled-components
是一个 CSS in JS 的类库,就是可以在 JS 中写 CSS 语法
使用 Sass/Less 等预处理语言需要在 Webpack 中进行各种 loader 的配置
而 styled-components 只需直接引用
import styled from 'styled-components';
- 样式化组件,主要作用是编写实际 CSS 代码来设计组件样式,无需组件和样式之间的映射,创建后实际就是一个 React 组件
- 使用后不再需要使用 className 来控制样式,而是写成更具语义化的组件
- 编译后的节点会随机生成 class,可以避免全局污染,但会增加维护难度
解决方案:在 babel 配置中加入 styled-components 插件
babel-plugin-styled-components
use: [
{
loader: 'babel-loader',
options: {
...
plugins: [
'babel-plugin-styled-components',
...
]
}
},
...
]
编译后:
基本使用
// src/components/styles/index.ts
...
interface ICardProps {
type?: string;
}
...
const typeMap = (type: string | undefined) => {
switch (type) {
case 'b':
return 'block';
case 'i':
return 'inline-block';
case 'f':
return 'flex';
case 'n':
return 'none';
default:
return 'block';
}
};
/**
* 卡片容器
*/
const Card = styled.div`
display: ${(props: ICardProps) => typeMap(props.type)};
padding: 24px 24px 15px;
background-color: #fff;
`;
Card.defaultProps = {
type: 'f',
};
...
// src/pages/shop/index.tsx
import styled from 'styled-components';
...
const Shop = () => {
...
return (
<Card type="b">
...
</Card>
);
}
全局样式
// src/style.ts
import { createGlobalStyle } from 'styled-components';
export const GlobalStyle = createGlobalStyle`
html,body,div,span,applet,object,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video,button {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
font-weight: normal;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section {
display: block;
}
ol,ul,li {
list-style:none;
}
...
`;
...
import { GlobalStyle } from '@/style';
...
const App: React.FC = () => {
return (
<Router>
<GlobalStyle />
...
</Router>
);
};
export default App;
代码片段
// src/components/styles/snippet.ts
import { css } from 'styled-components';
const mask = css`
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: radial-gradient(50% 129%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.1) 100%);
}
`;
...
export default {
mask,
...
};
// src/components/styles/index.ts
import styled from 'styled-components';
import s from './snippet';
...
const Img = styled.div`
...
${(props: ICardImgProps) => (props.mask ? s.mask : '')}
...
`
类型系统
TypeScript
微软开发的开源编程语言
是 JS 的一个超集,主要提供类型系统以及对尚未正式发布的 ECMAScript 新特性的支持,最终会被编译成纯 javascript 代码
优点:
- 编译阶段就能发现大部分错误
- 增加代码的可读性和可维护性,大部分函数看看类型定义就知道如何使用
- VSCode 对 TS 提供代码补全、接口提示、跳转定义等功能
缺点:
- 开发时要写很多类型定义,短期来看会增加开发成本,但对长期维护项目来看可以降低维护成本
- 集成到构建流程有一定工作量
- 与部分第三方库的结合使用可能不是很完美(比如styled-components.)
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"downlevelIteration": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"plugins": [
{
"transform": "typescript-plugin-styled-components",
"type": "config"
}
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
React Hook
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其它的 React 特性。
- Hook 强化了 React 函数组件的能力,使得函数组件可以做到类组件中的 state 和生命周期。Hook 让类组件能实现的在函数组件中也都可以实现
- 语法更加简洁,解决了高阶组件使用困难以及难以理解的问题
- 向后兼容,类组件不会被舍弃
类组件与函数组件
类组件
import React, { Component } from 'react';
export default class Button extends Component {
constructor() {
super();
this.state = { buttonText: 'Click' };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState(() => {
return { buttonText: 'Click Done' };
});
}
render() {
const { buttonText } = this.state;
return <button onClick={this.handleClick}>{buttonText}</button>;
}
}
函数组件
import React, { useState } from 'react';
export default function Button() {
const [buttonText, setButtonText] = useState('Click');
function handleClick() {
return setButtonText('Click Done');
}
return <button onClick={handleClick}>{buttonText}</button>;
}
类组件的缺点:
- 需手动绑定this指向
- 大型组件难以拆分和重构
- 业务逻辑分散在各生命周期函数中,会导致逻辑重复(函数组件 useEffect 解决)
- 引入了复杂编程模式,例如渲染属性(Render Props)、高阶组件(HOC)(函数组件自定义 Hook 解决)
Render Props
使用一个值为函数的prop来传递需要动态渲染的组件
import UIDemo from 'components/demo';
class DataProvider extends React.Component {
constructor(props) {
super(props);
this.state = {target: 'Payen'};
}
render() {
return (
<div>
{this.props.render(this.state)}
</div>
)
}
}
<DataProvider render={data => (
<UIDemo target={data.target} />
)}/>
<DataProvider>
{data => (
<UIDemo target={data.target}/>
)}
</DataProvider>
HOC
函数接收一个组件作为参数,经过一系列加工,返回一个新组件
const withUser = WrappedComponent => {
const user = sessionStorage.getItem('user');
return props => <WrappedComponent user={user} {...props}/>;
}
const UserPage = props => (
<div>
<p>name: {props.user}</p>
</div>
);
内置 Hook API
Hook API 名字以 use 开头
基础Hook:
- useState
- useEffect
- useContext
附加Hook:
- useReducer
- useCallback
- useMemo
- useRef
- useImperativeHandle
- useLayoutEffect
- useDebugValue
基础 Hook
useState
// 传入初始state值
const [state, setState] = useState(initState);
// 传入函数,函数返回值作为初始state,函数只会在初始渲染时被调用
const [state, setState] = useState(() => {
const initState = Func();
return initState;
});
useState
唯一参数是初始状态值,初始状态参数仅在第一次渲染时使用
返回当前的状态值(state)和用来变更状态的函数(setState)
类似于类组件中的this.setState
解构赋值语法允许我们将声明的状态赋予不同的名称
useEffect
useEffect(() => {
Func(state);
return () => {
// 组件卸载时以及后续渲染重新运行效果之前执行
};
}, [state]);
useEffect
接收一个包含命令式且可能有副作用代码的函数
React渲染阶段在函数组件主体内改变 DOM、设置定时器等包含副作用的操作是不允许的,因为会影响其它组件
useEffect
和类组件中的componentDidMount
、componentDidUpdate
、 componentWillUnmount
有相同用途
import { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
使用规则
- 只能在顶层调用 Hooks。不能在循环,条件和嵌套函数中使用 Hook API
- 仅在 React 函数组件中和自定义 Hooks 函数中使用 Hooks API
React 会根据调用 hook 的顺序依次将值存入数组
如果存在条件判断等可能会导致更新时不能获取对应的值,导致取值混乱
自定义Hook
为了与普通函数区分,自定义 Hook 命名以 use 开头
用来解决逻辑复用问题
// src/hooks/index.tsx
import React, { useState, useEffect, ... } from 'react';
...
// 元素可拖拽hook
function useDraggable(ref: React.RefObject<HTMLElement>) {
const [{ dx, dy }, setOffset] = useState({ dx: 0, dy: 0 });
useEffect(() => {
if (ref.current == null) {
throw new Error('[useDraggable] ref未注册到组件中');
}
const el = ref.current;
const handleMouseDown = (event: MouseEvent) => {
const startX = event.pageX - dx;
const startY = event.pageY - dy;
const handleMouseMove = (event: MouseEvent) => {
const newDx = event.pageX - startX;
const newDy = event.pageY - startY;
setOffset({ dx: newDx, dy: newDy });
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener(
'mouseup',
() => {
document.removeEventListener('mousemove', handleMouseMove);
},
{ once: true }
);
};
el.addEventListener('mousedown', handleMouseDown);
return () => {
el.removeEventListener('mousedown', handleMouseDown);
};
}, [dx, dy, ref]);
useEffect(() => {
if (ref.current) {
ref.current.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
}
}, [dx, dy, ref]);
}
// src/components/Usual/ButtonGroup.tsx
import { useDraggable, ... } from '@/hooks';
...
const ButtonGroup = (props: IButtonGroupProps) => {
...
// 预约弹窗可拖拽
const el = useRef<HTMLDivElement>(null);
useDraggable(el);
return (
<Space gap="b" nowrap>
...
<PopupWrapper ref={el}>
<AppointPopup
show={showAppointBox}
switchAppointBox={switchAppointBox}
formid={formid}
/>
</PopupWrapper>
</Space>
)
};
Hook 对比 HOC
import React from 'react';
function hocMatch(Component) {
return class Match React.Component {
componentDidMount() {
this.getMatchInfo(this.props.matchId)
}
componentDidUpdate(prevProps) {
if (prevProps.matchId !== this.props.matchId) {
this.getMatchInfo(this.props.matchId)
}
}
getMatchInfo = (matchId) => {
// 请求后台接口获取赛事信息
}
render () {
return (
<Component {...this.props} />
)
}
}
}
const MatchDiv=hocMatch(DivUIComponent)
const MatchSpan=hocMatch(SpanUIComponent)
<MatchDiv matchId={1} matchInfo={matchInfo} />
<MatchSpan matchId={1} matchInfo={matchInfo} />
function useMatch(matchId) {
const [ matchInfo, setMatchInfo ] = useState('');
useEffect(() => {
// 请求后台接口获取赛事信息
// ...
setMatchInfo(serverResult) // serverResult后端返回数据
}, [matchId]);
return [matchInfo];
}
...
export default function Match({matchId}) {
const [matchInfo] = useMatch(matchId);
return <div>{matchInfo}</div>;
}
其它问题
地图跳转
const jumpMap = (e: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => {
// coord_type坐标类型选择国测局坐标(火星坐标系)
window.open(
`http://api.map.baidu.com/marker?location=${pos?.lat},${pos?.lng}&title=${name ||
'我的位置'}&content=${children}&output=html&coord_type=gcj02`
);
e.stopPropagation();
};
二维码
import QRCode from 'qrcode.react';
...
<QRCode
value={qrCodeUrl}
size={300}
fgColor="#000"
imageSettings={{
src: logo,
height: 60,
width: 60,
excavate: false,
}}
/>
屏幕适配
CSS 适配
直接使用 CSS 媒体查询对窄屏下单独设置样式
const PageContent = styled.div`
width: 1200px;
padding-top: 16px;
margin: 0 auto;
@media (max-width: 900px) {
width: 740px;
}
`;
JS 适配
实现一个自定义 Hook
// src/hooks/index.ts
...
function useNarrowScreen() {
const isNarrow = () => window.innerWidth <= 900;
const [narrow, setNarrow] = useState(isNarrow);
useEffect(() => {
const resizeHandler = () => setNarrow(isNarrow());
window.addEventListener('resize', resizeHandler);
return () => window.removeEventListener('resize', resizeHandler);
});
return narrow;
}
// src/components/Base/PhotoAlbum.tsx
...
import { useNarrowScreen } from '@/hooks';
...
const PhotoAlbum = (props: IPhotoAlbum) => {
...
const [baseLen, setBaseLen] = useState(0);
const isNarrow = useNarrowScreen();
useEffect(() => {
setBaseLen(isNarrow ? 3 : 5);
...
}, [isNarrow, ...]);
};
日志相关
click日志
import React, { useEffect } from 'react';
import { sendLog } from '@/common/log';
...
useEffect(() => {
const listenedEles = document.querySelectorAll('[data-mod]') || [];
listenedEles.forEach((ele: HTMLElement) => {
ele.onclick = function() {
const mod = ele.getAttribute('data-mod');
...
};
});
}, []);
// src/hooks/useLog.ts
function useClickLog(ref: React.RefObject<HTMLElement>) {
useEffect(() => {
const el: any = ref.current;
el.onclick = function() {
sendLog(el);
};
}, [ref]);
}
const jumpMapRef = useRef(null);
useClickLog(jumpMapRef);
...
<MapHref
ref={jumpMapRef}
onClick={jumpMap}
data-mod="map_click"
>
查看
</MapHref>
show日志
页面进入埋点
在路由组件渲染回调函数中添加
// src/router/RouterWithSubRoutes.tsx
import React from 'react';
import { Route, Redirect, RouteComponentProps } from 'react-router-dom';
import { RouteInterface } from '@/types/route';
import { sendLog } from '@/common/log';
import { routers } from '@/common/router-config';
export const RouteWithSubRoutes = (
route: RouteInterface,
i: number,
authed: boolean,
authPath: string
) => {
return (
<Route
key={i}
path={route.path}
exact={route.exact}
render={(props: RouteComponentProps) => {
const { match } = props;
const location = props.location;
if (routers[location.pathname]) {
sendLog({
mod: 'detail_show',
s_type: 'show',
});
}
if (!route.auth || authed || route.path === authPath) {
return <route.component {...props} routes={route.routes} />;
}
return <Redirect to={{ pathname: authPath, state: { from: props.location } }} />;
}}
/>
);
};
页面离开埋点
利用react-router
提供的离开确认组件Prompt
// src/App.tsx
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { routes } from '@/router/router';
import { RenderRoutes } from '@/router/RenderRoutes';
import { GlobalStyle } from '@/style';
import { Prompt } from 'react-router';
import { sendLog } from '@/common/log';
import { routers } from '@/common/router-config';
...
const App: React.FC = () => {
...
return (
<Router>
<GlobalStyle />
{RenderRoutes(routes, authed, authPath)}
<Prompt
message={location => {
// 退出页面前的逻辑
window.scrollTo(0, 0);
const { pathname, search } = window.location;
if (routers[pathname]) {
// sendLog
}
return true;
}}
/>
</Router>
);
};
export default App;
页面状态缓存
Vue 中有keep-alive
的组件功能,但是 React 官方没有提供支持
使用<Route>
时,路由对应的组件在前进和后退无法被缓存,数据和行为会丢失
例如:列表页滚动到底后,点击跳转到详情页,返回后会回到列表顶部,数据以及滚动位置重置
<Route>
中配置的组件在路径不匹配时就会被卸载,对应真实节点也会从 DOM 树中移除
三种解决方案:
- 手动实现类似 Vue 的 keep-alive 功能
- 迁移其它第三方可以实现状态缓存的 Route 库
- 页面状态存入 sessionStorage
通过实现自定义钩子 useStorage,可以将数据代理到其它数据源
LocalStorage / SessionStorage
// src/pages/search/index.tsx
...
import { useStorage } from '@/hooks';
...
const Search = () => {
const location = useLocation();
const params = new URLSearchParams(location.search);
const tabSearch = params.get('tab') || 'shop';
...
// const [currentTab, changeCurrentTabState] = useState(tabSearch);
const [currentTab, changeCurrentTabState] = useStorage('zlhx_home_tab', tabSearch);
...
}
// src/hooks/index.ts
import React, { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';
...
function useStorage<T>(
key: string,
defaultValue?: T | (() => T), // 默认值
keepOnWindowClosed: boolean = false // 是否在窗口关闭后保持数据
): [T | undefined, Dispatch<SetStateAction<T>>, () => void] {
const storage = keepOnWindowClosed ? localStorage : sessionStorage;
// 尝试从Storage恢复值
const getStorageValue = () => {
try {
const storageValue = storage.getItem(key);
if (storageValue != null) {
return JSON.parse(storageValue);
} else if (defaultValue != null) {
// 设置默认值
const value =
typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue;
storage.setItem(key, JSON.stringify(value));
return value;
}
} catch (err) {
console.warn(`useStorage 无法获取${key}: `, err);
}
return undefined;
};
const [value, setValue] = useState<T | undefined>(getStorageValue);
// 更新组件状态并保存到Storage
const save = useCallback<Dispatch<SetStateAction<T>>>(
value => {
setValue(prev => {
const finalValue =
typeof value === 'function'
? (value as (prev: T | undefined) => T)(prev)
: value;
storage.setItem(key, JSON.stringify(finalValue));
return finalValue;
});
},
[storage, key]
);
// 移除状态
const clear = useCallback(() => {
storage.removeItem(key);
setValue(undefined);
}, [storage, key]);
return [value, save, clear];
}
以上是关于React Hook + TypeScript + styled-component 建站的主要内容,如果未能解决你的问题,请参考以下文章
React Hook + TypeScript 手把手打造类型安全的应用。
React:react-router-dom Typescript 中不存在 useLocation Hook
如何测试使用自定义 TypeScript React Hook 的组件?
如何测试使用自定义TypeScript React Hook的组件?