SolidJs节点级响应性

news2024/11/19 19:24:03

前言

随着组件化、响应式、虚拟DOM等技术思想引领着前端开发的潮流,相关的技术框架大行其道,就以目前主流的Vue、React框架来说,它们都基于组件化、响应式、虚拟DOM等技术思想的实现,但是具有不同开发使用方式以及实现原理,这里就不再赘述了相关内容,这里关注的焦点在于虚拟DOM。

无论是Vue还是React都应用虚拟DOM,通过虚拟DOM从而来减少频繁的DOM操作,优化页面性能。随着虚拟DOM应用到实际生产中后,无论是Vue还是React都少不了增加虚拟DOM对象以及相应Diff过程,特别是Diff过程恰恰是影响速度的重要点。人们渐渐思考虚拟DOM真的就比直接操作DOM要快吗?渐渐出现了放弃虚拟DOM的现代化技术框架,例如Solid、Svelte等。

Svelte和Solid都是放弃虚拟DOM但是应用组件化、响应式等技术的现代化框架,Svelte则比较偏向于Vue形式风格,Solid框架则偏向于React形式风格,但是底层响应式实现完全不同,这篇文章就聊聊Solid框架以及背后相关思想。

Solid基本说明

实际上最初Solid吸引我的点就是所谓号称比React还react的言论,作为多年的React框架使用者,我了解背后的实现思想以及实际开发中的痛点,而Solid框架有很多让我想要探索的点:

  • 放弃虚拟DOM下的快速更新效率,没有虚拟DOM 或广泛的差异对比
  • 保持React Hooks风格下的没有所谓的Hook规则
  • 细粒度的响应性控制,函数组件只执行一次以及节点级的UI视图更新策略

Solid最吸引我的点就是细粒度的响应性,相比于React以及Vue更新策略,Solid可以做到节点级的视图更新:

  • React更新策略基本描述:父组件的状态变更会导致当前子树下所有组件重新运行,需要对无需更新的子组件进行memo操作
  • Vue更新策略的基本描述:每一个组件都对应一个Watcher对象,组件响应性状态与视图Watcher对象关联,组件的状态变更只会影响与之关联的对应视图Watcher对象,即只更新与状态建立联系的组件
  • Solid更新策略的基本描述:组件的状态变更只会影响使用该状态的节点UI视图的更新

对于Solid的使用可以查看其官网,Solid的基本使用案例如下:

import { render } from 'solid-js/web';
import { createSignal } from 'solid-js';

function App() {
  const [loggedIn, setLoggedIn] = createSignal(false);
  const toggle = () => setLoggedIn(!loggedIn())
  
  return (
    <>
      <button onClick={toggle}>Log out</button>
      <button onClick={toggle}>Log in</button>
    </>
  );
}

render(() => <App />, document.getElementById('app'))

render处理

组件经过Solid相关工具编译处理得到的最终代码逻辑如下:

// render(() => <Counter />, document.getElementById("app")!);
render(() => _$createComponent(Counter, {}), document.getElementById("app"));

上面实例初始化阶段的处理逻辑,render函数的逻辑如下:

function render(code, element, init, options = {}) {
  let disposer;
  createRoot(dispose => {
    disposer = dispose;
    element === document ? code() : insert(element, code(), element.firstChild ? null : undefined, init);
  }, options.owner);
  return () => {
    disposer();
    element.textContent = "";
  };
}

从上面逻辑可以如下调用链:

render -> createRoot -> runUpdates -> 顺序执行createRoot传入的回调函数、completeUpdates

createRoot回调逻辑主要逻辑如下:

  element === document ? code() : insert(element, code(), element.firstChild ? null : undefined, init);

主要就是两点逻辑:

  • 执行code函数:code就是render传入的第一个参数,这里就会被执行,通常来说就是函数组件,即函数组件会被执行
  • 执行insert函数:实现组件内容挂载到页面上
insert函数

insert函数具体处理逻辑如下:

function insert(parent, accessor, marker, initial) {
  if (marker !== undefined && !initial) initial = [];
  if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
  createRenderEffect(current => {
  	insertExpression(parent, accessor(), current, marker);
  }, initial);
}

