神族九帝's blog 神族九帝's blog
首页
  • 神卡套餐 (opens new window)
  • 神族九帝 (opens new window)
  • 网盘资源 (opens new window)
  • 今日热点 (opens new window)
  • 在线PS (opens new window)
  • IT工具 (opens new window)
  • FC游戏 (opens new window)
  • 在线壁纸 (opens new window)
  • 面试突击
  • 复习指导
  • HTML
  • CSS
  • JavaScript
  • 设计模式
  • 浏览器
  • 手写系列
  • Vue
  • Webpack
  • Http
  • 前端优化
  • 项目
  • 面试真题
  • 算法
  • 精选文章
  • 八股文
  • 前端工程化
  • 工作笔记
  • 前端基础建设与架构 30 讲
  • vue2源码学习
  • 剖析vuejs内部运行机制
  • TypeScript 入门实战笔记
  • vue3源码学习
  • 2周刷完100道前端优质面试真题
  • 思维导图
  • npm发包
  • 重学node
  • 前端性能优化方法与实战
  • webpack原理与实战
  • webGl
  • 前端优化
  • Web3
  • React
  • 更多
  • 未来要做的事
  • Stirling-PDF
  • ComfyUI
  • 宝塔面板+青龙面板
  • 安卓手机当服务器使用
  • 京东自动评价代码
  • 搭建x-ui免流服务器(已失效)
  • 海外联盟
  • 好玩的docker
  • 收藏夹
  • 更多
GitHub (opens new window)

神族九帝,永不言弃

首页
  • 神卡套餐 (opens new window)
  • 神族九帝 (opens new window)
  • 网盘资源 (opens new window)
  • 今日热点 (opens new window)
  • 在线PS (opens new window)
  • IT工具 (opens new window)
  • FC游戏 (opens new window)
  • 在线壁纸 (opens new window)
  • 面试突击
  • 复习指导
  • HTML
  • CSS
  • JavaScript
  • 设计模式
  • 浏览器
  • 手写系列
  • Vue
  • Webpack
  • Http
  • 前端优化
  • 项目
  • 面试真题
  • 算法
  • 精选文章
  • 八股文
  • 前端工程化
  • 工作笔记
  • 前端基础建设与架构 30 讲
  • vue2源码学习
  • 剖析vuejs内部运行机制
  • TypeScript 入门实战笔记
  • vue3源码学习
  • 2周刷完100道前端优质面试真题
  • 思维导图
  • npm发包
  • 重学node
  • 前端性能优化方法与实战
  • webpack原理与实战
  • webGl
  • 前端优化
  • Web3
  • React
  • 更多
  • 未来要做的事
  • Stirling-PDF
  • ComfyUI
  • 宝塔面板+青龙面板
  • 安卓手机当服务器使用
  • 京东自动评价代码
  • 搭建x-ui免流服务器(已失效)
  • 海外联盟
  • 好玩的docker
  • 收藏夹
  • 更多
