eggjs笔记

一、egg.js

1. 什么是egg.js

  • express是基于es5的web开发框架
  • koa1.x 是express原班人员打造的基于es6的web开发框架
  • koa2.x 是express原班人员打造的基于es7的web开发框架
  • egg 是阿里基于koa有约束和规范的企业级web开发框架

2. egg.js的基本使用

2.1 安装

bash 复制代码
# 初始化
npm init -y
# 安装egg,egg模块是egg.js的核心模块
npm i egg -S
# 安装egg-bin,这个模块用于快速启动项目
npm i egg-bin -D

2.2 启动:快速启动项目,用于本地开发调试的模块

json 复制代码
// package.json
"scripts": {
    "dev": "egg-bin dev"
}

2.3 目录结构

egg-project
├── package.json
├── app.js(可选)
├── agent.js(可选)
├── app
|   ├── router.js
│   ├── controller
│   │   └── home.js
│   ├── service(可选)
│   │   └── user.js
│   ├── middleware(可选)
│   │   └── response_time.js
│   ├── schedule(可选)
│   │   └── my_task.js
│   ├── public(可选)
│   │   └── reset.css
│   ├── view(可选)
│   │   └── home.tpl
│   └── extend(可选)
│       ├── helper.js(可选)
│       ├── request.js(可选)
│       ├── response.js(可选)
│       ├── context.js(可选)
│       ├── application.js(可选)
│       └── agent.js(可选)
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js(可选)
|   ├── config.local.js(可选)
|   └── config.unittest.js(可选)
└── test
    ├── middleware
    |   └── response_time.test.js
    └── controller
        └── home.test.js

由框架约定的目录:

  • app/router.js 用于配置 URL 路由规则,具体参见 Router。
  • app/controller/** 用于解析用户的输入,处理后返回相应的结果,具体参见 Controller。
  • app/service/** 用于编写业务逻辑层,建议使用,具体参见 Service。
  • app/middleware/** 用于编写中间件,具体参见 Middleware。
  • app/public/** 用于放置静态资源,具体参见内置插件 egg-static。
  • app/extend/** 用于框架的扩展,具体参见 框架扩展。
  • config/config.{env}.js 用于编写配置文件,具体参见 配置。
  • config/plugin.js 用于配置需要加载的插件,具体参见 插件。
  • test/** 用于单元测试,具体参见 单元测试。
  • app.js 和 agent.js 用于自定义启动时的初始化工作,具体参见 启动自定义。关于 agent.js 的作用,参见 Agent 机制。

由内置插件约定的目录:

  • app/public/** 用于放置静态资源,具体参见内置插件 egg-static。
  • app/schedule/** 用于定时任务,具体参见 定时任务。

若需自定义自己的目录规范,参见 Loader API

  • app/view/** 用于放置模板文件,具体参见 模板渲染。
  • app/model/** 用于放置领域模型,如 egg-sequelize 等领域类相关插件。

2.4 app/router.js

在router.js中必须暴露出去一个方法,这个方法接受一个参数,这个参数就算服务器的实例对象

js 复制代码
module.exports = app => {
    console.log(app);
    /**
     * {
        env: 'local',
        name: 'egg',
        baseDir: 'C:\\Users\\Administrator\\Desktop\\egg',
        subdomainOffset: 2,
        config: '<egg config>',
        controller: '<egg controller>',
        httpclient: '<egg httpclient>',
        loggers: '<egg loggers>',
        middlewares: '<egg middlewares>',
        router: '<egg router>',
        serviceClasses: '<egg serviceClasses>'
        }
     */
    // 从服务器实例上解构出处理路由的对象和处理控制器的对象
    const { router, controller } = app

    // 监听路由请求
    // controller.home:相当于拿到了controller目录下的home.js(如果有多级可以通过.语法使用)
    router.get('/', controller.home.index)
}

2.5 app/controller/xx.js(xx.js可自定义名称)

这里是app/controller/home.js

js 复制代码
const Controller = require('egg').Controller

class HomeController extends Controller {
    async index() {
        this.ctx.body = 'hello egg.js'
    }
}

module.exports = HomeController

