Tuesday, February 8, 2022

SegmentFault 最新的文章

SegmentFault 最新的文章


Suspense对React的意义在哪里?

Posted: 07 Feb 2022 08:23 PM PST

大家好,我卡颂。

可能很多朋友在项目中还没用过Suspense,但是SuspenseReact未来发展非常重要的一环。

本文会讲解Suspense对于React的意义。

欢迎加入人类高质量前端框架群,带飞

React的迭代过程

React从v16到v18主打的特性经历了三次大的变化:

  • v16:Async Mode(异步模式)
  • v17:Concurrent Mode(并发模式)
  • v18:Concurrent Render(并发更新)

要了解这三次变化的意义,需要先了解React中一个很容易混淆的概念 —— render(渲染)。

ClassComponentrender函数执行时被称为render

class App extends Component {   render() {     // ...这是render函数   } }

而将render的结果渲染到页面的过程,被称为commit

Async Mode的目的是为了让render变为异步、可中断的。

Concurrent Mode的目的是让commit在用户的感知上是并发的。

由于Concurrent Mode包含breaking change,所以v18提出了Concurrent Render,减少开发者迁移的成本。

那么让commit在用户的感知上是并发的是什么意思呢?

"并发"的意义

说到并发,就不得不提Suspense。考虑如下代码:

const App = () => {   const [count, setCount] = useState(0);      useEffect(() => {     setInterval(() => {       setCount(count => count + 1);     }, 1000);   }, []);      return (     <>       <Suspense fallback={<div>loading...</div>}>         <Sub count={count} />       </Suspense>       <div>count is {count}</div>     </>   ); };

其中:

  • 每过一秒会触发一次更新,将状态count更新为count => count + 1
  • Sub中会发起异步请求,请求返回前,包裹SubSuspense会渲染fallback

假设请求三秒后返回,理想情况下,请求发起前后页面会依次显示为:

// Sub内请求发起前 <div class="sub">I am sub, count is 0</div> <div>count is 0</div>  // Sub内请求发起第1秒 <div>loading...</div> <div>count is 1</div>  // Sub内请求发起第2秒 <div>loading...</div> <div>count is 2</div>  // Sub内请求发起第3秒 <div>loading...</div> <div>count is 3</div>  // Sub内请求成功后 <div class="sub">I am sub, request success, count is 4</div> <div>count is 4</div>

从用户的视角观察,页面中有两个任务在并发执行:

  1. 请求Sub的任务(观察第一个div的变化)
  2. 改变count的任务(观察第二个div的变化)

Suspense带来的页面中多任务并发执行感觉,就是Concurrent(并发)在React中的含义。

其实在Async Mode时,已经支持Suspense。但是上面的代码在Async Mode的页面中表现如下:

// Sub内请求发起前 <div class="sub">I am sub, count is 0</div> <div>count is 0</div>  // Sub内请求发起第1秒 <div>loading...</div> <div>count is 0</div>  // Sub内请求发起第2秒 <div>loading...</div> <div>count is 0</div>  // Sub内请求发起第3秒 <div>loading...</div> <div>count is 0</div>  // Sub内请求成功后 <div class="sub">I am sub, request success, count is 4</div> <div>count is 4</div>

从用户的视角观察,当请求Sub的任务执行时,改变count的任务就被冻结了。

这就是为什么被称为Async(异步)而不是Concurrent(并发)。

Suspense的意义

可以看到,对于ConcurrentSuspense是必不可少的一环。

可以认为,Suspense的作用是划分页面中需要并发渲染的部分

比如上例中,通过Suspense请求Sub的任务改变count的任务划分开,从视觉上并发执行。

当明确了Suspense的意义后,你会发现,React接下来在做的事,就是不断扩充Suspense的场景(也就是说将更多场景纳入并发渲染的范畴)。

比如,当前已有的:

  • React.lazy
  • 通过React提供的fetch库改造后的异步请求
  • useTransition
  • useDeferredvalue

未来会加入的:

