如何开发一个 webpack loader

loader 是什么

Webpack Loader 是用于 Webpack 构建工具的一种插件机制,用于处理模块。在 Webpack 构建过程中,Loader 用于将不同类型的文件转换成可以被添加到依赖图中的模块,并且可以应用于这些模块的转换和处理。

webpack 编译流程:

原始代码 -> 翻译结果

A->B,一对一映射,实际上就是使用 Loader 函数来处理映射逻辑,可以类比为翻译,将各种形式的资源文件翻译成 JS。

loader 的主要作用

Webpack Loader 的主要作用有以下几个方面:

  1. 文件转换: Loader 负责将不同类型的文件(如 JavaScript、CSS、图片等)转换成 Webpack 可以处理的模块。
  2. 预处理: Loader 可以执行一些预处理步骤,例如在加载 JavaScript 文件之前使用 Babel 转译,或在加载样式文件之前使用 Less 或 Sass 进行处理。
  3. 代码分割: Loader 可以根据需要将代码进行拆分,实现按需加载,从而减小页面初始加载时的体积。
  4. 资源处理: Loader 可以处理和加载各种资源,例如图片、字体等,并将它们作为模块添加到构建流程中。
  5. 动态引入: Loader 可以通过动态引入的方式,在运行时动态加载模块。

Loader 是一组按照顺序执行的函数,每个函数都有对模块内容的访问权,可以对模块进行转换、处理和增强。它们以管道的方式链接在一起,每个 Loader 将处理前一个 Loader 的输出,并将处理结果传递给下一个 Loader。 Loader 可以是同步的,也可以是异步的,这使得它们非常灵活。

一个简单的 Loader 通常是一个 Node.js 模块,它导出一个函数。这个函数接收源文件的内容作为输入,返回转换后的内容作为输出。在 Webpack 配置中,通过 module.rules 来定义 Loader 的使用规则。

常用 loader:

loader 名称 作用
file-loader 和 raw-loader 用于加载文本文件的内容,例如 JSON 文件。
babel-loader 用于将 ECMAScript 2015+ 代码转换为向后兼容的 JavaScript 版本,以便在现代浏览器中执行。
style-loader 和 css-loader 用于处理样式表文件,将 CSS 代码插入到页面中。
sass-loader 和 less-loader 用于处理 Sass 和 Less 样式文件,将其转换为 CSS。
file-loader 和 url-loader 用于处理文件,例如图片、字体等,使它们成为模块的一部分。
html-loader 用于处理 HTML 文件,使其成为模块,并解决其中的资源引用问题。
eslint-loader 用于在构建过程中进行代码检查,基于 ESLint 规则检测 JavaScript 代码。
svg-url-loader 用于处理 SVG 文件,将其转换为DataURL。

下文会以这些 loader 中的实现为例子,来介绍 loader 开发。

如何开发一个 loader

Loader Context 接口是什么

使用 loader 来解决问题,首先需要熟悉 Loader 提供了哪些可以操作的 API。

Webpack 官网对 Loader Context 已经有比较详细的说明,这里简单介绍几个比较常用的接口:

序号 接口 讲解
1 fs Compilation 对象的 inputFileSystem 属性,我们可以通过这个对象获取更多资源文件的内容;
2 resource 当前文件路径;
3 resourceQuery 文件请求参数,例如 import "./a?foo=bar" 的 resourceQuery 值为 ?foo=bar;
4 callback 可用于返回多个结果;
5 getOptions 用于获取当前 Loader 的配置对象;
6 async 用于声明这是一个异步 Loader,开发者需要通过 async 接口返回的 callback 函数传递处理结果;
7 emitWarning 添加警告;
8 emitError 添加错误信息,注意这不会中断 Webpack 运行;
9 emitFile 用于直接写出一个产物文件,例如 file-loader 依赖该接口写出 Chunk 之外的产物;
10 addDependency 将 dep 文件添加为编译依赖,当 dep 文件内容发生变化时,会触发当前文件的重新构建;

翻译阶段:处理映射逻辑

在 loader 中添加额外依赖

在 less-loader 中包含这样一段代码:

javascript 复制代码
try {
  result = await (options.implementation || less).render(data, lessOptions);
} catch (error) {
  // ...
}