在eggjs中会自动给控制器挂载一些属性:

  • this.ctx:当前请求的上下文 Context 对象的实例,通过它我们可以拿到框架封装好的处理当前请求的各种便捷属性和方法。
  • this.app:当前应用 Application 对象的实例,通过它我们可以拿到框架提供的全局对象和方法。
  • this.service:应用定义的 Service,通过它我们可以访问抽象出的业务层,等价于 this.ctx.service。
  • this.config:应用运行时的配置项。
  • this.logger:logger 对象,上面有四个方法(debug、info、warn、error),分别代表打印四个不同级别的日志。使用方法和效果与 context logger 中介绍的相同,但是通过这个 logger 对象记录的日志,在日志前面会加上打印该日志的文件路径,以便快速定位日志打印位置。

3. egg.js 处理get/post请求

app/router.js

js 复制代码
module.exports = app => {
    const { router, controller } = app

    router.get('/getquery', controller.home.getquery)
    router.get('/getparams/:name/:age', controller.home.getparams)
    router.post('/postbody', controller.home.postbody)
}

app/controller/home.js

js 复制代码
const Controller = require('egg').Controller

class HomeController extends Controller {
    // 获取query参数
    async getquery() {
        // let query = this.ctx.request.query
        let query = this.ctx.query
        this.ctx.body = query
    }

    // 获取动态路由参数
    async getparams() {
        let params = this.ctx.params
        this.ctx.body = params
    }

    // 获取post请求参数,post请求默认会被阻止掉,需要在 config/config.default.js 中添加配置
    async postbody() {
        let body = this.ctx.request.body
        this.ctx.body = body
    }
}

module.exports = HomeController

注意:post请求默认会被阻止掉,需要在 config/config.default.js 中添加配置

js 复制代码
// config/config.default.js
module.exports = {
    security: {
        // 跨站点请求
        csrf: {
            ignoreJSON: true, // 默认为 false,设置为 true 时,会忽略所有 content-type 为 `application/json` 的请求
        }
    },
};

4. 处理静态资源

app目录下,新建public目录,在浏览器中通过http://xxx.com/public/xxx.png即可访问

5. 处理动态资源

需要使用插件(插件:特殊的中间件)

5.1 插件的使用

  1. 安装
bash 复制代码
npm i egg-view-ejs
  1. 对插件进行配置,在config目录下新建plugin.js文件
js 复制代码
// exports.xxx
exports.ejs = {
    enable: true,
    package: 'egg-view-ejs' // 配置使用的插件
}
  1. config.default.js中添加配置
js 复制代码
// config/config.default.js
module.exports = {
    // view目录下的文件
    view: {
        mapping: {
            '.html': 'ejs' // 在哪个后缀的文件使用插件
        }
    }
};
  1. app目录中新建view目录,将动态网页放到这个目录下,在控制器中通过上下文render方法渲染
js 复制代码
const Controller = require('egg').Controller

class HomeController extends Controller {
    async index() {
        // app/view/demo.html
        await this.ctx.render('demo', { msg: '数据' })
    }
}

module.exports = HomeController

6. 数据处理

在eggjs中无论是数据库中的数据还是处理网络数据,都是在Service中处理的

每一次用户请求,框架都会实例化对应的 Service 实例。因为它继承自 egg.Service。和控制器一样,Service类的this上也挂载了很多属性:

  • this.ctx:当前请求的上下文 Context 对象实例。通过它,我们可以获取框架封装的处理当前请求的各种便捷属性和方法。

  • this.app:当前应用 Application 对象实例。通过它,我们可以访问框架提供的全局对象和方法。

  • this.service:应用定义的 Service。通过它,我们可以访问到其他业务层,等同于 this.ctx.service。

  • this.config:应用运行时的 配置项。

  • this.logger:logger 对象。它有四个方法(debug,info,warn,error),分别代表不同级别的日志。使用方法和效果与 context logger 所述一致。但通过这个 logger 记录的日志,在日志前会加上文件路径,方便定位日志位置。

this.ctx上下文对象上还挂载了其他的属性:

  • 使用 this.ctx.curl 发起网络调用。
  • 通过 this.ctx.service.otherService 调用其他 Service。
  • 调用 this.ctx.db 发起数据库操作,db 可能是插件预挂载到 app 上的模块。
  1. app目录下,新建service目录,新建js文件
js 复制代码
// app/service/home.js
const Service = require('egg').Service

