超硬核!教你手搓一套船新架构的前端脚手架~

开始前先打个广子~

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:Tongxx_yj

背景

什么是脚手架?

尽管目前社区上已有很多优秀的脚手架工具,手搓脚手架的相关文章也层出不穷,但我今天想要带来的是一个全新架构设计的脚手架:Create-Neat(或称 cn)

Github:github.com/xun082/crea... (同学!点点 Star 呀!)

全新在哪里?我们通过了一套协议架构 来保证了插件框架构建工具 三个维度的自由组合,打破了指数型复杂度 带来的研发壁垒

我将会以这套架构设计为核心,介绍 cn 脚手架的实现,通过这篇文章,你将会了解到:

  1. 基本脚手架的实现方式
  2. 脚手架的技术壁垒
  3. 新型脚手架设计介绍

基本脚手架的实现方式

这一模块我并不会花太多的笔墨,只是讲解一下核心的实现逻辑,在以往的文章中,我也有做过相关的介绍:juejin.cn/post/736578...

基本定义与流程

在介绍基本脚手架的实现方式之前,我们先对核心概念做一个基本定义:

  • 插件 :项目生成中可扩展的相关能力

  • 构建工具:大家应该都知道,常见的有 webpack、vite......

  • 框架:也很简单,Vue、React......

  • 生成器 :我们将脚手架的逻辑内核称为生成器,这一步骤涉及到核心文件、配置的生成,我们简称为 Generator

  • 预设:用户对预期项目的选项集合

在对齐基本定义之后,我们再来看一个流程图:

上图就是一个比较传统的脚手架的逻辑实现,核心分为四个阶段:

  1. 命令行交互:与用户"对话",获取用户的选项
  2. 初始化处理:基于用户选项进行相关的初始化操作,如基本文件的创建选项相关依赖注入
  3. 生成器执行:基于用户选项进行核心文件的生成,比如用户选了 xx 插件,便会在生成器中调用对应的配置方法,将 xx 插件的相关配置、文件注入到目标文件夹
  4. 收尾处理:最后,项目会因为配置的更新注入了新的依赖,而需要再安装一次依赖,与此同时需要我们输出一些总结信息,告知用户相关的结果

生成器逻辑简述

先说核心逻辑:在生成器中,Generator 会基于预设去读取对应插件、构建工具、框架所在的文件路径,并:

  • 调用内部默认导出的一个方法
  • 读取内部的一个 template 文件内容,加工处理后转移至产物文件

再回答几个问题

  1. 对应的文件是什么:假设我们是一个 monorepo 的仓库结构,上述的逻辑都在一个 core 的子仓中,那像插件、构建工具、框架都会被放到一个个独立的子仓中,方便独立管理、发布

  2. 对应的文件有什么用 :存放了一些核心配置,分为:

    • template 模版文件夹:存放了独立文件配置,如:.eslintrc、webpack.config.js
    • 默认导出方法:通过执行这个方法,可以执行一些特殊操作,比如给 package.json 做配置更改
  3. 插件、构建工具、框架之间会有影响吗 :会!比如我们有一个 babel 插件,我们就需要在 webpack 的配置文件里面注入一些内容

  4. 影响是如何实现的 :我们核心是通过一套模版渲染引擎(比如 EJS)实现,比如:

    js 复制代码
    // apps/webpack/template/webpack.config.js
    <% if (preset.babel === true) { %>
        {
            test: /.(js|jsx)$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader'
            }
        }
    <% } %>
    
    // apps/core/Generator.js
    const ejs = require('ejs');
    const fs = require('fs');
    
    // 读取模板文件
    const template = 
        fs.readFileSync('../webpack/template/webpack.config.js', 'utf8');
    
    // 假设用户选择了 babel
    const preset = {
        babel: true
    };
    
    // 渲染模板
    const webpackConfigJs = ejs.render(template, data); 

到这一步,全流程的基本实现都已经讲清楚了,,如果对更多的细节感兴趣,可以转战juejin.cn/post/736578... 进行深造~

脚手架的技术壁垒

壁垒在哪里

目前的脚手架存在什么问题?我们就聊一个最严重的:不够灵活

为什么说不够灵活?我们可以看看 vue-cli 都支持什么插件、构建工具以及框架