const { css, imports } = result;

imports.forEach((item) => {
  // ...
  this.addDependency(path.normalize(item));
});

Loader Context 的 addDependency 接口用于添加额外的文件依赖,当这些依赖发生变化时,也会触发重新构建。上例中,path.normalize(item) 改变时,会触发重新构建。

依赖注册流程大致如下:

代码中首先调用 less 库编译文件内容,之后遍历所有 @import 语句(result.imports 数组),调用 addDependency 接口将 import 到的文件都注册为依赖,此后这些资源文件发生变化时都会触发重新编译。

为什么 less-loader 需要这么处理?

为了确保依赖变化时触发重新构建。less 工具本身已经会递归所有 Less 文件树,一次性将所有 .less 文件打包在一起,例如在 a.less 中:

javascript 复制代码
@import (less) './b.less' 

a、b 文件会被 less 打包在一起。a.less 对 b.less 的依赖,对 Webpack 来说是无感知的,如果不用 addDependency 显式声明依赖,后续 b.less 文件的变化不会触发 a.less 重新构建。

因此,addDependency 接口适用于那些 Webpack 无法理解隐式文件依赖的场景

除上例 less-loader,babel-loader 也是一个特别经典的案例。在 babel-loader 内部会添加对 Babel 配置文件如 .babelrc 的依赖,当 .babelrc 内容发生变化时,也会触发 babel-loader 重新运行。

此外,Loader Context 还提供了下面几个与依赖处理相关的接口:

接口 接口作用
addContextDependency(directory: String) 添加文件目录依赖,目录下内容变更时会触发文件变更
addMissingDependency(file: String) 用于添加文件依赖,效果与 addDependency 类似
clearDependencies() 清除所有文件依赖

处理二进制资源

当期望以二进制方式读入资源文件时,例如在 file-loader、image-loader 等场景中,可以按如下代码示例操作

javascript 复制代码
export default function loader(source) {/* ... */}

export const raw = true; // 添加这一句即可

之后,loader 函数中获取到的第一个参数 source 将会是 Buffer 对象形式的二进制内容。

输出阶段:如何控制输出结果

取消 loader 缓存

Webpack 默认会缓存 Loader 的执行结果直到资源或资源依赖发生变化,因为Loader 中执行的各种资源内容转译操作通常都是 CPU 密集型 ------ 这放在 JavaScript 单线程架构下可能导致性能问题;又或者异步 Loader 会挂起后续的加载器队列直到异步 Loader 触发回调,稍微不注意就可能导致整个加载器链条的执行时间过长。

开发者需要对此有个基本的理解,必要时可以通过 this.cachable 显式声明不作缓存。

在 loader 中返回多个结果

如果下游 loader 或 webpack 本身不止需要处理结果,还需要更多信息,可以用 callback 接口来返回更多信息。

例如在 webpack-contrib/eslint-loader 中:

javascript 复制代码
export default function loader(content, map) {
  // ...
  linter.printOutput(linter.lint(content));
  this.callback(null, content, map);
}

通过 this.callback(null, content, map) 语句,同时返回转译后的内容sourcemap 内容。

callback 的完整签名如下:

javascript 复制代码
this.callback(
  // 异常信息,Loader 正常运行时传递 null 值即可
  err: Error | null,
  // 转译结果
  content: string | Buffer,
  // 源码的 sourcemap 信息
  sourceMap?: SourceMap,
  // 任意需要在 Loader 间传递的值
  // 经常用来传递 AST 对象,避免重复解析
  data?: any
);

在 loader 中返回异步结果

涉及到异步或 CPU 密集操作时,Loader 中还可以以异步形式返回处理结果,

例如 webpack-contrib/less-loader 的核心逻辑:

javascript 复制代码
import less from "less";

async function lessLoader(source) {
  // 1. 获取异步回调函数
  const callback = this.async();
  // ...

  let result;

  try {
    // 2. 调用less 将模块内容转译为 css
    result = await (options.implementation || less).render(data, lessOptions);
  } catch (error) {
    // ...
  }

  const { css, imports } = result;

  // ...

  // 3. 转译结束,返回结果
  callback(null, css, map);
}

export default lessLoader;