class HomeService extends Service {
    async findNews() {
        // 在Service定义的方法中处理数据库和网络的数据即可
        // 1. 发送get请求,不带参数
        let response = await this.ctx.curl('http://jsonplaceholder.typicode.com/posts')
        // 2. 发送get请求,带参数
        let response = await this.ctx.curl('http://jsonplaceholder.typicode.com/comments?postId=1')
        // 3. 发送post请求,不带参数
        let response = await this.ctx.curl('http://jsonplaceholder.typicode.com/posts', {
            method: 'POST'
        })
        // 4. 发送post请求,带参数
        let response = await this.ctx.curl('xxx', {
            method: 'POST',
            data: {
                id: 1
            }
        })
        return response.data
    }
}

module.exports = HomeService

注意:

  1. service目录必须放在app目录中
  2. service目录支持多级目录,如果是多级目录,在调用的时候可以通过链式调用
  3. service目录下的js文件,如果是以_首字母大写,那么在调用的时候必须转换成驼峰命名:
    get_user.js => getUser
    GetUser.js => getUser
  1. 在控制器中调用

通过ctx上下文的service获取

js 复制代码
// app/controller/home.js
const Controller = require('egg').Controller

class HomeController extends Controller {
    async news() {
        let data = await this.ctx.service.home.findNews()
        this.ctx.body = data
    }
}

module.exports = HomeController
  1. 路由写接口
js 复制代码
// app/router.js
module.exports = app => {
    const { router, controller } = app
    router.get('/news', controller.home.news)
}

7. 处理cookie

生成cookie和获取cookie

  1. 不加密
js 复制代码
class HomeController extends Controller {
    async setCookie() {
        this.ctx.cookies.set('name', 'xiaotian', {
            path: '/',
            maxAge: 24 * 60 * 60 * 1000, // 有效时间
            httpOnly: true, // 只允许在服务端修改
            signed: true, // 生成cookie的时候,同时生成一个签名,根据config/config.default.js的keys设置。默认为true
        })

        this.ctx.body = '设置成功'
    }

    async getCookie() {
        let cookie = this.ctx.cookies.get('name')
        this.ctx.body = '获取cookie成功, ' + cookie
    }
}
  1. 加密和解密 encrypt: true
js 复制代码
class HomeController extends Controller {
    async setCookie() {
        this.ctx.cookies.set('name', 'xiaotian', {
            path: '/',
            maxAge: 24 * 60 * 60 * 1000, // 有效时间
            httpOnly: true, // 只允许在服务端修改
            signed: true, // 生成cookie的时候,同时生成一个签名,根据config/config.default.js的keys设置。默认为true
            encrypt: true // 对cookie加密后再保存
        })

        this.ctx.body = '设置成功'
    }

    async getCookie() {
        let cookie = this.ctx.cookies.get('name', {
            signed: true,
            encrypt: true
        })
        this.ctx.body = '获取cookie成功, ' + cookie
    }
}

8. 处理日志

eggjs自动生成logs目录

8.1 日志分类

  • ${appInfo.name}-web.log,例如 example-app-web.log,应用相关日志,供应用开发者使用的日志。我们在绝大多数情况下都在使用它。
  • egg-web.log:框架内核、插件日志。
  • common-error.log:ctx.logger.err 输出的错误日志
  • egg-agent.log:进程日志,框架和使用到 agent 进程执行任务的插件会打印一些日志到这里。
  • egg-schedule.log:定时任务的日志。

8.2 日志切割

在eggjs中默认会自动切割日志,每一天就是一个新的日志文件

9. 定时任务

9.1 使用场景:

  • 定时进行文件切割,临时文件删除
  • 定时上报应用状态

9.2 如何编写定时任务:

  1. app目录下新建schedule目录

所有的定时任务都统一存放在 app/schedule 目录下,每一个文件都是一个独立的定时任务,可以配置定时任务的属性和要执行的方法。

js 复制代码
const Subscription = require('egg').Subscription;

class UpdateCache extends Subscription {
  // 通过 schedule 属性来设置定时任务的执行间隔等配置
  static get schedule() {
    return {
      interval: '1m', // 1 分钟间隔
      type: 'all', // all表示当前服务器上所有相同node进程(之前说过同一个node项目可以开启多个进程)都执行
    };
  }

