【干货】耗时7个小时,用近50张图来学习enhance-resolve中的数据流动和插件调度机制

0. 食用本文的文档说明:

本篇文章 耗时 7个小时左右才完工,篇幅涉及到大量的源码及其分析的过程图解和数据,阅读前,请保证自己有充分的时间,尽情的去享受吸收知识进入脑子的过程

因为篇幅有限,希望你掌握以下前置知识:

  1. 已经学习过 enhanced-resolve 工作流程和插拔式插件机制点这里复习:webpack 核心库 enhanced-resolve 工作流程和插拔式插件机制
  2. 了解 tabaple 是一个订阅发布的设计模式(知道啥是订阅发布即可)
  3. 大致了解 node 中的模块查找机制,如:
require(./xxx.js’);
require('./xxx');
require('xxx');

通过本文你将学到如下内容(或者带着如下疑问去学习):

  1. enhance-resolve是如何在复杂的插件调用之间传递数据的?
  2. Resolver 和 ResolverFactory的关系是什么?
  3. Resolver是如何设计实现的?
  4. 软链接和硬链接是什么?区别在哪里?
  5. 如何开发一个enhance-resolve的插件应用到webpack 中?
  6. 如何去一步步的 debug 一个开源库?

1 webpack 和 enhance-resolve 的关系是什么?

webpack作为一个强大的打包工具,其强大的不仅仅是插件机制,还有其核心包enhance-resolve来实现模块的路径查找。功能上来说它可以增强Webpack的模块解析能力,使其更容易找到所需的模块,从而提高Webpack的性能和可维护性。从配置上来说它可以为Webpack解析器添加额外的搜索路径以及解析规则,让Webpack更好地解释路径和文件,进而让webpack更加专心的做模块打包相关的事情。

了解完背景和需求以后,如果让我们去实现一个enhance-resolve呢?

功能点:

  1. 首先解析器满足模块查找中的所有的规则 模块:通用JS模块 |节点.js v14.21.3 文档 (nodejs.org)
  2. 要和webpack一样,有强大的插件加载机制和良好的配置功能

自己可以心中默默的想一下如何实现上述功能点呢?

2. 接下来就根据上述功能点通过代码去了解一下 enhance-resolve

咱们上回太强了,3000字图文并茂的解析 webpack 核心库 enhanced-resolve 工作流程和插拔式插件机制,真香 - 掘金 (juejin.cn)说到:

  1. ResolverFactory.createResolver 根据 Resolver 类创建实例: myResolve (吃了配置,吐出对象myResolve)
  2. myResolve 上 注册并订阅 大量的 hook (枪支弹药贮备好,一刻激发)
  3. 调用 myResolver.resolve 方法开始进行 文件解析 的主流程
  4. 内部通过 resolve.doResolve方法,开始调用第一个 hook: this.hooks.resolve
  5. 找到之前 订阅 hook 的 plugin:ParsePlugin
  6. ParsePlugin 进行初步解析,然后 通过doResolve 执行下一个 hook parsed-resolve,前期准备工作结束,链式调用开始,真正的解析文件的流程也开始。

从上面的第2步开始整起,第2步注册了哪些hook呢?接下来开始瞅瞅

2.1 细细回顾 myResolve 上注册的hooks

代码跳转到 lib/ResolverFactory.js295 行左右,代码如下:

 pipeline 

resolver.ensureHook("resolve");
resolver.ensureHook("internalResolve");
resolver.ensureHook("newInternalResolve");
resolver.ensureHook("parsedResolve");
resolver.ensureHook("describedResolve");
resolver.ensureHook("rawResolve");
resolver.ensureHook("normalResolve");
resolver.ensureHook("internal");
resolver.ensureHook("rawModule");
resolver.ensureHook("module");
resolver.ensureHook("resolveAsModule");
resolver.ensureHook("undescribedResolveInPackage");
resolver.ensureHook("resolveInPackage");
resolver.ensureHook("resolveInExistingDirectory");
resolver.ensureHook("relative");
resolver.ensureHook("describedRelative");
resolver.ensureHook("directory");
resolver.ensureHook("undescribedExistingDirectory");
resolver.ensureHook("existingDirectory");
resolver.ensureHook("undescribedRawFile");
resolver.ensureHook("rawFile");
resolver.ensureHook("file");
resolver.ensureHook("finalFile");
resolver.ensureHook("existingFile");
resolver.ensureHook("resolved");