在 less-loader 中,包含三个重要逻辑:

  1. 调用 this.async 获取异步回调函数 ,此时 Webpack 会将该 Loader 标记为异步加载器,会 挂起 当前执行队列直到 callback 被触发;
  2. 调用 less 库将 less 资源转译为标准 css;
  3. 调用异步回调 callback 返回处理结果。

this.async 返回的异步回调函数签名与上一节介绍的 this.callback 相同:

javascript 复制代码
this.callback(
  // 异常信息,Loader 正常运行时传递 null 值即可
  err: Error | null,
  // 转译结果
  content: string | Buffer,
  // 源码的 sourcemap 信息
  sourceMap?: SourceMap,
  // 任意需要在 Loader 间传递的值
  // 经常用来传递 AST 对象,避免重复解析
  data?: any
);

在 loader 中直接写出文件

如果需要在 loader 中直接写出文件,可以使用Loader Context 的 emitFile 接口。

例如在 file-loader 中:

javascript 复制代码
export default function loader(content) {
  const options = getOptions(this);

  validate(schema, options, {
    name: 'File Loader',
    baseDataPath: 'options',
  });
  // ...

  if (typeof options.emitFile === 'undefined' || options.emitFile) {
    // ...
    this.emitFile(outputPath, content, null, assetInfo);
  }

  const esModule =
    typeof options.esModule !== 'undefined' ? options.esModule : true;

  return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`;
}

export const raw = true;

借助 emitFile 接口,我们能够在 Webpack 构建主流程之外提交更多产物,这有时候是必要的。

javascript 复制代码
emitFile(name: string, content: Buffer|string, sourceMap: {...})

日志处理

在 loadr 中正确处理日志

Webpack 内置了一套 infrastructureLogging 接口,专门用于处理 Webpack 内部及各种第三方组件的日志需求,infrastructureLogging 提供了根据日志分级筛选展示功能,从而将日志的写逻辑与输出逻辑解耦。

提示:作为对比,假如我们使用 console.log 等硬编码方式输出日志信息,用户无法过滤这部分输出,可能会造成较大打扰,体感很不好。

因此,在编写 Loader 时也应该尽可能使用 Webpack 内置的这套 Logging 规则,方法很简单,只需使用 Loader Context 的 getLogger 接口,如:

javascript 复制代码
export default function loader(source) {
  const logger = this.getLogger("xxx-loader");
  // 使用适当的 logging 接口
  // 支持:verbose/log/info/warn/error
  logger.info("information");

  return source;
}

getLogger 返回的 logger 对象支持 verbose/log/info/warn/error 五种级别的日志,最终用户可以通过 infrastructureLogging.level 配置项筛选不同日志内容,例如:

java 复制代码
module.exports = {
  // ...
  infrastructureLogging: {
    level: 'warn',
  },
  // ...
};

在 loader 中上报异常

Webpack Loader 中有多种上报异常信息的方式:

  1. 使用 logger.error,仅输出错误日志,不会打断编译流程。
  2. 使用 this.emitError 接口,同样不会打断编译流程。

与 logger.error 相比,emitError 不受 infragstrustureLogging 规则控制,必然会强干扰到最终用户;其次,emitError 会抛出异常的 Loader 文件、代码行、对应模块,更容易帮助定位问题。

  1. 使用 this.callback 接口提交错误信息,但注意导致当前模块编译失败,效果与直接使用 throw 相同,用法:
javascript 复制代码
export default function loader(source) {
  this.callback(new Error("发生了一些异常"));

  return source;
}

总的来说,这些方式各自有适用场景:

  1. 一般应尽量使用 logger.error,减少对用户的打扰;
  2. 对于需要明确警示用户的错误,优先使用 this.emitError;
  3. 对于已经严重到不能继续往下编译的错误,使用 callback 。

测试

在 Loader 中编写单元测试收益非常高,一方面对开发者来说,不用重复手动测试各种特性;一方面对于最终用户来说,带有一定测试覆盖率的项目通常意味着更高、更稳定的质量。常规的 Webpack Loader 单元测试流程大致如下:

  1. 创建在 Webpack 实例,并运行 Loader;
  2. 获取 Loader 执行结果,比对、分析判断是否符合预期;
  3. 判断执行过程中是否出错。

运行 loader

在 node 环境下运行调用 Webpack 接口

在 node 环境下运行调用 Webpack 接口,用代码而非命令行执行编译,很多框架都会采用这种方式,例如 vue-loader、stylus-loader、babel-loader 等,优点是运行效果最接近最终用户,缺点是运行效率相对较低(可以忽略)。

posthtml/posthtml-loader 为例,它会在启动测试之前创建并运行 Webpack 实例:

javascript 复制代码
// posthtml-loader/test/helpers/compiler.js 文件
module.exports = function (fixture, config, options) {
  config = { /*...*/ }

  options = Object.assign({ output: false }, options)

  // 创建 Webpack 实例
  const compiler = webpack(config)

  // 以 MemoryFS 方式输出构建结果,避免写磁盘
  if (!options.output) compiler.outputFileSystem = new MemoryFS()

  // 执行,并以 promise 方式返回结果
  return new Promise((resolve, reject) => compiler.run((err, stats) => {
    if (err) reject(err)
    // 异步返回执行结果
    resolve(stats)
  }))
}

提示:上面的示例中用到 compiler.outputFileSystem = new MemoryFS() 语句将 Webpack 设定成输出到内存,能避免写盘操作,提升编译速度。

编写一系列 mock 方法模拟环境

编写一系列 mock 方法,搭建起一个模拟的 Webpack 运行环境,例如 emaphp/underscore-template-loader ,优点是运行速度更快,缺点是开发工作量大通用性低,了解即可。

校验 Loader 执行结果

上例运行结束之后会以 resolve(stats) 方式返回执行结果,stats 对象中几乎包含了编译过程所有信息,包括:耗时、产物、模块、chunks、errors、warnings 等等,我们可以从 stats 对象中读取编译最终输出的产物,例如 style-loader:

javascript 复制代码
// style-loader/src/test/helpers/readAsset.js 文件
function readAsset(compiler, stats, assets) => {
  const usedFs = compiler.outputFileSystem
  const outputPath = stats.compilation.outputOptions.path
  const queryStringIdx = targetFile.indexOf('?')

  if (queryStringIdx >= 0) {
    // 解析出输出文件路径
    asset = asset.substr(0, queryStringIdx)
  }

  // 读文件内容
  return usedFs.readFileSync(path.join(outputPath, targetFile)).toString()
}

解释一下,这段代码首先计算 asset 输出的文件路径,之后调用 outputFileSystem 的 readFile 方法读取文件内容。

接下来,有两种分析内容的方法:

  1. 调用 Jest 的 expect(xxx).toMatchSnapshot() 断言,判断当前运行结果是否与之前的运行结果一致,从而确保多次修改的结果一致性,很多框架都大量用了这种方法;
  2. 解读资源内容,判断是否符合预期,例如 less-loader 的单元测试中会对同一份代码跑两次 less 编译,一次由 Webpack 执行,一次直接调用 less 库,之后分析两次运行结果是否相同。

对此有兴趣的同学,可以看看 less-loader 的 test 目录。

如何判断执行过程是否触发异常?

最后,还需要判断编译过程是否出现异常,同样可以从 stats 对象解析:

javascript 复制代码
export default getErrors = (stats) => {
  const errors = stats.compilation.errors.sort()
  return errors.map(
    e => e.toString()
  )
}

大多数情况下都希望编译没有错误,此时只要判断结果数组是否为空即可。某些情况下可能需要判断是否抛出特定异常,此时可以 expect(xxx).toMatchSnapshot() 断言,用快照对比更新前后的结果。

进阶:loader 链式调用模型详解

如何开发一个 loader 已经介绍完毕,现在我们来了解 一下在 Loader 代码之外,还有什么会影响 Loader 的执行。

举个例子,为了读取 less 文件,我们通常需要同时配置多个加载器:

javascript 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /.less$/i,
        use: ["style-loader", "css-loader", "less-loader"],
      },
    ],
  },
};

Webpack 启动后会以一种所谓"链式调用"的方式按 use 数组顺序从后到前调用 Loader:

  1. 首先调用 less-loader 将 Less 代码转译为 CSS 代码;
  2. 将 less-loader 结果传入 css-loader,进一步将 CSS 内容包装成类似 module.exports = "${css}" 的 JavaScript 代码片段;
  3. 将 css-loader 结果传入 style-loader,在运行时调用 injectStyle 等函数,将内容注入到页面的 标签。

链式调用这种设计有两个好处:

  1. 保持单个 Loader 的单一职责,一定程度上降低代码的复杂度;
  2. 细粒度的功能能够被组装成复杂而灵活的处理链条,提升单个 Loader 的可复用性。

不过,这只是链式调用的一部分,这里面有两个问题:

  1. Loader 链条启动之后,所有 Loader 都执行完毕才会结束,没有中断的机会 ------ 除非显式抛出异常;
  2. 某些场景下并不需要关心资源的具体内容,但 Loader 需要在 source 内容被读取出来之后才会执行。

为了解决这两个问题,Webpack 在 Loader 基础上叠加了 pitch 的概念。

什么是 pitch

Webpack 允许在 Loader 函数上挂载名为 pitch 的函数,运行时 pitch 会比 Loader 本身更早执行,例如:

javascript 复制代码
const loader = function (source){
  console.log('后执行')
  return source;
}

loader.pitch = function(requestString) {
  console.log('先执行')
}

module.exports = loader

Pitch 函数的完整签名:

javascript 复制代码
function pitch(
  remainingRequest: string, previousRequest: string, data = {}
): void {
}

包含三个参数:

  • remainingRequest : 当前 loader 之后的资源请求字符串;
  • previousRequest : 在执行当前 loader 之前经历过的 loader 列表;
  • data : 与 Loader 函数的 data 相同,用于传递需要在 Loader 传播的信息。

这些参数不复杂,但与 requestString 紧密相关,我们看个例子加深了解:

javascript 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /.less$/i,
        use: [
          "style-loader", "css-loader", "less-loader"
        ],
      },
    ],
  },
};

css-loader.pitch 中拿到的参数依次为:

javascript 复制代码
// css-loader 之后的 loader 列表及资源路径
remainingRequest = less-loader!./xxx.less
// css-loader 之前的 loader 列表
previousRequest = style-loader
// 默认值
data = {}

pitch 函数调度逻辑

Pitch 翻译成中文是_抛、球场、力度、事物最高点_等,它背后折射的是一整套 Loader 被执行的生命周期概念。

实现上,Loader 链条执行过程分三个阶段:pitch、解析资源、执行,设计上与 DOM 的事件模型非常相似,对应关系如下图:

pitch 阶段按配置顺序从左到右 逐个执行 loader.pitch 函数(如果有的话),开发者可以在 pitch 返回任意值中断后续的链路的执行

为什么要设计 pitch 这一特性

回顾一下前面提到过的 less 加载链条:

loader 作用
less-loader 将 less 规格的内容转换为标准 css;
css-loader 将 css 内容包裹为 JavaScript 模块;
style-loader 将 JavaScript 模块的导出结果以 link 、style 标签等方式挂载到 html 中,让 css 代码能够正确运行在浏览器上。

实际上, style-loader 只是负责让 CSS 在浏览器环境下跑起来,并不需要关心具体内容,很适合用 pitch 来处理,核心代码:

javascript 复制代码
// ...
// Loader 本身不作任何处理
const loaderApi = () => {};

// pitch 中根据参数拼接模块代码
loaderApi.pitch = function loader(remainingRequest) {
  //...

  switch (injectType) {
    case 'linkTag': {
      return `${
        esModule
        ? `...`
        // 引入 runtime 模块
        : `var api = require(${loaderUtils.stringifyRequest(
          this,
          `!${path.join(__dirname, 'runtime/injectStylesIntoLinkTag.js')}`
        )});
            // 引入 css 模块
            var content = require(${loaderUtils.stringifyRequest(
              this,
              `!!${remainingRequest}`
            )});

            content = content.__esModule ? content.default : content;`
      } // ...`;
    }

    case 'lazyStyleTag':
    case 'lazySingletonStyleTag': {
      //...
    }

    case 'styleTag':
    case 'singletonStyleTag':
    default: {
      // ...
    }
  }
};