插件:11 个(pwa、babel、eslint、vuex......)

构建工具:1 个(webpack)

框架:2 个(vue2、vue3)【实际上就算一个框架】

在上一部分中,我们已经看到有些时候三者之间会有影响情况,如果用一个具体的图片来呈现,那将会是这样的一个效果:

实现效果也很简单,我们还是通过 EJS 一把唆:

  1. webpack 里面配置 EJS 语法实现11个插件、1个框架的内容注入
  2. vue2/3 里面配置 EJS 语法实现11个插件的内容注入

那如果面对这样的情况呢?

  1. webpack 里面配置 EJS 语法实现11个插件、2个框架的内容注入
  2. vite 里面配置 EJS 语法实现11个插件、2个框架的内容注入
  3. rollup 里面配置 EJS 语法实现11个插件、2个框架的内容注入
  4. vue 里面配置 EJS 语法实现11个插件的内容注入
  5. react 里面配置 EJS 语法实现11个插件的内容注入

我们看看复杂度的比对:

  • 前者:11 + 1 + 11 = 23
  • 后者:11 + 2 + 11 + 2 + 11 + 2 + 11 + 11 = 61

我们把这个指标定义为:
开发的复杂度 A = (插件 + 框架) * 构建工具 + 插件 * 框架

与此同时,我们再定义一个指标:项目的丰富度 B插件 + 框架 + 构建工具

我们比对两个场景的数据差异:

指标 前者 后者
A 23 61
B 12 16

可以看到 B 的差异很小,但是后者的 A 却大了很多很多

我们再来个夸张的,20 个插件 3 个框架 6 个构建工具

  • A = (20 + 3)* 6 + 20 * 3 = 198
  • B = 20 + 3 + 6 = 29

综合得出一个规律:项目丰富度的小幅提升会带来框架的较大幅度提升

结合这个规律,是否能解释目前脚手架的技术壁垒呢?

------ 支持的越多,复杂度就越高(超级难维护!)

事实上,这个规律还是不严谨的。在实际的开发过程中,插件与插件之间甚至也会产生影响,我们还需要给插件分成多类,来实现更好的维护,但这种情况下,整体的复杂度就越来越趋于指数性增长

额外的,这对开发者的需求迭代也是一个恐怖的挑战,比如:小明要让脚手架一个 vite 构建工具,他要面对的则是十几个插件、以及构建工具的影响......

壁垒导致的现状

为了避免这种问题,目前的脚手架生态大部分都局限于一种构建工具、一种框架

当然对于框架,我倒觉得没有什么毛病 ------ 大部分提供脚手架的都是对应框架方,也没必要为其他框架服务~

但如果我们想要一个比较理想的脚手架,支持:

  1. 基本支持目前社区上绝大多数的内容
  2. 有多种构建工具选择

这是一个非常困难的事情,那目前社区上有相关实现吗?有的兄弟,有的

那就是 yeoman:相关学习参考 yowebapp.github.io/learning/

他是怎么解决这个问题的呢?

yeoman 提供了一套可以直接让我们定制化开发 Generator 的方法,通过发布模板至 npm 以调用

虽然完美解决了上述的问题,但最大的问题就是研发成本很高

那我们有没有办法解决这个研发成本问题呢?这就要谈及我们的新架构设计了!

新型脚手架设计介绍

核心目标

我们的核心目标:插件、框架、构建工具的影响,避免排列组合配置的情况出现

想法雏形

我们观察模版引擎渲染的实现,会发现一个规律:
xx 对 yy 的影响,会把影响内容放在 yy 的 template 文件里

举个例子:Babel 对 webpack 的影响,会在 webpack 的 template 文件里注入 EJS 语法

这种情况下,有什么问题?

------ 就算我们想通过一些抽象的方式来实现 webpack、vite 的 Babel 配置统一,我们还是得在两个不同的 template 文件里面注入 EJS 语法

那有什么解决方案呢?

------ 是不是可以把 xx 对 yy 的影响内容配置在 xx 里面,而不是通过模版引擎渲染来实现?

实现思路

基于上述的思路,我们看看怎么实现

目前最核心的,就是实现一套 "xx 影响 yy 内容" 的语法,在插件、框架 中配置相关的语法,经调用处理后,形成直接的配置影响,比如:

js 复制代码
/**
 * 框架受插件的影响处理
 * @param {object} pluginAffects 插件带来的影响集合
 * @param {string} template 当前框架
 */
const templateConfigGenerator = (pluginAffects, template) => {};

/**
 * 构建工具受框架的影响处理
 * @param {object} templateAffects 框架带来的影响集合
 * @param {string} buildTool 当前构建工具
 */
const buildToolConfigGenerator = (templateAffects, buildTool) => {};

基于这个目标,我们拆解成两个要解决的方向:

  1. xxxAffects 怎么写(或者说如何去描述这个结构体)
  2. 函数内部怎么处理 xxxAffects

抽象协议的概念

这两个问题如同我们规范了一套网络协议:

A 发送网络请求 ------> B 接收网络请求 ------> B 提取请求头 ------> B 基于请求头做 xx 处理

我们也可以把 xxxAffects 的描述和处理定义为一套抽象协议,通过相关的处理方式去解读协议、处理协议,最终对目标文件产生影响

开发这套协议,我们需要对协议的特性来规定统一的认知:

  • 特征:

    • 协议分为协议和协议处理器两部分 ,对应的 xxxAffectsxxxAffects 的处理函数
    • 协议只在插件、框架中定义
  • 性质:

    • 通用性: 每一个协议都是一种操作的描述,能够适用于不同的插件、框架
    • 抽象性: 我们应该将具体操作的实现细节抽象成配置项,便于扩展和维护
    • 自由性: 面对一些复杂特化的场景,支持灵活的定制行为

初步探索

我们先拿一个场景讲明白,比如插件对框架的影响,我们需要考虑到:

  1. 插件对框架影响什么内容,比如入口文件、 变量注入与消费

  2. 插件的影响应该是抽象的,不能具体基于某个框架做一个配置,对另一个也做一个配置,因为框架是不一样的,语法可能不同,但是影响的主题是一致的协议描述这个抽象的事,协议处理器来处理这个抽象的事

让我们针对插件对 Vue 和 React 的影响进行详细分析,并设计一个抽象协议,确保它能够适应不同框架的特性而不依赖于具体实现,下面是一个简单的示意:

场景分析

我们结合实际场景,探索一下插件对框架的影响内容:

  1. 入口文件 (import) 影响内容的入口文件配置,比如 less、sass 文件的引入
  2. 内容导出(export): 影响内容的导出,例如注入全局组件、导出高阶组件 HOC
  3. 环境变量注入: 插件可以影响框架的环境变量,特别是在开发和构建工具之间传递信息
  4. 业务代码更改: 插件可以改变框架的业务代码,比如给 react 返回的 jsx 内容包裹一层 <Router></Router>
  5. ...... 可能还有更多的协议,但前四条基本能 cover 全部的场景了

抽象协议的结构体设计

我们将设计一个抽象协议,描述插件对框架、构建工具的影响,比如以插件为基本单元

js 复制代码
const pluginImpactProtocol = {
    pluginName: "scss", // 插件名称
    version: "xxx", // 插件版本
    affects: {
        template: {
            description: "影响框架入口文件配置",
            changes: [
                entry_file: {
                    description: "入口文件引入全局 scss 文件",
                    content: "import './styles/main.scss'", // 全局样式文件路径
                }
            ],
            priority: 1, // 优先级
        },
        buildTool: {
            description: "影响构建工具配置",
            changes: [
                // ......
            ],
            priority: 2,
        },
    },
};

这个结构体中,描述了 scss 插件对 template(框架)、buildTool(构建工具) 的影响,其中 template 中,我们通过 changes 数组,描述了影响操作:如 entry_file,描述了往入口文件插入一个内容:import './styles/main.scss'

那么 entry_file 就是一个协议,他的结构体描述了影响内容

那我们为什么会以插件为基本单元,通过 changes 数组传入一个个协议呢?

这要回归到我们的最基础设计思路:降低业务复杂度

回归我们的目标:对比 EJS 模版渲染,我们把 A 对 B 的影响,从写在 B 中,转移至了 A 中,实现了谁做的谁负责到底。

这种行为也保证了我们避免了背景中描述的问题,十分合理~

不过协议只做到了描述,我们怎么实现对内容的影响呢?这就需要实现对协议的处理了

协议处理器设计

