比特记账 React 版项目记录

比特记账 React 版项目记录

让 React 支持 sass

React 支持 sass 本身需要 node-sass,但node-sass有两个缺点:下载速度慢,本地编译慢。

于是决定改用 dart-sass 代替 node-sass 。但是 React 本事又不支持 dart-sass。经过查阅资料和研究,我发现**npm6.9 有一个新功能:package alias**,它可以骗过 React 假装安装 node-sass,而实际使用的是 dart-sass

1
yarn add node-sass@npm:dart-sass

导入组件

为了使 react 可以直接用@import "xxx/yyy" 来引入 "src/xxx/yyy"

需要在 jsconfig.json 或 tsconfig.json 中添加如下配置:

1
2
3
4
5
6
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}

配置完成之后 yarn start 重启服务,.scss 和 .tsx 文件就都可以使用这种方法引入了。

使用 styled-components

本项目所有的 scss 全部使用 js 代码生成。使用这个插件的好处是元素的 css 样式直接和元素绑定在一起,不用再为元素添加 css 类名。

安装依赖:

1
2
yarn add styled-components
yarn add @types/styled-components

svg symbols

React 自定义 loader 修改 .svg 源文件

loader 解析 .svg 文件

引入 .svg 图标可以直接将 import 导入的变量作为参数传给 src ,这样实际上是引入的图片,不是标签。但是用这种方法引入的话,修改 .svg 源代码就比较麻烦。比如要修改 svgfill 属性,如果直接修改 svgfill 属性,当 svg 颜色较多时,不具有操作性。本项目使用 svg symbols 的方法引入 .svg 图标。需要用到 svg-sprite-loader 让 webpack 可以解析出 .svg 文件。

这样就只用修改 webpack.config.js 配置,就可以批量需改 .svg 源文件了。

在 React 中,这种方法需要先 eject

1
yarn eject		# Are you sure you want to eject? This action is permanent.(y/N) y

插曲:出现一个小错误

运行 yarn eject 之后,重新运行 yarn start 后报错如下:

小bug

我敏锐的观察到这是因为「Cannot find module」,于是我试运行 yarn add @babel/helper-create-regexp-features-pluginbug 迎刃而解了。

当然,这个小 bug 不是重点。

关键是我找到了一个万能的排错语句,记录下来:

1
2
rm -rf node_modules
yarn install

之后 src 目录下会多出 configscripts 等文件夹。

然后安装 svg-sprite-loader

1
yarn add svg-sprite-loader -D

svgo-loader 可以更方便的修改 .svg 图标相关属性,也要安装:

1
yarn add svgo-loader

然后配置 config/webpack.config.js 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module:{
rules:[
{
oneOf:[
{
test:/\.svg$/,
use:[
{loader:'svg-sprite-loader',options:{}},
{loader:'svgo-loader',options:{
plugins:[
{removeAttrs: {attrs: 'fill'}}
]
}}
]
}
]
}
]
}

配置完成需要重新 yarn start

然后就可以使用 svg symbols 方法引入 .svg 了。

引入单个 .svg 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
require("icons/money.svg");

const Nav = () => {
return (
<ul>
<li>
<svg fill="red">
<use xlinkHref="#money"/>
</svg>
<Link to="/money">记账</Link>
</li>
</ul>
);
};

一个 bug

我发信每次 import 之后必须要使用引入的变量(比如控制台打印变量)才可以用 svg symbols 方法引入 .svg 。 若 import 之后的 js 代码中又没有使用 import 引入的变量,则元素不会被渲染。

这显然很麻烦,而且控制台打印变量没有任何实际意义,代码也变得不易读。

查阅资料发现,import引入变量具有 Tree Shaking 特性,webpack 打包时会将没有使用的变量删除,从而不会得到编译出所需要的变量。

新的 webpack 4 正式版本,扩展了这个检测能力,通过 package.json"sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 “pure(纯的 ES2015 模块)”,由此可以安全地删除文件中未使用的部分。

解决方法是使用 require() 引入,原理是 require 方法不遵循 Tree Shaking

优化:一次性引入所有的 .svg 文件

项目中引入了三个 .svg 文件,使用了三次 require() 。出于优化考虑,希望以后都可以直接将所有 icon 一次性引入,不用那么麻烦的一个一个引入。搜索相关资料,最终找到了解决办法。

首先要安装 ts 依赖:

1
yarn add --dev @types/webpack-env

然后就可以使用 TypeScript 引入了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from "react";

// require 一整个文件夹
let importAll = (requireContext: __WebpackModuleApi.RequireContext) => requireContext.keys().forEach(requireContext);
try {
importAll(require.context('icons', true, /\.svg$/));
} catch (error) {
console.log(error);
}

type Props = { name:string };

const Icon = (props:Props) => {
return (
<svg className="icon">
<use xlinkHref={"#" + props.name}/>
</svg>
);
};

点击 output 屏幕触发显示计算器面板