为了便于理解,放出 ensureHook的部分核心代码,其主要作用就是创建一个 AsyncSeriesBailHook 异步串行保险型的 hook,(所谓的保险你可以想象成流浪星球2中的饱和式救援,1个任务派出多个救援队【订阅多个hook】,只要一个救援队成功了【一个hook存在返回值】这次救援就算成功了【这个订阅事件就算结束了】)

ensureHook(name) {
 if (typeof name !== "string") {
  return name;
 }
 name = toCamelCase(name);
 const hook = this.hooks[name];
 if (!hook) {
  return (this.hooks[name] = new AsyncSeriesBailHook(
   ["request", "resolveContext"],
   name
  ));
 }
 return hook;
}

PS: ensureHook的作用是

可以看到作者在头部特意写了一个简短的注释 pipeline ,翻译过来也就是流水线。

流水线是一种工业生产方式,它将一个大型工程分解成若干个小步骤,每个步骤都有专门的工人或机器来完成,从而提高生产效率。流水线的优势在于可以提高生产效率,减少生产成本,提高产品质量,并且可以更快地完成大型工程。在IT界就可以认为是模块间解耦,提高代码可读性和可维护性

到这里流水线流程组装完毕【可理解成为每个工种分配了相关的任务】,那下一步就是要开始组装每部分流程用到的工具集(plugins),【然后再为每个工种分配不同的工具】。部分核心代码如下:

// resolve
for (const { source, resolveOptions } of [
 { source: "resolve", resolveOptions: { fullySpecified } },
 { source: "internal-resolve", resolveOptions: { fullySpecified: false } }
]) {
 if (unsafeCache) {
  plugins.push(
   new UnsafeCachePlugin(
    source,
    cachePredicate,
    unsafeCache,
    cacheWithContext,
    `new-${source}`
   )
  );
  plugins.push(
   new ParsePlugin(`new-${source}`, resolveOptions, "parsed-resolve")
  );
 } else {
  plugins.push(new ParsePlugin(source, resolveOptions, "parsed-resolve"));
 }
}

// parsed-resolve
plugins.push(
 new DescriptionFilePlugin(
  "parsed-resolve",
  descriptionFiles,
  false,
  "described-resolve"
 )
);
plugins.push(new NextPlugin("after-parsed-resolve", "described-resolve"));

...... 此处省略部分注册插件逻辑

 RESOLVER 

for (const plugin of plugins) {
 if (typeof plugin === "function") {
  plugin.call(resolver, resolver);
 } else {
  plugin.apply(resolver);
 }
}

一直到最后把根据用户配置生成的相关的插件列表plugins给注册到 resolver 上,整个的resolver 的hook 和 plugin 的绑定才成功结束。

本次调试代码绑定的 总的插件的数量为 41个:

image.png

其中因为NextPlugin是流程推动性插件和业务逻辑无关,就过滤掉,还剩下 32个

image.png

2.2 开始调试正式流程吧 (流水线打开电源,跑起来了)

lib/Resolver.jsresolve 方法中是查找路径开始的起点,首先就是把 用户传入的 路径 path 和 要查找文件的路径 request 赋值给 obj 对象 【此 obj 是核心对象,将在各个插件中流转修改】。

image.png

然后就开始调用自身的 doResolve 方法,正式开始流程了。

image.png

3. 从 resolve hook 开始的流程,到结束

断点到 doResolve方法的 hook.callAsync 部分,看下相关的参数。

image.png

从图中可以看出,此 hook 名为 resolve,入参有两个:Array(2)[request,resolveContext],绑定此 hook 的插件只有一个 ParsePlugin 的插件,传递下去的参数是 request 对象:pathrequest是重要的数据。

下一步就开始进入 ParsePlugin 插件看看它究竟做了什么。

3.1 视察 ParsePlugin工种的工作

ParsePlugin 其核心 apply 代码如下:

apply(resolver) {
 const target = resolver.ensureHook(this.target);
 resolver
  .getHook(this.source)
  .tapAsync("ParsePlugin", (request, resolveContext, callback) => {
   // 调用 resolver 中的 parse 方法初步解析
   const parsed = resolver.parse(/** @type {string} */ (request.request));
   // 合并成新的 obj 对象
   const obj = { ...request, ...parsed, ...this.requestOptions };
   if (request.query && !parsed.query) {
    obj.query = request.query;
   }
   if (request.fragment && !parsed.fragment) {
    obj.fragment = request.fragment;
   }
   if (parsed && resolveContext.log) {
    if (parsed.module) resolveContext.log("Parsed request is a module");
    if (parsed.directory)
     resolveContext.log("Parsed request is a directory");
   }
   // There is an edge-case where a request with # can be a path or a fragment -> try both
   if (obj.request && !obj.query && obj.fragment) {
    const directory = obj.fragment.endsWith("/");
    const alternative = {
     ...obj,
     directory,
     request:
      obj.request +
      (obj.directory ? "/" : "") +
      (directory ? obj.fragment.slice(0, -1) : obj.fragment),
     fragment: ""
    };
    resolver.doResolve(
     target,
     alternative,
     null,
     resolveContext,
     (err, result) => {
      if (err) return callback(err);
      if (result) return callback(null, result);
      resolver.doResolve(target, obj, null, resolveContext, callback);
     }
    );
    return;
   }
   resolver.doResolve(target, obj, null, resolveContext, callback);
  });
}

经过断点发现,obj 对象第一次进入这个 plugin逛了一圈,然后最终走到了 resolver.doResolve(target, obj, null, resolveContext, callback); 这里,处理完的数据如下:【思考一下吃了啥数据,吐出了啥数据?】

image.png

image.png

ParsePlugin 吃了 obj,以后对其进行初步解析,增加了如下属性 【红色是吃进去的,绿色是吐出来的】

image.png

然后下一个要执行hook是parsedResolve,其绑定的业务插件是 DescriptionFilePluginNextPlugin插件属于流程插件,可以忽略。

image.png

3.2 视察 DescriptionFilePlugin工种的工作

当前流程的 DescriptionFilePlugin 插件的核心是在 DescriptionFileUtils.loadDescriptionFile方法里,

image.png

当看到 ['package.json']的那一刻是不是可以联想并猜测到:此插件的作用就是在实现查找当前的路径 是否是一个 具有package.json文件的模块?继续debug loadDescriptionFile方法,

image.png

看到这个路径拼接,验证了猜想是正确的,继续 debug 发现,走到了此方法的 callback 函数里,执行了一个 cdUp 的方法。

image.png

我们不去看方法实现,仅仅看变更,变量从directory变成了 dir,数据从/Users/fujunkui/Desktop/github-project/enhanced-resolve/demo/test-find-file变成了/Users/fujunkui/Desktop/github-project/enhanced-resolve/demo,卧槽,还真是进入了上级目录,cdUp 66666。

不出所料的话,他会一直 cdUp 知道进入到根目录的,查找 /package.json 为止 【图中,我把enhance-resolve 项目的package.json 文件给删除了,不删除的话找到这一级就停止了】
部分截图

image.png

image.png

最后找呀找呀,就是找不到一个目录具有package.json 文件,没办法只能走 callback 了。

image.png

结果就是这个插件一顿 cdUp 操作,啥都没变,注意此处的 callback()返回值为空,他就要进入此hook 的下一个插件了,NextPlugin 正式登场。

3.3 外卖小哥 NextPlugin 正式登场

NextPlugin 核心代码如下:

apply(resolver) {
 const target = resolver.ensureHook(this.target);
 resolver
  .getHook(this.source)
  .tapAsync("NextPlugin", (request, resolveContext, callback) => {
   resolver.doResolve(target, request, null, resolveContext, callback);
  });
}

直接调用 resolver.doResolve 把上一个 hook 的丢出的数据,给下一个 hook 使用,不做任何改变(像极了 辛苦帮商家送餐的外卖小哥,点赞)。

那就有请下一位 hook 闪亮登场:

image.png

好家伙,下一个hook 是 rawResolve,让我们来看看他的监听者 都有谁,拉倒吧,还是 NextPlugin 外卖小哥,这就是外卖小哥点饭(外卖小哥送给外卖小哥)???

