一、项目介绍和项目规范

网易云音乐项目:由网易云音乐团队发起,30多个前端(目前),经历过数年版本迭代最终完成的一个产品。
 
项目规范:项目中有一些开发规范和代码风格
  1. 文件夹、文件名称统一小写、多个单词以连接符(-)连接;
  1. JavaScript变量名称采用小驼峰标识,常量全部使用大写字母,组件采用大驼峰;
  1. CSS采用普通CSS和styled-component结合来编写(全局采用普通CSS、局部采用styled-component);
  1. 整个项目不再使用class组件,统一使用函数式组件,并且全面拥抱Hooks;
  1. 所有的函数式组件,为了避免不必要的渲染,全部使用memo进行包裹;
  1. 组件内部的状态,使用useState、useReducer;业务数据全部放在redux中管理;
  1. 函数组件内部基本按照如下顺序编写代码:
    1. 组件内部state管理;
    2. redux的hooks代码;
    3. 其他组件hooks代码;
    4. 其他逻辑代码;
    5. 返回JSX代码;
  1. redux代码规范如下:
    1. 每个模块有自己独立的reducer,通过combineReducer进行合并;
    2. 异步请求代码使用redux-thunk,并且写在actionCreators中;
    3. redux直接采用redux hooks方式编写,不再使用connect;
  1. 网络请求采用axios
    1. 对axios进行二次封装;
    2. 所有的模块请求会放到一个请求文件中单独管理;
  1. 项目使用AntDesign
    1. 项目中某些AntDesign中的组件会被拿过来使用;
    2. 但是大部分组件还是自己进行编写;
  1. 其他规范在项目中根据实际情况决定和编写;
 

二、前置问题的提出和解决

(一)为什么看不到 React devtools 标记

例如分别打开网易云音乐网站和知乎网站,知乎网站的React devtools会出现提示,但是网易云中并没有相关提示。
notion image
 
关闭React devtools的方法如下:
Flag to disable devtools
Updated Sep 10, 2019
 

(二)数据可变性的问题

在React开发中,我们总是会强调数据的不可变性:无论是类组件中的state,还是redux中管理的state;事实上在整个JavaScript编码过程中,数据的不可变性都是非常重要的。
假如现在数据的可变性引发的问题(案例):我们明明没有修改obj,只是修改了obj2,但是最终obj也被我们修改掉了。原因非常简单,对象是引用类型,它们指向同一块内存空间,两个引用都可以任意修改。
解决问题:进行对象的拷贝即可:Object.assign 或扩展运算符。但是从代码的角度来说,没有问题,也解决了我们实际开发中一些潜在风险;从性能的角度来说,有问题,如果对象过于庞大,这种拷贝的方式会带来性能问题以及内存浪费。
 
*️⃣ 认识ImmutableJS
为了解决上面的问题,出现了Immutable对象的概念:Immutable对象的特点是只要修改了对象,就会返回一个新的对象,旧的对象不会发生改变。为了节约内存,又出现了一个新的算法:Persistent Data Structure(持久化数据结构或一致性数据结构)
但是需要注意,持久化≠数据被保存到本地或者数据库,实际上这里的意思是用一种数据结构来保存数据,当数据被修改时,会返回一个对象,但是新的对象会尽可能的利用之前的数据结构而不会对内存造成浪费。这里可以通过结构共享实现。
ImmutableJS下的数据变化展示
ImmutableJS下的数据变化展示
底层原理来说,ImmutableJS 使用了一种称为 Hash Array Mapped Trie (HAMT)的数据结构,这是一种 32-叉树。
 

三、项目启动

(一)项目布局

1. 创建项目

首先使用脚手架创建项目 create-react-app web-music,替换掉自己的图标和删除部分冗余的代码。
💡
假如我们需要使用到Typescript,我们可以直接使用以下命令进行默认的Typescrpt项目,安装:create-react-app my-app --template typescript;假如我们需要使用Antd的模板,我们使用 yarn create react-app antd-demo --template typescript

2. 配置 .prettierrc 格式化代码

安装插件:yarn add prettier —dev

3. 划分文件结构

项目目录划分
项目目录划分