insert函数的accessor参数有两种情况:

  • 非函数类型:意味着是DOM挂载点,就会调用insertExpression,即将节点插入到页面DOM中,从而显示视图
  • 函数类型:意味着是响应性状态,需要调用createRenderEffect函数

Solid响应性原理

类比于React的useState、useEffect,Solid提供createSignal、createEffect这两个API:

  • createSignal提供响应性状态定义
  • createEffect提供监听响应性状态变更后需要运行的副作用处理

createSignal是响应性状态定义来源,这里以下面实例来看看其背后的响应性原理逻辑:

import { render } from "solid-js/web";
import { createSignal } from "solid-js";

function Counter() {
  const [getCount, setCount] = createSignal(1);
  const increment = () => setCount(getCount() + 1);
  // 只会执行一次
  console.log('hello');
  
  return (
    <>
      <button type="button" onClick={increment}>
        点击
      </button>
      <div>{getCount()}</div>
    </>
  );
}

render(() => <Counter />, document.getElementById("app")!);

很简单的点击按钮增加计数的逻辑,当点击按钮后计数加1,而对应的div节点就会更新但是button节点不会更新。

实际上上面实例中Counter组件经过处理会变成下面形式:

unction Counter() {
  const [getCount, setCount] = createSignal(1);
  const increment = () => setCount(getCount() + 1);
  return [(() => {
    const _el$ = _tmpl$();
    _el$.$$click = increment;
    return _el$;
  })(), (() => {
    const _el$2 = _tmpl$2();
    _$insert(_el$2, getCount);
    return _el$2;
  })()];
}

Counter组件的视图部分被处理成一个个自执行函数了,即button节点和div节点分别对应一个自执行函数,如果嵌套多级子节点呢?难道每一个节点都对应一个自执行函数吗?实际上并不是如此逻辑,从实际调试得到的两点逻辑如下:

  • 只有组件的一级子节点才会一一对应一个自执行函数,次级子节点不会
  • 只要节点包含响应性状态内容则会调用_$insert函数来处理

实际上render过程会触发根组件,即Counter函数组件执行,而函数组件内部中对于Signal状态的节点的处理需要调用_$insert,实际上该函数就是insert函数。此时insert函数的accessor参数就是响应性状态的getter函数,整个调用链如下:

  • 函数组件内部调用insert函数 -> createRenderEffect -> 顺序执行createComputation 、 updateComputation
  • updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数

createRenderEffect回调函数的逻辑如下:

  createRenderEffect(current => {
  	insertExpression(parent, accessor(), current, marker);
  }, initial);

其逻辑分为两点:

  • accessor函数执行:对应着Signal状态下上文getter函数,即响应性状态的getter函数会在runComputation后被调用处理
  • insertExpression:会将Signal状态值生成的DOM插入到父节点中

在accessor函数执行之前,需要了解createSignal创建响应性状态的具体逻辑,该API主要处理逻辑代码如下:

function createSignal(value, options) {
  ...
  const s = {
    value,
    observers: null,
    observerSlots: null,
    comparator: options.equals || undefined
  };
  const setter = value => {
  	...
    return writeSignal(s, value);
  };
  return [readSignal.bind(s), setter];
}

createSignal的主要逻辑很简单,就是返回响应的getter、setter函数并没有其他复杂逻辑执行:

  • 定义包含初始值的s对象,表示上下文对象,后面的getter函数的this对象就是该上下文对象,setter函数也需要使用该上下文对象
  • 上下文对象中的observers本意是观察者,必然是发布订阅模式实现的节点级更新逻辑,后面会与相关对应进行关联从而实现的,和Vue底层逻辑相似,但是不像Vue使用Proxy来实现,Solid仅仅就是单纯的函数
getter函数执行

当accessor函数执行时本质上就是执行Signal状态的getter函数,此时就会执行readSignal函数,下面是简化的逻辑:

function readSignal() {
  ...
  if (Listener) {
    const sSlot = this.observers ? this.observers.length : 0;
    if (!Listener.sources) {
      Listener.sources = [this];
      Listener.sourceSlots = [sSlot];
    } else {
      Listener.sources.push(this);
      Listener.sourceSlots.push(sSlot);
    }
    if (!this.observers) {
      this.observers = [Listener];
      this.observerSlots = [Listener.sources.length - 1];
    } else {
      this.observers.push(Listener);
      this.observerSlots.push(Listener.sources.length - 1);
    }
  }
  ...
  return this.value;
}