  // subscribe 是真正定时任务执行时被运行的函数
  async subscribe() {
    const res = await this.ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    });
    // cache 是自定义的
    this.ctx.app.cache = res.data;
  }
}

module.exports = UpdateCache;

还可以简写为:

js 复制代码
module.exports = {
  schedule: {
    interval: '1m', // 1 分钟间隔
    type: 'all',
  },
  async task(ctx) {
    const res = await ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    });
    ctx.app.cache = res.data;
  },
};

这个定时任务会在每一个 Worker 进程上每 1 分钟执行一次,将远程数据请求回来挂载到 app.cache 上。(cache是自定义的)

10. 启动自定义

我们常常需要在应用启动期间进行一些初始化工作,待初始化完成后,应用才可以启动成功,并开始对外提供服务。

框架提供了统一的入口文件(app.js)进行启动过程自定义。这个文件需要返回一个 Boot 类。我们可以通过定义 Boot 类中的生命周期方法来执行启动应用过程中的初始化工作。

框架提供了以下 生命周期函数 供开发人员处理:

  • 配置文件即将加载,这是最后动态修改配置的时机(configWillLoad);
  • 配置文件加载完成(configDidLoad);
  • 文件加载完成(didLoad);
  • 插件启动完毕(willReady);
  • worker 准备就绪(didReady);
  • 应用启动完成(serverDidReady);
  • 应用即将关闭(beforeClose)。

在根目录下新建app.js

js 复制代码
// app.js
class AppBootHook {
    constructor(app) {
        this.app = app;
    }

    configWillLoad() {
        // 此时 config 文件已经被读取并合并,但还并未生效
        // 这是应用层修改配置的最后机会
        // 注意:此函数只支持同步调用

        // 例如:参数中的密码是加密的,在此处进行解密
        this.app.config.mysql.password = decrypt(this.app.config.mysql.password);
        // 例如:插入一个中间件到框架的 coreMiddleware 之间
        const statusIdx = this.app.config.coreMiddleware.indexOf('status');
        this.app.config.coreMiddleware.splice(statusIdx + 1, 0, 'limit');
    }

    async didLoad() {
        // 所有配置已经加载完毕
        // 可以用来加载应用自定义的文件,启动自定义服务

        // 例如:创建自定义应用的实例
        this.app.queue = new Queue(this.app.config.queue);
        await this.app.queue.init();

        // 例如:加载自定义目录
        this.app.loader.loadToContext(path.join(__dirname, 'app/tasks'), 'tasks', {
            fieldClass: 'tasksClasses',
        });
    }

    async willReady() {
        // 所有插件已启动完毕,但应用整体尚未 ready
        // 可进行数据初始化等操作,这些操作成功后才启动应用

        // 例如:从数据库加载数据到内存缓存
        this.app.cacheData = await this.app.model.query(QUERY_CACHE_SQL);
    }

    async didReady() {
        // 应用已启动完毕

        const ctx = await this.app.createAnonymousContext();
        await ctx.service.Biz.request();
    }

    async serverDidReady() {
        // http/https 服务器已启动,开始接收外部请求
        // 此时可以从 app.server 获取 server 实例

        // 可以执行一些需要初始化的文件
    }
}

module.exports = AppBootHook;

11. 操作mysql

11.1 安装

bash 复制代码
npm i --save egg-mysql

11.2 开启插件

js 复制代码
// config/plugin.js
exports.mysql = {
  enable: true,
  package: 'egg-mysql'
};

11.3 配置

js 复制代码
// config/config.default.js
exports.mysql = {
  // 单数据库信息配置
  client: {
    // host
    host: 'mysql.com',
    // 端口号
    port: '3306',
    // 用户名
    user: 'test_user',
    // 密码
    password: 'test_password',
    // 数据库名
    database: 'test'
  },
  // 是否加载到 app 上,默认开启
  app: true,
  // 是否加载到 agent 上,默认关闭
  agent: false
};

11.4 使用

js 复制代码
await app.mysql.query(sql, values); // 单实例可以直接通过 app.mysql 访问

11.5 如何编写 CRUD 语句

Create

可以直接使用 insert 方法插入一条记录

js 复制代码
// 插入
const result = await this.app.mysql.insert('posts', { title: 'Hello World' }); // 在 posts 表中,插入 title 为 Hello World 的记录