4. 重置和统一样式

为了不同的浏览器相同的适配问题,我们需要对CSS进行统一化处理,此时需要用到一个CSS重置工具。
normalize.css
necolasUpdated May 21, 2024
我们在项目脚手架的环境下,可以直接使用yarn进行安装:yarn add normalize.css
然后我们在src/css文件夹中创建reset.css文件,导入重置工具。

5. 加入 Typescript 的支持

💡 通用方法
安装Typescript:yarn add --dev typescript
初始化TypeScript配置文件:在你的项目根目录下,运行以下命令来创建一个tsconfig.json文件:npx tsc --init
配置的具体内容如下网站所示:
 
对于React而言,官方提供了Typescript的支持,详见以下书签
需要在 tsconfig.json 文件中进行如下配置
💡
关于 jsx 属性的配置说明:
react:这个选项会将 JSX 转换为 React.createElement 调用。这是 React 16 及更早版本的默认行为。
react-jsx:这个选项会将 JSX 转换为 React.jsxReact.jsxs 调用。这是 React 17 及更高版本的默认行为。这种转换方式提供了更好的性能和更丰富的开发者体验。

6. 配置别名

之前我们提及到React脚手架已经隐藏了相关配置,所以我们也需要其他工具进行配置额外的webpack信息,首先安装craco:yarn add @craco/craco,然后修改package.json修改我们的项目启动方式。
然后需要在根目录下创建craco.config.js文件进行配置webpack
为了让Typescript识别到 @,我们需要在tsconfig.json里面加入一些配置,⚠️特别提醒需要在 compilerOptions 属性中加入。
 
以下是carco配置的官网

7. 代码提交检查 .commitlint

如果我们希望在代码提交时进行检查,可以使用Git Hooks和一些相关的工具,如Husky、Lint-staged和Commitlint
安装Husky:Husky是一个可以简化Git Hooks创建和修改的工具。安装:yarn add husky --dev
安装Lint-staged:Lint-staged是一个在Git提交代码之前,对暂存区的代码执行一系列的格式化的工具。安装:yarn add lint-staged --dev
安装Commitlint:Commitlint是一个用于校验Git Commit Message是否符合规范的工具。安装:yarn add @commitlint/cli @commitlint/config-conventional --dev
 
安装完成后,我们在项目中进行一些修改:
配置Husky:在你的项目的 package.json 文件中,添加以下配置:
配置Lint-staged:在你的项目的 package.json 文件中,添加以下配置:
配置Commitlint:在你的项目的根目录下,创建一个名为 commitlint.config.js 的文件,并添加以下配置,rules规定了代码提交的前缀。

8. 安装路由

首先安装路由的核心代码,命令行执行:yarn add react-router-dom。然后假如需要将路由放在统一的位置进行管理的话,我们需要另外安装 yarn add react-router-config
 
⚠️ 出现报错:
在 react-router-dom 的v6版本中,Switch 组件已经被替换为 Routes 组件。如果正在使用 react-router-dom 的v6版本,你应该使用 Routes 而不是 Switch
然而,react-router-config 库目前还不支持 react-router-dom 的v6版本,它仍然需要Switch 组件。所以,如果你需要使用 react-router-config,你可能需要降级你的 react-router-dom 到v5版本。
解决方法:

9. 样式的调整

按照之前所示的操作安装,安装styled-components,yarn add styled-components
假如我们需要对组件的样式进行修改,我们在组件的目录下创建style.js的文件,专门用于编写样式相关代码。

10. 安装组件库 Antd

安装命令:yarn add antd
假如我们需要使用Antd的图标Icon,此时需要安装 yarn add @ant-design/icons

11. 网易云接口

12. 网络请求

安装网络请求库axios,yarn add axios

13. Redux的安装和使用

