vite 5 源码分析: 预构建

本文使用vite 5.2.8版本

依赖预构建的入口是initDepsOptimizer函数,由initServer触发。

但在触发之前,通过isDepsOptimizerEnabled来判断,是否需要进行依赖预构建。

isDepsOptimizerEnabled的逻辑与文档保持一致。

如果你想完全禁用优化器,可以设置 optimizeDeps.noDiscovery: true 来禁止自动发现依赖项,并保持 optimizeDeps.include 未定义或为空。

在代码中也针对noDiscoveryoptimizeDeps.include进行了判断,并进行取反,如果不符合禁止依赖预构建的条件,那么就启用依赖预构建。

typescript 复制代码
export function isDepsOptimizerEnabled(
  config: ResolvedConfig,
  ssr: boolean,
): boolean {
   // 获取预构建配置
  const optimizeDeps = getDepOptimizationConfig(config, ssr)
   // 如果不符合禁止预构建的条件
  return !(optimizeDeps.noDiscovery && !optimizeDeps.include?.length)
}

initDepsOptimizer实际是启用了一个单例的依赖预构建,如果当前的配置已经存在一个对应的预构建对象,那么就使用缓存,否则就创建一个。

typescript 复制代码
export function getDepsOptimizer(
  config: ResolvedConfig,
  ssr?: boolean,
): DepsOptimizer | undefined {
   // 根据config从 WeakMap 获取预构建对象
  return (ssr ? devSsrDepsOptimizerMap : depsOptimizerMap).get(config)
}

export async function initDepsOptimizer(
  config: ResolvedConfig,
  server: ViteDevServer,
): Promise<void> {
   // 如果本地缓存 (WeakMap)获取不到预构建对象,使用createDepsOptimizer创建一个新的
  if (!getDepsOptimizer(config, false)) {
    await createDepsOptimizer(config, server)
  }
}

因此,我们的目光来到了createDepsOptimizer这个函数,我们分段理解这个函数。

createDepsOptimizer

首先,使用loadCachedDepOptimizationMetadata,通过配置中的config.cacheDir,并且拼接上deps_metadata.json,来获取本地存储的_metadata.json

一般情况下, config.cacheDir会被给予默认值node_modules/.vite,因此_metadata.json的默认地址是node_modules/.vite/deps/_metadata.json

typescript 复制代码
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr)

如果cachedMetadata是空的,也就是loadCachedDepOptimizationMetadata获取不到硬盘上存储的_metadata.json,那么就使用initDepsOptimizerMetadata,来初始化一个metadata数据。

typescript 复制代码
let metadata =
    cachedMetadata || initDepsOptimizerMetadata(config, ssr, sessionTimestamp)

initDepsOptimizerMetadata仅仅是初始化metadata,并不负责将它存储在硬盘上。

我们看看initDepsOptimizerMetadata初始化了什么数据。

typescript 复制代码
export function initDepsOptimizerMetadata(
  config: ResolvedConfig,
  ssr: boolean,
  timestamp?: string,
): DepOptimizationMetadata {
  const { lockfileHash, configHash, hash } = getDepHash(config, ssr)
  return {
    hash,
    lockfileHash,
    configHash,
    browserHash: getOptimizedBrowserHash(hash, {}, timestamp),
    optimized: {},
    chunks: {},
    discovered: {},
    depInfoList: [],
  }
}
  • lockfileHash:枚举常见包管理工具的lock文件,优先使用npm_config_user_agent中定义的包,否则依照以下优先级: package-lock.json > yarn.lock > pnpm-lock.yaml > bun.lockb从项目文件或者父目录中寻找,计算找到文件的hash
  • configHash:从config摘取了几个关键配置组成了JSON,这个JSONhash
  • hashlockfileHashconfigHash拼接到一起的字符串,计算出来的hash
  • browserHash:上面的hash和空JSON字符串以及时间戳拼接在一起的字符串,计算出来的hash
  • optimized:每个预构建依赖的对照集合。
  • depInfoList:依赖列表。
  • chunkschunk集合。
  • discovered:新发现的依赖。

因此,变量metadata在经过以上逻辑后,总会有值的。

然后,连同预构建配置项,一起包装成预构建对象,并存入本地缓存中。