之前使用 Vue 开发的 BitByBit 中,记账页面感觉有些拥挤,同时感觉 UI 有点单调,因此这次使用 React 我想增加一点空间,于是将收入和支出放到了一个下拉列表中。计算器面板则使用点击 output 触发显示的方法:将显示计算结果和唤醒计算器面板封装在 CalculatorOutput 组件中。

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Money() {
const [displayCalculator, setDisplayCalculator] = useState(false);
const displayPad = useCallback(() => {
setDisplayCalculator(() => true);
}, []);

return (
<Layout>
<CalculatorOutput
displayCalculatorPad = {Props.displayCalculatorPad}
output = {Props.output}
/>
{displayCalculator && <CalculatorPad/>}
</Layout>
);
}

export default Money;

CalculatorOutput .tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Props ={
displayCalculatorPad:()=>void,
output:string
}

const CalculatorOutput:React.FC<Props> = (Props:Props)=>{
return (
<div className="output" onClick={()=> {
Props.displayCalculatorPad();
}}>
{Props.output}
</div>
);
}

export default CalculatorOutput;

计算器面板无法紧贴底部

记账页面布局并不能随移动端长度变化而变化。预期是希望计算器面板总是紧贴底部的,我希望多出来的部分全部添加到 TagsSelection 组件上,即使用 flex-grow: 1; 。但是尽管 Layout 组件的 display 是 flex,而包裹所有组件的直接父元素是 Main,它的 display 为 block。因此必须首先给 Main 添加 display:flex;

一种方法是直接给 Layout 传入 className 参数,然后用外部 css 修改 Main 的 display。

不过我从一开始就不想使用外部 css ,因为这样之后命名比较麻烦,而且名字比较冗余,

于是我查阅 styled-components 官方文档,看有没有可以对自有组件进行二次封装的方法。

果然是有的:

首先还是要给 Layout 传 className 参数:

Layout.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
const Layout = (props: any) => {
return (
<div>
<Wrapper>
<Main className={props.className}>
{props.children}
</Main>
<Nav/>
</Wrapper>
</div>
);
};

然后对 Layout 进行二次封装:

MyLayout:

1
2
3
4
const MyLayout = sytled(Layout)`
display:flex;
flex-direction:column;
`;

然后就可以使用 MyLayout 了:

Monty.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Money() {
return (
<MyLayout>
<RecordSelection
displayCalPad={displayPad}
output={"100"}
/>
<NoteSection/>
<TagsSelection/>
displayCalculator && <CalculatorPad/>
</MyLayout>
);
}

将 TagSelection 的 flex-grow 设为 1 即可。

1
2
3
const TagsSelectionWrapper = styled.section`
flex-grow: 1;
`;

监听 Note input 事件变化

一开始我选择使用受控模式监听 input 输入变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const NoteSection: React.FC = () => {
const [note, setNote] = useState("");
console.log(note); // 其他操作代码
return (
<Wrapper>
<label>
<span>备注</span>
<input
type="text"
placeholder="此处添加备注"
value={note}
onChange={e => {
setNote(() =>
e.target.value
);
}}
/>
</label>
</Wrapper>
);
};

这样就可以实时监听输入端的变化了,但是总觉实时监听没什么必要,因为用户目的是记录每一笔流水,那么备注就只用在移出焦点时做一次监听就好了。于是后来我又改为使用非受控模式的监听来实现这一过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const NoteSection: React.FC = () => {
const [note, setNote] = useState("");
const refInput = useRef<HTMLInputElement>(null);
const currentInput = refInput.current;
const onBlur = () => {
if (currentInput) {
setNote(() =>
currentInput.value
);
}
};
return (
<Wrapper>
<label>
<span>备注</span>
<input
type="text"
placeholder="此处添加备注"
defaultValue={note}
ref={refInput}
onBlur={onBlur}
/>
</label>
</Wrapper>
);
};

在写完非受控模式的监听后,我发现了一个有意思的事:

HTML 的 onChange 是在鼠标移出(失去焦点)时触发的。

而 React 的 onChange 就是其字面意思:改变即触发(实时监听)。

也就是说:HTML 的 onChange 事件实际对应的是 React 的 onBlur 事件。

自定义 Hooks 要命名为 “useXXX”

在抽离 Tags 组件时,一开始我没有使用 “useXXX” 格式命名,导致报错:

自定义hooks要用use开头

原因是 react 规定,useState 只能在函数组件或者自定义 Hooks 中使用。函数组件要使用 首字母大写命名,自定义 Hooks 要以 “use” 开头,不能使用其他的驼峰命名方法。因此将 createTags 改名为 useTags 就好了:

1
2
3
4
5
6
7
8
import {useState} from "react";

const useTags = () => {
const [tags, setTags] = useState<string[]>(["衣服", "吃饭", "住房", "出行"]);
return {tags, setTags};
};

export {useTags};

Tags 深拷贝

Tags 无法直接修改,在对其进行操作前,必须先进行深拷贝

1
const tagsClone = JSON.parse(JSON.stringify(tags));

页面跳转上一页