我们需要安装Redux相关库,前者是Redux的核心代码,中间是将React组件和Redux进行连接,后者是通过Redux异步发送网络请求。安装:yarn add redux react-redux redux-thunk
为了提高开发效率官方建议使用 @reduxjs/toolkit,安装:yarn add @reduxjs/toolkit,以下是其部分的优点:
  • 配置 store:Redux Toolkit 提供了 configureStore() 函数,可以简化 store 的配置,并提供一些默认的配置项。
  • 定义 reducer:使用 createReducer()createSlice() 函数,可以简化 reducer 的定义和创建。
    • createReducer() 帮你将 action type 映射到 reducer 函数,而不是编写 switch...case 语句。另外,它会自动使用 immer 库来让你使用普通的 mutable 代码编写更简单的 immutable 更新,例如 state.todos[3].completed = true
    • createSlice() 接收一组 reducer 函数的对象,一个 slice 切片名和初始状态 initial state,并自动生成具有相应 action creator 和 action type 的 slice reducer。
  • 内置 Redux 插件:例如用于异步逻辑的 Redux Thunk,用于编写选择器 selector 的函数 Reselect 。
 
 
以下是React@16的写法,最原始的写法:
以下版本引入hooks
 
💡 假如我们现在新增一个网络请求并需要保存在Redux中,建议按照以下顺序进行添加代码:
  1. 在 /service 文件夹中,在对应的分类文件下添加网络请求
    1. 在组件下的 /store/constants.ts 文件中定义常量
      1. 在组件下的 /store/reducer.ts 加入以下代码
        1. 然后我们编写关于发起请求的代码
          1. 为了能将我们请求到的数据保存在Redux中,我们需要在 /store/actionCreators.ts 添加保存的操作
            1. 最终可以直接使用保存了的Redux的数据
               
              🔎 useSelector比较源码解析
              useSelector 函数默认使用 === 进行比较,以检测前后值的变化。也允许提供自定义的比较函数。这是通过 equalityFnOrOptions 参数实现的。如果这个参数是一个函数,那么它就会被用作比较函数。否则,如果这个参数是一个对象,那么对象中的 equalityFn 属性(如果存在)将被用作比较函数。
               

              14. 深浅比较以及==、===运算符

              💡 ==、===、浅比较和深比较的补充
              1️⃣ 关于 == ===
              对于 ===== 而言,他们在对象和数组的前提下都属于引用比较(比较内存地址)
              • == 是抽象相等比较,在比较前会进行类型转换(如果需要)。例如,当你比较一个字符串和一个数字时,JavaScript会尝试将字符串转换为数字,然后进行比较。
              • === 是严格相等比较,不进行类型转换,如果两个值的类型不同,那么它们就不相等。
               

              2️⃣ 浅层比较
              参考资料:
              关于浅比较,React官方实现的浅比较 shallowEqual.js
               
              对于第一部分,使用 Object.is() 而不是 === 的原因是:
              Object.is()=== 虽然基本相同,但是有两个例外:
              • Object.is 将+0和-0当作不相等,而 === 把他们当作相等
              • Object.is 把 Number.NaN和Number.NaN当作相等,而 === 把他们当作不相等
              所以,上面的 is() 方法就是对+0、-0、Number.NaN进行了特殊处理。
              即如果两个参数objA和objB有相同的值(基本类型值相等,引用类型引用相等),则它们会被认为相等。
               
              第二部分还需要检查是否其中一个参数不是对象或者是 null。前一个检查确保我们处理的两个参数是对象或数组,而后一个检查是过滤掉 null,因为 typeof null === 'object'。这个过程存在的原因是为第三部分比较两个复杂的数据结构做准备。
               
              第三部分确定只处理数组和对象的比较。首先,我们简单比较它们的键的数量是否相等。如果键的数量不相等,直接返回 false,不用进入第四部分,这样可以提高算法的效率。
               
              shallowEqual的核心部分。我们根据key遍历两个objA和objB并逐个比较value是否相等。基于上一步中生成的键数组 keysA,使用 hasOwnProperty 检查key是否实际上是对象自身的属性,并使用 is() 函数进行比较(判断引用是否相等)。
              只要一个value的引用不相等,那么shallowEqual就返回false。全部相等,返回true。
               
              以下测试 shallowEqual() 的实际效果:
              我们用prevDeps1、nextDeps1、prevDeps2和nextDeps2来模拟hooks的依赖数组。当对数组进行浅比较时,实际上是对其每个索引进行严格相等比较。
              • 基本类型company1和company2相等,因此prevDeps1、nextDeps1浅比较结果为true;
              • 对象person1和对象person2引用不相等,因此prevDeps2、nextDeps2浅比较结果为false。
              我们用prevProps1、nextProps1、prevProps2和nextProps2来模拟组件的props。同样,当对对象进行浅比较时,实际上是对其每个键值进行严格相等比较。(这种前提下的数据表明浅层比较对于部分的数据结构仍然是适用的
               

              3️⃣ 浅比较 VS ===
              浅比较=== 的区别是:浅比较是对数据所有键(索引)对应的值进行严格相等比较(使用 ===Object.is() ),而 === 是对数据本身进行严格相等比较
              以下面的数组比较为例:
              • arr1与arr2是不同的变量,引用(内存地址)不同,严格相等比较结果自然为false。
              • 当arr1与arr2进行浅比较时,person1与person1是同一变量,严格比较结果为true;company1与company2都为基本数据类型,值相同,严格比较结果也为true,所以arr1与arr2浅比较结果为true。
               

              4️⃣ 出现弊端
              但是我们从以下的React测试案例中就能发现问题。在demo中,我们用React.memo包装了 <App/> 的子组件 <ChildMemo/>。点击按钮后,state变化,触发重渲染,这时经过React.memo包装的组件会对props进行浅比较。如果props发生变化,则子组件也会重新渲染。
              子组件一prevProps为 {value: "子组件一"},nextProps为 {value: "子组件一"},shallowEqual结果为true,因此不渲染。
              子组件二prevProps为 {value: {data: "子组件二"}},nextProps为 {value: {data: "子组件二"}},shallowEqual结果为false,因此重渲染。
              所以我们可以看到问题在于对象的嵌套层级
               
              JS中的对象是可以嵌套的(有时层次会非常深)。如果在这种情况下我们需要比较两个对象的内容,浅比较就不能很好地发挥作用了。
              由于嵌套对象 prevProps2.person 和 nextProps2.person 是不同的对象实例,引用不同。因此,即使prevProps2和nextProps2具有相同的内容,shallowEqual(prevProps2, nextProps2) 也将会返回false。
              进行嵌套对象内容比较时需要用到深比较
               

              5️⃣ 深比较
              以下是深比较的一种实现
              ⚠️ 深比较浅比较相似,不同之处在于,当属性中包含对象时,将会对嵌套对象执行递归浅层比较。
               
              当然,在实际开发过程中,我们不用重复造轮子,需要深比较方法时可以直接调用如Node内置util模块的 isDeepStrictEqual(object1, object2) 或lodash库的 _.isEqual(value, other)_.isEqualWith(value, other, [customizer])
               

              6️⃣ 总结
              1. 引用比较(使用 =====Object.is() )用来确定操作数是否为同一个对象实例。
              1. 手动比较对象是否相等,需要针对属性值进行手动编写比较函数比较,因此这种方法非常灵活。
              1. 当被比较的对象有很多属性或无法提前确定对象的结构时,更好的方法是使用浅比较浅比较=== 的区别是:浅比较是对数据所有键(索引)对应的值进行严格相等比较(使用 ===Object.is() ),而 === 是对数据本身进行严格相等比较
              1. 如果比较的对象具有较深的嵌套层次,而我们只需要关注内容的(完全)一致性时,则应该进行深比较
               

              15. ImmutableJS

              具体的理论知识见上,以下是部分常见的API的使用
              • 对于对象-Map使用
              • 对于数组-List使用
              但是要注意的是,对于对象而言,Immutable.Map() 函数只能实现浅层转化,即对象中的对象还是普通的js对象,若想实现全部转换我们需要使用以下方法:
               
              项目中引入 ImmutableJS,首先需要安装 yarn add immutable,然后引入部分需要的API的函数,因为在项目中我们比较少对其中的数据进行直接修改,所以使用 fromJS() 方法比较浪费性能,所以使用 Map() 方法。
              此外,Redux官方为了打通Redux需要的数据和immutable数据的传递,推出了另一个库,我们需要安装 yarn add redux-immutable
              由于JS对象和ImmutableJS数据结构不一样,所以部分代码需要进行修改:
               
              🔎 redux-immutable 库中的 combineReducers 函数的源码解析
               

              16. Redux Toolkit 对 Redux 优化

              1️⃣ 根组件App代码基本没有变化,见上
               

              2️⃣ 配置全局store的入口index.js如下,从原来需要thunk中间件到现在不需要手动引入,因为Redux Toolkit 默认包含 thunk 中间件
              『新版』ToolKit加持简化版本
              如果我们需要使用新版和旧版的写法一起使用,我们可以使用 configureStore()热重载
              『旧版』手动配置版本
               
               

              3️⃣ 对于某个组件中的reducer.ts
              createSlice() 是Redux Toolkit的一个函数,它接受一个初始状态、一组reducer函数和一个"slice name",并自动生成与reducers和state对应的action creators和action types。这个API是编写Redux逻辑的标准方法。
              以下是 createSlice 的主要参数:
              • name:一个字符串,用于此状态切片的名称。生成的action类型常量将使用此名称作为前缀。
              • initialState:此状态切片的初始状态值。
              • reducers:包含Redux "case reducer"函数的对象(旨在处理特定的action类型,相当于switch语句中的单个case)。对象中的键将用于生成字符串action类型常量,这些常量将在它们被调度时显示在 Redux DevTools 扩展中。
              • extraReducers:一个「builder callback」函数,用于添加更多的reducers。
              createSlice 返回的对象包括:
              • actions:对应于reducer函数的action creators。
              • reducer:一个reducer函数,适合作为Redux store 的一部分。
              在你提供的代码中,createSlice 用于创建一个名为 'recommend' 的slice,其中包含两个extra reducers:fetchTopBanners.fulfilledfetchHotRecommend.fulfilled。这两个reducers分别处理 fetchTopBannersfetchHotRecommend 的fulfilled状态,更新 topBannershotRecommends 的状态。
               

              4️⃣ 对于某个组件的actionCreator,代码如下。需要注意⚠️,createAsyncThunk() 函数第一个参数是一个字符串,传入空字符串并不会影响程序执行,但是在Redux的浏览器插件中,action记录并没有记录,具体效果如下:
              传入第一个参数
              传入第一个参数
              没有传入第一个参数
              没有传入第一个参数
               

              5️⃣ 组件内方法的调用方式也需要进行更新
               

              17. ts代码技巧

              1️⃣ 使用type类型检测替代prop-types库

               

              2️⃣ axios返回值的ts类型定义

              当我们使用axios发送网络请求,假如后端返回的数据并不是标准的RESTful风格API,那么使用ts类型推断可能会出现问题。
              以下是Axios Response定义的类型:
              假设后端给我们返回的结果如下,我们可以发现返回的结果并没有 data 这个标准字段,取而代之的是 result
               
              💡 所以我们需要通过以下的定义使ts能够识别新的类型:
              以下是在Redux中调用:
               

              3️⃣ 关于hooks的类型声明

              需求:我们使用Antd UI库时,想通过按钮控制走马灯Carousel,进行图片的切换。但是ts无法进行相关的类型推断,以下是简化版代码:
              此时我们我们需要对变量 pageRef 进行类型声明,这个需要结合Antd的Carousel组件的类型声明。
              首先看React框架对Ref元素的定义 MutableRefObject<T>,包含 current 属性的定义,然后我们再看Antd框架的定义,从我们需要使用的 prev() 方法入手我们可以看到组件已经声明了的接口 CarouselRef,这就是我们需要指定的 T 值。
              所以,最终我们声明的代码写法如下:
               

              4️⃣ styled-components中的类型声明

              我们在使用styled-components时可能需要给组件传入一些宽度,尺寸大小等参数,如以下代码:
              但此时参数props会出现报错,主要是类型匹配检验不成功
               
              💡 解决方法:
               

              18. 复杂请求案例

              以首页排行榜为例,需要通过两次有顺序的请求才能获取数据。首先通过 /toplist/detail 获取整个排行榜单数据,然后根据每个 id,通过 /playlist/track/all?id= 获取对应的单个排行榜列表。
              思路:因为我们最终目的是获取到多个具体的排行榜数据,所以我们可以将两次请求放在同一个Redux流程中进行实现。⚠️ 以下代码实现忽略类型声明
               
              actionCreators 中进行网络请求,对于 store 中数据的整理,我们放在 reducer 中进行
               
              💡 在reducer中,我们可以通过 action.meta.arg 获取上述函数执行第二个 async 匿名函数传入的参数
               
              剩下的就是函数的调用,我们在View中进行调用即可
               

              19. 路由懒加载与分包

              普通的路由加载可能会导致在等大型项目中一次性请求太大的内容导致首屏加载速度很慢,所以,我们可以采用React提供的懒加载。
               
              但是加上这个代码之后可能会出现新的报错:
              A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.
              出现报错的原因是React认为我们异步加载组件时,可能会因为体积问题出现UI异常,其认为我们需要加载一个Loading指示器,具体代码如下,将路由代码使用 <Suspense> 组件进行包裹。
               

              四、Bug修复及记录

              (一)关于 Key 的 ts 报错

              报错内容:
              补充代码:
               
              解决方案:使用「断言 as」进行声明

              五、项目部署

              (一)项目打包

              对于使用脚手架创建的项目,打包是一件非常容易的事情:yarn build
              需要注意旧版本的create-react-app脚手架,Webpack会生成runtime~main.[hash].js文件,这个文件包含了Webpack运行时的代码。但在新版本中,这部分代码被合并到了main.[hash].chunk.js文件中。
              💡
              随着业务逻辑代码越多,main会变得非常臃肿。很多模块其实没有必要一开始就进行加载,会影响首屏加载速度。所以我们可以让组件进行懒加载,具体的逻辑见上19. 路由懒加载与分包
               

              (二)项目部署

              1. 手动部署

              1️⃣ 关于将build文件夹上传至服务器:
              2️⃣ 连接远程服务器有三种方法:
              3️⃣ 关于Linux服务器端配置Nginx参考

              2. 部署Bug记录

              1️⃣ 管理工具
              我们在开发中使用yarn管理工具辅助开发,假设在服务器端使用npm安装依赖并打包可能会出现错误,本次按照上述情况打包就会出现问题:
              全局安装yarn在重新执行安装和打包流程就没有出现问题
               
              2️⃣ 打包配置
              通过脚手架开发完成直接打包可能会出现以下错误:
              原因是在React项目中,CI=false 是一个环境变量,主要用于控制构建过程的行为。当设置为 true 时,Create React App会将警告视为构建失败。这在持续集成(Continuous Integration,简称CI)环境中非常有用,因为它可以帮助开发者确保代码的质量。
               
              为了我们打包成功我们可以修改 package.json 文件
               
              3️⃣ 打包后html路径设置问题
              打包后我们可以看到 index.html 文件对静态文件的引用如以下所示:
              这个细节可能会引起Nginx反向代理出现问题,假设我们通过访问 example.com/music 访问 /web-music 文件夹,但是这种写法会导致请求静态资源的时候,应该访问 /web-music/static 变成访问 /static。
              解决办法:package.json文件加入以下代码

              3. Jenkins自动化部署

              我们使用 Jenkins 来完成自动化打包、部署过程。但需要注意 Jenkins 依赖 Java 环境,所以我们现需要提前安装和配置 Java 环境。
              1️⃣ 安装Java:
              2️⃣ 安装Jenkins:
               
              以下是自动化执行的Shell脚本,该项目是比较常规的前端项目安装依赖和打包,然后搭配Ngin隐藏端口号进行访问。
               
              💡 后续Docker优化
              为了防止项目原本打包占用的端口相互占用,我们通过Docker可以自定义外界访问服务器的端口,以下是Dockerfile配置文件。
              然后用以下命令进行部署
              🎉 最后,我们可以通过「域名/music」进行访问。💡 如果想直接通过域名访问参见
               
               
              notion image

               
              React-router & HookReact SSR
              Loading...
              😈Zabanya
              😈Zabanya
              一名喜欢瞎折腾选手
              公告
               
              部分教程类文章篇幅过大,可能会导致加载时间稍微偏长,非常感谢您的耐心等待 ~ 🎉