typescript 复制代码
const depsOptimizer: DepsOptimizer = {
    metadata, // 上文读取的metadata
    registerMissingImport, // 发现新的依赖进行预构建
    run: () => debouncedProcessing(0), // 立即预构建
    isOptimizedDepFile: createIsOptimizedDepFile(config), //是否是经过预构建的文件
    isOptimizedDepUrl: createIsOptimizedDepUrl(config),//是否是经过预构建的
    getOptimizedDepId: (depInfo: OptimizedDepInfo) =>//预构建产物拼入hash
      `${depInfo.file}?v=${depInfo.browserHash}`,
    close, //结束预构建
    options, //预构建配置项
  }

  depsOptimizerMap.set(config, depsOptimizer)

接着,会判断如果本地硬盘没有找到_metadata.json,那么首先会处理optimizeDeps.include,通过跑一遍插件流水线resolveId钩子,计算出将它们的相关信息,然后推入metadatadiscovereddepInfoList字段。

这里需要注意的是,这些信息中的file字段,是这个依赖被预构建之后的地址,因为预构建还没开始,因此此时此刻,硬盘上并没有对应地址。

如果使用optimizeDeps.noDiscovery: true来禁止自动发现依赖项,那么调用runOptimizer,将只处理metadata中,optimizeddiscovered记录的依赖。

如果没有禁止自动发现依赖,那么就使用discoverProjectDependencies进行依赖扫描。

scanImports

虽然discoverProjectDependencies开启了依赖扫描,但实际核心函数在scanImports之中,discoverProjectDependencies是针对scanImports的进一步包装。

scanImports中,首先定义了两个空对象,收集的依赖deps和找不到的依赖missing,这俩相当重要!它们是依赖收集的主要容器。

