为了高效同步代码仓库,我写了一个自动化脚本

在日常开发工作中,我经常会遇到需要在两个代码仓库之间批量同步代码的需求。如果手动执行这些同步命令,不仅繁琐,还可能导致出错。而且一旦我需要请假,则要交代其他同事帮忙同步代码,尽管已经写好了教程文档,还是会有各种问题来咨询我,这让我意识到,如果有一个脚本可以帮我完成这些重复性的工作,那该多好,并且同事们拿到脚本只管运行、确认推送就可以了。于是,基于这些目的,我编写了一个自动化脚本,来实现批量代码同步和提交。

本文将通过展示脚本的初版和优化版,帮助大家更好地理解如何使用 Bash 脚本实现日常任务的自动化。

初版脚本的实现

实现的关键步骤如下:

  • 检查当前仓库地址,确认是否与指定地址匹配
  • 检查或配置目标仓库地址
  • 确认需要同步的分支是否存在
  • 同步源仓库代码到目标仓库的对应分支
  • 在推送前进行用户确认以防误操作

基于以上步骤,我实现了自动化脚本 fork-v1.sh,我们接着来看看初版脚本是如何实现的:

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
error_echo() {
echo -e "\033[31mERROR: $1\033[0m"
}

info_echo() {
echo -e "\033[34m$1\033[0m"
}

success_echo() {
echo -e "\033[32m$1\033[0m"
}

cmd_echo() {
echo -e "\033[33m$1\033[0m"
}

confirm_echo() {
echo -e "\033[47;35m$1\033[0m"
}

# 检查仓库
REPOSITORY="ssh://git@code.xxx.com/com1/repo1.git"
repository_check_result=$(git remote get-url origin 2>&1)
cmd_echo "$ git remote get-url origin"
echo "$repository_check_result"
if [ "$repository_check_result" == $REPOSITORY ]; then
success_echo "当前仓库检查通过"
else
error_echo "当前仓库检查不通过,应进入 $REPOSITORY 对应的本地仓库!"
exit 1
fi

# 检查源
TARGET_ORIGIN="ssh://git@code.xxx.com/com2/repo2.git"
new_origin_check_result=$(git remote get-url newOrigin 2>&1)
cmd_echo "$ git remote get-url newOrigin"
echo "$new_origin_check_result"
if [ "$new_origin_check_result" == $TARGET_ORIGIN ]; then
success_echo "当前仓库检查通过"
elif [[ "$new_origin_check_result" =~ "No such remote 'newOrigin'"]]; then
info_echo "未设置newOrigin,即将自动设置"
cmd_echo "$ git remote add newOrigin dev"
git remote add newOrigin dev
cmd_echo "$ git remote set-url newOrigin $TARGET_ORIGIN"
git remote set-url newOrigin $TARGET_ORIGIN
else
error_echo "newOrigin源检查不通过,请根据日志排查失败原因!"
exit 1
fi

# 检查环境参数
if [ $# -ne 1 ]; then
error_echo '请传入需要处理的环境参数,例如 fork.sh [dev|uat|prod]'
exit 1
else
info_echo "============ 即将执行 repo1 到 repo2 【$1】环境的同步 ============"
fi

# 获取分支名称
if [ $1 == prod ]; then
OLD_BRANCH_NAME="repo1-prod"
NEW_BRANCH_NAME="ver"
else
OLD_BRANCH_NAME="repo1-$1"
NEW_BRANCH_NAME="repo2-$1"
fi
info_echo "============ 即将执行 repo1 到 repo2 【$1】环境的同步 ============"

# 检查分支
git rev-parse --verify "origin/$OLD_BRANCH_NAME" > /dev/null 2>&1
if [ $? -eq 0 ]; then
# 分支存在
success_echo "分支检查通过"
info_echo "---------- 切换到该分支 ----------"
cmd_echo "$ git checkout $OLD_BRANCH_NAME"
git checkout $OLD_BRANCH_NAME
# 检查是否切换成功
current_branch=$(git symbolic-ref --short HEAD)
if [ $current_branch == $OLD_BRANCH_NAME ]; then
success_echo "已切换到 $OLD_BRANCH_NAME 分支"
else
error_echo "分支切换失败,当前分支[$current_branch]"
exit 1
fi
else
error_echo "分支检查失败,原因:该分支不存在,请检查传入的环境参数"
exit 1
fi

# 同步代码
info_echo "---------- 同步 repo1 仓库 $OLD_BRANCH_NAME 分支的远程代码 ----------"
cmd_echo "$ git fetch origin"
git fetch origin
cmd_echo "$ git reset --hard origin/$OLD_BRANCH_NAME"
git reset --hard origin/$OLD_BRANCH_NAME

info_echo "---------- 拉取 repo2 仓库 $NEW_BRANCH_NAME 分支的远程代码 ----------"
cmd_echo "$ git pull newOrigin $NEW_BRANCH_NAME --allow-unrelated-histories"
git pull newOrigin $NEW_BRANCH_NAME --allow-unrelated-histories

info_echo "---------- 本地 git log 前 5 条 commit 日志简览 ----------"
git log -n 5 --oneline

info_echo "---------- 请人工确认是否推送到远程 repo2 仓库的 $NEW_BRANCH_NAME 分支 ----------"
confirm_echo "输入y为确认推送,q为退出:"
while true; do
read user_input
case $user_input in
"y")
info_echo "---------- 正在推送到远程 repo2 仓库的 $NEW_BRANCH_NAME 分支 ----------"
git push newOrigin $OLD_BRANCH_NAME:$NEW_BRANCH_NAME
info_echo "============ 脚本执行完成,请检查是否推送成功 ============"
break
;;
"q")
info_echo "停止并退出fork程序"
exit 0
;;
*)
confirm_echo "未知指令,请输入'y'或'q'(y为确认推送,q为退出):"
;;
esac
done

虽然初版脚本基本满足需求,但在代码结构、容错性和兼容性上还有进一步优化的空间。

优化后的脚本

经过优化后的 fork-v2.sh 代码结构更加清晰,功能也更加健壮。以下为优化版脚本代码:

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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
#!/bin/bash

# 定义颜色代码常量
readonly RED='\033[31m'
readonly GREEN='\033[32m'
readonly BLUE='\033[34m'
readonly YELLOW='\033[33m'
readonly MAGENTA_BG_WHITE='\033[47;35m'
readonly NC='\033[0m' # No Color

error_echo() { printf "${RED}ERROR: %s${NC}\n" "$1"; }
info_echo() { printf "${BLUE}%s${NC}\n" "$1"; }
success_echo() { printf "${GREEN}%s${NC}\n" "$1"; }
confirm_echo() { printf "${MAGENTA_BG_WHITE}%s${NC}\n" "$1"; }
cmd_echo() { printf "${YELLOW}%s${NC}\n" "$1"; }

# 常量定义
readonly REPOSITORY="ssh://git@code.xxx.com/com1/repo1.git"
readonly TARGET_ORIGIN="ssh://git@code.xxx.com/com2/repo2.git"
readonly VALID_ENVS=("dev" "uat" "prod")

# 帮助信息
show_usage() {
cat << EOF
用法: $(basename "$0") <环境>
环境选项:
dev 开发环境
uat 测试环境
prod 生产环境
示例:
$(basename "$0") dev
EOF
exit 1
}