  • Server Component
  • Selective Hydration

总结

React的发展历程是:从同步异步,再到并发

当实现并发后,接下来的发展方向将是:不断扩展可以使用并发的场景。

Suspense的作用是划分页面中需要并发渲染的部分

这套发展路径从React诞生伊始就决定了,因为从架构上来说,React重度依赖运行时,为了优化性能,并发是这套架构下的最优发展方向。

断点调试之压缩引发的血案

Posted: 07 Feb 2022 03:58 AM PST

前段时间组里的小伙伴让我帮忙排查一个线上问题,我觉得排查流程比较有意思,想着记录一下看看是否能对其它同学有所帮助,遂有此文。

事情的起因是前几天线上突然收到一个报警,错误内容是 TypeError: C.fn is not a function。相关同学尝试排查无果后又回滚了最近上线的变更也没有排查到问题。虽然最终确认了复现路径,但是在本地却无法复现。

🔍 初步排查

在线上复现该错误后,点击错误堆栈的文件跳转,快速定位到线上出错的代码。由于线上都是压缩过的代码,这里我们可以点击左下角的 {} 进行代码美化。

经过美化后我们可以看出来,应该就是 189624 行出了问题。我们直接尝试在这一行上打断点,之后会发现代码会在这块疯狂打转。这是因为它处于一个 for 循环中。仔细观察不难看出代码其实上是 this.head 这个链的递归执行,每次执行完当前 C 都会被赋值成链的下一个值,并执行该值对应的 fn() 方法。也就是问题是这个链上的某个值没有 fn() 方法,最终导致了这个报错。

大概确认问题后,我们需要看一下最终这个 C 的值是什么。由于处在循环当中,一次一次的点击下一步实在是麻烦。由于我们有明确的目标,所以我们可以尝试添加条件断点,让只有符合我们条件的断点才停下来,否则都忽略正常执行。

在 189624 行右键点击 Add conditional breakpoint... 选项,并输入 typeof C.fn !== 'function' 作为条件表达式。这样我们就实现了一个仅在 C.fn 不是一个方法的时候才会触发的条件断点。

条件断点触发后,我们可以在控制台中基于断点时的上下文输出变量进行调试。可以从下左图我们可以清晰的看到,此时的 C.fn 的确是不存在的。

由于刚才我们已知 this.head 应该是一条链,依次执行链上的方法。所以理论上来说链上的每个元素都是一样的。于是乎我就尝试输出了 this.head 链上所有的元素想看一下这个链到底是什么样子的。模拟代码里的循环我也在控制台尝试写了下,发现输出的结果如下左图展示。在链的最后一个元素就是我们有问题的元素。

而之前我们已知的是在本地开发环境是无法复现这个问题的,所以我照猫画虎在本地同样的位置也输出了一下 this.head 链,结果见上右图。发现和线上输出的,除了最后这个有问题的元素,其它的输出基本是一样的。

看来问题的原因就在于线上的代码执行在链上增加了这么一个玩意导致的,而本地由于没有这个多余的元素所以没有触发问题。

🐞 确认问题

找到原因后我就想着从代码层面捋一下是哪里给增加了这么个玩意。由于之前的代码中可以明显的看到 i.prototype.finish 的字样,初步猜测这应该是一个类的定义。于是乎就想看看这个类是在哪里实例化执行的。

通过刚报错时的压缩后的代码,我们可以看到报错的模块是"protobuf.js"这个模块。于是乎我在项目和依赖中查找是哪个模块依赖了它,最终查到了是我们内部使用的一个 IM 消息模块有用到。

之后在具体的依赖模块中搜索 .finish() 相关字样,查到了最终的调用在如下地方。serialize() 方法会调用 Request.encode() 方法,它返回一个 $Writer 基类的实例,而 $Writer 就是 protobuf.js 模块中的 Writer 基类。Request.encode() 方法实例化完 Writer 基类后会执行一系列的成员函数,执行完毕后会返回 Writer 实例,并调用它的 finish() 方法。

了解执行流程之后,我就顺着 Request.encode(req).finish() 这一句开始向上对 Request.encode() 方法进行断点(下左图)。如下图先尝试在末尾断点输出 o.heado 是压缩后指向 Writer 实例的变量),发现此时已经存在异常链元素了(下右图)。

中间的代码稍微打了下断点发现也依旧如此。最终在头部断点处发现了端倪。尝试在开头增加断电之后,发现在 120274 行执行完毕之后 o.head 链上就已经存在了异常数据了。

那我们尝试翻看下代码看一下 o.create() 方法具体干了什么。从下图左我们可以看到 Writer.create() 本质其实就是 Writer 基类的实例化工厂方法。而下图中可以看到 Writer 的构造方法对一些成员属性赋了初值。其中关键的 this.head 的初值是一个 Op 基类的实例。下图右可以看到 Op 基类的构造方法中也是赋了一些初值。同时我们可以看到 function noop() {} 实际上就是一个空方法。也就是说 this.head 默认指向了一个空方法实例化的 Op 对象。

乍一看整个流程其实非常简单,本质上构造函数内都是一些简单的赋值操作,不会有什么问题。于是乎还是按照链路依次向上排查问题。因为上一趴我们排查到执行完 Writer.create() 工厂方法后就有问题了,所以这里我们需要对 Writer 的构造函数进行断点排查。

尝试如下图在构造方法末尾断点后,输出 this.head 链,发现此时已经有异常数据了。而这个时候不过只是做了初值的操作而已,这怎么就能出问题了呢?由于断点情况下我能在当前上下文中进行调试,所以此时我尝试自己执行一下 Op 基类的实例化操作(见下图)。这时候发现确实它的 next 属性不对,是我们要找的问题元素!

此时此刻,我感觉我们已经越来越接近真相了!

如下图左我们在 f 变量上 hover 一会儿,会出现它的定义处链接,点击后会直接跳转到它的定义处下图右(其实就离的不太远)。

大家可能也都注意到了,我们刚才看的代码中 this.next 明明是定义成 undefined 怎么这里给定义成 g 了?而这个 g 又对上了 189456 行 g = s.base64,所以我们才看到 this.head.next 的值这么奇怪。而我们尝试看一下引用的 protobuf.js 代码,发现代码里 this.next 虽然是等于 g 但是它并没有关联到 u.base64 上。

由于我之前有解决过一些压缩再压缩后代码异常的 Case,所以至此我基本上可以断定,由于 protobuf.js 在我们的依赖中是引入的压缩后的代码,而压缩后的代码再走压缩导致了变量指向出现错乱从而导致的问题。这也侧面印证了为什么只有线上可以,本地无法复现的原因。因为本地是没有走压缩的。

🛠 如何解决

找到问题后有两种解决方法。一是正向的去查找压缩工具造成这个问题的原因;二是反向的去规避该问题,我们不引入压缩后的代码而是正常引入未压缩的代码,最终统一由项目进行压缩处理。

这两种方法都能解决问题。而第一种需要的时间会比较久,所以我们先采用了第二种方法临时解决一下。由于该依赖包不是我们维护的,我们只能使用 patch-package 给模块打补丁的方式进行修复。它的功能是在安装完依赖后会根据我们的 diff 文件对依赖进行修改。

这里我们的修改比较简单,找到我们依赖模块引入 protobuf.min.js 的地方,将其修改成 protobuf.js 即可。

🗒 后记

undefined 在压缩后就变成了 g 这个初步猜想应该是本地想要定义一个没有定义的变量,这样就是 undefined 了。我尝试克隆了下 protobuf.js 仓库进行了尝试,发现应该是 UglifyJS 中配置了 marguel.eval 导致有这个特性。

以上就是压缩造成的血案完整的排查经过,整个的过程总结一下有以下几个经验可以供大家参考:

  1. 除了单步断点,我们还有条件断点、日志断点等多种断点方式帮助我们排查问题,合理使用会加速我们排查问题的速度。
  2. 断点后当前 JS 环境会停留在当时的上下文中,我们可以在控制台执行、输出我们想要的当时环境的数据帮助排查。
  3. 控制台中我们也可以 hover 查看定义位置,进行定义间快速跳转。
  4. 压缩后的代码不可怕,我们可以通过源码对比,无法压缩的关键字进行定位查找。
  5. 只要是可以复现的问题,那都不是问题!

最后祝大家开工大吉,新的一年没有 Bug!

还在用HttpUtil?试试这款优雅的HTTP客户端工具吧,跟SpringBoot绝配!

Posted: 07 Feb 2022 05:49 PM PST

我们平时开发项目时,就算是单体应用,也免不了要调用一下其他服务提供的接口。此时就会用到HTTP客户端工具,之前一直使用的是Hutool中的HttpUtil,虽然容易上手,但用起来颇为麻烦!最近发现一款更好用的HTTP客户端工具Retrofit,你只需声明接口就可发起HTTP请求,无需进行连接、结果解析之类的重复操作,用起来够优雅,推荐给大家!

SpringBoot实战电商项目mall(50k+star)地址:https://github.com/macrozheng/mall

简介

Retrofit是适用于AndroidJava且类型安全的HTTP客户端工具,在Github上已经有39k+Star。其最大的特性的是支持通过接口的方式发起HTTP请求,类似于我们用Feign调用微服务接口的那种方式。

SpringBoot是使用最广泛的Java开发框架,但是Retrofit官方并没有提供专门的Starter。于是有位老哥就开发了retrofit-spring-boot-starter,它实现了Retrofit与SpringBoot框架的快速整合,并且支持了诸多功能增强,极大简化开发。今天我们将使用这个第三方Starter来操作Retrofit。

使用

在SpringBoot中使用Retrofit是非常简单的,下面我们就来体验下。

依赖集成

有了第三方Starter的支持,集成Retrofit仅需一步,添加如下依赖即可。

<!--Retrofit依赖--> <dependency>     <groupId>com.github.lianjiatech</groupId>     <artifactId>retrofit-spring-boot-starter</artifactId>     <version>2.2.18</version> </dependency>

基本使用

下面以调用mall-tiny-swagger中的接口为例,我们来体验下Retrofit的基本使用。
  • 首先我们准备一个服务来方便远程调用,使用的是之前的mall-tiny-swagger这个Demo,打开Swagger看下,里面有一个登录接口和需要登录认证的商品品牌CRUD接口,项目地址:https://github.com/macrozheng...