为了达到回退指定页面的目的,本项目中,我采用把编辑页面两个回退 Icon 的 link 写死的方式完成。

但是编辑标签页面点击 删除标签 按钮,我使用的是回退上一页的方法处理的:

前期直接使用浏览器提供的方法:

1
2
3
const onClickBack = () => {
window.history.back();
};

查阅了 react-router-dom 文档之后,改用其封装好的 api 处理:

1
2
3
4
5
6
7
import {useHistory} from "react-router-dom";

const history = useHistory();
const onClickBack = () => {
// window.history.back();
history.goBack();
};

第一次刷新更行不执行代码

添加标签有一个 bug:当 localstorage 最开始为空数组时,useEffect 会在 undefined 变为 [] 时,执行一次 setTags() ;然后在 [] 变为 tags 时执行一次 setTags() 。显然第一次执行是不必要的,而且有时的确会出现tags 为空白的情款,为此,我单独度封装了一个自定义 hooks ,控制第一次更新不执行 setTags()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {useEffect, useRef} from "react";

export const useUpdate = (fn:()=>void, deps:any[]) => {
const count = useRef(0);
useEffect(() => {
count.current += 1;
});

useEffect(() => {
if (count.current > 1) {
fn();
}
}, deps);
};

自定义 prompt 组件

为了解决微信无法使用 window.prompt ,我单独封装了 OhMyPrompt 组件:

OhMyPrompt.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
import styled from "styled-components";
import React, {ChangeEventHandler, useImperativeHandle} from "react";
import {Input} from "./Imput";

const ElemCenter = styled.div`
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
background: linear-gradient(
to bottom right,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.5)
);
backdrop-filter: blur(2px);

&.visibility-false {
display: none;
}
`;

const MyPrompt = styled.div`
background: linear-gradient(to top right, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.5));
width: 300px;
height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
border: solid 1px #c4c3c3;
border-radius: 15px;
backdrop-filter: blur(2px);
text-align: center;

h3{
margin-top: 8px;
margin-bottom: 16px;
}

Label{
padding: 0 34px;
}

> div{
display: flex;
width: 100%;
padding: 16px 48px;
justify-content: space-around;
>button{
padding: 4px 8px;
font-weight: bolder;
&:active {
box-shadow: inset 0 0 5px rgba(0,0,0,0.1);
}
background: yellowgreen;
border-radius: 5px;
}
}
`;

type VisibleProps = {
isOpen: boolean;
} & React.AllHTMLAttributes<any>

function DisplayPrompt(props: VisibleProps) {
return (
<ElemCenter className={`visibility-${props.isOpen} `}>
{props.children}
</ElemCenter>
);
}

type myPromptProps = {
hidePad?: () => void;
newTag: string;
setNewTag: (value: string) => void;
addTag: (string: string) => boolean;
onRef: any;
}


function OhMyPrompt(props: myPromptProps) {
const [display, setDisplay] = React.useState(false);
const {newTag, setNewTag, addTag, onRef} = props;

useImperativeHandle(onRef, () => ({
openMyPrompt: () => {
setDisplay(true);
}
}));

const onAddtag: ChangeEventHandler<HTMLInputElement> = (e) => {
setNewTag(e.target.value);
};

const submitAddTag = () => {
addTag(newTag);
setNewTag("");
};


function closeMyPrompt() {
setDisplay(false);
}


return (
<>
<DisplayPrompt isOpen={display}>
<MyPrompt>
<h3>添加标签</h3>
<Input
label={"标签名"}
type={"text"}
placeholder={"此处添加新的标签名"}
value={newTag}
onChange={onAddtag}/>
<div>
<button
onClick={() => {
closeMyPrompt();
submitAddTag();
}}
>
提交
</button>
<button
onClick={closeMyPrompt}
>
取消
</button>
</div>
</MyPrompt>
</DisplayPrompt>
</>
);
}

export {OhMyPrompt};

使用 useRefuseImperativeHandle 让父组件可以调用子组件的 openMyPrompt()方法,这样控制弹窗显示的 Button 样式就可以完全由父组件自己设定:

父组件 TagsList.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const Button = styled.button`
color:#2a9e78;
font-size: 16px;
font-weight: bolder;
border: 1px solid #2a9e78;
padding: 8px 12px;
background: #f4faff;
border-radius: 10px;
&:active {
box-shadow: inset 0 0 5px rgba(0,0,0,0.1);
color:#00aeff;
background: #d9efff;
}
`;

const TagsList = () => {
const {tags, addTag, deleteTag} = useTags();
const [newTag, setNewTag] = useState("");
const child = useRef();
const onChild = () => {
// @ts-ignore
child.current.openMyPrompt();
};

return (
<Layout>
<TitleWrapper>
<ElementCenter>
<Button
onClick={() => {
onChild();
}}
>
新增标签
</Button>
<OhMyPrompt
newTag={newTag}
setNewTag={setNewTag}
addTag={addTag}
onRef={child}
/>
</ElementCenter>
</Layout>
);
};

export {TagsList};

版权声明:本文作者为「Andy8421」.本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!