主要逻辑就是:当Listener全局变量存在的情况下,就会将Listener存入上下文对象的observers属性中,那么Listener什么时候有值呢?源码中全局查找Listenerd的赋值操作,就有一个地方,即updateComputation函数的处理,但是该函数的调用来源很多。在初始化阶段updateComputation函数的调用链上面就已经描述清楚了,其来源于createRenderEffect。

updateComputation中Listener的相关处理逻辑如下:

function updateComputation(node) {
  if (!node.fn) return;
  ...
  Listener = Owner = node;
  runComputation(node, Transition && Transition.running && Transition.sources.has(node) ? node.tValue : node.value, time);
  ...
}

这里的node参数就是createComputation函数的对象,即Computation对象,该对象的属性有:

  const c = {
    fn,
    state: state,
    updatedAt: null,
    owned: null,
    sources: null,
    sourceSlots: null,
    cleanups: null,
    value: init,
    owner: Owner,
    context: Owner ? Owner.context : null,
    pure
  };

所以Listener本质上就是Computation对象,其中该对象的fn属性存放的就是触发响应性状态getter函数的回调函数。

所以当初始化调用链触发getter函数执行时,Listener就已经存在,之后的处理逻辑就是:

  • 对应Signal状态的上下文对象中保存Listener,即Signal状态上下文中保存Listener到Observes属性中
  • Listener指向的Computation对象的sources属性会保存对应Signal状态的上下文

此时Computation对象与Signal状态上下文对象就互相关联起来了。后续初始化的处理逻辑就是将生成的节点通过DOM插入方法添加到节点中,没有虚拟DOM Diff的过程,这里就不继续说明了。

故而函数组件在初始化阶段内部的处理逻辑就非常清晰,具体如下:

  • insert -> createRenderEffect -> 顺序执行createComputation、updateComputation
  • updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数
  • createRenderEffect传递的回调函数内部逻辑会顺序执行Signal状态getter函数、insertExpression,insertExpression会将Signal状态节点插入到父节点中
更新阶段

现在点击了按钮,此时上面实例中:首先是先调用getter函数,然后再调用setter函数。此时Signal状态的getter再次被执行,但是Listener会在每次赋值操作后被重置为之前的状态,即初始化阶段updateComputation最后处理时被重置为null,所以getter函数此时执行仅仅返回当前值而已。

setter函数执行

执行了setter函数,此时就会执行writeSignal函数,去看看该函数做了什么处理,下面是简化的逻辑(移除了Transition相关的逻辑):

function writeSignal(node, value, isComp) {
  let current = node.value;
  if (!node.comparator || !node.comparator(current, value)) {
  	...
  	node.value = value;
    if (node.observers && node.observers.length) {
      runUpdates(() => {
        for (let i = 0; i < node.observers.length; i += 1) {
          const o = node.observers[i];
          ...
          if (TransitionRunning ? !o.tState : !o.state) {
            if (o.pure) Updates.push(o);else Effects.push(o);
            if (o.observers) markDownstream(o);
          }
          if (!TransitionRunning) o.state = STALE;else o.tState = STALE;
        }
        if (Updates.length > 10e5) {
          Updates = [];
          if (false) ;
          throw new Error();
        }
      }, false);
    }
  }
  return value;
}

在初始化处理阶段,Signal状态对应的上下文对象中observers已经保存了Listener对应的Computation对象。在更新阶段时setter函数被调用,此时observers是有值的。故而其调用栈逻辑如下:

  • writeSignal -> runUpdates -> 顺序执行传入的回调函数、completeUpdates
  • 这里的回调函数的逻辑实际上就是将Computation对象更加对应的条件条件到Updates、Effects队列中
  • completeUpdates -> runQueue(对Updates、Effects 进行处理) -> 循环遍历队列依次处理,即依次调用runTop -> 一般情况下就会调用updateComputation函数

需要注意的是更新阶段updateComputation的处理有一个重要的逻辑,即cleanNode函数调用:

function updateComputation(node) {
  ...
  cleanNode(node);
  ...
  Listener = Owner = node;
  ...
}