接下来,我们需要设计一个协议处理器,用于根据协议生成具体的配置,比如我们写一个 entry_file 协议的处理器

js 复制代码
const srcDir = path.resolve(__dirname, 'src'); // src 目录路径
const templateChanges = pluginImpactProtocol.affects.template.changes;

// 处理入口文件
if (templateChanges.entry_file) {
  const entryFilePath = path.join(srcDir, "index.js"); // 假设入口文件为 index.js
  let entryContent = fs.readFileSync(entryFilePath, "utf-8");

  entryContent += templateChanges.entry_file.content;

  // 文件重写,实现插入
  // todo: 具体如何实现,其实很灵活,甚至可以借助 AST 进行
  fs.writeFileSync(entryFilePath, entryContent, "utf-8");
}

可以理解为,我们在插件中注册了一个"回调函数",在脚手架运行内核逻辑时,会对一个个插件进行处理。此时我们会遍历脚手架中绑定的"回调函数"并执行

走到这一步,全流程就已经被打通了,那接下来需要面对的问题就是:

  1. 抽象所有场景可能存在的协议内容,制定协议
  2. 基于不同的协议,开发不同的协议处理器

插件的类型划分

在做协议内容的例举之前,我们先来仔细研究研究插件的类型

为什么需要明确插件的类型,而不去讨论框架和构建工具的划分?

  1. 框架、构建工具的扩展性是有限的,且相对同质化的
  2. 插件则是天马行空,存在很多方向,我们需要一个统一的划分原则,进行有效归类

大体上插件分为三类

  1. 第一类:基础设施类插件
    这类插件的作用通常是提供基础的工具支持,不会直接影响框架的功能或构建流程的结构。它们通常只会修改配置文件或提供构建/开发时的基本支持,更多是作为开发环境的支撑工具
  • 常见插件: Babel、ESLint、Prettier、Husky、SWC

  • 特点:

    • 无框架依赖性: 不直接与框架绑定
    • 配置驱动: 通过配置文件来管理
    • 功能局限性: 仅在构建流程或开发时发挥作用
  1. 第二类:通用框架影响类插件
    这类插件通常影响的是特定开发文件的导入方式或配置 ,且这种影响不依赖于特定框架,它可以适用于多种框架(例如 Vue、React 等)
  • 常见插件: SCSS、TypeScript

  • 特点:

    • 通过配置或代码注入的方式改变开发文件(如 JS、CSS)行为
    • 不依赖于特定框架
  1. 第三类:特化框架影响类插件
    这类插件需要在开发文件中直接引入并且影响框架本身的结构,例如,修改应用的路由结构、UI 组件等。
  • 常见插件: Pinia、Mobx、VueRouter

  • 特点:

    • 通过修改框架的结构、引入特定的组件或模块来增强框架功能
    • 需要在框架中明确指定或配置
  1. 第四类:构建工具扩展型插件
    这类插件主要影响构建工具(如 Webpack、Vite 等),用于扩展或定制构建流程,通常涉及文件处理、模块解析、构建优化
  • 常见插件: PostCSS、Babel

  • 特点:

    • 影响 构建过程,包括文件打包、代码拆分等
    • 通常在构建配置中定义和使用

当然,某个具体的插件可能会包含一类或几类特征,需要视具体情况而定

协议开发

明确好相关设计后,我们来大概设计一两个协议试试效果

【通用协议】ENTRY_FILE

协议描述

用于 xx 给 yy 添加入口文件,比如 scss 插件,需要在 vue 框架的 template/src/index.vue 中注入一个 import 语句

协议的注册

我们以 scss 为例,在 scss 的默认导出方法中注册协议:

js 复制代码
const protocol = require("@/core/src/configs/protocol.ts");
const pluginToTemplateProtocol = protocol.pluginToTemplateProtocol;

/**
 * scss 的默认导出方法,会被生成器调用
 * @param generatorAPI 生成器暴露的一些 API,用于对产物内容进行操作
 */
module.exports = (generatorAPI) => {
  // extendPackage 用于在 package.json 中注入依赖
  generatorAPI.extendPackage({
    devDependencies: {
      scss: "^1.81.0", // todo: 暂时的版本
    },
  });

  // protocolGenerate 用于执行指定的协议处理器
  generatorAPI.protocolGenerate({
    // 注册 ENTRY_FILE 协议
    [pluginToTemplateProtocol.ENTRY_FILE]: {
      // 入口文件引入全局 scss 文件
      params: {
        content: "import './styles/main.scss'", // 全局样式文件路径
      },
      priority: 1, // 优先级
    },
  });
};