  • 我们先来调用下登录接口试试,在application.yml中配置好mall-tiny-swagger的服务地址;
remote:   baseUrl: http://localhost:8088/
  • 再通过@RetrofitClient声明一个Retrofit客户端,由于登录接口是通过POST表单形式调用的,这里使用到了@POST@FormUrlEncoded注解;
/**  * 定义Http接口,用于调用远程的UmsAdmin服务  * Created by macro on 2022/1/19.  */ @RetrofitClient(baseUrl = "${remote.baseUrl}") public interface UmsAdminApi {      @FormUrlEncoded     @POST("admin/login")     CommonResult<LoginInfo> login(@Field("username") String username, @Field("password") String password); }
  • 如果你不太明白这些注解是干嘛的,看下下面的表基本就懂了,更具体的话可以参考Retrofit官方文档;

  • 接下来在Controller中注入UmsAdminApi,然后进行调用即可;
/**  * Retrofit测试接口  * Created by macro on 2022/1/19.  */ @Api(tags = "RetrofitController", description = "Retrofit测试接口") @RestController @RequestMapping("/retrofit") public class RetrofitController {      @Autowired     private UmsAdminApi umsAdminApi;     @Autowired     private TokenHolder tokenHolder;      @ApiOperation(value = "调用远程登录接口获取token")     @PostMapping(value = "/admin/login")     public CommonResult<LoginInfo> login(@RequestParam String username, @RequestParam String password) {         CommonResult<LoginInfo> result = umsAdminApi.login(username, password);         LoginInfo loginInfo = result.getData();         if (result.getData() != null) {             tokenHolder.putToken(loginInfo.getTokenHead() + " " + loginInfo.getToken());         }         return result;     } }
  • 为方便后续调用需要登录认证的接口,我创建了TokenHolder这个类,把token存储到了Session中;
/**  * 登录token存储(在Session中)  * Created by macro on 2022/1/19.  */ @Component public class TokenHolder {     /**      * 添加token      */     public void putToken(String token) {         RequestAttributes ra = RequestContextHolder.getRequestAttributes();         HttpServletRequest request = ((ServletRequestAttributes) ra).getRequest();         request.getSession().setAttribute("token", token);     }      /**      * 获取token      */     public String getToken() {         RequestAttributes ra = RequestContextHolder.getRequestAttributes();         HttpServletRequest request = ((ServletRequestAttributes) ra).getRequest();         Object token = request.getSession().getAttribute("token");         if(token!=null){             return (String) token;         }         return null;     }  }

注解式拦截器

商品品牌管理接口,需要添加登录认证头才可以正常访问,我们可以使用Retrofit中的注解式拦截器来实现。
  • 首先创建一个注解式拦截器TokenInterceptor继承BasePathMatchInterceptor,然后在doIntercept方法中给请求添加Authorization头;
/**  * 给请求添加登录Token头的拦截器  * Created by macro on 2022/1/19.  */ @Component public class TokenInterceptor extends BasePathMatchInterceptor {     @Autowired     private TokenHolder tokenHolder;      @Override     protected Response doIntercept(Chain chain) throws IOException {         Request request = chain.request();         if (tokenHolder.getToken() != null) {             request = request.newBuilder()                     .header("Authorization", tokenHolder.getToken())                     .build();         }         return chain.proceed(request);     } }
  • 创建调用品牌管理接口的客户端PmsBrandApi,使用@Intercept注解配置拦截器和拦截路径;
/**  * 定义Http接口,用于调用远程的PmsBrand服务  * Created by macro on 2022/1/19.  */ @RetrofitClient(baseUrl = "${remote.baseUrl}") @Intercept(handler = TokenInterceptor.class, include = "/brand/**") public interface PmsBrandApi {     @GET("brand/list")     CommonResult<CommonPage<PmsBrand>> list(@Query("pageNum") Integer pageNum, @Query("pageSize") Integer pageSize);      @GET("brand/{id}")     CommonResult<PmsBrand> detail(@Path("id") Long id);      @POST("brand/create")     CommonResult create(@Body PmsBrand pmsBrand);      @POST("brand/update/{id}")     CommonResult update(@Path("id") Long id, @Body PmsBrand pmsBrand);      @GET("brand/delete/{id}")     CommonResult delete(@Path("id") Long id); }
  • 再在Controller中注入PmsBrandApi实例,并添加方法调用远程服务即可;
/**  * Retrofit测试接口  * Created by macro on 2022/1/19.  */ @Api(tags = "RetrofitController", description = "Retrofit测试接口") @RestController @RequestMapping("/retrofit") public class RetrofitController {          @Autowired     private PmsBrandApi pmsBrandApi;      @ApiOperation("调用远程接口分页查询品牌列表")     @GetMapping(value = "/brand/list")     public CommonResult<CommonPage<PmsBrand>> listBrand(@RequestParam(value = "pageNum", defaultValue = "1")                                                         @ApiParam("页码") Integer pageNum,                                                         @RequestParam(value = "pageSize", defaultValue = "3")                                                         @ApiParam("每页数量") Integer pageSize) {         return pmsBrandApi.list(pageNum, pageSize);     }      @ApiOperation("调用远程接口获取指定id的品牌详情")     @GetMapping(value = "/brand/{id}")     public CommonResult<PmsBrand> brand(@PathVariable("id") Long id) {         return pmsBrandApi.detail(id);     }      @ApiOperation("调用远程接口添加品牌")     @PostMapping(value = "/brand/create")     public CommonResult createBrand(@RequestBody PmsBrand pmsBrand) {         return pmsBrandApi.create(pmsBrand);     }     @ApiOperation("调用远程接口更新指定id品牌信息")     @PostMapping(value = "/brand/update/{id}")     public CommonResult updateBrand(@PathVariable("id") Long id, @RequestBody PmsBrand pmsBrand) {         return pmsBrandApi.update(id,pmsBrand);     }      @ApiOperation("调用远程接口删除指定id的品牌")     @GetMapping(value = "/delete/{id}")     public CommonResult deleteBrand(@PathVariable("id") Long id) {         return  pmsBrandApi.delete(id);     } }
  • 在Swagger中调用接口进行测试,发现已经可以成功调用。

全局拦截器

如果你想给所有请求都加个请求头的话,可以使用全局拦截器。

创建SourceInterceptor类继承BaseGlobalInterceptor接口,然后在Header中添加source请求头。

/**  * 全局拦截器,给请求添加source头  * Created by macro on 2022/1/19.  */ @Component public class SourceInterceptor extends BaseGlobalInterceptor {     @Override     protected Response doIntercept(Chain chain) throws IOException {         Request request = chain.request();         Request newReq = request.newBuilder()                 .addHeader("source", "retrofit")                 .build();         return chain.proceed(newReq);     } }

配置

Retrofit的配置很多,下面我们讲讲日志打印、全局超时时间和全局请求重试这三种最常用的配置。

日志打印