function cleanNode() {
  if (node.sources) {
    while (node.sources.length) {
      const source = node.sources.pop(),
      index = node.sourceSlots.pop(),
      obs = source.observers;
      if (obs && obs.length) {
       const n = obs.pop(), s = source.observerSlots.pop();
       if (index < obs.length) {
         n.sourceSlots[s] = index;
         obs[index] = n;
         source.observerSlots[index] = s;
       }
      }
    }
  }
}

Computation对象与Signal状态上下文对象建立的关联在cleanNode对象中被解绑了,即当某个Signal状态更新后,Solid会将对应Signal状态的旧Computation解绑,这就是更新阶段cleanNode主要处理逻辑。

updateComputation之后的处理就是设置新的Listener对象,之后的处理逻辑就更初始化时相同,即:

updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数

从而实现最新的Computation对象与Signal状态上下文关联,之后更新到对应的DOM节点,这个过程就实现了节点级别的视图更新。

在初始化阶段因为createRenderEffect传递的回调函数的参数中就有当前响应性状态所在位置的父节点,通过闭包特性保证了每次更新都是同一节点位置,从而实现Solid的节点级别的视图更新逻辑。

副作用处理

createEffect API是Solid用来处理副作用的方法,该方法类比React的useEffect,不同于useEffect的是不需要指明依赖项,它会自动收集依赖项。

通过下面实例来了解createEffect的执行过程:

import { render } from "solid-js/web";
import { createSignal } from "solid-js";

function Counter() {
  const [getCount, setCount] = createSignal(1);
  const increment = () => setCount(getCount() + 1);
  createEffect(() => {
  	console.log(getCount());
  });
  
  return (
    <>
      <button type="button" onClick={increment}>
        点击
      </button>
      <div>{getCount()}</div>
    </>
  );
}

render(() => <Counter />, document.getElementById("app")!);
初始化阶段

createEffect方法的逻辑具体如下:

function createEffect(fn, value, options) {
  runEffects = runUserEffects;
  const c = createComputation(fn, value, false, STALE),
    s = SuspenseContext && useContext(SuspenseContext);
  if (s) c.suspense = s;
  if (!options || !options.render) c.user = true;
  Effects ? Effects.push(c) : updateComputation(c);
}

在之前分析createSignal就梳理了初始化过程的关键处理过程,知道相关函数的作用:

  • createComputation:就是创建新的Computation对象
  • updateComputation:就是解绑对应Signal状态上下文对象与旧的Computation对象之间关联,之后将当前新的Computation对象重新与Signal状态上下文对象绑定

在初始化阶段createEffect逻辑主要就是两点:

  • 创建一个新的Computation对象,即每一个createEffect都会对应一个Computation对象
  • 判断Effects队列是否存在,Effects为数组就会将Computation对象保存到Effects队列中

当render函数执行时其内部会调用runUpdates函数,之后会执行completeUpdates函数,该函数内部逻辑就是处理Effects、Updates队列中内容。根据render函数的处理过程可知:

createEffect内部保存在Effects队列中副作用逻辑,会在组件挂载到节点之后被执行,初始化阶段createEffect副作用函数会立即执行,此时并没有于Signal状态上下文建立关联

当副作用逻辑执行时,如果内部存在Signal状态对象就会执行其getter函数,从而将当前Computation对象与Signal状态上下文建立关联,从而实现依赖的收集。

更新阶段

当createEffect对应的Computation与Signal状态对象建立关联后,所谓的更新阶段就是Signal状态更新时的处理逻辑流程了,实际上就是处理createEffect对应的Computation对象而已。这里需要注意的是更新阶段createEffect的处理有以下几点说明:

  • createEffect对应的Computation对象的处理总是在视图节点的Computation对象之后的
  • Solid中createEffect整个的处理过程是同步而非异步,不同于React useEffect的异步处理

总结

实际上从Solid的风格以及底层原理实现,你可以看到其他框架的影子:

  • Solid底层实现和Vue底层响应性实现非常相似,都是基于发布订阅模式实现两个对象的绑定从而构建响应的基石,只不过在Vue中Dep对象与Watcher对象,Solid中是Signal状态上下文对象和Computation对象
  • 除了采用React Hooks,Solid只支持函数组件这一种形式,还支持并发渲染即时间分片,底层实现方式与React相似,这里不再赘述了