那就继续吧,看看这个 rawResolve 的下一个hook是谁,监听的插件都有谁?

image.png

下一个 hook 名叫 normalResolve,竟然有3个插件监听了此 hook,那么开始表演吧。

3.4 视察 hook 名为normalResolve 下面的三个工种(插件)的工作

3.4.1 第一位和第二位 靓仔都是 ConditionalPlugin (翻译为中文就是:条件插件)

大致猜测一下条件插件:就是满足了哪些条件才会继续执行下去。

两者的区别在初始化的传参里:

plugins.push(
 new ConditionalPlugin(
  "after-normal-resolve",
  { module: true },
  "resolve as module",
  false,
  "raw-module"
 )
);
plugins.push(
 new ConditionalPlugin(
  "after-normal-resolve",
  { internal: true },
  "resolve as internal import",
  false,
  "internal"
 )
);

总体代码是:

class ConditionalPlugin {
 constructor(source, test, message, allowAlternatives, target) {
  this.source = source;
  this.test = test;
  this.message = message;
  this.allowAlternatives = allowAlternatives;
  this.target = target;
 }
 
 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  const { test, message, allowAlternatives } = this;
  const keys = Object.keys(test);
  resolver
   .getHook(this.source)
   .tapAsync("ConditionalPlugin", (request, resolveContext, callback) => {
    for (const prop of keys) {
     if (request[prop] !== test[prop]) return callback();
    }
    resolver.doResolve(
     target,
     request,
     message,
     resolveContext,
     allowAlternatives
      ? callback
      : (err, result) => {
        if (err) return callback(err);

        // Don't allow other alternatives
        if (result === undefined) return callback(null, null);
        callback(null, result);
        }
    );
   });
 }
};

执行结果如下:
第一次 插件的 callback 结果是 空【下图】,进入 第二个 插件,

image.png

第二个插件的 callback 结果是 空【下图】, 进入 JoinRequestPlugin 插件

image.png

3.4.2 视察 JoinRequestPlugin 插件的工作

看名字就知道是干啥的,任务比较简单,就是把 path 和 request 合并成新的路径 赋值给 path(绿色圈中部分),

resolver.join(request.path, request.request),

image.png

这个 hook 的事情完成了,有请下一个 hook relative,以及它的两位监听者们。

image.png

3.5 视察 hook 名为relative 下面的两个工种(插件)的工作

兜兜转转的又进入 DescriptionFilePlugin 插件了,但是 此时的参数和之前的不一样了,但是好像也没有什么不同,最后还是 callback 为空,灰头土脸的走进下一个插件了。

image.png

继续走到 NextPlugin,然后被送到 describedRelative 的hook,此hook的监听者有:

image.png

3.5 视察 hook 名为describedRelative 下面的两个工种(条件插件)的工作

条件插件要满足的第一个逻辑就是,不是文件夹,推测我们是满足的,开始debug。

plugins.push(
 new ConditionalPlugin(
  "described-relative",
  { directory: false },
  null,
  true,
  "raw-file"
 )
);
plugins.push(
 new ConditionalPlugin(
  "described-relative",
  { fullySpecified: false },
  "as directory",
  true,
  "directory"
 )
);

image.png
确实满足了不是文件夹的条件,推进到下一个hook rawFile,其相关的监听者有5个。

image.png

3.6 视察 hook 名为rawFile 下面的工种的工作

不满足此插件,走进下一个插件TryNextPlugin:

// raw-file
plugins.push(
 new ConditionalPlugin(
  "raw-file",
  { fullySpecified: true },
  null,
  false,
  "file"
 )
);

TryNextPlugin(尝试下一个插件) 的代码如下:

apply(resolver) {
 const target = resolver.ensureHook(this.target);
 resolver
  .getHook(this.source)
  .tapAsync("TryNextPlugin", (request, resolveContext, callback) => {
   resolver.doResolve(
    target,
    request,
    this.message,
    resolveContext,
    callback
   );
  });
}

个人感觉其实此处的逻辑更应该是尝试下一个hook,而不是插件,所以改为 TryNextHook更好.之所以这么说看下面的代码:

plugins.push(new TryNextPlugin("raw-file", "no extension", "file"));

上面代码简单理解为,被查找的文件是 不带扩展的文件,可以直接走到 名为 file 的 hook里。此hook 的监听插件有:

image.png

那就继续走 NextPlugin 插件的逻辑,然后走向了 finalFile 的 hook 【下图】,进入 FileExistsPlugin 插件的逻辑里。

image.png

3.7 视察 hook 名为finalFile 下面的工种FileExistsPlugin插件的工作

代码比较简单:获取查找路径,直接判断是不是文件即可。

image.png

发现不是文件,那就执行callback函数,此插件的callback函数是Resolver 中的 hook.callAsync 中的callback 函数

image.png

然后 Resolver 中的 hook.callAsync 中的 callback 函数接受到的 err 和 result 都是 undefined,就又走了 doResolve 中接受的 callback 函数,那就要开始从现在这个 finalFile 向前找了,查找的过程要忽略掉 外卖小哥型插件 比如TryNextPluginNextPlugin

image.png

finalFile 上一个是 file的hook监听 (NextPlugin可忽略), file 的上一个是 raw-file,触发 raw-file 下的插件的监听,接下来就是查找监听了hook位 raw-file 的插件了。

这块的代码可能因为都叫callback,并且跳来跳去的有些难以理解,可以参考我下面简化过的代码。

let { AsyncSeriesBailHook } = require("tapable");

const hook1 = new AsyncSeriesBailHook(["request", "resolveContext"], "hook1");
const hook2 = new AsyncSeriesBailHook(["request", "resolveContext"], "hook2");

const hook1Tap1 = hook1.tapAsync(
 "hook1Tap1",
 (request, resolveContext, callback) => {
  console.log("hook1Tap1", request, resolveContext);
  return callback();
 }
);

const hook1Tap2 = hook1.tapAsync(
 "hook1Tap2",
 (request, resolveContext, callback) => {
  console.log("hook1Tap2", request, resolveContext);
  return callback();
 }
);

const hook2Tap1 = hook2.tapAsync(
 "hook2Tap1",
 (request, resolveContext, callback) => {
  console.log("hook2Tap1", request, resolveContext);
  return callback();
 }
);

const hook2Tap2 = hook2.tapAsync(
 "hook2Tap2",
 (request, resolveContext, callback) => {
  console.log("hook2Tap2", request, resolveContext);
  return callback("err");
 }
);

hook1.callAsync("111", "222", () => {
 console.log("hook1 callback");
 hook2.callAsync("333", "455", err => {
  console.log("hook2 callback", err);
 });
});

执行结果如下:

image.png

这块的内容是定义了两个异步的hook,然后在hook1 调用 callAsync 的时候,里面传递了 hook2 的 callAsync 调用,这样就会在调用完 hook1 的触发事件,然后去接着调用 hook2 的触发事件。

这样是不是可以理解 多个hook 之前传递 callback 的逻辑了?

那么接下来就要找监听了hook名为 raw-file 的插件有哪些了,直接看 ResolverFactory 注册时间得知 【下图】,有3个插件监听了。而现在的顺序 又是按照监听顺序倒着执行callback的,那就应该是先执行 AppendPlugin 插件了,打上断点,跑一下

image.png

3.8 回首掏,去视察 hook 名为raw-file 下面的工种AppendPlugin插件的工作

AppendPlugin 代码较为简单,就是把传入的 this.appendingrequest.path 进行拼接,生成新的 request.path

module.exports = class AppendPlugin {

 constructor(source, appending, target) {
  this.source = source;
  this.appending = appending;
  this.target = target;
 }
 
 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("AppendPlugin", (request, resolveContext, callback) => {
    const obj = {
     ...request,
     path: request.path + this.appending,
     relativePath:
      request.relativePath && request.relativePath + this.appending
    };
    resolver.doResolve(
     target,
     obj,
     this.appending,
     resolveContext,
     callback
    );
   });
 }
};

查找 this.appending 是在实例化时候传入的,断点得知。这个就是我们传入的 extensions 配置

const myResolver = ResolverFactory.createResolver({
 fileSystem: new CachedInputFileSystem(fs, 4000),
 extensions: [".json", ".js", ".ts"]
});

image.png

然后断点到此处,看吃进去了啥,吐出来了啥。

image.png

然后下一个 hook 是 file,只有一个 NextPlugin 插件监听了此hook,用来推进流程【下图】。