  • 默认配置下Retrofit使用basic日志策略,打印的日志非常简单;

  • 我们可以将application.yml中的retrofit.global-log-strategy属性修改为body来打印最全日志;
retrofit:   # 日志打印配置   log:     # 启用日志打印     enable: true     # 日志打印拦截器     logging-interceptor: com.github.lianjiatech.retrofit.spring.boot.interceptor.DefaultLoggingInterceptor     # 全局日志打印级别     global-log-level: info     # 全局日志打印策略     global-log-strategy: body
  • 修改日志打印策略后,日志信息更全面了;

  • Retrofit支持四种日志打印策略;

    • NONE:不打印日志;
    • BASIC:只打印日志请求记录;
    • HEADERS:打印日志请求记录、请求和响应头信息;
    • BODY:打印日志请求记录、请求和响应头信息、请求和响应体信息。

全局超时时间

有时候我们需要修改一下Retrofit的请求超时时间,可以通过如下配置实现。

retrofit:   # 全局连接超时时间   global-connect-timeout-ms: 3000   # 全局读取超时时间   global-read-timeout-ms: 3000   # 全局写入超时时间   global-write-timeout-ms: 35000   # 全局完整调用超时时间   global-call-timeout-ms: 0

全局请求重试

  • retrofit-spring-boot-starter支持请求重试,可以通过如下配置实现。
retrofit:   # 重试配置   retry:     # 是否启用全局重试     enable-global-retry: true     # 全局重试间隔时间     global-interval-ms: 100     # 全局最大重试次数     global-max-retries: 2     # 全局重试规则     global-retry-rules:       - response_status_not_2xx       - occur_exception     # 重试拦截器     retry-interceptor: com.github.lianjiatech.retrofit.spring.boot.retry.DefaultRetryInterceptor
  • 重试规则global-retry-rules支持如下三种配置。

    • RESPONSE_STATUS_NOT_2XX:响应状态码不是2xx时执行重试;
    • OCCUR_IO_EXCEPTION:发生IO异常时执行重试;
    • OCCUR_EXCEPTION:发生任意异常时执行重试。

总结

今天体验了一把Retrofit,对比使用HttpUtil,确实优雅不少!通过接口发起HTTP请求已不再是Feign的专属,通过Retrofit我们在单体应用中照样可以使用这种方式。当然retrofit-spring-boot-starter提供的功能远不止于此,它还能支持微服务间的调用和熔断降级,感兴趣的朋友可以研究下!

参考资料

官方文档:https://github.com/LianjiaTec...

项目源码地址

https://github.com/macrozheng...

本文 GitHub https://github.com/macrozheng/mall-learning 已经收录,欢迎大家Star!

No comments:

Post a Comment