export default loaderApi;

关键点:

  • loaderApi 为空函数,不做任何处理;
  • loaderApi.pitch 中拼接结果,导出的代码包含:
    • 引入运行时模块 runtime/injectStylesIntoLinkTag.js;
    • 复用 remainingRequest 参数,重新引入 css 文件。

运行后,关键结果大致如:

javascript 复制代码
var api = require('xxx/style-loader/lib/runtime/injectStylesIntoLinkTag.js')
var content = require('!!css-loader!less-loader!./xxx.less');

注意了,到这里 style-loader 的 pitch 函数返回这一段内容,后续的 Loader 就不会继续执行,当前调用链条中断了

之后,Webpack 继续解析、构建 style-loader 返回的结果,遇到 inline loader 语句:

javascript 复制代码
var content = require('!!css-loader!less-loader!./xxx.less');

所以从 Webpack 的角度看,对同一个文件实际调用了两次 loader 链,第一次在 style-loader 的 pitch 中断,第二次根据 inline loader 的内容跳过了 style-loader。

实践

下文将通过几个具体 Loader 的实现,来实践上文讲述的开发方式。

eslint-loader

javascript 复制代码
const childProcess = require('child_process')
const exec = (command, cb) => {
    childProcess.exec(command, (error, stdout) => {
        cb && cb(error, stdout)
    })
}
const schema = {
    type: 'object',
    properties: {
        fix: 'boolean'
    }
}