// SQL 语句相当于
// INSERT INTO `posts`(`title`) VALUES('Hello World');

console.log(result);
// 输出为
// {
//   fieldCount: 0,
//   affectedRows: 1,
//   insertId: 3710,
//   serverStatus: 2,
//   warningCount: 2,
//   message: '',
//   protocol41: true,
//   changedRows: 0
// }

// 判断插入成功
const insertSuccess = result.affectedRows === 1;
Read

可以直接使用 get 方法或 select 方法获取一条或多条记录。select 方法支持条件查询与结果定制。 可以使用 count 方法对查询结果的所有行进行计数。

  • 查询一条记录
js 复制代码
const post = await this.app.mysql.get('posts', { id: 12 });

// SQL 语句相当于
// SELECT * FROM `posts` WHERE `id` = 12 LIMIT 0, 1;
  • 查询全表
js 复制代码
const results = await this.app.mysql.select('posts');

// SQL 语句相当于
// SELECT * FROM `posts`;
  • 条件查询和结果定制
js 复制代码
const results = await this.app.mysql.select('posts', { // 搜索 posts 表
  where: { status: 'draft', author: ['author1', 'author2'] }, // WHERE 条件
  columns: ['author', 'title'], // 要查询的字段
  orders: [['created_at','desc'], ['id','desc']], // 排序方式
  limit: 10, // 返回数据量
  offset: 0, // 数据偏移量
});

// SQL 语句相当于
// SELECT `author`, `title` FROM `posts`
// WHERE `status` = 'draft' AND `author` IN('author1','author2')
// ORDER BY `created_at` DESC, `id` DESC LIMIT 0, 10;
  • 统计查询结果的行数
js 复制代码
const total = await this.app.mysql.count('posts', { status: 'published' }); // 统计 posts 表中 status 为 published 的行数

// SQL 语句相当于
// SELECT COUNT(*) FROM `posts` WHERE `status` = 'published'
Update

可以直接使用 update 方法更新数据库记录。

js 复制代码
// 修改数据
const row = {
  id: 123,
  name: 'fengmk2',
  otherField: 'other field value', // 其他想要更新的字段
  modifiedAt: this.app.mysql.literals.now, // 数据库服务器上的当前时间
};
const result = await this.app.mysql.update('posts', row); // 更新 posts 表中的记录

// SQL 语句相当于
// UPDATE `posts` SET `name` = 'fengmk2', `modifiedAt` = NOW() WHERE `id` = 123;

// 判断更新成功
const updateSuccess = result.affectedRows === 1;

// 如果主键是自定义的 ID 名称,如 custom_id,则需要在 `where` 里配置
const row2 = {
  name: 'fengmk2',
  otherField: 'other field value', // 其他想要更新的字段
  modifiedAt: this.app.mysql.literals.now, // 数据库服务器上的当前时间
};

const options = {
  where: {
    custom_id: 456
  }
};
const result2 = await this.app.mysql.update('posts', row2, options); // 更新 posts 表中的记录

// SQL 语句相当于
// UPDATE `posts` SET `name` = 'fengmk2', `modifiedAt` = NOW() WHERE `custom_id` = 456 ;

// 判断更新成功
const updateSuccess2 = result2.affectedRows === 1;
Delete

可以直接使用 delete 方法删除数据库记录。

js 复制代码
const result = await this.app.mysql.delete('posts', {
  author: 'fengmk2',
});

// SQL 语句相当于
// DELETE FROM `posts` WHERE `author` = 'fengmk2';

11.6 直接执行 SQL 语句

插件本身也支持拼接与直接执行 SQL 语句。使用 query 方法可以执行合法的 SQL 语句。

js 复制代码
const postId = 1;
const results = await this.app.mysql.query('update posts set hits = (hits + ?) where id = ?', [1, postId]);

// => update posts set hits = (hits + 1) where id = 1;

12. eggjs的配置文件

config
|- config.default.js  所有环境都会加载(如果其他文件有同名,会覆盖掉默认的)
|- config.prod.js  只有上线环境才会加载
|- config.unittest.js  只有测试环境才会加载
|- config.local.js  只有开发环境才会加载

config.default.js 为默认的配置文件,所有环境都会加载这个配置文件,一般也会作为开发环境的默认配置文件。

