精读《用 Babel 创造自定义 JS 语法》

news2024/9/25 23:21:28

1 引言

在写这次精读之前,我想谈谈前端精读可以为读者带来哪些价值,以及如何评判这些价值。

前端精读已经写到第 123 篇了,大家已经不必担心它突然停止更新,因为我已养成每周写一篇文章的习惯,而读者也养成了每周看一篇的习惯。所以我想说的其实是一种更有生命力的自媒体运作方式,定期更新。一个定期更新的专栏比一个不不定期更新的专栏更有活力,也更受读者喜爱,因为读者能看到文章之间的联系,跟随作者一起成长。个人学习也是如此,养成定期学习的习惯,比在培训班突击几个月更有用,学会在生活中规律的学习,甚至好过读几年名牌大学。

前端精读想带给读者的不仅是一篇篇具体的内容和知识,知识是无穷无尽的,几万篇文章也说不完,但前端精读一直沿用了“引言-概述-精读-总结”这套学习模式,无论是前端任何领域的问题,还是对人生和世界的思考都可以套用,希望能为读者提供一套学习思维框架,让你能学习到如何找到好的文章,以及如何解读它。

至今已经选择了许多源码解读的题材,与培训思维的源码解读不同,我希望你不要带着面试的目的学习源码,因为这样会让你只局限在 react、vue 这种热门的框架上。前端精读选取的框架类型之所以广泛,是希望你能静下心来,吸取不同框架风格与作者的优势,培养一种优雅编码的气质。

进入正题,这次选择的文章 《用 Babel 创造自定义 JS 语法》 也是培养编码气质的一类文章,虽然对你实际工作用处不大,但这篇文章可以培养几个程序员梦寐以求的能力:深入理解 Babel、深入理解框架拓展机制。理解一个复杂系统或培养框架思维不是一朝一夕的,但持续阅读这种文章可以让你越来越接近掌握它。

之所以选择 Babel,是因为 Babel 处理的一直是语法树相关的底层逻辑,编译原理是程序世界的基座之一,拥有很大的学习价值。所以我们的目的并不是像文章标题说的 - 创造一个自定义 JS 语法,因为你创造的语法只会让 JS 复杂体系更加混乱,但可以让你理解 Babel 解析标准 JS 语法的原理,以及看待新语法提案时,拥有从实现层面思考的能力。

最后,不必多说,能重温 Babel 经典的插件机制,你可以发现 Babel 的插件拓展机制和 Antrl4 很像,在设计业务模块拓展方案时也可以作为参考。

2 概述

我们要利用 Babel 实现 function @@ 的新语法,用 @@ 装饰的函数会自动柯里化:

// '@@' makes the function `foo` curried
function @@ foo(a, b, c) {
  return a + b + c;
}
console.log(foo(1, 2)(3)); // 6

可以看到,function @@ foo 描述的函数 foo 支持 foo(1, 2)(3) 这种柯里化调用。

实现方式分为两步:

  1. Fork babel 源码。
  2. 创建一个 babel 转换器插件。

不要畏惧这些步骤,“如果你读完了这篇文章,你将成为同事眼中的 Babel 大神” - 原文。

首先 Fork babel 源码到本地,执行下面的命令可以初始化并编译 babel:

$ make bootstrap
$ make build

babel 使用 Makefile 执行编译命令,并且采用 monorepo 管理,我们这次要关心的是 package/babel-parser 这个模块。

词法

首先要了解词法知识,更详细的可以阅读原文或精读之前的一篇系列文章:精读《词法分析》。