module.exports = function (content) {
    const resourcePath = this.resourcePath
    // 调用 this.async 获取异步回调函数,此时 Webpack 会将该 Loader 标记为异步加载器,
    // 会 挂起 当前执行队列直到 callback 被触发;
    const callback = this.async()
    const command = `npx eslint ${resourcePath}`
    exec(command, (error, stdout) => {
        if (error) {
            console.log(stdout)
        }
        // 调用异步回调 callback 返回处理结果。
        callback(null, content)
    })
}

file-loader

javascript 复制代码
const loaderUtils = require('loader-utils')
module.exports = function (content, map = null, meta = {}) {
    // 是否被url-loader处理过,处理过的话返回base74,url-loader在下面小结具体实现
    const { url,base64 } = meta
    if (url) {
        return `module.exports = "${base64}"`
    } else {
        // 根据当前的上下文,生成一个文件路径,基于dist打包目录,这里生成的文件地址就是:dist/assets/img.jpg
        const interpolateName = loaderUtils.interpolateName(
            this,
            'assets/[name].[contenthash].[ext][query]',
            { content }
        }
        // webpack特有方法,生成一个文件
        this.emitFile(interpolateName, content);
        return `module.exports = "${interpolateName}"`
    }
}
// 添加标记,表示这是一个raw loader
module.exports.raw = true