我们知道,依赖扫描实际上是依靠esbuild实现的,所以,之后调用computeEntries计算出入口文件。在computeEntries中,进行了以下逻辑。

  1. 初始化entries数组。

  2. 检查配置中是否存在明确的入口(optimizeDeps.entries)。

    • 如果存在,使用 globEntries 根据这些模式解析匹配的文件路径,并将结果存储在 entries 数组中。
  3. 如果不存在明确的入口模式,检查配置中是否存在 build.rollupOptions.input

    • 如果存在,则根据 rollupOptions.input 的类型进行处理:

      • 如果是字符串,则将其解析为绝对路径,并将其添加到 entries 数组中。
      • 如果是数组,则对数组中的每个路径执行相同的操作。
      • 如果是对象,则对对象的每个值(路径)执行相同的操作。
      • 如果 rollupOptions.input 不是字符串、数组或对象,则抛出错误。
  4. 如果既没有明确的入口模式也没有 rollupOptions.input,则默认使用 **/*.html 作为入口模式,并使用 globEntries 函数解析匹配的文件路径。

  5. 这还没完事,还需要对确定的入口文件路径进行过滤:

    • 排除不支持的入口文件类型和虚拟文件。
    • 排除不存在的文件。
  6. 返回entries数组。

如果computeEntries计算出来的入口数组有值,那么使用prepareEsbuildScanner函数处理。

prepareEsbuildScanner中,定义了esbuild的扫描插件esbuildScanPlugin

typescript 复制代码
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

注意,这个插件把插件流水线container、上文中把相当重要的depsmissing以及算出来的入口数组entries传入了。

扫描插件往depsmissing写入数据,因为内存不变的原因,是可以不用通过返回值就可以拿到的。

我们待会看这个插件逻辑。

在构造完扫描插件后,还会尝试取optimizeDeps.esbuildOptions的数据,并把其中的插件选项剥离出来。

typescript 复制代码
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}

然后针对esbuildtsconfigRaw进行兼容处理。

最后调用esbuild.context并使prepareEsbuildScanner返回它的上下文对象。

typescript 复制代码
// prepareEsbuildScanner
return await esbuild.context({
    absWorkingDir: process.cwd(),
    write: false,
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [...plugins, plugin],
    ...esbuildOptions,
    tsconfigRaw,
  })

然后在scanImports触发这个上下文对象的rebuild函数,让esbuild进行真正的构建。当构建结束的时候,返回已经被填充好的、并且排好序的deps对象和missing对象。

typescript 复制代码
  const result = esbuildContext
    .then((context) => {
      return context
        .rebuild()
        .then(() => {
          return {
            deps: orderedDependencies(deps),
            missing,
          }
        })
    })

而这个result其实也作为scanImports的返回值返回给了discoverProjectDependencies

之前提到过,discoverProjectDependencies实际上是scanImports的包装,因此,它将result再次包装处理:

  • 如果missing是空对象,那么就抛出错误------找不到对应的依赖。
  • 只返回deps。也就是收集的依赖。

好了,我们现在通过esbuild得到了扫描到的依赖对象,接下来跟处理optimizeDeps.include一样,把它们对应的信息推入depInfoListdiscovered之中。

同样的,这些信息中的file字段,是这个依赖被预构建之后的地址,因为预构建还没开始,因此此时此刻,硬盘上并没有对应地址。

最后把已知依赖组合起来,传入runOptimizeDeps

runOptimizeDeps

首先runOptimizeDeps会往node_modules/.vite/创建一个deps_temp_[hash]的文件夹。

之所以不直接创建或者修改deps文件夹,是因为deps文件可能是存在的,并且此时还没进行依赖预构建,而是仅仅完成了依赖收集,因此如果依赖预构建出现了错误,就没有回退余地了。所以创立了临时目录,当预构建成功结束后,临时目录转正。

然后再次通过initDepsOptimizerMetadata计算出一份新的metadata模板。与之前相同,不同的是本次发现了依赖,因此browserHash不能以空对象作为基准计算hash了,而是前面组合的已知依赖。

然后使用prepareEsbuildOptimizerRun进行依赖预构建。

prepareEsbuildOptimizerRun

预构建部分逻辑很核心,但比较简单。

首先,跟prepareEsbuildScanner类似,尝试取optimizeDeps.esbuildOptions的数据,并把其中的插件剥离出来。

然后,它会处理传入的依赖,也就是上文的出来的已知依赖组合,将其扁平化为 flatIdDeps 对象,并提取每个依赖项的导出信息,将其存储在 idToExports 对象中。

接着,它准备一些构建配置,包括定义一些全局变量、选择平台、指定外部模块、设置插件等。

typescript 复制代码
const context = await esbuild.context({
  absWorkingDir: process.cwd(),
  // 入口,上文扁平的flatIdDeps
  entryPoints: Object.keys(flatIdDeps),
  bundle: true,
  format: 'esm',
  // 构建输出的 JavaScript 版本
  target: ESBUILD_MODULES_TARGET,
  // 是否生成源映射文件
  sourcemap: true,
  // 输出目录
  outdir: processingCacheDir,
  // 是否生成元数据文件,用于后续分析依赖关系
  metafile: true,
  // 构建插件,用于在构建过程中对代码进行处理
  plugins,
  //略
})

我们省略了一些配置,剩下的配置需要我们重点关照:

  • entryPoints: 将每一个依赖作为入口文件。
  • format: 转为esm
  • outdir:输出目录,还记得我们之前新建了临时目录吗?就是那个临时目录。
  • metafile:元数据,我们之后要用。

最后,它使用这些配置调用 esbuild.context 方法,生成一个新的构建上下文,并返回该上下文以及导出信息对象 idToExports。当然,这个是esbuild.context 方法,因此并没有实际构建。

因此在runOptimizeDeps中,调用完prepareEsbuildOptimizerRun后,会调用返回的esbuild上下文的rebuild方法,触发构建,构建结束后,临时目录中已经有已知依赖的esm产物了。

注意:是已知依赖,不是所有依赖。以上逻辑是vite启动时候的逻辑。当项目运行中,新增依赖,页面也引用了,但没有访问对应页面,那么这个依赖就是未知依赖。

当构建结束后,我们可以从rebuildthen方法获取metafile。我们使用metafile.outputs,根据已知依赖,来填充前文刚建立起来的metadata.optimized

这里需要注意的是,虽然两次都是调用了esbuild

但第一次的入口是项目入口,所遍历出来的依赖是项目直接引用的依赖。

第二次入口是这些依赖,因此构建出来的产物不仅仅有这些依赖的esm格式,还有它们本身的依赖也会被打包进chunk里面。

同时,如果是异步依赖,会自动在名称后面拼入hash

因此,根据metafile.outputs,不存在已知依赖列表的产物,都会被填充到metadata.chunks里面------包括上面的异步依赖------它们被拼入一个hash字符串,并且依赖扫描不会收集异步依赖。

最后构建出一个对象successfulResult,作为预构建调用链的返回值,填充到runOptimizeDeps的返回值的result属性上。

这个successfulResult对象暴露三个属性。

  • 之前构建的metadata
  • 取消函数cancel
  • 更新deps文件的commit函数。

最重要的就是commit。但我们稍后再讲。

最后runOptimizeDeps的返回值挂载到createDepsOptimizeroptimizationResult属性上。

意味着,只要能访问到optimizationResult,那么就可以通过await optimizationResult.result获取上文定义的successfulResult。从而可以更新deps或者获取填充的metadata

但到目前为止,预构建已经结束了。

我们整理一下现状:

  • metadata有两份,一个是createDepsOptimizer初始化的,另一个是runOptimizeDeps返回值,挂载到optimizationResult上的。
  • 两个metadata已经填充完毕了,但都只在内存中,并没有写入硬盘。
  • createDepsOptimizermetadata数据比较基础,依赖最多收集到了依赖扫描到的依赖。discovered是有值的。
  • optimizationResultmetadata数据比较全,discovered没有值。
  • 依赖预构建产物已经生成了,但放入的是临时文件夹。
  • 创建、更新deps文件的函数commitmetadata挂载到了optimizationResult属性上。

那么什么时候才会将metadata填充到_metadata.json中呢?以及什么时候预构建产物所在文件夹才会转正呢?

onCrawlEnd

onCrawlEnd函数用于处理静态文件爬取结束后的依赖优化行为。主要负责处理临时文件转正和写入metadata的操作。

我们来看看它的逻辑。

首先它会使用await取出optimizationResult的内容。

然后清空optimizationResult

typescript 复制代码
const afterScanResult = optimizationResult.result
optimizationResult = undefined
const result = await afterScanResult

如果createDepsOptimizer中的metadata.discovered所记录的依赖都能在optimizationResultmetadataoptimized找到。

那么就可以进行写入_metadata.json和临时文件转正了。

如果optimizeddiscovered少,说明存在遗漏依赖,或者虽然一一对应,但数据属性不一致。

那么optimizationResult中的数据作废,删除临时文件夹。并且将optimized中多了的依赖添加到discovered中,并且立刻重新开始一轮运行时的预构建。

注意,这里是将optimizeddiscovered多的依赖添加到discovered,确实存在这种情况,比如optimized的依赖是【A,B,D】而discovered是【A,B,C,E】,那么就会把D添加到discovered

最后执行runOptimizer(result)执行依赖预构建收尾,我们待会看看runOptimizer做了什么。

我们先看看onCrawlEnd是如何被调用的。

createDepsOptimizer中,将onCrawlEnd使用server._onCrawlEnd推入到了onCrawlEndCallbacks这个数组中。

这个数组最后是被谁消费呢?答案是setupOnCrawlEnd

setupOnCrawlEnd内部定义了一个逻辑,确保onCrawlEndCallbacks中的回调函数只会执行一次,它返回一个函数,叫做crawlEndFinder

这些不重要,重要的是crawlEndFinder最终被_registerRequestProcessing包装了下,并被doTransform调用。

doTransform是不是很眼熟,没错,就是模块依赖图的核心逻辑。

typescript 复制代码
  const result = loadAndTransform(...)

  if (!ssr) {
    const depsOptimizer = getDepsOptimizer(config, ssr)
    if (!depsOptimizer?.isOptimizedDepFile(id)) {
      server._registerRequestProcessing(id, () => result)
    }
  }

如果当前文件不是需要预构建的文件,那么就会调用_registerRequestProcessing。从而最终调用runOptimizer(result)

registerMissingImport

我们上文提到了运行时的预构建。什么意思?

比如现在项目完全启动起来,并且浏览器已经渲染出目标页面。当前情况已经超出了我们上文分析过的任何逻辑,是一个运行时的环境。

那么通过新增依赖包,并在页面进行引用,此时新增的包同样需要进行预构建。

那么需要一个对应的逻辑处理这种情况,那么就需要运行时的预构建。

当然,显而易见,增加一个运行时是不太划算的,那么目前vite有什么可以针对新引入的包做出反应的功能吗?

答案就是钩子。

更加具体点就是vite:resolve插件的resolveId钩子。

这个钩子如果发现引入的模块是一个并没有被预构建的钩子,那么就会调用registerMissingImport方法。

这个方法我们之前提到过,是一个专门注册未预构建依赖的方法。

这个方法会检测新发现的模块在不在待预构建的列表里面,也就是在不在discovered里面,如果在,就返回待预构建的信息。

否则就将此模块添加到discovered里面,并调用一次runOptimizer(),同时在控制台打印new dependencies found: xxx

runOptimizer

好了,现在两个方法最后都调用runOptimizer。不同的是,onCrawlEnd会传入optimizationResult.result,而registerMissingImport不会传入任何参数。

  • 如果传入参数的话,那么入参会赋值给processingResult
  • 否则,就会整理已知依赖,然后调用runOptimizeDeps,把它的返回值赋值给optimizationResult,并且把optimizationResult.result赋值给processingResult。换句话说,又走了遍初始化metadata-创建临时文件夹-将已知依赖构建输出到临时文件夹这个流程。

之后,会调用processingResult.commit(),并在调用结束后,将createDepsOptimizer中的metadata.discovered所记录的依赖赋值给optimizationResultmetadatadiscovered

也就是说,runOptimizer起到了收束的作用,让不同时态的预构建逻辑,收束为调用commit,也就是更新deps

commit

我们看看commit的逻辑,首先,会往临时目录下写入_metadata.json

typescript 复制代码
fs.writeFileSync(
    dataPath,
    stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)

好了,现在临时目录什么都有了:预构建产物、_metadata.json。只差转正临门一脚了。

接着会判断现在当下有没有.vite/deps目录。如果有,将它重命名为一个临时文件。

然后将之前创建的临时文件夹重命名为.vite/deps,完成转正。

最后删除之前已经被重命名临时文件的原.vite/deps

为什么这么做呢?

这也做是为了最大程度地减少.vite/deps处于不一致状态的时间。因为重命名-重命名(然后在后台删除旧文件夹)比删除-重命名操作快。

同时我们也从侧面看出,如果进行依赖预构建,一直是针对所有已知依赖的预构建------即使仅仅新增了一个依赖包。

看到这里,我们应该了解依赖预构建的流程了,但是,构建是结束了,那么怎么将请求的包,指向构建结束后的产物呢?

当然还是vite:resolve插件的resolveId钩子。

当它第一次处理依赖的时候,就会执行tryOptimizedResolve函数。

tryOptimizedResolve

tryOptimizedResolve中,会使用await等待依赖扫描结束。

然后获取createDepsOptimizermetadata。查询当前依赖是否存在optimizeddiscoveredchunks之中,如果存在,那么就返回metadata记录的元数据。

然后拿这个元数据去调用预构建对象的getOptimizedDepId

getOptimizedDepId我们一开始就提到了,代码很简单,就是拿元数据的file拼上一个hash然后返回。

typescript 复制代码
getOptimizedDepId: (depInfo: OptimizedDepInfo) => `${depInfo.file}?v=${depInfo.browserHash}`

file,正是预构建产物所在的deps目录。

可能有人问了,这是当前依赖可以在预构建metadata中找到,如果找不到呢?

如果找不到,就会尝试从package.jsonbrowser拿地址,当然,如果还拿不到,就会调用tryNodeResolve方法。

tryNodeResolve方法可能大家不熟,它里面有个情况就是发现当前包是一个新依赖,就会调用registerMissingImport------发现新依赖,并添加到预构建列表。

也就是tryNodeResolveregisterMissingImport前置逻辑。

当然,上面的逻辑并非重新请求(刷新页面)都会调用一次,而是项目启动后,只会调用一次。

因为处理完当前依赖后,之后依赖都被指向预构建的地址。

准确来说,会被vite:import-analysis插件的transform钩子中的interopNamedImports方法,将原来的依赖地址,使用MagicString替换为预构建产物的地址。

MagicString是一个用于处理字符串的JavaScript库。它可以让你在字符串中进行插入、删除、替换等操作。可以看这篇文章

替换完毕后,这个预构建产物就如同普通模块一样,在之后的逻辑中,被记录在模块依赖图里面。

之后再次刷新页面,由于地址已经被重写,并且模块依赖图存在对应数据,因此直接从模块依赖图拿数据,返回给浏览器。

好了,以上是预构建的所有逻辑,但我们还有个坑没有填,扫描插件是什么样的?

esbuildScanPlugin

还得提一下那两个比较重要的对象depsmissing,这两个分别记录收集到的依赖和找不到的依赖。

一开始,会定义以下规则:

  • data请求不处理。
  • httphttps开头的模块不处理。
  • worker不做处理。
  • 剔除virtual-module前缀,Svelte<script>Vue<script setup>会被增加这个虚拟前缀。
  • 如果是.html, .vue, .svelte, .astro, 或 .imba 结尾的文件,并且可被插件流水线的resolveId解析,那么返回解析后的链接,并归类到html类别。
  • css等样式文件,jsonwasm和其它静态文件,如果不被指定为入口文件,那么就不处理。

对于bare imports,会进行以下操作

  1. 首先会判断扫描到的依赖是否是被optimizeDeps.exclude排除的,并且不是@vite/client@vite/env
  2. 如果是的话,就return,不过return的时候还会判断是不是入口模块,如果不是入口模块,那么就返回忽略标记,让esbuild遇到相同的路径的时候进行忽略。
  3. 如果已经记录到deps里面,就return,同样的,如果不是入口模块,那么就返回忽略标记,让esbuild遇到相同的路径的时候进行忽略。
  4. 然后进入插件流水线的resolveId进行解析,如果解析不成功,那么就放到missing里面。【这里收集到了missing依赖】
  5. 如果解析成功还会判断解析出来的是否是绝对路径。如果不是绝对路径,或者是虚拟模块,那么就return,并根据是否是入口模块打上忽略标记。
  6. 如果是node_modules模块,或者记录在optimizeDeps.include,那么就将此模块收集到deps中。并根据是否是入口模块打上忽略标记。【这里收集到了deps依赖】
  7. 如果不是node_modules模块,会按需归为html类别。

那些归为html类别的文件,会进行以下操作

  1. 首先会读取源码,然后正则匹配所有的script标签。
  2. 针对每个匹配,会进行如下处理。
    1. 如果是.html结尾的文件,并且当前匹配的type不是module,那么跳过。
    2. 如果scripttype不是 javaScriptecmascriptmodule,则跳过。
    3. 确定loader,如果是 typeScripttsx,则使用对应的loader,否则默认使用 javaScriptloader
    4. 如果标签包含 src 属性,则将其作为模块导入。否则,如果内容不为空,则处理内容。
    5. 如果是typescript的话,那么需要把导入的模块再次全量引入,这样可以防止 esbuild 优化代码的时候把它们删除,比如在vue3setup中,使用import { ModuleA } from './modules',然后在<template>使用ModuleA,但esbuild不认<template>,会认为ModuleA是无效引入,从而删除,因此这里会在内容后面拼接\nimport './modules',从而避免esbuild删除。
    6. 根据内容是否包含 import.meta.glob 来确定是否需要转换 glob 导入路径,并将内容存储到 scripts 对象中,以供后续加载时使用。
    7. 生成virtual-module路径------还记得上文中剔除的virtual-module前缀吗,在这里添加的。
    8. 根据文件类型和上下文属性生成导出语句。进行引入或者重导出。
    9. 对于非 Vue 文件或者不包含默认导出的文件,在末尾添加默认导出。
    10. 最后返回loader和处理后的内容。

这样就可以让上面的bare imports逻辑捕获刚刚生成的内容。从而解析html类别文件中的依赖。

结束

至此,Vite开发环境下的源码已经分析了大致脉络,但需要学习的东西还有很多,因此我们依然在路上。

相关推荐
幽络源小助理19 分钟前
苹果CMS V10 MXPro V4.5模版下载, 自适应视频主题源码, 幽络源源码
前端·开源·源码·php源码
kyriewen42 分钟前
坏了,黑客学会用AI写外挂了
前端·程序员·ai编程
xiangxiongfly9151 小时前
Vue3 根据角色权限动态加载路由
前端·javascript·vue.js·动态加载路由
达达尼昂2 小时前
Claude 多 Agent 系统:从零搭建一个 4 Agent 团队
前端·架构·ai编程
费曼学习法2 小时前
React 18 并发模式(Concurrent Mode):Fiber 架构的终极进化
javascript·react.js
容智信息2 小时前
AI Agent(智能体)的输出格式应该从 Markdown 转向 HTML吗?
前端·人工智能·rust·编辑器·html·prompt
_风满楼2 小时前
TDD 进阶:换个角度看会议室预约
前端·javascript·github
Amy_yang2 小时前
uni-app 安卓端纯前端预览 DOCX 的实现思路
前端·vue.js
x_y_2 小时前
分享一个自己总结的前端开发skill~ requirement-to-delivery
前端·ai编程
梨子同志2 小时前
CSS Grid
前端·css