要解析语法,首先要进行词法分析。任何语法输入都是一个字符串,比如 function @@ foo(a, b, c),词法分析就是要将这个长度为 24 的字符拆分为一个个有语义的单词片段:function @@ foo ( a

由于 @@ 是我们创造的语法,所以我们第一个任务就是让 babel 词法分析可以识别它。

下面是 package/babel-parser 的文件结构:

- src/
  - tokenizer/
  - parser/
  - plugins/
    - jsx/
    - typescript/
    - flow/
    - ...
- test/

可以看到,分为词法分析 tokenizer,语法分析 parser,以及支持一些特殊语法的插件,以及测试用例 test

推荐使用 Test-driven development (TDD) - 测试驱动开发的方式,就是先写测试用例,再根据测试用例开发。这种开发方式在后端或者 babel 这种底层框架很常见,因为 TDD 方式开发的逻辑能保证测试用例 100% 覆盖,同时先看测试用例也是个很好的切面编程思维。

// packages/babel-parser/test/curry-function.js

import { parse } from '../lib';

function getParser(code) {
  return () => parse(code, { sourceType: 'module' });
}

describe('curry function syntax', function() {
  it('should parse', function() {
    expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot();
  });
});

可以利用 jest 直接测试这段代码:

BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/c

结果会出现如下报错:

SyntaxError: Unexpected token (1:9)

at Parser.raise (packages/babel-parser/src/parser/location.js:39:63)
at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16)
at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18)
at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23)
at Parser.parseIdentifier (packages/babel-pars

第 9 个字符就是 @,说明程序现在还不支持函数前面的 @ 解析。我们还可以在错误堆栈中找到报错位置,并把当前 Token 与下一个 Token 打印出来:

// packages/babel-parser/src/parser/expression.js

parseIdentifierName(pos: number, liberal?: boolean): string {
  if (this.match(tt.name)) {
    // ...
  } else {
    console.log(this.state.type); // current token
    console.log(this.lookahead().type); // next token
    throw this.unexpected();
  }
}

this.state.type 代表当前 Token,this.lookahead().type 表示下一个 Token。lookahead 是词法分析的专有词,表示向后查看。打印之后,我们会发现输出了两个 @ Token:

TokenType {
  label: '@',
  // ...
}

下一步,我们需要让 babel 词法分析识别 @@ 这个 Token。首先需要注册这个 Token:

// packages/babel-parser/src/tokenizer/types.js

export const types: { [name: string]: TokenType } = {
  // ...
  at: new TokenType('@'),
  atat: new TokenType('@@'),
};

注册了之后,我们要在遍历 Token 时增加判断 “如果当前字符是 @ 且下一个字符也是 @,则整体构成了 @@ Token 并且光标向后移动两格”:

// packages/babel-parser/src/tokenizer/index.js

getTokenFromCode(code: number): void {
  switch (code) {
    // ...
    case charCodes.atSign:
      // if the next character is a `@`
      if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) {
        // create `tt.atat` instead
        this.finishOp(tt.atat, 2);
      } else {
        this.finishOp(tt.at, 1);
      }
      return;
    // ...
  }
}

再次运行测试文件,输出变成了:

// current token
TokenType {
  label: '@@',
  // ...
}

// next token
TokenType {
  label: 'name',
  // ...
}

到这一步,已经能正确解析 @@ Token 了。

语法

词法已经可以将 @@ 解析为 atat Token,下一步我们就要利用这个 Token,让生成的 AST 结构中包含柯里化函数的信息,并利用 babel 插件在解析时实现柯里化功能。

首先我们可以在 Babel AST explorer 看到 AST 解析的结构,我们拿 generator 函数测试,因为这个函数结构与柯里化函数类似:

可以看到,babel 通过 generator async 属性来标识函数是否为 generator 或者 async 函数。同理,增加一个 curry 属性就可以实现第一步了:

要实现如上效果,只需在词法分析 parser/statement 文件的 parseFunction 处新增 atat 解析即可:

// packages/babel-parser/src/parser/statement.js

export default class StatementParser extends ExpressionParser {
  // ...
  parseFunction<T: N.NormalFunction>(
    node: T,
    statement?: number = FUNC_NO_FLAGS,
    isAsync?: boolean = false
  ): T {
    // ...
    node.generator = this.eat(tt.star);
    node.curry = this.eat(tt.atat);
  }
}

eat 是吃掉的意思,实际上可以理解为吞掉这个 Token,这样做有两个效果:1. 为函数添加了 curry 属性 2. 吞掉了 @@ 标识,保证所有 Token 都被识别是 AST 解析正确的必要条件。

关于递归下降语法分析的更多知识,可以参考 精读《手写 SQL 编译器 - 语法分析》,或者阅读原文。

我们再次执行测试函数,发现测试通过了,一切都在预料中。