在上述的逻辑中,Generator 执行到 scss 的导出方法后,就会执行 protocolGenerate 对应的逻辑,大致实现如下:

ts 复制代码
protocolGenerate(protocols) {
  // 统一定义协议所需参数
  const props: ProtocolProps = {
    preset: this.generator.getPreset(), // 用户所选的预设
    buildToolConfigAst: this.generator.buildToolConfigAst, // 构建工具配置文件语法树
  };
  let api: ProtocolAPI = undefined;
  // 此处会遍历调用的各个协议,并将 Generator 的数据(例如用户preset)传入协议处理器中去
  // [xx]To[yy]API 内部注册了相关的协议处理函数
  for (const protocol in protocols) {
    if (protocol in pluginToTemplateProtocol) {
      api = new PluginToTemplateAPI(protocols, props, protocol);
    } else if (protocol in pluginToBuildToolProtocol) {
      api = new PluginToBuildToolAPI(protocols, props, protocol);
    } else if (protocol in templateToBuildToolProtocol) {
      api = new TemplateToBuildToolAPI(protocols, props, protocol);
    }
    // 调用 [xx]To[yy]API 的协议处理函数
    api.generator();
  }
}

在对应的实例中,调用的实际逻辑如下:

js 复制代码
// [xx]To[yy]API 内部的 generator
generator() {
  const protocol = this.protocol;
  const protocols = this.protocols;
  // 执行目标协议处理器
  this[protocol](protocols[protocol]);  // 等价于 this.ENTRY_FILE(protocols['ENTRY_FILE'])
}

ENTRY_FILE() {
  // ......
}
协议处理器

这一步就需要我们来完善 ENTRY_FILE 的实现,大致如下:

ts 复制代码
ENTRY_FILE(params) {
  const srcDir = path.resolve(import.meta.dirname, "src"); // src 目录路径
  const content = params.content;

  // 处理入口文件
  if (content) {
    const entryFilePath = path.join(srcDir, "index.js"); // 假设入口文件为 index.js
    let entryContent = fs.readFileSync(entryFilePath, "utf-8");
    entryContent += content;

    // 文件重写,实现插入
    fs.writeFileSync(entryFilePath, entryContent, "utf-8");
  }
}

到这一步我们就顺利地实现了协议的注册和处理,最终实现了 scss 对 vue 的影响,整体流程如下:

【插件对框架】SLOT_INJECT

顾名思义:为插槽注入内容

当我们面对插件对框架的影响,如 mobx、react-router 等,会对框架的内容进行比较复杂的更改,这个时候用 EJS 或 AST 操作都有一定的缺陷:

  • EJS:EJS 的语法面对比较复杂的场景会显得非常复杂,导致可读性、可维护性非常差

    • 比如:

      html 复制代码
      <% if (preset.reactRouter === true) { %>
          <Router history={history}>
      <% } %>
              <div>......</div>
      <% if (preset.reactRouter === true) { %>
          </Router>
      <% } %> 
    • 更复杂的:

      html 复制代码
      <% if (preset.reactRouter === true) { %>
          <Router history={history}>
      <% } %>
          <% if (preset.mobx === true) { %> 
              <Provider store={store}> 
          <% } %>
                  <div>......</div>
          <% if (preset.mobx === true) { %> 
              </Provider> 
          <% } %>
      <% if (preset.reactRouter === true) { %>
          </Router>
      <% } %> 
  • AST:实现成本高,且不好抽象(比如实现 xx 对 yy 影响时,插入指定的位置需要结合上下文判断,实现逻辑比较特化)

基于这种背景,我们需要设计一种协议来实现更加灵活的插入,且保证"工作重心"仍然维持在我们的插件中!

我们可以结合 EJS 的优势来设计一个插槽的体系:在模版文件中注册一个个插槽,通过 id 等唯一标识区分位置,并在协议传参中带入 id、content实现内容注入,实现如下:

插槽注册