当指定 env 时,会同时加载默认配置和对应的配置(具名配置)文件。具名配置和默认配置将合并成最终配置,具名配置项会覆盖默认配置文件的同名配置。例如,prod 环境会加载 config.prod.jsconfig.default.js 文件,config.prod.js 会覆盖 config.default.js 的同名配置。

但通常会用 cross-env 第三方库来配置项目环境

二、pm2

1. pm2 的好处

  1. pm2 进程守护可以在程序崩溃后自动重启
  2. pm2 自带日志记录功能,可以很方便的记录错误日志和自定义日志
  3. pm2 可以启动多个node进程,充分利用服务器资源

2. pm2 的基本使用

2.1 安装pm2

bash 复制代码
npm i pm2 -g

2.2 查看pm2版本

bash 复制代码
pm2 --version

2.3 启动pm2

bash 复制代码
pm2 start index.js

3. pm2 常用指令

3.1 启动应用程序

bash 复制代码
pm2 start index.js

3.2 列出启动的所有应用程序

bash 复制代码
pm2 list

3.3 重启应用程序

bash 复制代码
pm2 restart 应用id/name

3.4 杀死并重启所有进程

bash 复制代码
pm2 restart all

3.5 查看应用程序详细信息

bash 复制代码
pm2 info 应用id/name

pm2 show

3.6 显示指定应用程序的日志

bash 复制代码
pm2 log 应用id/name

3.7 监控应用程序

bash 复制代码
pm2 monit 应用id/name

3.8 停止应用程序

bash 复制代码
pm2 stop 应用id/name

3.9 停止所有应用程序

bash 复制代码
pm2 stop all

3.10 关闭并删除指定应用程序

bash 复制代码
pm2 delete 应用id/name

3.11 关闭并删除所有应用程序

bash 复制代码
pm2 delete all

3.12 杀死pm2管理的所有进程

bash 复制代码
pm2 kill

4. pm2常用配置

  1. 新建pm2.confif.json文件
json 复制代码
{
    "name": "应用程序名称",
    "script": "入口文件名称",
    "watch": true, // 文件被修改是否自动重启
    "ignore_watch": [ // 忽略监听哪些文件的改变
        "node_modules",
        "logs"
    ],
    "error_file": "logs/错误日志文件名称.log",
    "out_file": "logs/自定义日志文件名称.log",
    "log_date_format": "yyyy-MM-dd HH:mm:ss" // 给日志添加时间
}
  1. 运行
bash 复制代码
pm2 start pm2.config.json

5. 负载均衡

node是单线程的,服务器是多核的,要充分利用服务器资源,可以使用pm2启动多个node进程,只需要在配置文件中增加 instances 配置,可以启动多个node进程(想启动几个就启动几个,但是不能超过服务器cpu的核数)

json 复制代码
{
    "name": "应用程序名称",
    "script": "入口文件名称",
    "watch": true, // 文件被修改是否自动重启
    "ignore_watch": [ // 忽略监听哪些文件的改变
        "node_modules",
        "logs"
    ],
    "error_file": "logs/错误日志文件名称.log",
    "out_file": "logs/自定义日志文件名称.log",
    "log_date_format": "yyyy-MM-dd HH:mm:ss", // 给日志添加时间
    "instances": 4 // 开启多个node进程,不能超过cpu核数
}
相关推荐
Komorebi.py2 小时前
【Linux】-学习笔记05
linux·笔记·学习
亦枫Leonlew3 小时前
微积分复习笔记 Calculus Volume 1 - 6.5 Physical Applications
笔记·数学·微积分
冰帝海岸8 小时前
01-spring security认证笔记
java·笔记·spring
小二·9 小时前
java基础面试题笔记(基础篇)
java·笔记·python
wusong99911 小时前
mongoDB回顾笔记(一)
数据库·笔记·mongodb
猫爪笔记11 小时前
前端:HTML (学习笔记)【1】
前端·笔记·学习·html
Resurgence0312 小时前
【计组笔记】习题
笔记
前端李易安12 小时前
Webpack 热更新(HMR)详解:原理与实现
前端·webpack·node.js
pq113_612 小时前
ftdi_sio应用学习笔记 3 - GPIO
笔记·学习·ftdi_sio
爱米的前端小笔记13 小时前
前端八股自学笔记分享—页面布局(二)
前端·笔记·学习·面试·求职招聘