babel 插件

现在我们得到了标记了 curry 的 AST,那么最后需要一个 babel 解析插件,实现柯里化。

首先我们通过修改 babel 源码的方式实现的效果,是可以转化为自定义 babel parser 插件的:

// babel-plugin-transformation-curry-function.js

import customParser from './custom-parser';

export default function ourBabelPlugin() {
  return {
    parserOverride(code, opts) {
      return customParser.parse(code, opts);
    },
  };
}

这样就可以实现修改 babel 源码一样的效果,这也是做框架常用的插件机制。

其次我们要理解如何实现柯里化。柯里化可以通过柯里函数包装后实现:

function currying(fn) {
  const numParamsRequired = fn.length;
  function curryFactory(params) {
    return function (...args) {
      const newParams = params.concat(args);
      if (newParams.length >= numParamsRequired) {
        return fn(...newParams);
      }
      return curryFactory(newParams);
    }
  }
  return curryFactory([]);
}

// from
function @@ foo(a, b, c) {
  return a + b + c;
}

// to
const foo = currying(function foo(a, b, c) {
  return a + b + c;
})

柯里化函数通过构造参数数量相关的递归,当参数传入不足时返回一个新函数,并持久化之前传入的参数,最后当参数齐全后一次性调用函数。

我们需要做的是,将 @@ foo 解析为 currying() 函数包裹后的新函数。

下面就是我们熟悉的 babel 插件部分了:

// babel-plugin-transformation-curry-function.js

export default function ourBabelPlugin() {
  return {
    // ...
    visitor: {
      FunctionDeclaration(path) {
        if (path.get('curry').node) {
          // const foo = curry(function () { ... });
          path.node.curry = false;
          path.replaceWith(
            t.variableDeclaration('const', [
              t.variableDeclarator(
                t.identifier(path.get('id.name').node),
                t.callExpression(t.identifier('currying'), [
                  t.toExpression(path.node),
                ])
              ),
            ])
          );
        }
      },
    },
  };
}

FunctionDeclaration 就是 AST 的 visit 钩子,这个钩子在执行到函数时被触发,我们通过 path.get('curry') 拿到 柯里化函数,并利用 replaceWith 将这个函数构造为一个被 currying 函数包裹的新函数。

剩下最后一个问题:currying 函数源码放在哪里。

第一种方式,创建类似 babel-plugin-transformation-curry-function 这样的插件,在 babel 解析时将 currying 函数注册到全局,这是全局思维的方案。

第二种是模块化解决方案,创建一个自定义的 @babel/helpers,注册一个 currying 标识:

// packages/babel-helpers/src/helpers.js
helpers.currying = helper("7.6.0")`
  export default function currying(fn) {
    const numParamsRequired = fn.length;
    function curryFactory(params) {
      return function (...args) {
        const newParams = params.concat(args);
        if (newParams.length >= numParamsRequired) {
          return fn(...newParams);
        }
        return curryFactory(newParams);
      }
    }
    return curryFactory([]);
  }
`;

在 visit 函数使用 addHelper 方式拿到 currying

path.replaceWith(
  t.variableDeclaration('const', [
    t.variableDeclarator(
      t.identifier(path.get('id.name').node),
      t.callExpression(this.addHelper("currying"), [
        t.toExpression(path.node),
      ])
    ),
  ])
);

这样在 babel 转换后,就会自动 import helper,并引用 helper 中导出的 currying

最后原文末尾留下了一些延伸阅读内容,感兴趣的同学可以 点击到原文。

3 精读

读完这篇文章,相信你不仅对 babel 插件有了更深刻的认识,而且还掌握了如何为 js 添加新语法这种黑魔法。

我来帮你从 babel 这篇文章总结一些编程模型和知识点,借助 babel 创造自定义语法的实例,加深对它们的理解。

TDD

Test-driven development 即测试驱动的开发模式。

从文章的例子可以看出,创造一个新语法,可以先在测试用例先写上这个语法,通过执行测试命令通过报错堆栈一步步解决问题。这种方式开发可以让测试覆盖率更高,目的更专注,更容易保障代码质量。

联想编程

