news 2026/9/30 1:17:04

从原生到 Promise:手写一个实用的 Ajax 封装指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从原生到 Promise:手写一个实用的 Ajax 封装指南

做前端这些年,和 Ajax 打交道的时间几乎占了日常开发的三分之一。你问我为什么?因为页面上所有和服务器打交道的动作,背后都站着这尊大神。原生 Ajax 写起来不难,难的是每个项目、每个页面都复制粘贴那一套又臭又长的 XMLHttpRequest 代码:今天这个接口要设置超时,明天那个接口要处理特殊编码,后天又冒出来一个统一错误弹窗的需求。如果不做代码封装,光是这些重复劳动就能把人的热情磨光。我早期带项目时,就见过一个页面里七八个 axios 风格的请求函数,每个函数都在重复处理 loading、错误提示、状态码判断,看到那场面才意识到,Ajax 请求的 JavaScript 代码封装不是一个“有没有必要”的问题,而是一个“早晚要做”的问题。

这篇文章就从我实际改造一个后台管理系统的经历出发,把 Ajax 请求封装这件事从设计思路到落地实现完整梳理一遍。目标读者是写过原生 Ajax、但还没系统整理过请求层的同学,以及已经用 axios 但想自己搞一个小工具库的进阶选手。我会直接给出可用的代码,也会把里面那些容易踩的坑一并说清楚。

1. 为什么 Ajax 代码一定要封装

1.1 原生 Ajax 的“重复体力活”

如果你在项目中直接写 XMLHttpRequest,大概率会遇到这样的场景:每次请求都要重新声明一个XMLHttpRequest对象,然后绑定onreadystatechange,再判断readyState === 4 && status === 200,最后手动用JSON.parse解析返回字符串。一次两次还行,次数多了,你会发现代码里到处都是重复的结构,而需要改动的地方又分散在各个页面。

举个最简单的原生例子:

function getUser(id) { var xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user?id=' + id); xhr.send(); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if (xhr.status === 200) { var data = JSON.parse(xhr.responseText); renderUser(data); } else { alert('请求失败:' + xhr.status); } } }; }

这段代码本身没有问题,但如果你有十个这样的接口,就要写十份几乎一样的逻辑。更麻烦的是,当产品经理提出“所有接口请求失败时统一弹一个错误提示”时,你要去每个地方改;当要加“请求超时 10 秒自动中断”时,你又要去每个地方改。原生 Ajax 不是不能写,而是不适合在业务代码里大量裸写。

1.2 一次封装能解决哪些问题

封装的核心不是把代码变短,而是把变化集中到一处。我个人的体会是,一个好的 Ajax 封装至少能解决四类问题:

  • 统一入口:所有请求都走同一个函数,调用方只需要关心url、type、data这几个业务参数。
  • 统一错误处理:状态码异常、网络异常、超时都能在一个地方处理,接口失败时不会出现各种风格的弹窗。
  • 统一请求配置:全局的 baseURL、超时时间、请求头、loading 开关都可以通过配置控制,而不是在业务代码里到处塞。
  • 方便扩展:后续要加请求拦截、响应拦截、取消请求、重复提交校验,改动只发生在封装层,不影响业务代码。

说白了,封装就是把“造轮子”的过程集中一次,把“用轮子”的成本降到最低。这也是为什么现在前端项目几乎离不开 axios 这种库的原因——它已经把上面这些事都替我们做完了。

2. 封装前的设计思路与选型

2.1 先想清楚 API 长什么样

动手写代码之前,我习惯先把“调用体验”定下来。只有先明确最终调用方式是怎样的,才能倒推封装函数需要往外暴露什么接口。

我最常用的是对象参数形式:

ajax({ url: '/api/user/list', type: 'GET', params: { page: 1, size: 20 }, data: {}, timeout: 10000 }).then(function(res) { // 成功处理 }).catch(function(err) { // 失败处理 });

之所以用对象参数而不是多个位置参数,是因为对象参数可读性好、可扩展性强。以后要加headers、responseType、cancelToken这些配置时,不用改函数签名,直接在对象里加一个字段就行。当然也可暴露一些快捷方法,比如get(url, params)、post(url, data),内部再转成统一的ajax调用,这样可以满足不同人的习惯。

另外,我还会为一些高频场景做二次封装,比如:

function getUsers(params) { return ajax.get('/api/user/list', params); }

这样业务层面对的就是“语义化”的函数,而不是一堆冷冰冰的 URL 字符串。

2.2 参数设计:约定优于配置

参数越多,维护成本越高,所以封装时一定要有取舍。我把常用的配置项分为三类:

  • 基础参数:url、method、params(URL 查询参数)、data(请求体数据)、headers。
  • 控制参数:timeout(超时时间)、responseType(返回类型)、withCredentials(是否携带 Cookie)。
  • 扩展参数:onUploadProgress(上传进度回调)、cancelToken(取消请求标识)、beforeSend(发送前拦截)。

这些参数并不是全部都要暴露,我觉得最核心的就是url、method、params、data、timeout、headers这六个。其他参数按需添加,没必要一开始就把功能堆满,否则封装层本身就变成了一个复杂系统,反而失去了轻量封装的意义。

默认值也很关键。比如method默认'GET',timeout默认0(不超时),headers默认空对象。这些默认值放在一个config对象里,方便后续修改。

2.3 用 Promise 还是回调?

早期 Ajax 封装常用回调风格:

ajax({ url: '/api', success: function(data) {}, error: function(err) {} });

这种写法在简单场景下没问题,但一旦需要串行请求、并行请求,回调嵌套就会变得很难看。所以我强推 Promise 风格。

用 Promise 包裹XMLHttpRequest其实非常简单,只需要把异步逻辑放进new Promise(function(resolve, reject) { ... })里,成功走resolve,失败走reject。好处是可以用then/catch链式处理,也可以用async / await写出同步风格的代码:

async function loadData() { try { const data = await ajax.get('/api/data'); render(data); } catch (err) { console.error(err); } }

如果你的项目需要兼容老浏览器,Promise 本身可能不存在,那时候引入一个es6-promisepolyfill 就够了。这个成本相比带来的代码收益,非常划算。

3. 手写一个可用的 Ajax 封装

3.1 基础版:基于 XMLHttpRequest 的封装

先写一个最基础、但能直接用的版本。下面这段代码我习惯放在utils/request.js里,项目所有请求都走它。

// utils/request.js (function (global) { var DEFAULT_CONFIG = { baseURL: '', method: 'GET', timeout: 0, headers: {} }; function formatUrl(url, params) { if (!params) return url; var queryStr = Object.keys(params) .filter(function (key) { return params[key] !== undefined && params[key] !== null; }) .map(function (key) { return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); }) .join('&'); return url + (queryStr ? (url.indexOf('?') > -1 ? '&' : '?') + queryStr : ''); } function parseResponse(xhr) { var contentType = xhr.getResponseHeader('Content-Type') || ''; if (contentType.indexOf('application/json') > -1) { try { return JSON.parse(xhr.responseText); } catch (e) { return xhr.responseText; } } return xhr.responseText; } function ajax(options) { var config = {}; for (var key in DEFAULT_CONFIG) { config[key] = DEFAULT_CONFIG[key]; } if (options) { for (var key in options) { config[key] = options[key]; } } return new Promise(function (resolve, reject) { var xhr = new XMLHttpRequest(); var method = (config.method || 'GET').toUpperCase(); var url = config.baseURL + formatUrl(config.url, config.params); xhr.open(method, url, true); xhr.timeout = config.timeout; // 设置请求头 var headers = config.headers || {}; Object.keys(headers).forEach(function (key) { xhr.setRequestHeader(key, headers[key]); }); xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return; if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) { resolve(parseResponse(xhr)); } else { reject({ status: xhr.status, statusText: xhr.statusText, response: parseResponse(xhr) }); } }; xhr.onerror = function (e) { reject({ type: 'error', message: '网络请求异常', event: e }); }; xhr.ontimeout = function (e) { reject({ type: 'timeout', message: '请求超时', event: e }); }; var body = null; if (method === 'POST' || method === 'PUT' || method === 'PATCH') { var contentType = headers['Content-Type'] || ''; if (contentType === 'application/json') { body = JSON.stringify(config.data || {}); } else if (contentType === 'application/x-www-form-urlencoded') { body = formatUrl('', config.data || {}).slice(1); } else if (config.data instanceof FormData) { body = config.data; } else { body = config.data || null; } } xhr.send(body); }); } // 暴露 get/post 方法 ajax.get = function (url, params, options) { options = options || {}; options.url = url; options.method = 'GET'; options.params = params; return ajax(options); }; ajax.post = function (url, data, options) { options = options || {}; options.url = url; options.method = 'POST'; options.data = data; return ajax(options); }; if (typeof module !== 'undefined' && module.exports) { module.exports = ajax; } else { global.$http = ajax; } })(window);

这一段代码的核心逻辑很简单:formatUrl负责拼接 URL 查询参数,parseResponse负责根据响应头决定是否 JSON 解析,ajax内部用 Promise 包裹 XHR 并统一处理成功和失败。

有一个容易被忽略的点:xhr.timeout属性设为0表示不超时,设成大于0的毫秒数才生效。而且ontimeout事件必须在onreadystatechange之外单独绑定,否则超时后还会触发 readyState 变化,导致结果混乱。

3.2 进阶版:超时、取消与请求拦截

基础版解决的是“能用”,进阶版解决的是“好用”。实际项目里我最常遇到的需求有三个:请求超时可控、重复提交可取消、请求前后可做统一拦截。

先看超时,直接给options加一个timeout字段,并在 XHR 上设置即可。上面基础版已经包含了,这里不再重复。重点说取消请求。

取消请求对 XHR 来说,最简单的方式就是调用xhr.abort()。但问题是,同一个页面可能有多个请求同时进行,怎么知道取消哪一个?我常用的做法是给每个请求生成一个id,用一个对象保存当前活跃的 XHR 引用,需要取消时按id找到并调用abort()。

var activeXhr = {}; var requestSeq = 0; function ajax(options) { var seq = ++requestSeq; // ... 前面的配置、xhr 创建 ... activeXhr[seq] = xhr; xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return; delete activeXhr[seq]; // 成功/失败处理 }; // 提供 cancel 方法 ajax.cancel = function (seq) { if (seq && activeXhr[seq]) { activeXhr[seq].abort(); delete activeXhr[seq]; } }; return promise; }

这里seq也可以换成/api/xxx这种语义化标识,方便在控制台排查。

再来看请求拦截。拦截器在 axios 里很常见,它的作用是在请求正式发出去之前统一修改配置,在响应回来之后统一处理数据。我们自己的封装也可以加一个简单的中间件机制:

ajax.before = function (fn) { ajax._before = fn; }; ajax.after = function (fn) { ajax._after = fn; }; // 在 send 之前 if (typeof ajax._before === 'function') { config = ajax._before(config) || config; } // 在 resolve/reject 之前 if (typeof ajax._after === 'function') { response = ajax._after(response) || response; }

这样做的好处是,比如你想在每次请求前给 header 加一个 token,只需要全局设置一次:

ajax.before(function (config) { config.headers = config.headers || {}; config.headers['Authorization'] = localStorage.getItem('token'); return config; });

不需要改动任何业务代码。

3.3 基于 fetch 的封装(备选方案)

如果你负责的是一个纯现代浏览器项目,没有 IE 兼容压力,那么基于fetch封装会更轻量。fetch 本身就是 Promise 的,代码结构可以更简洁:

function request(url, options) { return fetch(url, options).then(function (res) { if (!res.ok) { throw new Error('HTTP ' + res.status); } return res.json(); }); }

但 fetch 有两个坑需要注意:第一,它默认不会携带跨域 Cookie,需要手动设置credentials: 'include';第二,它对 HTTP 状态码不会自动 reject,即使请求返回 404、500 也只会 resolve,必须自己在then里判断res.ok。所以我本人的习惯是项目统一用 XHR 封装,除非是纯前端 demo 或者明确不需要兼容老环境的项目。

4. 关键细节:编码格式、参数处理与常见坑

4.1 请求编码格式怎么设置

很多人问 Ajax 请求怎么设置编码格式,其实编码格式主要由请求体Content-Type和 URL 参数编码方式决定。

先说 GET 请求。GET 参数是拼在 URL 后面的,浏览器会自动对 URL 做百分号编码,但中文、特殊字符在不同浏览器上可能表现不一致,所以建议在拼接前用encodeURIComponent手动编码。我上面formatUrl里就是这么做的,这样不管参数是中文还是空格,都能保证后端拿到的是正确值。

再说 POST 请求。常用三种编码格式:

  • application/x-www-form-urlencoded:表单提交格式,参数格式是key1=value1&key2=value2,后端通常用request.form接收。
  • application/json:JSON 字符串格式,最常用,后端用request.get_json()或@RequestBody接收。
  • multipart/form-data:文件上传格式,通常用FormData对象作为请求体。

我的封装里根据headers['Content-Type']来决定body的序列化方式。这块新手最容易踩坑:调用ajax.post时什么都不设置,默认Content-Type为空,于是config.data直接以 plain text 格式发送,后端解析不了参数。我的建议是,在服务器统一接收 JSON 的情况下,直接在ajax.post里默认设置Content-Type: application/json,除非业务明确要传表单。

4.2 参数赋值与序列化技巧

“给 ajax 请求参数赋值”这件事听起来很简单,但实际开发中经常要处理嵌套对象、数组、空值过滤。比如一个筛选条件:

var formData = { keyword: 'hello', category: 0, tags: ['js', 'ajax'], sort: '', page: 1, size: 10 };

如果直接把sort: ''传给后端,后端可能当成一个无意义的空字符串参数;如果直接把category: 0漏掉,后端又可能拿不到 0 这个值。所以我封装时加了一个过滤逻辑,只保留undefined和null,不丢弃0和空字符串,具体过滤规则要看业务需要。

序列化嵌套对象时,我习惯用递归处理:

function serialize(data, prefix) { if (!data || typeof data !== 'object') return data; var result = {}; Object.keys(data).forEach(function (key) { var fullKey = prefix ? prefix + '[' + key + ']' : key; if (Array.isArray(data[key])) { data[key].forEach(function (item, index) { result[fullKey + '[]'] = item; }); } else if (typeof data[key] === 'object') { Object.assign(result, serialize(data[key], fullKey)); } else { result[fullKey] = data[key]; } }); return result; }

这个函数在表单提交、URL query 拼接时都能用。需要注意的是,序列化结果里 key 可能带[]或[child],后端框架一般都能正确解析为数组或对象。

顺带说一句,热词里那个“js 判断字符串是否包含”,我经常在参数校验和 URL 判断中用到。比如判断一个地址是不是外链:

function isExternal(url) { return url.indexOf('http://') === 0 || url.indexOf('https://') === 0; }

如果你的环境支持 ES6,用String.prototype.includes更简洁:

function isExternal(url) { return url.includes('http://') || url.includes('https://'); }

这种工具函数虽然小,但能和 Ajax 封装配合得很好,比如在请求拦截里校验 URL 合法性、在参数序列化时过滤非法字段。

4.3 状态码判断与返回数据类型

后端接口的状态码判断有两种层次:HTTP 的状态码,以及业务层自己定义的 code。我的封装里会先把 HTTP 状态码判断处理好,然后预留一个地方给业务层继续判断业务 code。

XHR 的status在 2xx 和 304 时都可以视为成功,这一点常被忽略。有些文件接口会返回 304 Not Modified 表示缓存有效,如果你只判断>= 200 && < 300,就会把 304 当作失败。我的基础版里特意加上了|| xhr.status === 304。

返回数据类型的处理也很关键。封装里我通过响应的Content-Type来决定是否用JSON.parse。如果后端返回的是字符串、二进制流,就不能强行 JSON.parse。即使判断为application/json,也建议用try/catch包一层,防止后端给了空字符串。

顺带把“js json 转换成数组”一点就透。很多时候后端返回的是:

{ "code": 0, "data": { "list": [1, 2, 3] } }

要在前端拿到数组,直接:

var list = response.data.list;

如果是 JSON 字符串形式,先JSON.parse(str)再取字段。本质上不是“json 转换成数组”,而是“从 json 结构中取出数组字段”。

5. 实际项目中的组合应用

5.1 配合 ECharts 做动态图表

“原生 js、jquery、ajax、echarts 结合制作网页”是一个很典型的场景。我在很早的项目里就用过把封装好的ajax.get与 ECharts 联合起来做数据可视化。

操作步骤一般是这样:

  1. 页面加载时调用ajax.get('/api/trend', { start: '2025-01-01', end: '2025-01-31' })。
  2. 拿到响应后提取dates和values数组。
  3. 初始化 ECharts 实例并setOption渲染图表。
  4. 切换筛选条件时重新调用同一个函数,更新图表数据。

关键点在于请求函数返回 Promise,可以在then里直接写渲染逻辑,不用回调嵌套。如果请求失败,配合封装的统一错误处理,页面不会出现一堆红叉。

5.2 与 jQuery 混用时的注意点

项目里如果已经引入了 jQuery,很多人会直接用$.ajax。但有些场景可能不想引入 jQuery,或者团队想逐步迁移,这时候自己的轻量封装就是一个过渡方案。

混用时有两点需要注意:

  • 返回值类型:jQuery 的$.ajax返回的是 jqXHR 对象,它本身是 promise-like 的,但不是标准 Promise。如果和async/await混用,语法没问题,但行为上可能有些差异。自己封装的返回标准 Promise,兼容性更好。
  • 全局配置:如果项目里同时存在$.ajax和自定义$http,两套配置、两套错误处理并存,容易混乱。建议定一个约定:新代码统一走$http,老代码慢慢迁移。

5.3 开源代码封装可以借鉴什么

说到“开源代码封装”,前端最值得参考的就是 axios。它并不是什么高深莫测的库,核心也还是对 XHR 或 fetch 的封装,但它把“拦截器”、“取消请求”、“默认配置”、“适配器”这些设计拆得非常清楚。

我自己在实现轻量封装时,就借鉴了 axios 的拦截器模式,但只在内部保留了before和after两个钩子,并没有做成完整链路。因为对于中小型项目,过多的抽象反而增加维护成本。开源库的思路可以参考,但要结合自己的项目体量做减法。

如果你要写一个自己的开源封装,建议最起码把配套文档写好,README 里给出快速开始和完整配置项说明。代码本身的可用性是一方面,别人能看懂、能改,才是开源协作的核心价值。

6. 常见问题与排查技巧实录

6.1 中文乱码问题

中文乱码是最常见的问题之一。它通常不是前端 CSS 或页面编码导致的,而是请求或响应过程中的字符集不一致。

排查步骤我一般这样走:

  • 先确认前端页面本身是 UTF-8 编码,<meta charset="UTF-8">要写在 head 最前面。
  • 再确认请求头中的Content-Type是否带charset=UTF-8。如果是 JSON 格式,通常设置Content-Type: application/json; charset=UTF-8。
  • 最后看后端是否按 UTF-8 解析请求体。如果后端默认 ISO-8859-1,那前端怎么改都白搭,只能在服务端过滤器和连接字符串层面统一编码。

我的封装里,POST JSON 时会默认设置Content-Type: application/json; charset=UTF-8,这样能省掉不少麻烦。

6.2 请求重复发送与重复提交

用户连续点击按钮,同一个请求被触发多次,这是用 Ajax 最典型的问题。我常用的解决办法是“请求锁”:

var isLoading = false; function submitData(data) { if (isLoading) return; isLoading = true; ajax.post('/api/save', data) .then(function () { // 成功逻辑 }) .finally(function () { isLoading = false; }); }

这是最直观的防抖方式。如果希望做到全局级别,可以在封装层加一个 key 维度的“进行中”标记,比如指定一个requestKey,在请求发出时记录,请求完成时清除,重复请求直接忽略或取消上一个。

6.3 跨域问题

跨域是 Ajax 绕不开的话题。如果你在控制台看到No 'Access-Control-Allow-Origin' header is present,说明后端没有开启 CORS。

常见解决方案有三种:

  • 后端开启 CORS:属于最标准的方式,在响应头加Access-Control-Allow-Origin。
  • 本地开发用代理:前端框架的 devServer 可以配置 proxy,把/api转发到后端地址。
  • JSONP 兜底:老项目里用过,但它只支持 GET,而且需要后端配合返回callback包裹的脚本。

我的建议是优先 CORS,开发环境用代理。不要在业务层硬拼跨域,那样只会越搞越复杂。

6.4 IE 兼容与缓存问题

老浏览器项目里,Ajax 有一个非常经典的坑:GET 请求结果被浏览器缓存,导致接口更新后页面数据不变。解决办法是在 URL 上追加一个时间戳:

function bustCache(url) { var separator = url.indexOf('?') > -1 ? '&' : '?'; return url + separator + '_ts=' + new Date().getTime(); }

我的封装里可以在formatUrl后自动判断是否追加这个参数,但默认建议关闭,避免影响 CDN 缓存。另外 IE9 及以下不支持XMLHttpRequest,要用ActiveXObject('Microsoft.XMLHTTP')做兼容,现代项目基本不用考虑这个,但如果写通用工具库,还是要留意。

还有一个 IE 的坑:默认最多只能对同一个域名发起 6 个并发请求,超出部分会排队。如果你在页面里同时触发多个图片上传和接口请求,有可能出现某个请求长时间不返回的假象。这时候要主动控制并发数量,或者用队列方式逐个发送。

7. 写到最后:一点个人体会

项目里的 Ajax 封装不是越大越好,而是越顺手越好。我从最开始复制粘贴原生代码,到写一个又长又复杂的请求库,再到现在收敛成一个只有几十行、但刚好满足项目需求的小工具,整个过程最大的体会就是:封装的边界应该根据团队习惯和业务场景来定。你可以在封装里预留拦截器、取消请求、统一错误处理这些能力,但不要在业务代码还没复杂到那个程度时,就强行引入一整套重量级抽象。

最后再贡献一个非常实用的小技巧:调试 Ajax 封装时,可以在before拦截器里把当前请求的 method、url、params、data 全部console.log出来,接口联调阶段这个习惯能帮你省下一大半排查时间。所有请求都打同一个日志前缀,比如[request],控制台一搜就全出来了。这个不涉及任何黑魔法,但确实是我每次在项目中落地封装后,觉得最值回票价的一个操作。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 1:17:03

嵌入式Linux ASoC音频驱动:Codec驱动与音频控件核心实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:16:39

海光C86嵌入式CPU国产化迁移实战:从评估到落地的完整路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:16:28

ESP8266+Arduino IDE+巴法云:零基础物联网远程控制实战教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:16:23

泰勒公式展开全攻略:常见函数泰勒级数推导与实战应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:16:05

uni-app小程序接入百度云人脸识别:从录入到1:N搜索全流程实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:16:05

ZYNQ传统方式移植Linux:从FSBL到根文件系统的完整启动链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华