GitHub (opens new window)
  • 工作笔记

  • 前端基础建设与架构 30 讲

    • 开篇词 像架构师一样思考,突破技术成长瓶颈
    • npm 安装机制及企业级部署私服原理
    • Yarn 的安装理念及如何破解依赖管理困境
    • CI 环境上的 npm 优化及更多工程化问题解析
    • 横向对比主流构建工具,了解构建工具的设计考量
    • Vite 实现:从源码分析出发,构建 bundlele 开发工程
    • core-j 及垫片理念:设计一个“最完美”的 Polyfill 方案
    • 梳理混乱的 Babel,不再被编译报错困扰
    • 探索前端工具链生态,制定一个统一标准化 babel-preet
    • 从实战出发,从 0 到 1 构建一个符合标准的公共库
    • 代码拆分和按需加载:缩减 bundle ize,把性能做到极致
    • Tree Shaking:移除 JavaScript 上下文中的未引用代码
    • 如何理解 AST 实现和编译原理?
    • 工程化思维处理方案:如何实现应用主题切换功能?
    • 解析 Webpack 源码,实现自己的构建工具
    • 从编译到运行,跨端解析小程序多端方案
    • 原生跨平台技术:移动端跨平台到 Flutter 的技术变革
    • 学习 axio:封装一个结构清晰的 Fetch 库
    • 对比 Koa 和 Redux:分析前端中的中间件理念
    • 如何理解软件开发灵活性和高定制性?
    • 如何理解前端中面向对象的思想?
    • 如何利用 JavaScript 实现经典数据结构?
    • 剖析前端中的数据结构应用场景
    • npm cript:打造一体化的构建和部署流程
      • 自动化代码检查:剖析 Lint 工具和工程化接入&优化方案
      • 如何设计一个前端 + 移动端离线包方案?
      • 如何设计一个“万能”项目脚手架?
      • 同构渲染架构:实现一个 SSR 应用
      • 设计性能守卫系统:完善 CICD 流程
      • 实践打造网关:改造企业 BFF 方案
      • 实现高可用:使用 Puppeteer 生成性能最优的海报系统
      • 结束语 再谈项目的基建和架构,个人的价值和方向
    • vue2源码学习

    • 剖析vuejs内部运行机制

    • TypeScript 入门实战笔记

    • vue3源码学习

    • 2周刷完100道前端优质面试真题

    • 思维导图

    • npm发包

    • 重学node

    • 前端性能优化方法与实战

    • webpack原理与实战

    • webGl

    • 前端优化

    • Web3

    • React

    • 更多

    • 笔记
    • 前端基础建设与架构 30 讲
    wu529778790
    2024-04-07

    npm cript:打造一体化的构建和部署流程

    之前我们提到过,一个顺畅的基建流程离不开 npm scripts。npm scripts 将工程化的各个环节串联起来,相信任何一个现代化的项目都有自己的 npm scripts 设计。那么作为架构师或资深开发者,我们如何设计并实现项目配套的 npm scripts 呢?关于 npm scripts 我们如何进行封装抽象,做到复用或基建统一呢?

    这一讲,我们就围绕如何使用 npm scripts,打造一体化的构建和部署流程展开。

    npm scripts 原理介绍

    这一部分,我们将对 npm scripts 是什么,以及其核心原理进行讲解。

    npm scripts 是什么

    我们先来系统地了解一下 npm scripts。Node.js 在设计 npm 之初,允许开发者在 package.json 文件中,通过 scripts 字段来自定义项目的脚本。比如我们可以在 package.json 中这样使用:

    {
     // ...
      "scripts": {
        "build": "node build.js",
        "dev": "node dev.js",
        "test": "node test.js",
      }
      // ...
    }
    

    对应上述代码,我们在项目中可以使用命令行执行相关的脚本:

    $ npm run build
    $ npm run dev
    $ npm run test
    

    其中build.js、dev.js、test.js三个 Node.js 模块分别对应上面三个命令行执行命令。这样的设计,可以方便我们统计和集中维护项目工程化或基建相关的所有脚本/命令,也可以利用 npm 很多辅助功能,例如下面几个功能。

    • 使用 npm 钩子,比如pre、post,对应命令npm run build的钩子命令就是:prebuild和postbuild。

    • 开发者使用npm run build时,会默认自动先执行npm run prebuild再执行npm run build,最后执行npm run postbuild,对此我们可以自定义:

        {
         // ...
          "scripts": {
            "prebuild": "node prebuild.js",
            "build": "node build.js",
            "postbuild": "node postbuild.js",
          }
          // ...
        }
    
    • 使用 npm 提供的process.env.npm_lifecycle_event等环境变量。通过process.env.npm_lifecycle_event,可以在相关 npm scripts 脚本中获得当前运行的脚本名称。

    • 使用 npm 提供的npm_package_能力,获取 package.json 中的相关字段,比如下面代码:

      // 获取 package.json 中的 name 字段值
      console.log(process.env.npm_package_name)
    
      // 获取 package.json 中的 version 字段值
      console.log(process.env.npm_package_version)
    

    更多 npm 为 npm scripts 提供的“黑魔法”,我们不再一一列举了。你可以前往 https://docs.npmjs.com/ 进行了解。

    npm scripts 原理

    其实,npm scripts 原理比较简单。我们依靠npm run xxx来执行一个 npm scripts,那么核心奥秘就在于npm run了。npm run会自动创建一个 Shell(实际使用的 Shell 会根据系统平台而不同,类 UNIX 系统里,如 macOS 或 Linux 中指代的是 /bin/sh, 在 Windows 中使用的是 cmd.exe),我们的 npm scripts 脚本就在这个新创建的 Shell 中被运行。这样一来,我们可以得出几个关键结论:

    • 只要是 Shell 可以运行的命令,都可以作为 npm scripts 脚本;

    • npm 脚本的退出码,也自然遵守 Shell 脚本规则;

    • 如果我们的系统里安装了 Python,可以将 Python 脚本作为 npm scripts;

    • npm scripts 脚本可以使用 Shell 通配符等常规能力。

    比如这样的代码:

      {
       // ...
        "scripts": {
          "lint": "eslint **/*.js",
        }
        // ...
      }
    

    *表示任意文件名,**表示任意一层子目录,在执行npm run lint后,就可以对当前目录下,任意一层子目录的 js 文件进行 lint 审查。

    另外,请你思考:npm run创建出来的 Shell 有什么特别之处呢?

    我们知道,node_modules/.bin子目录中的所有脚本都可以直接以脚本名的形式调用,而不必写出完整路径,比如下面代码:

    {
     // ...
      "scripts": {
        "build": "webpack",
      }
      // ...
    }
    

    在 package.json 中直接写webpack即可,而不需要写成:

    {
     // ...
      "scripts": {
        "build": "./node_modules/.bin/webpack",
      }
      // ...
    }
    

    的形式。这是为什么呢?

    实际上,npm run创建出来的 Shell 需要将当前目录的node_modules/.bin子目录加入PATH 变量中,在 npm scripts 执行完成后,再将 PATH 变量恢复。

    npm scripts 使用技巧

    这里我们简单讲解两个常见场景,以此介绍 npm scripts 的关键使用技巧。

    传递参数

    任何命令脚本,都需要进行参数传递。在 npm scripts 中,可以使用--标记参数。比如下面代码:

    $ webpack --profile --json > stats.json
    

    另外一种传参的方式是通过 package.json,比如下面代码:

    {
     // ...
      "scripts": {
        "build": "webpack --profile --json > stats.json",
      }
      // ...
    }
    

    串行/并行执行脚本

    在一个项目中,任意 npm scripts 可能彼此之间都有会依赖关系,我们可以通过&&符号来串行执行脚本。比如下面代码:

    $ npm run pre.js && npm run post.js
    

    如果需要并行执行,可以使用&符号,如下代码:

    npm run scriptA.js & npm run scriptB.js
    

    这两种串行/并行执行方式其实是 Bash 的能力,社区里也封装了很多串行/并行执行脚本的公共包供开发者选用,比如:npm-run-all 就是一个常用的例子。

    最后的提醒

    最后,特别强调两点注意事项。

    首先,npm scripts 可以和 git-hooks 相结合,为项目提供更顺畅、自然的能力。比如 pre-commit、husky、lint-staged 这类工具,支持 Git Hooks 各种种类,在必要的 git 操作节点,执行我们的 npm scripts。

    同时需要注意的是,我们编写的 npm scripts 应该考虑不同操作系统上兼容性的问题,因为 npm scripts 理论上在任何系统都应该 just work。社区为我们提供了很多跨平台的方案,比如 un-script-os 允许我们针对不同平台进行不同的定制化脚本,如下代码:

    {
      // ...
      "scripts": {
        // ...
        "test": "run-script-os",
        "test:win32": "echo 'del whatever you want in Windows 32/64'",
        "test:darwin:linux": "echo 'You can combine OS tags and rm all the things!'",
        "test:default": "echo 'This will run on any platform that does not have its own script'"
        // ...
      },
      // ...
    }
    

    再比如,更加常见的https://www.npmjs.com/package/cross-env,可以为我们自动在不同的平台设置环境变量。

    好了,接下来我们从一个实例出发,打造一个 lucas-scripts,实践操作 npm scripts,同时丰富我们的工程化经验。

    打造一个 lucas-scripts

    lucas-scripts 其实是我设想的一个 npm scripts 插件集合,通过 Monorepo 风格的项目,借助 npm 抽象“自己常用的”npm scripts 脚本,以在多个项目中达到复用的目的。

    其设计思想其实源于 Kent C.Dodds(https://kentcdodds.com/blog)的:Tools without config 思想。事实上,在 PayPal 公司内部,有一个 paypal-scripts(未开源),借助 paypal-scripts 的设计思路,就有了 lucas-scripts。我们先从设计思想上分析,不管是 paypal-scripts 还是 lucas-scripts,它们主要解决了哪类问题。

    谈到前端开发,各种工具配置着实令人头大,而对于一个企业级团队来说,维护统一的企业级工具配置或设计,对工程效率的提升至关重要。这些工具包括但不限于:

    • 测试工具及方案

    • Client 端打包工具及方案

    • Linting 工具及方案

    • Babel 工具及方案

    等等,这些工具及方案的背后往往是烦琐的配置,同时,这些配置的设计却至关重要。比如我们的 Webpack 可以工作,但是它的配置设计却经常经不起推敲;Linters 经常过时,跟不上语言的发展,使得我们的构建流程无比脆弱而容易中断。

    在此背景下,lucas-scripts 负责维护和掌管工程基建中的种种工具及方案,同时它的使命不仅仅是 Bootstrap 一个项目,而是长期维护基建方案,可以随时升级,随时插拔。

    这很类似我们熟悉的 create-react-app,create-react-app 可以帮助 React 开发者迅速启动一个项目,它以黑盒的方式维护了 Webpack 构建以及 Jest 测试、Eslint 等能力。开发者只需要使用 react-scripts 就能够满足构建和测试等需求,开发者只需要关心业务开发。lucas-scripts 的理念相同:开发者只需要使用 lucas-scripts,就可以使用开箱即用的各类型 npm scripts 插件,npm scripts 插件提供基础工具的配置和方案设计。

    但需要注意的是,create-react-app 官方并不允许开发者自定义这些工具的配置及方案设计,而我们的 lucas-scripts 理应实现更灵活的配置能力。如何做到开发者自定义配置的能力呢?设计上,我们支持开发者在项目中添加.babelrc或在项目的 package.json 中添加相应的 babel 配置项,lucas-scripts 在运行时读取这些信息,并采用开发者自定义的配置即可。

    比如,我们支持项目中 package.json 配置:

    {
      "babel": {
        "presets": ["lucas-scripts/babel"],
        "plugins": ["glamorous-displayname"]
      }
    }
    

    上述代码可以做到使用 lucas-scripts 定义的 Babel 预设,同时支持开发者使用名为 glamorous-displayname 的 Babel 插件。

    下面,我们就以 lucas-scripts 中封装的 Babel 配置进行详细讲解。

    在使用 lucas-scripts 的 Babel 方案时,我们提供了默认的一套 Babel 设计方案,具体代码如下:

    const path = require('path')
    // 支持使用 DEFAULT_EXTENSIONS,具体见:https://www.babeljs.cn/docs/babel-core#default_extensions
    const {DEFAULT_EXTENSIONS} = require('@babel/core')
    const spawn = require('cross-spawn')
    const yargsParser = require('yargs-parser')
    const rimraf = require('rimraf')
    const glob = require('glob')
    // 工具方法
    const {
      hasPkgProp,
      fromRoot,
      resolveBin,
      hasFile,
      hasTypescript,
      generateTypeDefs,
    } = require('../../utils')
    let args = process.argv.slice(2)
    const here = p => path.join(__dirname, p)
    // 解析命令行参数
    const parsedArgs = yargsParser(args)
    // 是否使用 lucas-scripts 提供的默认 babel 方案
    const useBuiltinConfig =
      !args.includes('--presets') &&
      !hasFile('.babelrc') &&
      !hasFile('.babelrc.js') &&
      !hasFile('babel.config.js') &&
      !hasPkgProp('babel')
    // 使用 lucas-scripts 提供的默认 babel 方案,读取相关配置
    const config = useBuiltinConfig
    ? ['--presets', here('../../config/babelrc.js')]
    : []
    // 是否使用 babel-core 所提供的 DEFAULT_EXTENSIONS 能力
    const extensions =
    args.includes('--extensions') || args.includes('--x')
    ? []
    : ['--extensions', [...DEFAULT_EXTENSIONS, '.ts', '.tsx']]
    // 忽略某些文件夹,不进行编译
    const builtInIgnore = '/tests/,/mocks/'
    const ignore = args.includes('--ignore') ? [] : ['--ignore', builtInIgnore]
    // 是否复制文件
    const copyFiles = args.includes('--no-copy-files') ? [] : ['--copy-files']
    // 是否使用特定的 output 文件夹
    const useSpecifiedOutDir = args.includes('--out-dir')
    // 默认的 output 文件夹名为 dist
    const builtInOutDir = 'dist'
    const outDir = useSpecifiedOutDir ? [] : ['--out-dir', builtInOutDir]
    const noTypeDefinitions = args.includes('--no-ts-defs')
    // 编译开始前,是否先清理 output 文件夹
    if (!useSpecifiedOutDir && !args.includes('--no-clean')) {
    rimraf.sync(fromRoot('dist'))
    } else {
    args = args.filter(a => a !== '--no-clean')
    }
    if (noTypeDefinitions) {
    args = args.filter(a => a !== '--no-ts-defs')
    }
    // 入口编译流程
    function go() {
    // 使用 spawn.sync 方式,调用 @babel/cli 
    let result = spawn.sync(
    resolveBin('@babel/cli', {executable: 'babel'}),
    [
    ...outDir,
    ...copyFiles,
    ...ignore,
    ...extensions,
    ...config,
    'src',
    ].concat(args),
    {stdio: 'inherit'},
    )
    // 如果 status 不为 0,返回编译状态
    if (result.status !== 0) return result.status
    const pathToOutDir = fromRoot(parsedArgs.outDir || builtInOutDir)
    // 使用 Typescript,并产出 type 类型
    if (hasTypescript && !noTypeDefinitions) {
    console.log('Generating TypeScript definitions')
    result = generateTypeDefs(pathToOutDir)
    console.log('TypeScript definitions generated')
    if (result.status !== 0) return result.status
    }
    // 因为 babel 目前仍然会拷贝一份需要忽略不进行编译的文件,所以我们将这些文件手动进行清理
    const ignoredPatterns = (parsedArgs.ignore || builtInIgnore)
    .split(',')
    .map(pattern => path.join(pathToOutDir, pattern))
    const ignoredFiles = ignoredPatterns.reduce(
    (all, pattern) => [...all, ...glob.sync(pattern)],
    [],
    )
    ignoredFiles.forEach(ignoredFile => {
    rimraf.sync(ignoredFile)
    })
    return result.status
    }
    process.exit(go())
    

    通过上面代码,我们将 Babel 方案强制使用了一些最佳实践,比如使用了特定 loose、moudles 设置的 @babel/preset-env 配置项,使用了 @babel/plugin-transform-runtime,使用了 @babel/plugin-proposal-class-properties,各种原理我们已经在 07 讲《梳理混乱的 Babel,不再被编译报错困扰》中有所涉及。

    了解了 Babel 的设计方案,我们在使用 lucas-scripts 时是如何调用设计方案并执行 Babel 编译的呢?我们看看相关逻辑源码,如下:

    const path = require('path')
    // 支持使用 DEFAULT_EXTENSIONS,具体见:https://www.babeljs.cn/docs/babel-core#default_extensions
    const {DEFAULT_EXTENSIONS} = require('@babel/core')
    const spawn = require('cross-spawn')
    const yargsParser = require('yargs-parser')
    const rimraf = require('rimraf')
    const glob = require('glob')
    // 工具方法
    const {
      hasPkgProp,
      fromRoot,
      resolveBin,
      hasFile,
      hasTypescript,
      generateTypeDefs,
    } = require('../../utils')
    let args = process.argv.slice(2)
    const here = p => path.join(__dirname, p)
    // 解析命令行参数
    const parsedArgs = yargsParser(args)
    // 是否使用 lucas-scripts 提供的默认 babel 方案
    const useBuiltinConfig =
      !args.includes('--presets') &&
      !hasFile('.babelrc') &&
      !hasFile('.babelrc.js') &&
      !hasFile('babel.config.js') &&
      !hasPkgProp('babel')
    
    // 使用 lucas-scripts 提供的默认 babel 方案,读取相关配置
    const config = useBuiltinConfig
      ? ['--presets', here('../../config/babelrc.js')]
      : []
    // 是否使用 babel-core 所提供的 DEFAULT_EXTENSIONS 能力
    const extensions =
      args.includes('--extensions') || args.includes('--x')
        ? []
        : ['--extensions', [...DEFAULT_EXTENSIONS, '.ts', '.tsx']]
    // 忽略某些文件夹,不进行编译
    const builtInIgnore = '**/**tests**/**,**/**mocks**/**'
    const ignore = args.includes('--ignore') ? [] : ['--ignore', builtInIgnore]
    // 是否复制文件
    const copyFiles = args.includes('--no-copy-files') ? [] : ['--copy-files']
    // 是否使用特定的 output 文件夹
    const useSpecifiedOutDir = args.includes('--out-dir')
    // 默认的 output 文件夹名为 dist
    const builtInOutDir = 'dist'
    const outDir = useSpecifiedOutDir ? [] : ['--out-dir', builtInOutDir]
    const noTypeDefinitions = args.includes('--no-ts-defs')
    // 编译开始前,是否先清理 output 文件夹
    if (!useSpecifiedOutDir && !args.includes('--no-clean')) {
      rimraf.sync(fromRoot('dist'))
    } else {
      args = args.filter(a => a !== '--no-clean')
    }
    if (noTypeDefinitions) {
      args = args.filter(a => a !== '--no-ts-defs')
    }
    // 入口编译流程
    function go() {
     // 使用 spawn.sync 方式,调用 @babel/cli 
      let result = spawn.sync(
        resolveBin('@babel/cli', {executable: 'babel'}),
        [
          ...outDir,
          ...copyFiles,
          ...ignore,
          ...extensions,
          ...config,
          'src',
        ].concat(args),
        {stdio: 'inherit'},
      )
      // 如果 status 不为 0,返回编译状态
      if (result.status !== 0) return result.status
      const pathToOutDir = fromRoot(parsedArgs.outDir || builtInOutDir)
     // 使用 Typescript,并产出 type 类型
      if (hasTypescript && !noTypeDefinitions) {
        console.log('Generating TypeScript definitions')
        result = generateTypeDefs(pathToOutDir)
        console.log('TypeScript definitions generated')
        if (result.status !== 0) return result.status
      }
      // 因为 babel 目前仍然会拷贝一份需要忽略不进行编译的文件,所以我们将这些文件手动进行清理
      const ignoredPatterns = (parsedArgs.ignore || builtInIgnore)
        .split(',')
        .map(pattern => path.join(pathToOutDir, pattern))
      const ignoredFiles = ignoredPatterns.reduce(
        (all, pattern) => [...all, ...glob.sync(pattern)],
        [],
      )
      ignoredFiles.forEach(ignoredFile => {
        rimraf.sync(ignoredFile)
      })
      return result.status
    }
    process.exit(go())
    

    通过上面代码,我们就可以将 lucas-script 的 Babel 方案融会贯通了。

    整体设计思路我 fork 了 https://github.com/kentcdodds/kcd-scripts,并进行部分优化和改动,你可以在https://github.com/HOUCe/kcd-scripts中进一步学习。

    总结

    这一讲我们先介绍了 npm scripts 的重要性,接着分析了 npm scripts 的原理;后半部分,从实践出发,分析了 lucas-scripts 的设计理念,以此进一步巩固 npm scripts 相关知识。

    本讲内容总结如下:

    npm scripts:打造一体化的构建和部署流程.png

    说到底,npm scripts 就是一个 Shell,我们以前端开发者所熟悉的 Node.js 来实现 npm scripts,当然这还不够。事实上,npm scripts 的背后是对一整套工程化体系的理解,比如我们需要通过 npm scripts 来抽象 Babel 方案、抽象 Rollup 方案等。相信通过这一讲的学习,你会有所收获。

    下一讲,我们将深入工程化体系的一个重点细节——自动化代码检查,并反过来使用 lucas-scripts 再实现一套智能的代码 Lint 脚本,请你继续学习。


    # 精选评论

    # **嘉

    这个没看太懂,是怎么通过这个lucas-scripts 然后就可以运行到项目中的呢,原理是什么,有具体说下吗

    #     讲师回复

        可以了解一下命令行的原理

    编辑 (opens new window)
    上次更新: 2025/03/17, 12:21:00
    剖析前端中的数据结构应用场景
    自动化代码检查:剖析 Lint 工具和工程化接入&优化方案

    ← 剖析前端中的数据结构应用场景 自动化代码检查:剖析 Lint 工具和工程化接入&优化方案→

    最近更新
    01
    Code Review
    10-14
    02
    ComfyUI
    10-11
    03
    vscode插件开发
    08-24
    更多文章>
    Power by vuepress | Copyright © 2015-2025 神族九帝
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式
    ×