这里梳理下Solid整体的主要处理流程,具体如下图:

Solid流程

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1039354.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Elasticsearch:与多个 PDF 聊天 | LangChain Python 应用教程(免费 LLMs 和嵌入)

在本博客中&#xff0c;你将学习创建一个 LangChain 应用程序&#xff0c;以使用 ChatGPT API 和 Huggingface 语言模型与多个 PDF 文件聊天。 如上所示&#xff0c;我们在最最左边摄入 PDF 文件&#xff0c;并它们连成一起&#xff0c;并分为不同的 chunks。我们可以通过使用 …

在线商城项目EShop【ListView、adapter】

要求如下&#xff1a; 1、创建在线商城项目EShop&#xff1b; 2、修改布局文件activity_main.xml&#xff0c;使用LineaLayout和ListView创建商品列表UI&#xff1b; 3、创建列表项布局list_item.xml&#xff0c;设计UI用于显示商品图标、名称和价格信息&#xff1b; 4、创…

IT监控制度,IT监控体系如何分层

IT监控系统是指监控和管理it服务管理的软件。它涵盖了监控管理、服务台管理、问题管理和变更管理&#xff0c;旨在帮助组织更有效的运营信息系统&#xff0c;提高运营事故的响应速度。  通过建立集中监控平台&#xff0c;IT监控系统与信息系统的融合可以完成统一的展示和管理…

ICMP差错包

ICMP报文分类 Type Code 描述 查询/差错 0-Echo响应 0 Echo响应报文 查询 3-目的不可达 0 目标网络不可达报文 差错 1 目标主机不可达报文 差错 2 目标协议不可达报文 差错 3 目标端口不可达报文 差错 4 要求分段并设置DF flag标志报文 差错 5 源路由…

【从0学习Solidity】 50. 多签钱包

【从0学习Solidity】50. 多签钱包 博主简介&#xff1a;不写代码没饭吃&#xff0c;一名全栈领域的创作者&#xff0c;专注于研究互联网产品的解决方案和技术。熟悉云原生、微服务架构&#xff0c;分享一些项目实战经验以及前沿技术的见解。关注我们的主页&#xff0c;探索全栈…

Mac磁盘空间满了怎么办?Mac如何清理磁盘空间

你是不是发现你的Mac电脑存储越来越满&#xff0c;甚至操作系统本身就占了100多G的空间&#xff1f;这不仅影响了电脑的性能&#xff0c;而且也让你无法存储更多的重要文件和软件。别担心&#xff0c;今天这篇文章将告诉你如何清除多余的文件&#xff0c;让你的Mac重获新生。 一…

【kafka实战】01 3分钟在Linux上安装kafka

本节采用docker安装Kafka。采用的是bitnami的镜像。Bitnami是一个提供各种流行应用的Docker镜像和软件包的公司。采用docker的方式3分钟就可以把我们想安装的程序运行起来&#xff0c;不得不说真的很方便啊&#xff0c;好了&#xff0c;开搞。使用前提&#xff1a;Linux虚拟机&…

找不到msvcp110dll,无法继续执行代码,msvcp110dll丢失是什么意思

MSVCP110.dll是一个动态链接库文件&#xff0c;它是Microsoft Visual C 2012 Redistributable package的一部分。这个文件通常用于支持许多Microsoft Visual Studio 2012开发的应用程序。当您在运行某些程序时遇到“找不到msvcp110.dll”的错误时&#xff0c;这意味着您的计算机…

PHY6230低成本遥控灯控芯片国产蓝牙BLE5.2 2.4G SoC

高性价比的低功耗高性能蓝牙5.2系统级芯片&#xff0c;适用多种PC/手机外设连接场景。 高性能多模射频收发机&#xff1a; 通过硬件模块的充分复用实现高性能多模数字收发机。发射机&#xff0c;最大发射功率10dBm&#xff1b;BLE 1Mbps速率接收机灵敏度达到-96dBm&#xff1…

查准率(precision,也叫精确率)和查全率(recall,也叫召回率)