# 检查环境参数
check_env() {
if [ $# -ne 1 ]; then
error_echo "请传入需要处理的环境参数"
show_usage
fi

# 使用 =~ 和 IFS 来检查数组是否包含元素
# 通过在数组元素前后添加空格(" ${VALID_ENVS[*]} "),可以确保准确匹配完整的环境名称,避免部分匹配的问题(比如 "de" 匹配到 "dev"
[[ " ${VALID_ENVS[*]} " =~ " $1 " ]] || {
error_echo "无效的环境参数: $1"
show_usage
}
}

# 检查仓库配置
check_repository() {
cmd_echo "$ git remote get-url origin"

local repo_url
repo_url=$(git remote get-url origin 2>&1) || {
error_echo "获取仓库地址失败"
exit 1
}

echo "$repo_url"

if [ "$repo_url" != "$REPOSITORY" ]; then
error_echo "当前仓库检查不通过,应进入 $REPOSITORY 对应的本地仓库!"
exit 1
fi
success_echo "当前仓库检查通过"
}

# 设置目标仓库
setup_target_repository() {
cmd_echo "$ git remote get-url newOrigin"

local target_url
target_url=$(git remote get-url newOrigin 2>&1)

echo "$target_url"

if [ "$target_url" == "$TARGET_ORIGIN" ]; then
success_echo "目标仓库检查通过"
return
fi

if [[ "$target_url" =~ "No such remote 'newOrigin'" ]]; then
info_echo "未设置newOrigin,即将自动设置"
git remote add newOrigin dev && \
git remote set-url newOrigin "$TARGET_ORIGIN" || {
error_echo "设置目标仓库失败"
exit 1
}
return
fi

error_echo "newOrigin源检查不通过,请根据日志排查失败原因!"
exit 1
}

# 获取分支名称
get_branch_names() {
if [ "$1" == "prod" ]; then
OLD_BRANCH_NAME="repo1-prod"
NEW_BRANCH_NAME="ver"
else
OLD_BRANCH_NAME="repo1-$1"
NEW_BRANCH_NAME="repo2-$1"
fi
}

# 切换并同步分支
switch_and_sync_branch() {
# 检查分支是否存在
git rev-parse --verify "origin/$OLD_BRANCH_NAME" > /dev/null 2>&1 || {
error_echo "分支 $OLD_BRANCH_NAME 不存在,请检查环境参数"
exit 1
}

success_echo "分支检查通过"
info_echo "---------- 切换到该分支 ----------"

# 切换分支
git checkout "$OLD_BRANCH_NAME" || {
error_echo "分支切换失败"
exit 1
}

# 验证当前分支
local current_branch
current_branch=$(git symbolic-ref --short HEAD)
if [ "$current_branch" != "$OLD_BRANCH_NAME" ]; then
error_echo "分支切换失败,当前分支[$current_branch]"
exit 1
fi
success_echo "已切换到 $OLD_BRANCH_NAME 分支"
}

# 主函数
main() {
check_env "$1"
check_repository
setup_target_repository
get_branch_names "$1"

info_echo "============ 即将执行 repo1 到 repo2 【$1】环境的同步 ============"
switch_and_sync_branch

# 同步代码
info_echo "---------- 同步 repo1 仓库 $OLD_BRANCH_NAME 分支的远程代码 ----------"
git fetch origin && \
git reset --hard "origin/$OLD_BRANCH_NAME" || {
error_echo "同步远程代码失败"
exit 1
}

# 拉取目标仓库代码
info_echo "---------- 拉取 repo2 仓库 $NEW_BRANCH_NAME 分支的远程代码 ----------"
git pull newOrigin "$NEW_BRANCH_NAME" --allow-unrelated-histories || {
error_echo "拉取目标仓库代码失败"
exit 1
}

# 显示日志
info_echo "---------- 本地 git log 前 5 条 commit 日志简览 ----------"
git log -n 5 --oneline

# 确认推送
info_echo "---------- 请人工确认是否推送到远程 repo2 仓库的 $NEW_BRANCH_NAME 分支 ----------"
confirm_echo "输入y为确认推送,q为退出:"
while true; do
read -r user_input
case $user_input in
"y")
info_echo "---------- 正在推送到远程 repo2 仓库的 $NEW_BRANCH_NAME 分支 ----------"
if git push newOrigin "$OLD_BRANCH_NAME:$NEW_BRANCH_NAME"; then
info_echo "============ 推送成功 ============"
else
error_echo "推送失败"
exit 1
fi
break
;;
"q")
info_echo "停止并退出fork程序"
exit 0
;;
*)
confirm_echo "未知指令,请输入'y'或'q'(y为确认推送,q为退出):"
;;
esac
done
}

main "$@"

优化点说明

  • 代码结构优化:通过模块化结构,将各功能封装成独立的函数,如 check_envcheck_repositoryswitch_and_sync_branch 等。每个函数负责特定任务,使代码更清晰,也更易于维护和扩展。

  • **echo -e 替换为 printf**:fork-v2.sh 中用 printf 替换了 echo -e,提高了跨平台兼容性。echo 的行为在不同的 Unix 系统上可能略有不同,特别是 -e 选项的支持情况不一致(如某些系统默认不支持 -e),导致转义字符解析可能不一致。printf 是 POSIX 标准命令,能保证颜色输出在各种系统中的一致性和格式控制。

  • 错误检查和处理机制优化:优化后的脚本对每一步操作都增加了容错处理。每个关键操作(如仓库地址检查、分支切换、代码同步)都使用 || 来保证错误提示和安全退出,防止后续代码在失败条件下继续执行。此外,switch_and_sync_branch 函数在切换分支失败时立即退出,避免错误传播,增强了脚本的稳定性。

通过本次优化,脚本在执行批量代码同步时不仅更加稳定,而且具备了良好的容错和提示信息,提升了工作效率。希望本文对你在脚本开发中有所帮助!


附录

常用的颜色与文字样式

文字颜色