联想编程不属于任何编程模型,但从简介的思路来看,作者把 “为 babel 创建一个新 js 语法” 看作一种探案式探索过程,通过错误堆栈和代码阅读,一步一步通过合理联想实现最终目的。

在 AST 那一节,还借助了 Babel AST explorer 工具查看 AST 结构,通过联想到 generator 函数找到类似的 AST 结构,并找到拓展 AST 的突破口。

随着解决问题的不同,联想方式也不同,如果能够举一反三,对不同场景都能合理的联想,才算是具备了技术专家的软素质。

词法、语法分析

词法、语法分析属于编译原理的知识,理解词法拆分、递归下降,可以帮助你技术走的更深。

不论是 Babel 插件的使用、还是 Babel 增加自定义 JS 语法,都要具备基本编译原理知识。编译原理知识还能帮助你开发在线编辑器,做智能语法提示等等。

插件机制

如下是 babel 自定义 parser 的插件拓展方式:

export default function ourBabelPlugin() {
  return {
    parserOverride(code, opts) {
      return customParser.parse(code, opts);
    },
  };
}

这只是插件拓展的一种,有申明式,也有命令式;有用 JS 书写的,也有用 JSON 书写的。babel 选择了通过对象方式拓展,是比较适合对 AST 结构统一处理的。

做框架首先要确定接口规范,比如 parser,先按照接口规范实现一套官方解析,对接时按照接口进行对接,就可以自然而然被用户自定义插件替代了。

可以参考的文章: 精读《插件化思维》

柯里化

柯里化是面试经常考察的一个知识点,我们能学到的有两点:理解递归、理解如何将函数变成柯里化。

这里再拓展一下,我们还可以想到 JS 尾递归优化。如何快速写一个支持尾递归的函数?

const fn = tailCallOptimize(() => {
  if ( /* xxx */ ) {
    fn()
  }
})

通过封装 tailCallOptimize 函数,可以很方便的构造一个支持尾递归的函数,这个函数可以这么写:

export function tailCallOptimize<T>(f: T): T {
  let value: any;
  let active = false;
  const accumulated: any[] = [];
  return function accumulator(this: any) {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = (f as any).apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

感兴趣的读者可以在评论里解释一下这个函数的原理。

AST visit

遍历 AST 树常采用的方案是做一个遍历器 visitor,所以在遍历过程中进行拓展常采用 babel 这种方式:

return {
  // ...
  visitor: {
    FunctionDeclaration(path) {
      if (path.get('curry').node) {
        // const foo = curry(function () { ... });
        path.node.curry = false;
        path.replaceWith(
          t.variableDeclaration('const', [
            t.variableDeclarator(
              t.identifier(path.get('id.name').node),
              t.callExpression(t.identifier('currying'), [
                t.toExpression(path.node),
              ])
            ),
          ])
        );
      }
    },
  },
};

visitor 下每一个 key 名都是遍历过程中的拓展点,比如上面的例子,我们可以对函数定义位置进行拓展和改写。

内置函数注册

babel 提供了两种内置函数注册方式,一种类似 polyfill,在全局注册 window 级的变量,另一种是模块化的方式。

除此之外,可以学习的是 babel 通过 this.addHelper("currying") 这种插件拓展方式,在编译后会自动从 helper 引入对应的模块,前提是 @babel/helper 需要注册 currying 这个 helper。

babel 将编译过程隐藏了起来,通过一些高度封装的函数调用,以较为语义化方式书写插件,这样写出来的代码也容易理解。

4 总结

《用 Babel 创造自定义 JS 语法》这篇文章虽然说的是 babel 相关知识,但可以从中提取到许多通用知识,这就是现在还去理解 babel 的原因。

从某个功能点为切面,走一遍框架的完整流程是一种高效的进阶学习方式,如果你也有看到类似这样的文章,欢迎推荐出来。

讨论地址是:精读《用 Babel 创造自定义 JS 语法》 · Issue #210 · dt-fe/weekly

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1486520.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

C++进阶(二) 多态

一、多态的概念 多态的概念&#xff1a;通俗来说&#xff0c;就是多种形态&#xff0c; 具体点就是去完成某个行为&#xff0c;当不同的对象去完成时会 产生出不同的状态。举个栗子&#xff1a;比如买票这个行为&#xff0c;当普通人买票时&#xff0c;是全价买票&#xff1b;学…

无人机镜头稳定的原理和相关算法

无人机的镜头稳定主要基于两个关键技术&#xff1a;镜头平衡技术和实时电子稳像。无人机镜头稳定的原理和相关算法主要是通过镜头平衡技术和实时电子稳像技术来保持摄像镜头的稳定性&#xff0c;从而拍摄出清晰、稳定的画面。无人机镜头稳定的原理主要是通过传感器和算法来实现…

基于SpringBoot的在线拍卖系统(附项目源码+论文)

摘要 在线拍卖系统&#xff0c;主要的模块包括管理员&#xff1b;首页、个人中心、用户管理、商品类型管理、拍卖商品管理、历史竞拍管理、竞拍订单管理、留言板管理、系统管理&#xff0c;用户&#xff1b;首页、个人中心、历史竞拍管理、竞拍订单管理、留言板管理&#xff0…

SSH教程

ssh 是远程连接的利器, 可以说凡是涉及到 linux 服务器, ssh 就是一个绕不开的话题. 本文作为一个教程, 尽可能详细的帮助读者设置 ssh, 并给出一些常用的 ssh 配置方法 (主要用于 linux 系统的远程登录和文件传输). 1. 简介 ssh 分为两个部分, sshd 服务端和 ssh 客户端. ssh…

2.2 mul、div、and、or乘除指令及所有寄存器英文名

汇编语言 1. mul乘指令 两个相乘的数&#xff0c;要么都是8位&#xff0c;要么都是16位 两个8位数相乘 一个默认放在al中&#xff0c;另一个放在8位reg或内存字节单元中8位乘法&#xff0c;结果默认放在ax中例如&#xff1a;计算100*10 100和10小于255&#xff0c;可以做8位…

优选算法|【双指针】|1089.复写零

目录 题目描述 题目解析 算法原理讲解 代码 题目描述 1089. 复写零 给你一个长度固定的整数数组 arr &#xff0c;请你将该数组中出现的每个零都复写一遍&#xff0c;并将其余的元素向右平移。 注意&#xff1a;请不要在超过该数组长度的位置写入元素。请对输入的数组 就…

基于图数据库构建知识图谱平台应用实践

▏摘要 中信证券基于分布式图数据库StellarDB&#xff0c;替代国外开源图数据库产品&#xff0c;打造全新的企业级知识图谱平台&#xff0c;应用于同一客户集团画像、科创板关联发现、风险事件报告、全球企业关联图谱、产业链图谱、投研图谱、反洗钱与稽核图谱、元数据图谱等应…

《程序员职业规划手册》学习笔记

《程序员职业规划手册》不是一本具体的书&#xff0c;而是由前阿里技术总监雪梅老师讲授的一个专栏课程&#xff0c;总共有20讲&#xff0c;内容基本都是图片和文字形式&#xff0c;也有对应的语音讲述。 回顾了下毕业工作的这几年&#xff0c;我买过很多学习课程&#xff0c;…

java八股文复习-----2024/03/03

1.接口和抽象类的区别 相似点&#xff1a; &#xff08;1&#xff09;接口和抽象类都不能被实例化 &#xff08;2&#xff09;实现接口或继承抽象类的普通子类都必须实现这些抽象方法 不同点&#xff1a; &#xff08;1&#xff09;抽象类可以包含普通方法和代码块&#x…

openGauss学习笔记-234 openGauss性能调优-系统调优-资源负载管理-资源管理准备-设置控制组

文章目录 openGauss学习笔记-234 openGauss性能调优-系统调优-资源负载管理-资源管理准备-设置控制组234.1 背景信息234.2 前提条件234.3 操作步骤234.3.1 创建子Class控制组和Workload控制组234.3.2 更新控制组的资源配额234.3.3 删除控制组 234.4 查看控制组的信息 openGauss…

Docker的基本操作(黑马学习笔记)

镜像操作 镜像名称 首先来看下镜像的名称组成&#xff1a; ● 镜名称一般分两部分组成&#xff1a;[repository]:[tag]。 ● 在没有指定tag时&#xff0c;默认是latest&#xff0c;代表最新版本的镜像 如图&#xff1a; 这里的mysql就是repository&#xff0c;5.7就是tag&…

第八节 龙晰Anolis 8.8 安装 DDE 桌面环境

一、前言 最小化安装的龙晰 Anolis OS 8.8 是不带图形化界面的&#xff0c;只能使用命令行&#xff0c;有些时候需要用到桌面环境&#xff0c;而DDE (Deepin Desktop Enviroment) 就是很好的桌面环境&#xff0c;它是指龙晰 Anolis 所搭载的中国自主桌面环境&#xff0c;用起来…

信息安全技术第1章——信息网络安全基本概念

课程介绍 网络信息安全是医学信息工程专业的限选课。主要围绕计算机网络安全所涉及的主要问题进行讲解&#xff0c;内容包括&#xff1a;对称密码与公钥密码的基本原理、相关算法及应用。电子邮件的安全&#xff0c;IP安全&#xff0c;Web安全&#xff0c;恶意软件及防火墙等内…

逼迫大模型消除幻觉,就像杨永信电击治疗网瘾少年

在科技高速发展的领域&#xff0c;进步往往伴随着争议。数字化时代&#xff0c;我们被海量信息所环绕&#xff0c;利用大模型来提取信息和生成答案&#xff0c;有时会遇到模型给出的所谓“幻觉&#xff08;hallucination&#xff09;”回应。这就带来了一个问题&#xff1a;是否…

2024阿里云服务器ECS--安全,稳定,购买灵活,低成本

阿里云服务器ECS英文全程Elastic Compute Service&#xff0c;云服务器ECS是一种安全可靠、弹性可伸缩的云计算服务&#xff0c;阿里云提供多种云服务器ECS实例规格&#xff0c;如经济型e实例、通用算力型u1、ECS计算型c7、通用型g7、GPU实例等&#xff0c;阿里云百科aliyunbai…

设计模式—命令模式:探索【命令模式】的奥秘与应用实践!

命令模式 命令模式是一种行为设计模式&#xff0c;它的主要目的是将请求封装成一个对象&#xff0c;从而使得请求的发送者和接收者之间进行解耦。 在命令模式中&#xff0c;命令被封装为一个对象&#xff0c;包含了需要执行的操作以及执行这些操作所需的所有参数。 命令的发送者…

【详识JAVA语言】抽象类和接口

抽象类 抽象类概念 在面向对象的概念中&#xff0c;所有的对象都是通过类来描绘的&#xff0c;但是反过来&#xff0c;并不是所有的类都是用来描绘对象的&#xff0c;如果 一个类中没有包含足够的信息来描绘一个具体的对象&#xff0c;这样的类就是抽象类。 比如&#xff1a;…

经典的算法面试题(1)

题目&#xff1a; 给定一个整数数组 nums&#xff0c;编写一个算法将所有的0移到数组的末尾&#xff0c;同时保持非零元素的相对顺序。 示例: 输入: [0,1,0,3,12] 输出: [1,3,12,0,0] 注意&#xff1a;必须在原数组上操作&#xff0c;不能拷贝额外的数组。尽量减少操作次数。 这…

[Redis]——Redis命令手册set、list、sortedset

&#x1f333;List类型常见命令 LPUSH / RPUSH [KEY] [element] …… 向列表左侧或者右侧插入一个或多个元素 LPOP / RPOP [key] 删除左边或者右边第一个元素 LRANGE [key] start end 返回索引start到end的元素&#xff08;索引从0开始&#xff09; BLPOP / BRPOP [key] [等…

Vue.js+SpringBoot开发社区买菜系统

目录 一、摘要1.1 项目介绍1.2 项目录屏 二、系统设计2.1 功能模块设计2.1.1 数据中心模块2.1.2 菜品分类模块2.1.3 菜品档案模块2.1.4 菜品订单模块2.1.5 菜品收藏模块2.1.6 收货地址模块 2.2 可行性分析2.3 用例分析2.4 实体类设计2.4.1 菜品分类模块2.4.2 菜品档案模块2.4.3…