跳到主要内容

· 阅读需 8 分钟
youniaogu

前言

这篇文章主要是记录我第一次在 React-Native 上遇到的内存泄露问题,以及我是如何复现、定位和解决

这个问题是一个网友发现的,他在 Github 上提了个 issues,说自己使用批量更新在 200 本漫画更新至 130 本左右时应用闪退,并出现下面这些报错

MangaReader[34387:6766278] [javascript] ┘
W1105 14:09:47.729962 1810935808 JSIExecutor.cpp:378] Memory warning (pressure level: 1) received by JS VM, unrecognized pressure level

可以看到这个警告是在说应用内存占用过高,结合他说的应用闪退,很可能是因为内存泄露导致内存占用太高,最后进程被系统强制关闭

在初步确认问题后,要开始想办法复现问题

复现问题

他在 v0.6.3 版本遇到这个问题,更新至 v0.6.6(最新版)问题依旧存在,然后使用的是平板并且是 ipados v16.3.1

我没有平板,模拟器和手机都还是 ios v15,所以有可能无法复现

开始我用自己添加的 420 本漫画进行测试,没有遇到闪退,怀疑可能测试数据不对,于是找网友要备份数据,但也没能复现

图片有点模糊,但从最左边的 RAM 可以看到,更新完后内存稳定在 500M 左右,没有遇到闪退的问题(这里虽然没有闪退,但已经存在内存泄露,只不过运气好没溢出而已,后面会解释

然后网友提供的视频录制里可以看到 RAM 已经去到了 1600M,下一秒应用进程就被杀死然后闪退

没能复现,我开始怀疑是不是 OS 问题,于是我去谷歌搜索相关内容,我甚至还检查了备份数据内容,但都没能发现有用的信息

定位问题

虽然没能复现问题,但我很确定这是内存泄露,所以我决定跳过问题复现,直接检测内存泄露,用的工具是 Xcode 的 Instruments

下面是我第一次的内存泄露内存分配的检测截图

可以看到内存占用一直在增加,从最开始的 50M 飙到了 500M,中间有内存回收的迹象,但总体是在上升的,是很明显的内存泄露但 Leaks 却没有提示

既然 Leaks 没能检测出泄露,那么我只能自己在内存分配里查找线索注意蓝色的用户图标,它代表这部分是我们创建的代码

注意横线部分关键字,bridge 应该是指负责 native 和 js 通信的部分,const 是 js 定义变量关键字,表明我们定义了一个 517M 的变量通过 bridge 传给了 native

右下角里可以看到有 dynamic const 的关键字,这很可能是在说我们定义了一个 447M 的变量,并且这个变量一直没有回收

最后综合分析一下,在批量更新过程中,我们定义了一个很大的变量交给 native,这个变量没有回收导致内存泄露,再结合代码中的业务逻辑,可以发现问题出在数据同步上面

解决问题

接下来说的内容涉及业务相关的,如果不感兴趣可以跳到 总结 部分

为什么可以断定是数据同步,因为数据同步符合上面提到的特征

  • 批量更新会加载漫画详情数据,加载完成后会触发数据同步
  • 数据同步会处理 Redux 里的数据,所以会定义很大的变量
  • 数据同步会把这些数据传给 native 并存在本地

接下来要确认和定位问题,方法很简单,用排除法把相关代码注释再去看内存分配情况

下面是注释数据同步后的内存分配检测截图

可以看到内存变化很平稳,虽然内存一直在增大,但内存最高只有 170M,同时有内存回收迹象

右下角也看不到上一个测试里的大变量,很显然问题就出数据同步

接下来我们需要再进一步的定位到具体代码,这一步我是基于业务和主观猜测找出来了

我重构了数据同步,并删除了数据同步的打断逻辑,因为我觉得频繁打断数据同步会造成任务中定义的备份数据内存泄露

然后下面是最终的内存分配检测截图

我们可以看到一座座美丽的内存回收小山

总结

至此内存泄露问题已经解决,但这过程中我也犯了一些小错误

其中我因为缺乏内存泄露的处理经验,错误判断了复现结果

最开始尝试复现的时候,我想复现应用闪退(内存溢出导致进程被杀死),但实际上只要不是长时间运行或者重复大量操作内存泄露不一定会导致内存溢出

所以第一次复现时已经成功了,只不过我的关注点错了,以至于后面我的关注点偏到备份数据和 OS 上面

最后还有个小疑问,为什么同样的备份数据,网友会内存溢出闪退,而我却不会?

事实上是因为网速不同

这些内存泄露的直接原因是数据同步被打断,导致中间生成的备份数据没法被释放,所以越频繁的打断数据同步,造成内存泄露量就越大

而我用的代理比较差,漫画加载速度比较慢,数据同步任务被打断次数较少,所以内存泄露量也较少,最终结果就是同样的备份数据,我这边复现操作不会闪退

· 阅读需 8 分钟
youniaogu

前言

上次写文章还是在 22 年 4 月,中间没有写并不是因为弃坑,而是我找到了想做的事

所以这次就来回顾下,过去这段时间里我做了哪些事情

App 开发

2022 年初开始,我一直致力于开发和维护 MangaReader 项目,从最开始的 v0.1.0,到现在最新的 v0.6.5,一共发布了 39 个版本,处理了 60+ 个 issues,10 个可用插件,完成了一次 React-Native 大版本升级,为相关依赖库贡献 issues 和 pr。在这期间,网友们也提出了很多不错的功能建议以及 bug 反馈,真的非常感谢 🙏

这是我的第二个 React-Native 项目,第一个也是类似的漫画 app,可惜的是我没能坚持维护下去,不过正因为有第一次失败的经验,让我更加清楚自己想要的效果是怎么样的

至于为什么要开发 MangaReader 这个项目,除了readme上说的兴趣使然外,根本原因是 ios 上没有满足我需求的 app,以及我想积累自己的跨平台开发经验

备考 N1

有这个想法是在 2023 年初的时候,真正开始备考则是从 2023 年 3 月说起,每天上下班路上背单词并看一到两篇 NHK 新闻,午休或空闲时间会看下日本新闻或节目(月曜日 👍),都是三十分钟以内的视频,晚上到家上网课学习、做阅读理解(有时候会维护开源项目),周六日类似不过会稍微放松些,睡个懒觉看看番自己做饭之类的

报考的是 12 月初的考试,距离考试只有 33 天工作日,6 个双休日。在这期间我要完成 45 节网课,10 套模拟真题,并预留一个星期时间做考前冲刺。因为工作的原因,我的空闲时间都压在了周六日,所以不能再想之前一样悠闲,要开始绷紧神经认真应对

为什么要考 N1,一方面是我对日本文化非常感兴趣,高中时就自学过一段时间的日语,高三报考志愿第一个填的就是日语专业(虽然最后落选了,不过也因祸得福,要是当时进了日语专业,我现在可能连饭都吃不起),并且大三时自学通过了 N3 入门考试,所以我心里一直有个念头要考过 N1

还有一方面是想提高个人竞争力,出来工作这么多年,越来越觉得,会一门外语真的很有优势。不过我的目标并不只是 N1 证书,我更希望能熟练运用日语,拒绝哑巴日语。就算 12 月的考试通过了,我也会继续背单词看书看新闻看节目还有口语练习

博客迁移

博客目前经历了三个版本,第一版部署在 github pages,使用gitbook打包,gh-pages发布,功能简单样式好看,但打包和发布工具已经不维护,经常会出现发布失败或打包失败的情况,非常头疼

第二版放弃自己打包和发布,全部交给gitbook 平台处理,维护是轻松了,但不开通会员功能有限,更最重要的是域名不能自己更改,随机的地址很丑 👎

第三版回归 github pages,打包和发布用docusaurus,aio 结构并且自由度高,基于 react 开发,随时都可以对它进行 diy,默认样式也很好看,自带一键打包发布,而且社区正在积极维护中。迁移也很方便,一个晚上的时间就搞好了,只不过目前还没有进行自定义,主题色和首页还是默认的

总结

开发和维护 RN 应用有一年半时间,在这期间我也面临着一些老生常谈的问题,web 开发者的局限性以及社区活跃度不足,这两点形成了组合拳,想要突破这些,就必须了解原生开发并具备自己开发 Module 的能力。所以在接下来的时间我要想办法突破这些,做出自己的 React-Native Module 或者为 RN 社区做出贡献

然后关于语言,其实在 IT 这个领域,英语比日语更有优势(非洲都有人说英语)。选择日语,只是因为对于我来说比较好上手,而且我的最终目的并不只是日语,英语也是其中之一,通过 N1 后我也会开始系统的学习英语包括口语

再聊下为什么要写博客,我的答案是巩固知识深度,以及锻炼组织表达能力。做技术的过程中我们会遇到很多问题,其中的难点往往不是解决问题,而是归纳总结,将复杂的问题简单化,并清晰的讲解给其他人听,让其他人遇到类似问题也能迎刃而解。在这个归纳总结的过程中你可能会无形间发散这个问题,这样子的话会怎样,那样子的话呢,以及怎么组织语言、怎么开头、怎么结尾、举什么样的例子或者画什么样图,让更多的人理解并深入这个问题,这时候就起到了巩固和锻炼的作用

最后也祝我 12 月 N1 考试顺利 ,加油!💪

· 阅读需 4 分钟
youniaogu

说谎的 console.log

1、前言

console.log,前端在调试时用得最多的小伙伴,但你可能不知道,它有时候也会说谎

之所以要说这个,原因是最近修 🐛 时,无意中发现console.log断点调试输出与非断点输出不一致

2、为什么 console.log 输出会不一致?

首先,我们要明确一个观念,console.log是同步的还是异步的?

理论上,console.log是同步的,他们实时输出数据,但在输出引用类型的数据,会有不同的行为

可以看到,引用类型数据如果存在嵌套时(图中红线),log 不会继续输出里面的数据,而是会省略

而且引用类型还可以展开,用来查看里面嵌套的数据,这是我们平时调试常用的动作

console.log输出不一致的原因,就在于展开这个动作

之前console.log输出的数据并不完整,只能说是一种快照。而在展开时,则会往堆内存里拿最新的数据,这中间如果有对数据进行修改的话,就会出现异步的假象

举个 🌰

下面的代码,立即展开和三秒后展开会有不同的结果

const obj = { name: "start" };

console.log(obj);

setTimeout(() => {
obj.name = "after 3 seconds";
}, 3000);

回到最开始的问题,为什么断点和非断点调试,console.log输出会不一致

答案很明了,代码里存在对输出数据的修改,导致两次调试结果不一致。

而就结果来看,断点调试的输出才是正确的,也是我们想要的(都断点了,谁还看 log 呀)。

3、为什么会这么设计?

主要原因在于引用类型会存在循环引用的情况,一次性打印出来的话就会死循环,而如果缓存数据,则会占用资源

注意:以上测试与结论都基于 chrome 浏览器,不同浏览器实现可能不一样

4、总结

console.log在打印引用类型时,会立马输出当前的快照,并会省略嵌套的引用类型数据。而后面展开数据时,会往堆里拿取最新数据,同时也会缓存起来(后续开关不会更新数据)

并不只是console.log有这个特性,只要是涉及展开这个动作,就会存在

了解这个特性可以让我们 debug 少走弯路,但有一点要明白,最好的 debug 方法还得是断点

除此之外,良好的代码习惯也是必不可少的(前言里的问题是因为在 react 里直接修改 state,然后使用 forceUpdate 进行 render 导致,我真是佩服写出这个的老哥)

· 阅读需 5 分钟
youniaogu

如何处理 package-lock.json 与 yarn.lock

1、前言

package-lock.jsonyarn.lock分别是npmyarn在安装依赖后自动生成的文件,用来记录安装依赖的具体版本

每当执行npm installyarn install都会去创建或更新 lock 文件,将变更提交到 git 后经常会导致 merge,所以有些人会把 lock 文件写进.gitignore 里,但这个行为是有风险的

2、举个 🌰:

你独自负责一个项目,开发了几年,项目稳定,依赖也一直没有出现问题。某天项目扩招,来了个新同事,你十分开心,心想自己可以轻松一些了,但新同事在yarn install后,提示了好几个警告,项目也跑不起来

再来一个 🌰:

一个项目几年没动过,某天领导要求你去开发个小需求。半天不到你就搞定了,本地验证完成,提交代码至 dev,等待 ci 然后验证,心里暗想又可以滑几天水。但你突然发现 ci 没跑过(又或者是效果不对

问题在于依赖版本变更,新同事和 ci 上安装的依赖与你本地的依赖版本不同,导致结果有出入

上面例子出现的可能性低,但这些问题是确实存在的,而且随着时间推移,可能性就越大(本人遇到过第二种情况)

3、那么我们该如何避免这些问题?

简而言之,你需要锁死依赖版本,并不是在 package.json 里指定依赖版本,而是让依赖安装时以 lock 文件为主

首先保证 lock 文件提交到远程仓库(lock 文件只能提供信息,并没有锁死版本的功能),然后你需要使用npm ciyarn install --frozen-lockfile安装依赖(这些指令会根据 lock 文件安装依赖,当 lock 文件不存在或者 lock 文件与 package.json 有出入时会抛出错误)

4、约束:

上面说的都是规范,规范如果不被认可不被准守,那就没有意义,所以需要约束

  1. 用 yarn 还是用 npm?

用哪个都可以,甚至可以两个都用,但需要同时维护package-lock.jsonyarn.lock,可以使用yarn importsynp将 lock 文件进行转换,postinstall 里设置好转换的脚本

不建议同时维护两种,没啥好处,反而增加麻烦,而且混合使用会有警告提示

  1. 如何让团队成员只用 yarn 或 npm?

还真有,可以看下这个 => Force yarn install instead of npm install for Node module?,推荐使用only-allow

  1. 能不能让npm installyarn install默认锁版本,不更新 lock 文件?

添加--install.frozen-lockfile true.yarnrc,可以让--frozen-lockfile成为yarn install的默认参数

npm install没办法做到 yarn 那样

5、总结

最后总结下最佳实践流程:

1、请务必将 lock 文件提交至远程,为了自己也为了后面接手的同事(后面同事接手项目跑不起来,翻 git 记录也没有 lock 文件,就会很难受)

2、从 yarn、npm 中选择一个作为包管理器(建议 yarn),并用only-allow进行限制

3、如果是 yarn,请添加--install.frozen-lockfile true.yarnrc。npm 可以在团队内约定统一使用npm ci

4、定期升级版本,旧版本可能藏雷,定期升级可以在遇到之前排除

· 阅读需 7 分钟
youniaogu

小程序开发经验分享

这里主要是分享小程序开发时遇到的问题,以及解决方案。

1. 小程序的缺点

1.1 npm支持不完全

小程序只支持纯 js 库,而且必须有主入口(需要指定 main),相关文档

只要用到 babel 编译的库,都没办法直接引入到小程序里,必须手动引入构建后的包才行,但这样就失去 npm 版本管理的意义。

1.2 缺少状态管理

这里是指缺少类似于 redux、vuex、mobx 的能力,状态管理是开发大型项目必不可少的需求。

小程序的 storage 与 localStorage 类似,没有响应变化的能力。

1.3 缺少环境变量

环境变量主要是用来区分开发、测试、正式这三种,也可以决定一些库是否导入或使用。

小程序只能区分开发版、体验版、正式版,但把它们对标开发、测试、正式不太好,因为我们可能会在开发者工具里调试开发、测试、正式三种环境。

1.4 不会处理cookies

小程序请求时不会自动带上 cookies,返回的 reponse 里虽然有 cookies,但不会自动储存。

1.5 入口很多,缺少控制层

小程序启动时,App 的生命周期虽然第一个执行,但它不能阻塞其他页面,这意味它不能作为项目里的控制层(比如:登录完成前显示 loading,不显示页面)。

而且小程序的路由是平铺开来的,如果要做登录拦截,有多少个路由,就要写多少个拦截。

2. 如何应对

2.1 自己打包

既然要保留版本管理,又要构建,那我们自己打包输出到miniprogram_npm不就可以了。

因为只用输出纯 js,所以我们选用 rollup 来打包。

除此之外,使用yarn workspaces来管理本地依赖(这里我们直接打包到miniprogram_npm,这点可有可无。主要是方便指令控制,以及项目分类)。

这一步十分重要,是下面两个应对的前提!

2.2 引入 redux

在讲之前,我们先要知道Component 是可以作为 Page 使用的,而且Component 的功能比 Page 更为强大(Page 属于遗留产物,很鸡肋)。

引入 redux 就需要 connect,小程序没有现成,所以我们得自行封装。有两种思路,一个是behaviors,另一个是Component,原理都是劫持生命周期,注入 redux 订阅事件。

因为我们用到redux-saga做异步处理,里面带有generator function,所以打包时需要引入@babel/plugin-transform-runtime

babel7 里的@babel/env只提供语法支持,api 则是由runtime提供

注意:redux 里有使用到process.env.NODE_ENV环境变量,这个在下面会一并解决。

2.3 自定义环境变量

打包脚本上挂载环境变量,rollup 打包中统一替换。

//package.json
"scripts": {
"start": "RUNNER_ENV=dev runner-scripts",
"build": "RUNNER_ENV=prod runner-scripts"
},

//rollup.config.js
replace({
'process.env.NODE_ENV': JSON.stringify(env),
preventAssignment: true,
}),

接下来是环境区分,建议打包环境与wx.getAccountInfoSync结合使用,下面是我项目里的环境区分。

这样写的好处有两点:

  • 配置灵活,本地可以调试任意环境
  • 可以使用测试环境提审,发布后自动转变成正式环境,能与后端同时上线

2.4 封装请求

这个好说,小程序 storage 可以持续化储存,从 response 的 header 里取出Set-Cookie,再放进 storage 里这样就实现 cookies 的存储。

在请求之前,从 storage 获取 cookies,并带在 header 的 Cookies 上即可。

需要注意的是,正常 cookies 会有过期时间,存储时需要把 expires 和 max-age 一起存进去,获取时校验 cookies 是否过期。

2.5 维持生命周期

关于控制层方面,小程序没有解决方案,只能在需要控制的页面上增加控制的逻辑,但我们可以把这一层抽象出来,减少重复代码。

思路:组件 Layout 控制页面是否渲染,hold(一个 Behavior)通过劫持页面来维持生命周期,直到 Layout 发出通行的信号才执行。通过控制生命周期和页面渲染,达到控制页面的效果,同时组件与 Behavior 都能复用,减少重复率。

BUG:开发时发现页面内组件的生命周期无法维持,需要增加wx:if="{{!HOLDING}}"

3. 最后

3.1 关于 eslint

如果使用 eslint,请指定使用到的微信 api,因为编辑器不知道微信的全局 api 有那些,会直接报错。

3.2 canvas 绘制

如果手动绘制海报,注意将文案的对齐方式(textBaseline)设置为 middle,y 轴定位加上一半行高即可

3.3 js 支持

在开发前最好看下JavaScript 支持情况

举个例子,其中小程序禁用evalnew Function@babel/plugin-transform-runtime在编译后会有 Function 代码导致报错,详细见issues

· 阅读需 1 分钟
youniaogu

更好的线性渐变背景

做渐变背景,比如进度条时,只要把背景的宽度撑满外部容器,就能获得更好看的效果

.inside {
width: 30%;
height: 16px;
border-radius: 8px;
background: linear-gradient(to right, #ff3737, #3939ff);
background-size: 333.33%; //根据width动态计算出大小
}
效果图:

线上地址:https://youniaogu.github.io/react-demo/component

· 阅读需 6 分钟
youniaogu

什么是 redux-toolkit

1. 介绍

redux-toolkit 下面简称为 rtk,是一个结合 redux 工具和语法糖的库,目的是为了解决三个问题

  • redux 配置复杂(一般 🤔)
  • redux 需要添加一些库配合使用(一般 🤔)
  • redux 模版代码过多(重点 💪)

2. createAction、createReducer

2、3、4 主要是 API 的简单介绍,详细见官网文档,还有与普通 redux 使用的比较

createAction类似于actionCreater的形式,两者在传参和返回上有些不同

import { createAction } from '@reduxjs/toolkit';

// 使用createAction
const loadUserInfo = createAction<string>('user/loadUserInfo');
// 或者
const loadUserInfo = createAction('user/loadUserInfo', function prepare(id: string) {
return {
payload: {
id,
},
}
});

// 基于actionCreater
function loadUserInfo(id: string) {
return {
type: 'LOAD_USER_INFO'
id
}
}

createReducer用来简化创建 reducer 的函数,内部使用immer大大简化了数据变更的操作

// 正常的userReducer
const initialUserState = {
name: "小明",
age: 18,
sex: 1,
};
function userReducer(state = initialUserState, action) {
switch (action.type) {
case "SET_NAME": {
return {
...state,
name: action.name,
};
}
case "SET_AGE": {
return {
...state,
age: action.age,
};
}
case "SET_SEX": {
return {
...state,
sex: action.sex,
};
}
default:
return state;
}
}

// 基于`createReducer`简化后的`userReducer`
const initialUserState = {
name: "小明",
age: 18,
sex: 1,
};
const userReducer = createReducer(initialUserState, (builder) => {
builder
.addCase("SET_NAME", (state, action) => {
// 无需返回新的对象,直接对state进行操作即可
state.name = action.payload;
})
.addCase("SET_AGE", (state, action) => {
state.age = action.payload;
})
.addCase("SET_SEX", (state, action) => {
state.sex = action.payload;
});
});

3. createSlice

createSlice是由createActioncreateReducer组合而成,更进一步简化模版代码

语法:

function createSlice({
// 会添加在action type的前面,[name]/[action type]
// 例如:user/GET_USER_INFO
name: string,
// reducer的初始值
initialState: any,
// 在这里通过object(key-value)形式定义你需要的action和reducer
// key也就是action的名称,只能以string的形式定义
// value有两种形式:
// (state, action) => {} //对state进行操作
// 或者
// {
// reducer: (state, action) => {},
// prepare: (...args) => { return { payload: any} }
// }
// prepare主要在执行reducer函数前对action传参进行修改
// 如果不定义prepare,将会默认把第一个参数作为payload传进去
reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
// extraReducers用来捕抓指定的action,并做出相应动作
// 自身的action无法捕抓
// 可以自定义捕抓条件
// 如何定义:
// 类似与reducers的object(key-value)形式,key定义捕抓的action,value表示捕抓成功后的相应动作
// 通过builder的addCase、addMatcher、addDefaultCase进行定义
extraReducers?:
| Object<string, ReducerFunction>
| ((builder: ActionReducerMapBuilder<State>) => void)
});

举个 🌰:

function timeout(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const getUserInfo = createAsyncThunk<{name: string; sex: 1 | 0 | -1; age: number;}>(
"user/getUserInfo",
async (payload, thunkApi) => {
await timeout(1000); // 请求接口
return { name: "小明", sex: 0, age: 24 }; // 返回结果
}
);
const loadOrderList = createAsyncThunk<{list: Array<{orderId: number, price: number}>, page: number, total: number}>(
"order/loadOrderList",
async (payload, thunkApi) => {
await timeout(2000); // 请求接口
return {
list: [
{orderId: 1, price: 500},
{orderId: 2, price: 1300},
{orderId: 3, price: 900}
],
page: 1,
total: 3
}; // 返回结果
}
);
const userSlice = createSlice({
name: 'user',
initialState: {
loadStatus: 0,
name: '',
sex: -1,
age: 0,
},
reducers: {
'getUserInfo/pending': (state) => {
state.loadStatus === 1
},
'getUserInfo/fulfilled': (state, action) => {
const { name, sex, age } = action.payload;
state.loadStatus === 2
state.name = name;
state.sex = sex;
state.age = age;
},
'getUserInfo/rejected': (state) => {
state.loadStatus === 0
},
},
extraReducers: (builder) => {
builder.addCase(loadOrderList.pending, (state, action) => {
//...
}).addCase(loadOrderList.fulfilled, (state, action) => {
//...
}).addCase(loadOrderList.rejected, (state, action) => {
//...
})
}
});

4. createAsyncThunk

createAsyncThunk是基于redux-thunk封装的 api,用来处理异步逻辑,创建出来的type有三种状态

例如定义的 type 为user/getUserInfo

statustype
pendinguser/getUserInfo/pending
fulfilleduser/getUserInfo/fulfilled
rejecteduser/getUserInfo/rejected
  1. 在触发action的时候,就会进入pending状态

  2. 正常return会进入fulfilled状态

  3. 而使用thunkAPI.rejectWithValue返回则会进入rejected状态(相关 api

34.png

5、createEntityAdapter

createEntityAdapter可以理解为封装 CRUD(增删查改)后的reducerselector函数

熟练掌握后可以加快列表、字典等业务逻辑的开发速度,不强制要求学习

封装程度较高,建议查阅相关 API 文档学习

6、实践 QA

因为一些原因,没能在实际项目中实践运用,但我写了两个 codesandbox,它们实现了相同的功能,可以对比一下差异

7、总结

优点:
  • 使用slice代替action、reducer,减少文件碎片化
  • 使用immer,大大简化state的操作
  • 对于数据操作频繁的项目来说,熟练掌握createEntityAdapter后可以提高业务逻辑开发速度
优点:
  • 存在学习成本,即使熟练运用redux,也需要一定时间上手
  • slice复杂度比action、reducer
如果说redux是最原始的加减法,那rtk就是乘除法,它能带来的收益是远远大于付出的

· 阅读需 4 分钟
youniaogu

通过 babel 看 class

1. 前言

最近在写单元测试,在使用jest.spyOn测试生命周期方法和函数时,发现箭头函数无法使用下列代码进行测试

//Error: Cannot spy the handleCardClick property because it is not a function; undefined given instead
const handleCardClick = jest.spyOn(App.prototype, "handleCardClick");
const wrapper = shallow(<App />);

报错提示说明App.prototype.handleCardClickundefined,这就有意思了

2. 编译简化

通过babel tranfer online编译,发现箭头函数与普通函数处理有点不同

为了易于理解,以下编译后代码经过简化,如果对非简化部分感兴趣,建议自行编译查看

class App {
fn() {}
handleCardClick = () => {};
}

↓↓↓ 相当于 ↓↓↓

function App() {
this.handleCardClick = function () {};
}
App.prototype.fn = function () {};

可以看到,函数fn绑定在App.prototype上,而箭头函数handleCardClick绑定在App的实例上

问题解决了,但秉持着学习的心态,我把class里大部分定义变量和函数的情况都列举了出来

class App {
v1 = "v1";
static v2 = "v2";
constructor() {
this.v1 = "fake v1";
this.v2 = "fake v2";
this.v3 = "v3";
}

fn1() {}
fn2 = () => {};
static fn3() {}
static fn4 = () => {};
}

↓↓↓ 相当于 ↓↓↓

function App() {
this.v1 = "v1";

this.v1 = "fake v1";
this.v2 = "fake v2";
this.v3 = "v3";

this.fn2 = function () {};
}
App.prototype.fn1 = function () {};
App.fn3 = function () {};
App.v2 = "v2";
App.fn4 = function () {};

有一点需要注意,constructorclass里定义的上下位置对编译结果无影响

class App {
v1 = "v1";
constructor() {
this.v1 = "fake v1";
}
}

class App {
constructor() {
this.v1 = "fake v1";
}
v1 = "v1";
}

上面两个类编译后结果一致

function App() {
this.v1 = "v1";

this.v1 = "fake v1";
}

总结:

  • 具有 static 属性的变量和函数会直接定义在构造函数上,优先级最高
  • 箭头函数在构造函数内,会定义在实例上面
  • constructor 的定义会覆盖类上的定义

3. 继承

最后再研究下继承以及super编译后的结果

class A {}
class B extends A {}
var A = function A() {};

var B = (function B(_A) {
//super相当于new关键字
var super = function () {
var Super = _B.__proto__;
var result = Super.apply(this, arguments);

if (
result &&
(_typeof(result) === "object" || typeof result === "function")
) {
return result;
}
return this;
};

function _B() {
//相当于new B(...arguments)
return super.apply(this, arguments);
}

//继承_A的prototype,并将原型指向_A
_B.prototype = {
..._A.prototype,
constructor: _B,
};
_B.__proto__ = _A;

return _B;
})(A);

· 阅读需 2 分钟
youniaogu

记一个 react 问题

1. 前言

前几天看到一篇文章:我在大厂写 React,学到了什么?性能优化篇,文章最开始提到了神奇的children,讲诉使用children避免了不必要的渲染,蛮有意思的。而这篇文章,主要是分享我对文章中问题的理解,同时梳理了关于 react 渲染机制的知识

2. 问题是什么?

文中ChildNonTheme里没有依赖 context,但修改 context 的时候,为什么会触发渲染?

因为父节点发生改变时(state、props、context),子节点无论怎么样都会重新渲染,就是这么简单

如果需要追根溯源的话,根本原因是 react diff 算法在比较新旧节点的时候,如果父节点发生改变,将会直接替换整个节点,而不会对子节点进行比较。所以即使ChildNonTheme里的状态没发生改变,也会重新渲染

3. 怎么解决?

文中使用children方法之所以能够解决问题,个人认为ChildNonTheme此时并不属于ThemeApp的子节点,而是属于App,所以ThemeApp节点改变时,并不能触发ChildNonTheme的渲染(单纯个人猜测,如有更好解释或发现错误,欢迎指出

除了children外,还可以使用React.memo,对应 Component 写法是React.PureComponent,这样不需要改变代码结构,更加方便

· 阅读需 8 分钟
youniaogu

typescript 实践心得

先说下总结:

  1. typescript 的静态检查能够规避很多错误,越用越香。

  2. 类型检查需要环环相扣,如果运用不当,将会成为如梗在喉般的存在

  3. redux 的单一数据源思想与 typescript 十分搭配

1. 前言

几个月前我开始学习 typescript,并将其运用到项目中,这篇文章主要用来记录实战过程中一些心得,主要围绕 redux 状态与 typescript 来讲述

tips:在开发项目前,建议先查阅去相关库文档(比如:redux),可能会有一些关于 typescript 的教程

2. 项目搭建

使用cra搭建,因为开发进度紧急,没有多余的时间来研究webpackbabel配置(而且我相信cra做的肯定比我好)

3. connect 和 typescript

在 typescript 中,react.component需要定义propsstate的类型,所以在使用connect的情况下,需要对传递的props进行类型定义

mapStateToProps为函数类型,mapDispatchToProps为对象类型,所以最好的办法是使用范型工具ReturnTypetypeof

type PropsTypes = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & { ... };

const mapStateToProps = function (state: RootTypes) { return state.app };
const mapDispatchToProps = { launch };

class App extends component<PropsTypes> { ... };

上面例子的RootTypesredux中整个state的类型,下面就来介绍如何获取它

4. reducer 和 typescript

reducer作为纯函数,用ReturnType很简单就可以获得返回的类型

import { combineReducers } from "redux";

export function launch() {
return {
type: "LAUNCH",
};
}
export function launchCompletion(error: Error | undefined) {
return {
type: "LAUNCH_COMPLETION",
error,
};
}

const initialState: 0 | 1 | 2 = 0;
function appReducer(state = initialState, action): typeof initialState {
switch (action.type) {
case "LAUNCH": {
return 1;
}
case "LAUNCH_COMPLETION": {
if (action.error) {
return 0;
}
return 2;
}
default:
return state;
}
}
const rootReducer = combineReducers({ app: appReducer });

export type RootTypes = ReturnType<typeof rootReducer>;
export default rootReducer;

注意appReducer方法必须定义返回的类型,如下

function appReducer(state = initialState, action): typeof initialState { ... }

如何不定义,返回的类型将会是never!

上面的reducer虽然能返回正确的RootTypesaction还没有定义action的类型为any,这样RootTypes将毫无意义,因为case里可以随意修改返回的state所以为action添加类型尤为重要

action creator为函数类型,所以只需要将action creator返回的类型导出,然后导入到reducer里,为action提供类型

...
export function launch() { ... }
export function launchCompletion() { ... }
const initialState: 0 | 1 | 2 = 0;
function appReducer(state = initialState, action): typeof initialState {
switch (action.type) {
case "LAUNCH": { ... }
case "LAUNCH_COMPLETION": {
const { error } = action as ReturnType<typeof launchCompletion>
...
}
default:
return state;
}
}
...

这样子可行,但如果case数量多起来,就需要导入多个类型,多次为action提供类型,偏麻烦。这里可以使用 typescript辨析联合类型的特性优化一下

export function launch() { ... }
export function launchCompletion(error: Error | undefined) { ... }
export function loadUserList(page: number, pageSize: number) { ... }
export function loadUserListCompletion(error: Error | undefined, data: {[key: string]: any}) { ... }
export type ActionTypes =
| ReturnType<typeof launch>
| ReturnType<typeof launchCompletion>
| ReturnType<typeof loadUserList>
| ReturnType<typeof loadUserListCompletion>

const initialState: 0 | 1 | 2 = 0;
function appReducer(state: = initialState, action: ActionTypes): typeof initialState {
switch (action.type) {
case "LAUNCH": { ... }
case "LAUNCH_COMPLETION": { ... }
case "LOAD_USER_LIST": { ... }
case "LOAD_USER_LIST_COMPLETION": { ... }
default:
return state;
}
}

只需要引入ActionTypes,并为action添加类型,typescript 会根据action.type推断出当前 action 的类型

5. saga 和 typescript

saga 需要进行类型定义的主要有两点

  • 第一:请求完接口后返回的数据
  • 第二:使用select返回的state

接口返回的数据和格式是不可控的,如果对其进行类型定义将会花费大量精力,不可取,建议any带过。重点放在第二点上

select的返回总是any,即使selector定义了返回的类型,详细可以看:issues

比较好的解决方法主要有两种

  • 第一种:将selector拿出来,使用ReturnType为返回结果进行定义
const selector = (state: RootTypes) => state.app;
const app: ReturnType<typeof selector> = yield select(selector);

逻辑和最初一样,写法上绕了一下

  • 第二种:利用yield select()等同于getState()的特点,将select的返回作为传参传入selector
const app = ((state: RootTypes) => state.app)(yield select())

写法简单,但与最初的逻辑有出入,比如进行测试时,需要传入整个 state

6. 全局变量

ActionTypesRootTypes都是唯一的,只需要在全局定义这两个类型,就可以减少导入代码花费的时间

step 1. 确认tsconfig.json里的配置,把全局配置的.d.ts文件放在编译的范围内即可

step 2. 写入全局类型

//actions.js
...
export type ActionTypes =
| ReturnType<typeof launch>
| ReturnType<typeof launchCompletion>
| ReturnType<typeof loadUserList>
| ReturnType<typeof loadUserListCompletion>

//reducers.js
...
export type RootTypes = ReturnType<typeof rootReducer>;
export default rootReducer;

// global.d.ts
declare type RootTypes = import('reducers').RootTypes;
declare type ActionTypes = import('actions').ActionTypes;

step 3. 需要注意的点

第一,import 导入是在 typescript2.9 加入的,并且只能导入类型

第二,在添加全局类型后,有可能会出现编译器报错,而编译通过的问题(见下图),重启 vscode 即可解决

33.png

7. 写在最后

最开始使用 typescript,新手上路,不知道如何使用范型,写了很多重复的类型定义,费时又费力。而且修改 redux 里的 state 定义,需要修改好几处的 props,是真的很难受,感觉很不值得

后面慢慢学会如何运用范型,结合 redux 单一数据源设计,数据至上而下传递,类型也可以随着数据至上而下传递,不用在各个地方定义类型,用更少的代码获得更多的类型定义,typescript 慢慢香了起来

typescript 是 javascript 的超集,语法上学习起来很简单,但要想有个好的体验和实践,没有想象中那么简单,就跟画马一样

32.png

希望这篇文章对你有所帮助,谢谢!