在 Webpack 中,module.exports.raw 是一个标志,用于指示一个 loader 是否返回原始的二进制数据而不是 UTF-8 编码的字符串。

url-loader

javascript 复制代码
const { getExtByPath } = require('../utils')
const schema = {
    type: 'object',
    properties: {
        limit: {
            type: 'number'
        }
    }
}
module.exports = function (content) {
    // 默认值500K
    const options = this.getOptions(schema) || { limit: 1000 * 500 }
    const { limit } = options
    // 超过阈值则返回原内容
    const size = Buffer.byteLength(content)
    if (size > limit) {
        return content
    }
    // 读取buffer
    const buffer = Buffer.from(content)
    const ext = getExtByPath(this)
    // 将buffer转为base64字符串
    const base64 = 'data: image/' + ext + ';base64,' + buffer.toString('base64');
    // 这里返回了第四个参数------meta,表示这张图片已经被url-loader处理过,上层的file-loader应该使用base64变量
    this.callback(null, content, null, { url: true, base64 })
}

module.exports.raw = true

参考资料

loader API:webpack-v3.jsx.app/api/loaders...

webpack5核心原理与应用实践:掘金小册

webpack loader实战------手撕8个常用loader - 掘金

相关推荐
GISer_Jing40 分钟前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲6 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5816 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter7 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry8 小时前
Jetpack Compose 中的状态
前端
dae bal9 小时前
关于RSA和AES加密
前端·vue.js
柳杉9 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化