image.png

NextPlugin 插件是将流程 从 file 推向了 final-file hook,走到 3.7 的流程,判断一下带有此后缀的文件是否存在,不存在的话,继续 重复 raw-file hook 的 AppendPlugin 的流程,此时的参数是 this.appending.js 【下图】

image.png

继续 重复以上的操作: NextPlugin 插件是将流程 从 file 推向了 final-file hook,然后 FileExistsPlugin 插件判断到,此文件存在,推进流程到 existingFile 的hook,此hook 有2个插件监听【下图】。

image.png

3.9 文件存在了,下一步去视察 hook 名为existingFile 下面的插件的工作

先去执行SymlinkPlugin 通过 fs.readlink 方法判断其是否是符号链接下的文件,符号链接symlink_什么是符号链接或符号链接? 如何为Windows和Linux创建Symlink?_cunjiu9486的博客-CSDN博客

再补充一点 硬链接和软链接的区别? - 掘金 (juejin.cn)

关于符号链接这里有特殊说明,假设你新建了 b.js,删除了当前目录下的 a.js,当前目录情况如下:

image.png
建立硬链接 进行测试:

image.png

建立软链接,进行测试:

image.png

其实软链接,还区分绝对路径和相对路径的情况【下图】,本次只考虑相对路径,大家可以使用绝对路径进行debug.

image.png

我们进行软链接的debug,最后发现查找到b.js 的路径,那么继续debug。

image.png

到此是发现了软链接的源文件,那么下一步肯定是判断 此源文件是否是存在,又走到 existingFile的 hook 【下图】,重复3.9的步骤,又走 SymlinkPlugin 插件的逻辑(担心软链接的源文件还是软链接),

image.png

继续debug SymlinkPlugin,发现走到了 callback() 的情况【下图】,那就是要进入下一个监听者 (NextPlugin)了,

image.png

NextPlugin中发现终于走到了最后的hook resolved,只有一个插件 ResultPlugin 进行监听。

image.png

进入 ResultPlugin 插件内部,其主要是调用了 result 的hook,

apply(resolver) {
 this.source.tapAsync(
  "ResultPlugin",
  (request, resolverContext, callback) => {
   const obj = { ...request };
   if (resolverContext.log)
    resolverContext.log("reporting result " + obj.path);
   resolver.hooks.result.callAsync(obj, resolverContext, err => {
    if (err) return callback(err);
    if (typeof resolverContext.yield === "function") {
     resolverContext.yield(obj);
     callback(null, null);
    } else {
     callback(null, obj);
    }
   });
  }
 );
}

debug 一下那些插件监听了此 hook,发现是空的,直接走到自身的 callback 函数里,

image.png

继续debug 此 callback 函数,就会发现这个 callback 在一层一层的向上传递值,接着传到 Resolver 里的 resolve 函数里, 经过 finishResolved 处理解析一次【下图】,最后传递给 我们自身的callback 函数里。

image.png

debug 停在我们自己监听的callback 函数里,至此完成整体流程。

image.png

4 完结撒花,回顾总结。

通过一步一步的debug,会发现 enhance-resolve 这个库,把 tapable 给用的出神入化,核心的处理逻辑都在 Resolver 上,而 ResolverFactory 则像是 流水线的 线长,借用Resolver 的能力,去指定流水线的流程,分配流水线每个流程应该协作的工种。

总的逻辑通下来,你会发现,所有的插件都是在对 obj 对象做数据变更,每个插件都有自己的职责,互不干涉,互不影响,通过 NextPlugin,这个外卖小哥插件,把 数据在各个 hook 流程之间进行流转,进而建立起一套高效的流水线系统,耦合性低,定制化程度高,功能强大

这里就不画流程图做总结了,偷个懒,因为此文章耗时 7个小时左右 (啊,我的眼镜),从头到尾 debug 下来,发现收获不少,以后完全可以模仿此库基于自己的业务流程,开发定制一套属于自己的高效可定制化的可插拔插件的工程。

希望大家看完此文章会有所收获,慢慢的开始自己的学习源码之路。冲吧,兄弟们。

别忘记思考解答一下开头的问题,学有所获。下一篇文档 的方向是 解析webpack 源码。

image.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值