颜色 代码
黑色 \033[30m
红色 \033[31m
绿色 \033[32m
黄色 \033[33m
蓝色 \033[34m
紫色 \033[35m
青色 \033[36m
白色 \033[37m

背景颜色

颜色 代码
黑色背景 \033[40m
红色背景 \033[41m
绿色背景 \033[42m
黄色背景 \033[43m
蓝色背景 \033[44m
紫色背景 \033[45m
青色背景 \033[46m
白色背景 \033[47m

其他常用格式

样式 代码
重置所有属性 \033[0m
高亮/加粗 \033[1m
下划线 \033[4m
闪烁 \033[5m
反显 \033[7m

特殊参数说明

  • $0:脚本名称或调用脚本的命令。
  • $1, $2, ...:脚本接受的第一个、第二个等参数。
  • $#:传递给脚本的参数个数。
  • $@:所有参数的数组形式。
  • $*:所有参数的字符串形式。
  • $$:当前脚本的进程 ID。
  • $?:前一个命令的退出状态码。

EOF 用法

EOF(End of File)是结束标记,常用于多行字符串输出,还可以替换为其他单词,比如 ENDSTOP 等。两个 EOF 之间的所有内容都会被当作文本输出,最后的 EOF 必须独占一行且顶格写。

1
2
3
cat << EOF
多行内容
EOF

basename "$0" 用法

basename "$0" 提取脚本文件名,不带路径。常用于帮助信息。

1
2
3
4
5
6
# 例如
basename "/Users/caijialinxx/Desktop/fork.sh"
# 输出: fork.sh

basename "/path/to/file.txt"
# 输出: file.txt

命令行操作时遇到的一些报错解决方案(持续更新...)

Github - SSH

官网文档:https://docs.github.com/en/authentication/troubleshooting-ssh

REMOTE HOST IDENTIFICATION HAS CHANGED!

Error: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the RSA key sent by the remote host is
SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s.
Please contact your system administrator.
Add correct host key in /Users/caijialinxx/.ssh/known_hosts to get rid of this message.
Offending RSA key in /Users/caijialinxx/.ssh/known_hosts:1
Host key for github.com has changed and you have requested strict checking.
Host key verification failed.
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.

Github 远程主机地址变更,导致 SSH 链接失败。解决方法:

  • ~/.ssh/known_hosts 删除对应IP的host记录
  • 或者删除整个 ~/.ssh/known_hosts 文件

其他

xcrun: error: invalid active developer path

xcrun: error: invalid active developer path (/Library/Developer/CommandLineTools), missing xcrun at: /Library/Developer/CommandLineTools/usr/bin/xcrun

更新完 macOS 版本之后,使用终端出现这个报错,是因为 xcode 在更新后可能会需要重新安装。执行下行命令重装 xcode 即可:

1
$ xcode-select --install

React 17 的重要更新

React 17 版本没有产生新的特性,是一个为了后续版本升级更方便、安全的垫脚石。其中比较重要升级点如下:

  • 事件委托。从挂在到 document 上,变更为挂在到 rootNode (React应用挂载的根节点)上。
    React 17 的事件委托
    这使得当存在多个不同版本的 React 树嵌套,或者嵌入到其他程序构建的应用程序中时,不会破坏 e.stopPropagation()
    另外,React 17 的事件传递更接近原生 DOM 。具体表现为在 React 16 及更早版本中,即使你在 React 事件中调用了 e.stopPropagation()document 监听器器仍然会接收到,这是因为原生事件已经处于 document 层级了。在 React 17 中传递将会按要求停止,不会再触发 document 对应的事件。
  • 新的 JSX 转换。与 Babel 合作,提供一种全新版本的 JSX 转换方式——无需额外导入 React 。
    1
    2
    3
    4
    5
    import React from 'react';

    function App() {
    return <h1>Hello World</h1>;
    }
    旧的 JSX 转换方式会将它转成下列代码:
    1
    2
    3
    4
    5
    import React from 'react';

    function App() {
    return React.createElement('h1', null, 'Hello world');
    }
    而在 React 17 中,源码可以写成这样:
    1
    2
    3
    function App() {
    return <h1>Hello World</h1>;
    }
    新的 JSX 转换方式会将它转成下列代码:
    1
    2
    3
    4
    5
    6
    // Inserted by a compiler (don't import it yourself!)
    import {jsx as _jsx} from 'react/jsx-runtime';

    function App() {
    return _jsx('h1', { children: 'Hello world' });
    }
    在升级到 React 17 过程中,如果想批量移除无用的 React 导入,可以在你的项目中执行 npx react-codemod update-react-imports ,即可批量移除:
    1
    2
    3
    4
    5
    import React from 'react';

    function App() {
    return <h1>Hello World</h1>;
    }
    将会被替换成
    1
    2
    3
    function App() {
    return <h1>Hello World</h1>;
    }
    如果使用了其他如 Hook 的 React 导入:
    1
    2
    3
    4
    5
    6
    import React from 'react';

    function App() {
    const [text, setText] = React.useState('Hello World');
    return <h1>{text}</h1>;
    }
    将会被替换成
    1
    2
    3
    4
    5
    6
    import { useState } from 'react';

    function App() {
    const [text, setText] = React.useState('Hello World');
    return <h1>{text}</h1>;
    }
  • 移除了事件池
    React 在旧浏览器中为了性能而重用不同事件之间的事件对象,并在它们之间将所有事件字段设置为null。在 React 16 及更早版本,你必须调用 e.persist() 才能正确使用事件,否则需要提前读取需要的属性。而现代浏览器中,这个性能优化并没有作用,所以 React 17 已经彻底移除了“事件池”
  • 异步执行 useEffect 清理函数
    此前 useEffect 在组件卸载时,是同步执行的,类似于 componentWillUnmount ,这对于大型应用程序来说并不理想,因为它会减慢大屏过渡的速度。在 React 17 中这个过程将会变成异步执行,即在更新已渲染完成后执行。若想要保持同步执行,那么可以使用 useLayoutEffect 来代替。当然,不必担心的是, React 17 会保证在执行任何新的副作用之前执行完所有副作用的清理函数。
    当然这个改动会有潜在的问题,当组件中使用了 ref 来执行一些清理操作,如下代码所示:
    1
    2
    3
    4
    5
    6
    useEffect(() => {
    someRef.current.someSetupMethod();
    return () => {
    someRef.current.someCleanupMethod();
    };
    });
    由于 ref 的值是可变的,所以在异步调用时,这个 ref 的值已经被设置为 null 了,也就意味着这个清理函数并无法执行。那么解决方案除了使用上述的 useLayoutEffect 同步执行以外,还可以这样:
    1
    2
    3
    4
    5
    6
    7
    useEffect(() => {
    const instance = someRef.current;
    instance.someSetupMethod();
    return () => {
    instance.someCleanupMethod();
    };
    });
    将 ref 的值的引用赋值到一个新的变量,那么在清理函数执行时,可以保证这个引用还存在在内存中,可以调用到里面的方法。
  • render 中返回 undefined 时一致的报错表现
    此前, React 只在 class 组件或函数组件中做了“返回 undefined”的检查,错误遗漏了 forwardRefmemo 组件, React 17 中已经将这个错误修复了。如果有意返回空内容,可以返回 null
  • 更好的组件堆栈跟踪。报错时,不再是简单的 JS 堆栈,而是可以显示出具体的 React 组件堆栈信息,并可以快速定位到。

TypeScript 的使用心得及笔记

入职这家公司后,项目中大多有使用 TypeScript 进行开发,经过一段时间的使用,我打算为它写写博客。

TypeScript 是什么,与 JavaScript 有什么区别?

TypeScript 是 JavaScript 的超集,它支持所有 JavaScript 的特性,同时为 JavaScript 提供了类型。也就是说,用一个公示表示即为 TypeScript = JavaScript + Type 。 JavaScript 可以直接运行在浏览器和 Node.js 中,但 TypeScript 不行,它需要经过编译转换成 JavaScript 才行。

为什么需要 TypeScript ,相比 JavaScript 有什么优势?

JavaScript 从不检查变量的类型,这其实是比较危险的一件事,开发人员编写代码时很容易变得没有“规矩”,运行代码时可能会遇到意料之外的表现或者 Bug 。正确严格地使用 TypeScript 可以帮助我们在编写代码时就避免掉类型或条件判断上的低级错误,并且 IDE 可以给我们提供类型甚至代码提示

为什么我要强调“正确”、“严格”呢?因为在工作中我就遇到少部分同事因为偷懒不使用类型或者随意使用 any ,当我需要查看某一个变量、函数入参或函数返回值等的类型时,往往需要去到声明或定义的地方阅读代码才能确保它的类型到底是哪个(些),这多少会造成一定的不便及心智负担。

而在遇到规范使用 TypeScript 写出来的代码时,我感觉到心情都舒畅不少。

一些特殊的类型

顶级类型 any

any 属于顶级类型(top type),滥用它会使 TypeScript 的作用大打折扣,因为这个类型就是不做任何检查,它允许任何类型的值赋值给 any ,同时也可以将 any 类型的变量任意赋值给其他有严格类型限制的变量。当然,我们可以在 TypeScript 的编译配置中给 noImplicitAny 设置为 true ,即可在编译阶段检查到若使用了 any 就报错,以限制其使用。

另一个顶级类型 unknown

any 相同的是, unknown 也是顶级类型,允许任何类型的值赋值给 unknown 类型的变量。但不同的是, unknown 的类型检查比 any 严格,当将 unknown 类型的变量赋值给其他严格类型的变量时,会报错:

1
2
3
const value: unknown = "Hello World";
const someString: string = value;
// 报错:Type 'unknown' is not assignable to type 'string'.(2322)

与前两者完全相反的 never

never 是底类型(bottom type),表示不应该出现的类型,任何类型的变量都不能赋值给 never 类型的变量,并且 never 类型的变量也不能赋值给其他类型的变量。对于它的使用,尤雨溪给出了一个示例,看看这个例子,我们就能知道它的应用场景是什么了。

type V.S. interface

在平时的开发中,你们使用 type 还是 interface 更多呢?我的使用习惯中,通常对对象的类型定义我是时候后者,而对于前者往往是用在需要用到联合类型(Union Type)的时候。它们两者的区别有以下:

  • 前者实际上是创建一个类型别名,它不创建一个新的类型,而后者会。
  • 前者使用 & 来实现类型的扩展,而后者使用 extends 实现接口继承。
  • 前者不可以多次定义,否则会报错,类型于 const ,而后者可以重复定义,并且通过重复定义可实现扩展。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    interface ObjectProps {
    a: string
    }
    interface ObjectProps {
    b: number
    }
    const obj: ObjectProps = { a: '123', b: 123 };

    type Y = string;
    type Y = number;
    // Duplicate identifier 'Y'.(2300)
  • 前者可以直接定义为简单类型,而后者不行。

常用的工具类型(未完待续…)

详参考Utility Types

  • Partial<Type>Required<Type>
  • Pick<Type, Keys>Omit<Type, Keys>
  • Exclude<UnionType, ExcludedMembers>Extract<Type, Union>

一些经典的JS手写函数实现

以下手写函数的测试/调试地址:https://jsbin.com/ruyukifofu/4/edit?js,console

防抖节流

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
/*
* 防抖:在xx时间内连续触发不执行,直至停止操作后的xx时间后才执行一次。
* 假设一个点代表一个时间间隔,防抖设置等待5个时间间隔,#表示一个时间间隔内触发一次动作,!表示实际触发结果:
* ##··#·#·······##······
* ············!········!
*/
const debounce = (fn, delay) => {
let timer = null;
return (...args) => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(undefined, args);
}, delay);
};
};

/*
* 节流:第一次触发执行后,需等待xx时间后才能再次触发,期间连续触发不执行。
* 假设一个点代表一个时间间隔,节流设置等待5个时间间隔,#表示一个时间间隔内触发一次动作,!表示实际触发结果:
* ##··#·#·······##······
* !·····!·······!·······
*/
const throttle = (fn, delay) => {
let timer = null;
return (...args) => {
if (!timer) {
fn.apply(undefined, args);
timer = setTimeout(() => {
timer = null;
}, delay);
}
};
};

// 测试代码
const fn = debounce((...args) => console.log(args), 2000);
const fn2 = throttle((...args) => console.log(args), 2000);
let intervalCount = 0;
const timerId = setInterval(() => {
intervalCount += 1;
console.log(intervalCount);
fn1("debounce", intervalCount);
fn2("throttle", intervalCount);
if (intervalCount >= 10) {
clearTimeout(timerId);
}
}, 1000);

Promise

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
class Promise {
#PromiseState = "pending";
#PromiseResult = undefined;
constructor(fn) {
const resolve = (response) => {
this.#PromiseState = "fulfilled";
this.#PromiseResult = response;
};
const reject = (reason) => {
this.#PromiseState = "rejected";
this.#PromiseResult = reason;
};
fn.call(this, resolve, reject);
}
then(onfulfilled, onrejected) {
return new Promise((resolve, reject) => {
try {
let result;
if (this.#PromiseState === "fulfilled" && typeof onfulfilled === "function") {
result = onfulfilled(this.#PromiseResult);
} else if (this.#PromiseState === "rejected" && typeof onfulfilled === "function") {
result = onrejected(this.#PromiseResult);
}
resolve(result);
} catch (e) {
reject(e);
}
});
}
catch(onrejected) {
return new Promise((resolve, reject) => {
try {
resolve(onrejected(this.#PromiseResult));
} catch (e) {
reject(e);
}
});
}
}
Promise.resolve = (data) => new Promise((resolve) => resolve(data));
Promise.reject = (data) => new Promise((_, reject) => reject(data));
Promise.all = (promises) => {
const result = [];
let fulfilledCount = 0;
return new Promise((resolve, reject) => {
promises.forEach((p, i) => {
if (!(p instanceof Promise)) {
fulfilledCount += 1;
result[i] = p;
} else {
p.then(
(data) => {
fulfilledCount += 1;
result[i] = data;
if (fulfilledCount >= promises.length) {
resolve(result);
}
},
(data) => {
reject(data);
}
);
}
});
});
};

// 测试代码
var p1 = new Promise((resolve, reject) => resolve(1.1));
p1.then((data) => { console.log(2.1, data); return 2.1; }, (data) => { console.log(2.2, data); return 2.2; }) // => 2.1 1.1
.then((data) => { console.log(3.1, data); return 3.1; }, (data) => { console.log(3.2, data); return 3.2; }); // => 3.1 2.1

var p2 = new Promise((resolve, reject) => reject(1.2));
p2.then((data) => { console.log(2.1, data); return 2.1; }, (data) => { console.log(2.2, data); throw 2.2; }) // => 2.2 1.2
.then((data) => { console.log(3.1, data); return 3.1; }, (data) => { console.log(3.2, data); return 3.2; }); // => 3.2 2.2

var p3 = new Promise((resolve, reject) => resolve(1.1));
p3.then((data) => { console.log(2.1, data); throw 2.1; }, (data) => { console.log(2.2, data); return 2.2; }) // => 2.1 1.1
.then((data) => { console.log(3.1, data); return 3.1; }, (data) => { console.log(3.2, data); return 3.2; }); // => 3.2 2.1

var p4 = new Promise((resolve, reject) => resolve(1.1));
p4.then((data) => { console.log(2.1, data); throw 2.1; }) // => 2.1 1.1
.catch((data) => { console.log("catch", data); throw "catch error"; }); // catch 2.1

Promise.all([1, Promise.resolve(2)]).then((values) => { console.log(values); }); // [1,2]
Promise.all([1, Promise.reject('err')]).catch((err) => { console.log(err); }); // 'err'

深拷贝

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
// 1.类型判断   2.递归   3.检查环
const deepClone = (originalData, cache) => {
if (!cache) {
cache = new Map();
}
if (originalData instanceof Object) {
if (cache.get(originalData)) {
// 检查环,如果已经有拷贝过这个对象,则直接返回
return cache.get(originalData)
}
let result
if (originalData instanceof Function) {
// 函数
if (originalData.prototype) {
// function() {}
result = function () { originalData.apply(this, arguments) }
} else {
// 箭头函数
result = (...args) => { originalData.apply(undefined, args) }
}
} else if (originalData instanceof Array) {
// 数组
result = [];
} else if (originalData instanceof Date) {
// 日期
result = new Date(originalData - 0);
} else if (originalData instanceof RegExp) {
// 正则
result = new RegExp(originalData.valueOf());
} else if (typeof originalData.valueOf() !== 'object') {
// 构造函数new出来的boolean、number、string
result = new originalData.constructor(originalData.valueOf())
} else {
// 普通对象
result = {};
}
cache.set(originalData, result); // 为当前处理的对象cache当前结果,需要递归执行前,否则递归进入后无法获取到cache
for (let key in originalData) {
result[key] = deepClone(originalData[key], cache);
}
return result;
} else if (typeof originalData === 'symbol') {
return Symbol(originalData.description)
} else {
// boolean,string,number,undefined,null,bigint
return originalData
}
}

// 测试函数
deepClone(123); // 123
deepClone(Symbol('this is a symbol')); // Symbol(this is a symbol)
deepClone(BigInt(12345678901234567890)); // 12345678901234567168n
const obj = {
num: NaN,
txt: 'text',
bool: new Boolean(1),
symb: Symbol('symbol'),
fn: (v1, v2) => { console.log('箭头函数', this, v1, v2); }, // this->window
obj: {
fn: function(v1, v2) { console.log('普通函数', this, v1, v2); }, // this->obj
arr: [1, null, { a: [3], b: { c: 7 } }, BigInt(13245), undefined, false],
date: new Date('1996-7-8'),
num: Number()
},
reg: new RegExp(/\s+/g)
}
obj.self = obj
const clonedObj = deepClone(obj);
clonedObj.self === clonedObj; // true
obj.obj.arr[0] = 0;
clonedObj.obj.arr[0]; // 1

数组去重

1
2
3
4
5
6
7
8
9
10
11
// 实现一:
const uniq = (array) => {
const map = new Map();
array.forEach((item) => {
map.set(item, 1);
});
return [...map.keys()];
}

// 实现二
const uniq2 = (array) => [...new Set(array)];

AJAX

1
2
3
4
5
6
7
8
9
10
const xhr = new XMLHttpRequest();
xhr.open("GET", "/archives/");
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) {
console.log(xhr.response);
}
}
};
xhr.send();

发布订阅

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
const EventHub = {
eventMap: {}, // 映射
on: (type, fn) => {
EventHub.eventMap[type] = EventHub.eventMap[type] || [];
EventHub.eventMap[type].push(fn);
},
emit: (type, ...args) => {
if (EventHub.eventMap[type]) {
EventHub.eventMap[type].forEach((fn) => {
fn.apply(undefined, args);
});
}
},
off: (type, fn) => {
const queue = EventHub.eventMap[type];
if (queue && queue.includes(fn)) {
const index = queue.indexOf(fn);
queue.splice(index, 1);
}
},
};

// 测试代码
const fn = (...args) => console.log("xxx emit", ...args);
EventHub.on("xxx", fn);
setTimeout(() => {
EventHub.emit("xxx", "jialin", "cai");
EventHub.off("xxx", fn);
}, 3000);
setTimeout(() => {
EventHub.emit("xxx", "caijialinxx");
}, 4000);

事件委托

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function delegate(selector, eventType, fn) {
document.addEventListener(eventType, (e) => {
let target = e.target;
while(!target.matches(selector)) {
target = target.parentElement;
if(target === document.documentElement) {
target = null;
break;
}
}
if(target) {
fn(target)
}
})
}

可拖拽的元素

凑数用的,手写一个可拖拽的元素。预览链接:https://codepen.io/caijialinxx/pen/JjaMjVm
这个加上了边框计算:https://jsbin.com/bikojavuva/1/edit?css,js,output

1
2
3
<div class="box">
<div class="dragger"></div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.box {
width: 400px;
height: 400px;
background-color: pink;
position: relative;
box-sizing: border-box;
margin-top: 100px;
margin-left: 50px;
}

.dragger {
border-radius: 50%;
width: 20px;
height: 20px;
border: 1px solid red;
cursor: pointer;
position: absolute;
top: 0px;
left: 0px;
box-sizing: border-box;
}
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 dragger = document.querySelector('.dragger');
const box = document.querySelector('.box');

let draggable = false;
let x, y; // 记录当一次鼠标的坐标
const boxWidth = box.offsetWidth;
const boxHeight = box.offsetHeight;
const offsetX = box.offsetLeft; // dragger可移动范围的横向偏移量
const offsetY = box.offsetTop; // dragger可移动范围的纵向偏移量
const maxLeft = boxWidth - dragger.offsetWidth;
const maxTop = boxHeight - dragger.offsetHeight;

// 帮助函数:获取范围内的值
const getValueInRange = (val, min, max) => {
if(val < min) return min;
if(val > max) return max;
return val
}

// 鼠标点击拖动目标时设置draggable为true并记录当前鼠标坐标。
dragger.onmousedown = (e) => {
draggable = true;
x = e.clientX;
y = e.clientY;
}

// 在document上监听鼠标移动事件
document.onmousemove = (e) => {
if(!draggable) return; // 当draggable为false时不执行拖动逻辑
const deltaX = e.clientX - x; // 计算横向偏移量
const deltaY = e.clientY - y; // 计算纵向偏移量
let left = parseFloat(dragger.style.left || 0) + deltaX; // 计算当前dragger的left值
let top = parseFloat(dragger.style.top || 0) + deltaY; // 计算当前dragger的top值

// 设置dragger的left/top在规定范围内
dragger.style.left = getValueInRange(left, 0, maxLeft) + 'px';
dragger.style.top = getValueInRange(top, 0, maxTop) + 'px';

// 设置x、y坐标在当前规定范围内
x = getValueInRange(e.clientX, offsetX, offsetX + boxWidth);
y = getValueInRange(e.clientY, offsetY, offsetY + boxHeight);
}

// 在全局松开鼠标即设置draggable为false不拖动
document.onmouseup = (e) => {
draggable = false;
}

事件委托——让事件监听更便捷

我们知道,在 HTML 中为一个元素绑定事件,需要对这个元素添加事件监听,如 onclick 属性或者 addEventListener()

1
2
3
4
5
6
7
8
<ul id="list">
<li id="item-1" onclick="console.log('I am Item 1)">Item 1</li>
<li id="item-2" onclick="console.log('I am Item 2)">Item 2</li>
<li id="item-3" onclick="console.log('I am Item 3)">Item 3</li>
<li id="item-4" onclick="console.log('I am Item 4)">Item 4</li>
<li id="item-5" onclick="console.log('I am Item 5)">Item 5</li>
<li id="item-6" onclick="console.log('I am Item 6)">Item 6</li>
</ul>
1
2
3
4
5
6
document.querySelector('#item-1').addEventListener('click', () => { console.log('I am Item 1') })
document.querySelector('#item-2').addEventListener('click', () => { console.log('I am Item 2') })
document.querySelector('#item-3').addEventListener('click', () => { console.log('I am Item 3') })
document.querySelector('#item-4').addEventListener('click', () => { console.log('I am Item 4') })
document.querySelector('#item-5').addEventListener('click', () => { console.log('I am Item 5') })
document.querySelector('#item-6').addEventListener('click', () => { console.log('I am Item 6') })

这样看起来实在是太笨啦!并且在动态增减列表元素的场景下,它无法及时地添加或移除事件监听。怎么让事情变得更简单些呢?那就是使用事件委托来进行监听:

1
2
3
4
5
document.querySelector('#list').addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
console.log(`I am ${e.target.textContent}`)
}
})

代码是不是立马就变得清爽起来~我们只用一个事件监听就实现在每个 <li> 元素点击时都能执行某些操作。

但现实中,我们的页面结构往往不会这么简单,需要产生交互的元素里面可能包含了很多子元素,例如图标、被其他标签包裹的文本等,这使得 event.target 不一定是目标元素。这个时候我们就得对当前点击的元素逐层向上寻找父元素,若找到目标元素,则说明我们需要执行对应的操作。所以,我们需要写一个更通用的事件委托:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function delegate(eventType, targetSelector, fn) {
document.addEventListener(eventType, (e) => {
let currentEl = e.target;
while(!currentEl.matches(targetSelector)) { // 通过选择器来匹配,而不是写死tagName匹配
if(currentEl === document.documentElement) { // 当已经查到顶后,及时停止向上查找
currentEl = null;
break;
}
currentEl = currentEl.parentElement;
}
if(currentEl) {
fn(currentEl);
}
})
}

delegate('click', 'li', () => { ... })

最后,你可以到这里来 Try And Code 一下。

创建多个Git用户、推送多个远程仓库

创建多个Git用户

  1. 生成多个ssh-key,并将对应的publicKey添加到git管理仓库中
    1
    $ ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
  2. 配置~/.ssh/config
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # The git info for company
    # gitlab user(your_email1@example1.com)
    Host git@x.x.x.x
    HostName x.x.x.x
    PasswordAuthentication no
    Port xxxx
    User your_email1
    IdentityFile ~/.ssh/example1/id_rsa

    # The git info for my github
    # Default github user(your_email2@example2.com)
    Host git@github.com
    HostName github.com
    User your_email2
    IdentityFile ~/.ssh/example2/id_rsa
  3. 复制完整的 SSH 公钥并在对应的远程仓库中添加 SSH key 配置。
  4. 验证 SSH 连接
    1
    2
    $ ssh -T [Host]
    # 如 ssh -T git@github.com

推送项目到不同仓库地址

  1. 进入项目,添加远程仓库B地址

    1
    $ git remote add 远程仓库B的别名(随意) 远程仓库B的地址
  2. 在A项目中将commit提交到远程仓库B

    1
    $ git push 远程仓库B的别名

useState 的原理及模拟实现 —— React Hooks 系列(一)

2023-3-11更新:
今天回顾之前写的这篇博客,发现在模拟 useState 实现中,有个很明显的 Bug ,并且有些代码链接也失效了,故更新一版,更新部分会用引用说明,详见下文。

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React, { useState } from "react";
import ReactDOM from "react-dom";

function App() {
const [count, setCount] = useState(0);
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

原理

按照 React 16.8.0 版本之前的机制,我们知道如果某个组件是函数组件,则这个 function 就相当于 Class 组件中的 render() ,不能拥有自己的状态(故又称其为无状态组件,stateless components),所以数据(输入)必须是来自父组件的 props 。而在 >=16.8.0 中,函数组件支持通过使用 Hooks 来为其引入 state 的能力,例如上面所展示的例子:这个 App 组件提供了一个按钮,每次点击这个都会执行 setCount 使得 count 增加 1 ,并更新在视图上。

为了更直观地看到使用了 Hooks 的函数组件和 Class 组件的区别,我写了一个对比示例

2023-3-11更新:
上方的代码链接失效了,我直接将代码贴在下方:

import React, { Component, useState } from "react";
import ReactDOM from "react-dom";

const logNewValueAfterOneSecond = (c, value) => console.log(`1 second later, ${c}’s count:`, value);

const FunctionComponent = () => {
  const [count, setCount] = useState(0);
  console.log("A’s count:", count);
  return (
    <p>
      <span>A: {count}</span>
      <button
        onClick={() => {
        setCount(count + 1);
        setTimeout(() => logNewValueAfterOneSecond("A", count), 1000);
        }}
      >
        A+1
      </button>
    </p>
   );
 };

class ClassComponent extends Component {
  constructor() {
    uper();
    this.state = { count: 0 };
  }
  render() {
    console.log("B’s count:", this.state.count);
    return (
      <p>
        <span>B: {this.state.count}</span>
        <button
          onClick={() => {
            this.setState({ count: this.state.count + 1 });
            setTimeout(
              () => logNewValueAfterOneSecond("B", this.state.count),
              1000
            );
          }}
        >
          B+1
        </button>
      </p>
    );
  }
}

function App() {
  return (
    <div>
      <FunctionComponent />
      <ClassComponent />
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

新的代码链接:Function Component vs Class Component

A 是使用了 Hooks 的函数组件, B 是我们熟悉的 Class 组件。当我们分别点击两个按钮更新数据时,我们已知 B 组件不会重新调用而是在 setState 通知变化之后重新渲染(render),而 A 组件是如何处理更新的呢?让我们来分析一下这个过程:

  • A. 点击按钮「A+1」

    1. 按钮「A+1」的 click 事件触发,执行异步的 setCount(count + 1)
    2. 开启定时器,将在一秒之后执行回调函数 () => logNewValueAfterOneSecond('A', count)
    3. 按钮「A+1」的 click 事件执行到结尾,异步 setCount 起效,输出“A’s count: 1”, <span>0</span> 更新为 <span>1</span> (只更新 span 的文本)
    4. 一秒到了,定时器的回调函数执行,输出“1 second later, A’s count: 0
  • B. 点击按钮「B+1」

    1. 按钮「B+1」的 click 事件触发,执行异步的 this.setState(state => ({ count: state.count + 1 }))
    2. 开启定时器,将在一秒之后执行回调函数 () => logNewValueAfterOneSecond('B', this.state.count)
    3. 按钮「B+1」的 click 事件执行到结尾,异步 setState 起效,输出“B’s count: 1”且 <span>0</span> 更新为 <span>1</span> (只更新 span 的文本)
    4. 一秒到了,定时器的回调函数执行,输出“1 second later, B’s count: 1

Hooks+函数组件 V.S. Class组件

通过上面的步骤分析,我们可以看到,有区别的就是步骤 14 。先说说 B ——在 B.1 中 setState 通知变化后, React 不会立即更新组件,而是会延迟调用它,到了步骤 3 时 React 对 state 的变更才真正生效(类似于 Object.assign(previousState, {count: state.count + 1})), this.state.count 的值更新为 1 ,且由于 shouldComponentUpdate() 默认返回 true ,所以成功触发新的一次渲染,于是组件 B 的 DOM 更新(不清楚组件更新过程的同学可以戳这里补功课,重点放在生命周期setState两个模块)。整个过程没有重新生成这个组件实例,它们始终在同一个环境,所以一秒后输出 Bcount1 ,而 A :

  1. 首先, A 中的 countsetCount 是由钩子函数 useState() 返回的两个值。
  2. count 的初始值是 0 ,与我们赋予的一样。
  3. 上面我说到,函数组件相当于 Class 组件中的 render() ,所以我们大胆假设在按钮第一次点击异步执行 setCount(count + 1) 之后,重新 render ,即 A() 被重新调用,此时 countuseState() 中再次获取到的值更新为 1 ,返回的值通过 diff 最终局部更新。

但是奇怪的是,为什么一秒之后定时器回调函数执行后输出的 count 是旧的值呢?请大家再看一遍 3 ,「A() 被重新调用」,「countuseState() 中再次获取值」,那么此时的 count 还为彼时的 count 吗?答案当然是「不」!它们已经是存在于不同环境的两个变量了。
假设 2 中的 count 是 count0(值为0),而 3 中的 count 是 count1(值为1)。定时器是什么时候设定的?在第一次点击按钮时。这个时候 logNewValueAfterOneSecond() 传入的 count 实际上是还没有更新的 count0 ,一秒之后,虽然 count 已更新为 count1 ,但 logNewValueAfterOneSecond() 调用的 count 并没有更新,还是之前的 count0 ,所以必然输出 0 。
这下看起来明了了一些吗?接下来我们来尝试模拟实现一下 useState() 吧~

模拟实现

React.useState() 里都做了些什么:

  1. 将初始值赋给一个变量我们称之为 state

  2. 返回这个变量 state 以及改变这个 state 的回调函数我们称之为 setState

  3. setState() 被调用时, state 被其传入的新值重新赋值,并且更新根视图

    1
    2
    3
    4
    5
    6
    7
    8
    function useState(initialState) {
    let _state = initialState;
    const setState = (newState) => {
    _state = newState;
    ReactDOM.render(<App />, rootElement);
    };
    return [_state, setState];
    }
  4. 每次更新时,函数组件会被重新调用,也就是说 useState() 会被重新调用,为了使 state 的新值被记录(而不是一直被重新赋上 initialState),需要将其提到外部作用域声明

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let _state;
    function useState(initialState) {
    _state = _state === undefined ? initialState : _state;
    const setState = (newState) => {
    _state = newState;
    ReactDOM.render(<App />, rootElement);
    };
    return [_state, setState];
    }

    好的,现在让我们来调用一下,目前暂时是达到了 React.useState() 一样的效果。Open in CodeSandbox
    模拟 React.useState 1.0

  5. 但是,如果添加多个 useState() ,就一定会出现 BUG 了。因为当前的 _state 只能存放一个单一变量。如果我将 _state 改成数组存储呢?让这个数组 _state 根据当前操作 useState() 的索引向内添加 state

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    let _state = [], _index = 0;
    function useState(initialState) {
    let curIndex = _index; // 记录当前操作的索引
    _state[curIndex] = _state[curIndex] === undefined ? initialState : _state[curIndex];
    const setState = (newState) => {
    _state[curIndex] = newState;
    ReactDOM.render(<App />, rootElement);
    _index = 0; // 每更新一次都需要将_index归零,才不会不断重复增加_state
    }
    _index += 1; // 下一个操作的索引
    return [_state[curIndex], setState];
    }

    虽然通过使用数组存储 _state 成功模拟了多个 useState() 的情况(Open in CodeSandbox),但这要求我们保证 useState() 的调用顺序,所以我们不能在循环、条件或嵌套函数中调用 useState() ,这在 React.useState() 同样要求,官网还给出了专门的解释

  6. 让我们的 setState() 也支持函数式更新惰性初始stateOpen in CodeSandbox

  7. 通过 Object.is() 比较算法来判断 _state 是否需要更新:Open in CodeSandbox

2023-3-11更新

细心的同学可能在第4步是就已经发现问题了——判断 _state 是初始赋值还是处于更新阶段时,上文使用的条件是 _state === undefined ? initialState : _state[curIndex] 。那么这样就会导致一个问题——当我想要更新的值就是 undefined 时,岂不是会被判定为是“初始赋值”并被更新值为传入的 initialState 而非当前想要的结果。
那么如何把这个条件判断写得更合理呢?结合第5步,我们修改代码如下:

// 使用hasOwnProperty去判断当前索引是否被创建,如果有这个索引,说明已经对这个state赋初始值了,如果没有,则说明是新创建的,需要使用initialState赋初始值。
_state[curIndex] = !_state.hasOwnProperty(curIndex) ? initialState : _state[curIndex];

最终版代码:useState final version - Open in CodeSandbox

至此,我的模拟实现已结束。而实际上, React 并不是真的是这样实现的。上面提到的 _state 其实对应 React 的 memoizedState ,而 _index 实际上是利用了链表。有兴趣的同学可以自己去读源码或者参阅这篇博客

总结

1
const [state, setState] = React.useState(initialState);
  • React 16.8.0 正式增加了 Hooks ,它为函数组件引入了 state 的能力,换句话说就是使函数组件拥有了 Class 组件的功能。
  • React.useState() 返回的第二个参数 setState 用于更新 state ,并且会触发新的渲染。同时,在后续新的渲染中 React.useState() 返回的第一个 state 值始终是最新的。
  • 为了保证 memoizedState 的顺序与 React.useState() 正确对应,我们需要保证 Hooks 在最顶层调用,也就是不能在循环、条件或嵌套函数中调用。
  • React.useState() 通过 Object.is() 来判断 memoizedState 是否需要更新。

参考资料:

以上 React 的参考资料的中文版直接在前面加“zh-hans.”即可,即 https://zh-hans.reactjs.org/… ,不过似乎要翻墙才可以😂

从 npm 引入 React Hooks 轮子库报错 Minified React error#321 的解决方法

最近把自己(未完待续)的轮子库 cui-demo 尝试发布到 npm 上,在测试项目中尝试引用时,报了一个无情的错。
Minified React error #321

「不看废话版」

排除了我的代码问题后,这个报错的原因应该是我的轮子库没有成功获取到测试项目(宿主环境)的依赖 reactreact-dom 。解决方法如下:

  1. 在 webpack 配置中将 reactreact-dom 标记为 externals(这同时要求 output.libraryTargetumd ),使轮子库可以在运行时获取到宿主环境的依赖。即
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // webpack.config.js
    module.exports = {
    // ...
    externals: {
    react: {
    commonjs: 'react',
    commonjs2: 'react',
    amd: 'react',
    root: 'React',
    },
    'react-dom': {
    commonjs: 'react-dom',
    commonjs2: 'react-dom',
    amd: 'react-dom',
    root: 'ReactDOM',
    },
    }
    };
  2. package.json 中为 reactreact-dom 添加同伴依赖 peerDependencies 的映射,这是为了检测宿主环境中这两项依赖的版本如果低于你规定的最低版本,那么在 npm@3 中会给出警告(npm@1 和 npm@2 中会自动安装)。配置如下:
    1
    2
    3
    4
    5
    6
    7
    {
    // ...
    "peerDependencies": {
    "react": "^16.8.4",
    "react-dom": "^16.8.4"
    }
    }

Debug 开始:

为什么说这个错误提示无情呢?让我们来慢慢走进这个报错世界:

  1. 它让我转到 https://reactjs.org/docs/error-decoder.html/?invariant=321 去看完整信息。好,我知道这个报错是非法 Hook 调用(Invalid Hook Call)的问题了。

  2. 然后我继续看 https://fb.me/react-invalid-hook-call 提供的 debug 方法。

    • Breaking the Rules of Hooks
      违反 Hooks 规则的错误,我用我的狗头保证,不是的🐶。

    • Mismatching Versions of React and React DOM
      React DOM 版本与 React 不匹配(低于16.8.0),也排除了。(见下图)

    • Duplicate React
      意外地引入了两个 React ,还是排除了。(见下图)

      根据官网进行的debug

      而且我的轮子库的 package.json 中对 reactreact-dom 的版本设置也是符合要求的:

      1
      2
      3
      4
      5
      6
      7
      8
      {
      // ...
      "devDependencies": {
      // ...
      "react": "^16.8.4",
      "react-dom": "^16.8.4"
      }
      }

      在这两个证据之下,我不得不怀疑自己的狗头是不是保不住了… 难不成我真的违反了 Hooks 的规则??不应该呀… 如果真是这样,那我平时自己写轮子运行的时候就会报错不通过了呀…

  3. 带着疑问,我决定去参考 ant-designelement-uipackage.json 文件,看看它们是如何配置依赖的。后来发现它们还在 peerDependencies 中指定了兼容的版本号。虽说我的轮子库对 react / react-dom 的依赖宽松到 >=16.8.4 <17.0.0 ,完全兼容测试项目的 ^16.12.0peerDependencies 在这应该不起作用,但为了防止其他用户使用我这个轮子库时宿主环境的依赖版本低于要求,我还是得给 package.json 添加这个映射:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {
    ...
    "devDependencies": {
    ...
    "react": "^16.8.4",
    "react-dom": "^16.8.4"
    },
    + "peerDependencies": {
    + "react": "^16.8.4",
    + "react-dom": "^16.8.4"
    + },
    "dependencies": {}
    }

    抱着“瞎猫碰上死耗子”的心态,我重新把更改后的轮子库发布到 npm 中。但最后这个更改果然并没有让 #321 的报错消失。

    无情展露无余。


在几次顽强地挣扎和重试后,我真的摸不着头脑了。谷歌了很多篇文章,终于,老天不负有心人,我终于找到了问题所在——原来项目中真的存在着多个 react 实例, cui-demo 打包时 webpack 把 react 一起把它打包进 bundles 里了:

webpack

为了解决这个问题,我应该在 webpack 构建时将 reactreact-dom 标记为 externals (外部扩展),这样它们就不会被打包进 bundles 中,而是在运行时自动从宿主环境中获取这些扩展依赖。配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// webpack.config.js
module.exports = {
// ...
output: {
path: path.resolve(__dirname, 'dist/lib'),
library: 'CUI',
libraryTarget: 'umd' // 当扩展依赖是对象({ root, amd, commonjs, ... })时,libraryTarget必须为'umd'。查看:https://webpack.js.org/configuration/externals/#object
},
externals: {
react: {
commonjs: 'react',
commonjs2: 'react',
amd: 'react',
root: 'React',
},
'react-dom': {
commonjs: 'react-dom',
commonjs2: 'react-dom',
amd: 'react-dom',
root: 'ReactDOM',
},
}
};

webpack 新增 externals 配置

好的,再重新发布看看。

YES!终于成功了!在 webpack 中排除依赖打包进外部 bundles 中即可解决我测试项目中 Minified React error #321 的报错~戳体验一下新鲜出炉的轮子库 cui-demo 吧!


参考资料:

原来React里的key这么有用!

相信React用户在撸代码的日常中,一定遇到过这个报错:

React中常见的key属性警告

嗯,没错了,熟悉的配方,熟悉的报错~ 有些小伙伴看到是「Warning」也就置之不理了,有些凭经验反手给元素加上一个 key={唯一的值} 属性就迅速解决这个报错了。

不知道有多少小伙伴知道为什么React这么看重这个 key 属性,反正我以前凭直觉添加个唯一的id,再不济用索引就完事了。今天才明白,原来 key 这么有用,而且用好了还能避免性能问题甚至是 Bug !

Key 为何而来?

在正式开始解释 key 属性之前,我得先说说React的Diffing算法。为了避免本文重心偏离及篇幅太长,我打算概括性地介绍一下这个过程:

首先检查根元素的类型。

  1. 如果类型不一样,则卸载整个旧的DOM树,并重建新的DOM树。
  2. 如果类型一样,React会检查两者的属性。如果属性有改变,就更新相应的属性。
  3. 在比较完该节点后,递归地检查其子节点。默认情况下,React会同时遍历旧树和新树的子节点列表,在存在差异时则触发一个变化。
    例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <ul>
    <li>first</li>
    <li>second</li>
    </ul>

    <ul>
    <li>first</li>
    <li>second</li>
    <li>third</li>
    </ul>
    React成功匹配两个 <li>first</li> ,然后有成功匹配两个 <li>second</li> ,下一步发现新树多了 <li>third</li> ,于是插入该新的子节点。但是如果我们是这样去插入一个新的节点,性能将会降低:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <ul>
    <li>first</li>
    <li>second</li>
    </ul>

    <ul>
    <li>third</li>
    <li>first</li>
    <li>second</li>
    </ul>
    React不会按我们的预期那样将 <li>first</li><li>second</li> 当作是原有的子节点,它会将三个 <li> 都当成新的变化来更新。毕竟当数据量大了之后,这将会是一个很大的性能问题。

那么为了解决这个问题,React提出使用 key 属性来匹配旧树和新树的子节点。例如:

1
2
3
4
5
6
7
8
9
10
<ul>
<li key="1">first</li>
<li key="2">second</li>
</ul>

<ul>
<li key="3">third</li>
<li key="1">first</li>
<li key="2">second</li>
</ul>

这样一来,React在检查到这些子节点时,就知道key为 "3" 的节点是新项,而 "1""2" 是老朋友了,大家挪个位欢迎新朋友就座就好了。

Key 如何设置?

  • 一般来说,我们会将 key 的值设置为数据的「id」。例如 <li key={todo.id}>{todo.content}</li>
  • 如果没有id的话,我们可以为数据模型添加新的id属性,或者对部分内容进行hash处理,以生成每一项的key。
    PS: key 仅在它的邻项之间要求唯一,并不具有全局性哦!
  • 再不济,我们可以用索引index来作为key值,但是这个方法在一些情况下容易出Bug!例如说在需要重新排序时,由于组件示例的更新和复用是以它们的key为根据的,所以当我们对项目重新排序时, key 由于索引的变化也会跟着改变,而诸如非受控组件的状态却不会随之一起改变。官方给出的两个例子就能很好地说明问题:以index为key v.s. 以id为key
    当然,如果我们将第一个例子中的 <input/> 改为受控组件,也可以避免这个问题(送上我改写第一个例子的传送门)。

在长篇大论的铺垫中,终于说完了那么一丁点儿正文。本文就当作是官方文档的一个不对口的翻译吧,如果有理解错误或者不够深入的地方敬请指出~!送上官方原文。也欢迎大家给我多排除「知识陷阱」~

END.