我们通过一套自定义语法来描述传参:/* slot: <slot-name> */,在命中相关的 slot-name 之后,便会注入指定的内容,比如:

js 复制代码
/* slot: router-start-slot */
    <div>......</div>
/* slot: router-end-slot */
协议传参

我们在插件中定义相关的协议

js 复制代码
module.exports = (generatorAPI) => {
  generatorAPI.protocolGenerate({
    [pluginToTemplateProtocol.SLOT_CONTENT_PROTOCOL]: {
      params: {
        slotConfig: [
          {
            url: "src/App",
            slotName: "router-start-slot",
            slotContent: "<Router>",
          },
          {
            url: "src/App",
            slotName: "router-end-slot",
            slotContent: "</Router>",
          },
        ],
      },
    },
  });
};

通过 slotName 锁定插槽后,插入对应的 slotContent,实现最终的效果:

html 复制代码
<Router>
    <div>......</div>
</Router>

如果再复杂点,也不会影响可读性,相比之前的 EJS 描述要好了很多!

js 复制代码
/* slot: router-start-slot */
    /* slot: mobx-start-slot */
    <div>......</div>
    /* slot: mobx-end-slot */
/* slot: router-end-slot */

当然由于不同插件有着不同的特点,会有越来越多的协议需要实现,但在实现一定程度后,所有的插件都能基本被已有的协议覆盖,最终实现了有限的协议满足了几乎无限的插件!至此,脚手架在灵活性上的壁垒也基本得到了解决

协议开发深入

当然,协议的开发不可能仅此而已,实际的复杂度与功能抽象的要求使得很多方案难以实现,这时我们就需要借助一些强力的方案,比如:回调函数、AST 等

回调函数

当我们很难通过协议传参来配合协议处理器时,我们可以将回调函数作为参数,在协议处理器中执行,比如:

js 复制代码
module.exports = (generatorAPI) => {
  generatorAPI.protocolGenerate({
    [pluginToTemplateProtocol.XXX_PROTOCOL]: {
      params: {
        func: (xx) => {
            // 具体回调内容
        }
      },Ï
    },
  });
};

AST

AST 对于内容的修改是非常细粒度的,在我们的场景中,AST 可以实现在协议的处理器中,对于一些位置明确结构规范稳定 的协议是非常适用的

但面对上述两种情况以外时,我们应该尽可能地避免使用 AST,原因是:

  1. 位置不明确会导致 AST 难以定位上下文,使得难度给到了协议的传参规范中,但这往往是不可靠的
  2. 结构不规范、稳定,会让 AST 的处理复杂度大大提升,可能需要非常多的判断语句

面对这种场景,我们应该更多地去考虑拆分协议~

开发规范

保证协议顺利实现的同时,我们还需要约束一定的规范,保证我们的协议质量,下面是一个合理的协议开发结构:

最后

目前协议的开发还在推进过程中,因此并没有合入主分支,感兴趣的同学可以在 feat/generator-upgrade 分支了解源码

对于后续的发展方向,基本会围绕在几个主题:

  1. 协议的扩展
  2. 插件、构建工具的持续扩展
  3. 可视化
  4. ......

非常欢迎大家参与贡献 🎉

相关推荐
大龄大专大前端2 小时前
JavaScript闭包的认识/应用/原理
前端·javascript·ecmascript 6
字节源流2 小时前
【SpringMVC】常用注解:@SessionAttributes
java·服务器·前端
肥肠可耐的西西公主2 小时前
前端(vue)学习笔记(CLASS 4):组件组成部分与通信
前端·vue.js·学习
烛阴2 小时前
JavaScript 函数绑定:从入门到精通,解锁你的代码超能力!
前端·javascript
花椒和蕊2 小时前
【vue+excel】导出excel(目前是可以导出两个sheet)
javascript·vue.js·excel
泫凝2 小时前
使用 WebP 优化 GPU 纹理占用
前端·javascript
magic 2453 小时前
CSS块元素、行内元素、行内块元素详解
前端·css
returnShitBoy3 小时前
前端面试:React hooks 调用是可以写在 if 语句里面吗?
前端·javascript·react.js
love黄甜心3 小时前
Sass:深度解析与实战应用
前端·css·sass
加减法原则3 小时前
深入理解 Vue 3 中 watch 与 watchEffect 的区别
javascript