精确率和召回率是广泛用于信息检索和统计学分类领域的两个度量值&#xff0c;用来评价结果的质量。其中精确率是检索出相关文档数与检索出的文档总数的比率&#xff0c;衡量的是检索系统的查准率&#xff1b;召回率是指检索出的相关文档数和文档库中所有的相关文档数的比率&…

【算法小课堂】滑动窗口

滑动窗口 基本概念&#xff1a; 滑动窗口本质是双指针算法的一种演变 本质上就是同向双指针&#xff0c;窗口的范围就是[left,right&#xff09; 滑动窗口大致可以分为两类 窗口大小不变的窗口大小变化的 滑动窗口遇到一些验证重复性的问题的时候可以用哈希表来优化 核心思想…

​旅行季《乡村振兴战略下传统村落文化旅游设计》许少辉八一书作想象和世界一样宽广

​旅行季《乡村振兴战略下传统村落文化旅游设计》许少辉八一书作想象和世界一样宽广

DAZ To UMA⭐二.设置DAZ导出的形态键 和 Daz贴图位置

文章目录 🟧 形态键介绍及在Unity3D中的用途1️⃣ Daz中的形态键2️⃣ Blender 中的形态键3️⃣ 形态键在Unity中的作用🟩 设置DAZ导出的形态键1️⃣ 找到要导出的形态键名称2️⃣ 打开导出面板3️⃣ 设置导出规则举例 : 导出身体Morphs举例:导出嘴部Morphs🟦 获取模型纹…

uni-app 之 去掉顶部导航

uni-app 之 去掉顶部导航 uniapp怎么样去掉顶部导航 uniapp去掉顶部导航的方法&#xff1a; 1、去掉所有导航栏&#xff1b; 2、单一页面去掉顶部导航栏。 image.png uniapp去掉顶部导航的方法&#xff1a; 1、去掉所有导航栏 "globalStyle": {"navigationBar…

个人电脑怎么搭建服务器 花生壳内网穿透 设置路由器虚拟服务器

在个人电脑上搭建服务器并使用花生壳进行内网穿透&#xff0c;以及在路由器上设置虚拟服务器&#xff0c;需要一些步骤&#xff1a; 内网穿透设置&#xff1a; 配置内网穿透&#xff0c;选择服务器软件的端口号&#xff0c;然后将其映射到您的服务器的内部IP地址和端口号。这将…

web信息收集

1.绕过cdn方法 2.搜索引擎语法 3.whois查询 4.端口探查 5.网站架构 6.其他 7.网站部署结构

Unity3D 检测鼠标位置的Sprite像素颜色

思路 获取鼠标所在屏幕坐标(Vector2)通过相机ScreenToWorldPoint(Vector3)转为世界坐标 (注意Vector3的z是距离相机的距离&#xff0c;相机需要正交)通过SpriteRenderer访问边界Bounds通过Bounds.Contain检测世界坐标是否在SpriteBounds内通过比例计算来确定在Sprite内的UV坐标…

企业sdwan组网要求:企业使用SD-WAN组网时有哪些要求?

在当今信息时代&#xff0c;企业对于网络的稳定性和高效性要求越来越高。SD-WAN(软件定义广域网)作为 一种新兴的网络技术&#xff0c;为企业提供了更加灵活、可靠和安全的广域网解决方案。在组建企业SD- WAN网络时&#xff0c;有一些关键要求需要考虑。 1. 高可用性&#xff…

全球首发搭载“舱驾一体”的智能座舱,诺博汽车如何引领未来出行?

智能座舱升级战已经全面打响。 一方面&#xff0c;智能座舱已经进入了3.0时代&#xff0c;车企对于差异化要求越来越高&#xff0c;如何进一步提升单一功能体验并进行深度融合&#xff0c;已经成为了智能座舱市场比拼的重点。 另一方面&#xff0c;在5G、车联网、大数据、人工…

Web安全扫描工具:Appscan安装和使用,无偿分享安装包与教程

Appscan 1、简介2、具体使用规则1、常用界面2、工作原理3、设置小技巧 3、实例扫描1、验证码绕过2、越权扫描3、手动绕过验证码扫描 1、简介 AppScan是一款商业化的web安全扫描工具&#xff0c;web扫描领域十分受欢迎 点击此处&#xff0c;先领取资料再阅读&#xff0c;附安装…