diff --git a/examples/with-request/src/app.tsx b/examples/with-request/src/app.tsx index 2203173ac..3c3851da3 100644 --- a/examples/with-request/src/app.tsx +++ b/examples/with-request/src/app.tsx @@ -1,7 +1,21 @@ import { request as requestAPI } from 'ice'; import { defineRequestConfig } from '@ice/plugin-request/esm/types'; -const requestConfig = { +export async function getAppData() { + try { + return await requestAPI('/user'); + } catch (err) { + console.log('request error', err); + } +} + +export default { + app: { + rootId: 'app', + }, +}; + +export const requestConfig = defineRequestConfig(() => ({ // 可选的,全局设置 request 是否返回 response 对象,默认为 false withFullResponse: false, baseURL: '/api', @@ -50,20 +64,4 @@ const requestConfig = { }, }, }, -}; - -export async function getAppData() { - try { - return await requestAPI('/user'); - } catch (err) { - console.log('request error', err); - } -} - -export default { - app: { - rootId: 'app', - }, -}; - -export const request = defineRequestConfig(() => requestConfig); +})); diff --git a/packages/plugin-request/src/runtime.ts b/packages/plugin-request/src/runtime.ts index 1d56f2f77..bed980034 100644 --- a/packages/plugin-request/src/runtime.ts +++ b/packages/plugin-request/src/runtime.ts @@ -2,9 +2,12 @@ import type { RuntimePlugin } from '@ice/runtime/esm/types'; import { createAxiosInstance, setAxiosInstance } from './request.js'; import type { RequestConfig } from './types'; +const EXPORT_NAME = 'requestConfig'; + const runtime: RuntimePlugin = async ({ appContext }) => { const { appExport } = appContext; - const requestConfig: RequestConfig = (typeof appExport.request === 'function' ? await appExport.request() : appExport.request) || {}; + const exported = appExport[EXPORT_NAME]; + const requestConfig: RequestConfig = (typeof exported === 'function' ? await exported() : exported) || {}; // Support multi configs. if (Array.isArray(requestConfig)) { diff --git a/website/docs/guide/advanced/request.md b/website/docs/guide/advanced/request.md new file mode 100644 index 000000000..5c8426ea4 --- /dev/null +++ b/website/docs/guide/advanced/request.md @@ -0,0 +1,517 @@ +# 网络请求 + +大部分前端应用都会选择通过 HTTP(s) 协议与后端服务通讯。 +ice.js 提供了一套从 UI 交互到请求服务端数据的完整方案,通过切面编程的方式统一了数据请求管理,简化了设置参数、错误处理等逻辑的实现。 + +## 安装 [request 插件](https://www.npmjs.com/@ice/plugin-request) + +网络请求是可选能力,在使用前需要单独安装 `@ice/plugin-request` 插件。 + +```bash +npm i @ice/plugin-request -D +``` + +在配置文件中添加插件: + +```tsx title="ice.config.mts" +import { defineConfig } from '@ice/app'; +import request from '@ice/plugin-request'; + +export default defineConfig(() => ({ + plugins: [ + request(), + ], +})); +``` + +## 目录约定 + +框架约定 `service` 目录用于收敛请求逻辑,目录组织如下: + +```diff + src + ├── models ++├── services // 定义全局数据请求,非必须 ++│ └── user.ts + └── pages + | ├── home + | │ ├── models ++| │ ├── services // 定义页面级数据请求 ++| │ | └── repo.ts + | │ └── components + | ├── about + | │ ├── services + | │ ├── components + | │ └── index.tsx + └── app.ts +``` + +通过调用 `request` 定义数据请求如下: + +```ts title="pages/home/service/repo.ts" +import { request } from 'ice'; + +export default { + // 简单场景 + async getUser() { + return await request('/api/user'); + }, + + // 参数场景 + async getRepo(id) { + return await request(`/api/repo/${id}`); + }, + + // 格式化返回值 + async getDetail(params) { + const data = await request({ + url: `/api/detail`, + params + }); + + return data.map(item => { + return { + ...item, + price: item.oldPrice, + text: item.status === '1' ? '确定' : '取消' + }; + }); + } +} +``` + +## 消费 service + +消费 service 主要有两种方式: + +- 在模型中调用 service:`service` -> `model` -> `view` +- 在视图中调用 service:`service` -> `view` + +### 在模型中调用 service + +> 结合 [状态管理](./store.md) 使用 + +- `service`:约定数据请求统一管理在 services 目录下; +- `model`:约定数据请求统一在 models 里进行调用; +- `view`:最终在视图里通过调用 models 的 effects 的方法触发数据请求。 + +在模型中调用定义好的 service: + +```ts +import userService from '@/services/user'; + +// src/models/user.ts +export default { + state: { + name: 'taoxiaobao', + age: 20, + }, + reducers: { + update(prevState, payload) { + return { ...prevState, ...payload }; + }, + }, + effects: (dispatch) => ({ + async fetchUserInfo() { + const data = await userService.getUser(); + dispatch.user.update(data); + }, + }), +}; +``` + +- 在视图中调用模型方法: + +```tsx +import React, { useEffect } from 'react'; +import store from '@/store'; + +const HomePage = () => { + // 调用定义的 user 模型 + const [userState, userDispatchers] = store.useModel('user'); + + useEffect(() => { + // 调用 user 模型中的 fetchUserInfo 方法 + userDispatchers.fetchUserInfo(); + }, []); + + return <>Home; +}; +``` + +### 在视图中调用 service + +- `service`:约定数据请求统一管理在 services 目录下; +- `view`:最终在视图里通过 useRequest 直接调用 service 触发数据请求。 + +```tsx +import React, { useEffect } from 'react'; +import { useRequest } from 'ice'; +import userService from '@/services/user'; + +export default function HomePage() { + // 调用 service + const { data, error, loading, request } = useRequest(userService.getUser); + + useEffect(() => { + // 触发数据请求 + request(); + }, []); + + return <>Home; +} +``` + +## API + +### request + +request 基于 axios 进行封装,在使用上整体与 axios 保持一致,差异点: + +1. 默认只返回服务端响应的数据 `Response.data`,而不是整个 Response,如需返回整个 Response 请通过 `withFullResponse` 参数开启 +2. 在 axios 基础上默认支持了多请求实例的能力 + +使用方式如下: + +```ts +import { request } from 'ice'; + +async function getList() { + const resData = await request({ + url: '/api/user', + }); + console.log(resData.list); + + const { status, statusText, data } = await request({ + url: '/api/user', + withFullResponse: true + }); + console.log(data.list); +} +``` + +常用使用方式: + +```js +request(RequestConfig); + +request.get('/user', RequestConfig); +request.post('/user', data, RequestConfig); +``` + +RequestConfig: + +```js +{ + // `url` is the server URL that will be used for the request + url: '/user', + // `method` is the request method to be used when making the request + method: 'get', // default + // `headers` are custom headers to be sent + headers: {'X-Requested-With': 'XMLHttpRequest'}, + // `params` are the URL parameters to be sent with the request + // Must be a plain object or a URLSearchParams object + params: { + ID: 12345 + }, + // `data` is the data to be sent as the request body + // Only applicable for request methods 'PUT', 'POST', and 'PATCH' + data: { + firstName: 'Fred' + }, + // `timeout` specifies the number of milliseconds before the request times out. + // If the request takes longer than `timeout`, the request will be aborted. + timeout: 1000, // default is `0` (no timeout) + // `withCredentials` indicates whether or not cross-site Access-Control requests + // should be made using credentials + withCredentials: false, // default + // `responseType` indicates the type of data that the server will respond with + // options are: 'arraybuffer', 'document', 'json', 'text', 'stream' + responseType: 'json', // default + // should be made return full response + withFullResponse: false, + // request instance name + instanceName: 'request2' +} +``` + +更完整的配置请 [参考](https://github.com/axios/axios#request-config)。 + +返回完整 Response Scheme 如下: + +```ts +{ + // `data` is the response that was provided by the server + data: {}, + + // `status` is the HTTP status code from the server response + status: 200, + + // `statusText` is the HTTP status message from the server response + statusText: 'OK', + + // `headers` the HTTP headers that the server responded with + // All header names are lower cased and can be accessed using the bracket notation. + // Example: `response.headers['content-type']` + headers: {}, + + // `config` is the config that was provided to `axios` for the request + config: {}, + + // `request` is the request that generated this response + // It is the last ClientRequest instance in node.js (in redirects) + // and an XMLHttpRequest instance in the browser + request: {} +} +``` + +### useRequest + +使用 useRequest 可以极大的简化对请求状态的管理,useRequest 基于 [ahooks/useRequest](https://ahooks.js.org/zh-CN/hooks/use-request/index) 封装,差异点: + +- 将 `requestMethod` 参数默认设置为上述的 `request`(即 axios),保证框架使用的一致性 +- manual 参数默认值从 `false` 改为 `true`,因为实际业务更多都是要手动触发的 +- 返回值 `run` 改为 `request`,因为更符合语义 + +#### API + +```ts +const { + // 请求返回的数据,默认为 undefined + data, + // 请求抛出的异常,默认为 undefined + error, + // 请求状态 + loading, + // 手动触发请求,参数会传递给 service + request, + // 当次执行请求的参数数组 + params, + // 取消当前请求,如果有轮询,停止 + cancel, + // 使用上一次的 params,重新执行请求 + refresh, + // 直接修改 data + mutate, + // 默认情况下,新请求会覆盖旧请求。如果设置了 fetchKey,则可以实现多个请求并行,fetches 存储了多个请求的状态 + fetches +} = useRequest(service, { + // 默认为 true 即需要手动执行请求 + manual, + // 初始化的 data + initialData, + // 请求成功时触发,参数为 data 和 params + onSuccess, + // 请求报错时触发,参数为 error 和 params + onError, + // 格式化请求结果 + formatResult, + // 请求唯一标识 + cacheKey, + // 设置显示 loading 的延迟时间,避免闪烁 + loadingDelay, + // 默认参数 + defaultParams, + // 轮询间隔,单位为毫秒 + pollingInterval, + // 在页面隐藏时,是否继续轮询,默认为 true,即不会停止轮询 + pollingWhenHidden, + // 根据 params,获取当前请求的 key + fetchKey, + // 在屏幕重新获取焦点或重新显示时,是否重新发起请求。默认为 false,即不会重新发起请求 + refreshOnWindowFocus, + // 屏幕重新聚焦,如果每次都重新发起请求,不是很好,我们需要有一个时间间隔,在当前时间间隔内,不会重新发起请求,需要配置 refreshOnWindowFocus 使用 + focusTimespan, + // 防抖间隔, 单位为毫秒,设置后,请求进入防抖模式 + debounceInterval, + // 节流间隔, 单位为毫秒,设置后,请求进入节流模式。 + throttleInterval, + // 只有当 ready 为 true 时,才会发起请求 + ready, + // 在 manual = false 时,refreshDeps 变化,会触发请求重新执行 + refreshDeps, +}); +``` + +#### 常用使用方式 + +```ts +import { useRequest } from 'ice'; +// 用法 1:传入字符串 +const { data, error, loading } = useRequest('/api/repo'); + +// 用法 2:传入配置对象 +const { data, error, loading } = useRequest({ + url: '/api/repo', + method: 'get', +}); + +// 用法 3:传入 service 函数 +const { data, error, loading, request } = useRequest((id) => ({ + url: '/api/repo', + method: 'get', + data: { id }, +}); +``` + +更多使用方式详见 [ahooks/useRequest](https://ahooks.js.org/zh-CN/hooks/use-request/index) + +### 请求配置 + +在实际项目中通常需要对请求进行全局统一的封装,例如配置请求的 baseURL、统一 header、拦截请求和响应等等,这时只需要在应用的的 appConfig 中进行配置即可。 + +```ts title="src/app.tsx" +import { defineRequestConfig } from '@ice/plugin-request/esm/types'; + +export const requestConfig = defineRequestConfig({ + // 可选的,全局设置 request 是否返回 response 对象,默认为 false + withFullResponse: false, + + baseURL: '/api', + headers: {}, + // ...RequestConfig 其他参数 + + // 拦截器 + interceptors: { + request: { + onConfig: (config) => { + // 发送请求前:可以对 RequestConfig 做一些统一处理 + config.headers = { a: 1 }; + return config; + }, + onError: (error) => { + return Promise.reject(error); + }, + }, + response: { + onConfig: (response) => { + // 请求成功:可以做全局的 toast 展示,或者对 response 做一些格式化 + if (!response.data.status !== 1) { + alert('请求失败'); + } + return response; + }, + onError: (error) => { + // 请求出错:服务端返回错误状态码 + console.log(error.response.data); + console.log(error.response.status); + console.log(error.response.headers); + return Promise.reject(error); + }, + }, + }, +}); +``` + +### 多个请求配置 + +在某些复杂场景的应用中,我们也可以配置多个请求,每个配置请求都是单一的实例对象。 + +```ts title="src/app.tsx" +import { defineRequestConfig } from '@ice/plugin-request/esm/types'; + +export const requestConfig = defineRequestConfig([ + { + baseURL: '/api', + // ...RequestConfig 其他参数 + }, + { + // 配置 request 实例名称,如果不配默认使用内置的 request 实例 + instanceName: 'request2', + baseURL: '/api2', + // ...RequestConfig 其他参数 + } +]); +``` + +使用示例: + +```ts +import { request } from 'ice'; + +export default { + // 使用默认的请求方法,即调用 /api/user 接口 + async getUser() { + return await request({ + url: '/user', + }); + }, + + // 使用自定义的 request 请求方法,即调用接口 /api2/user + async getRepo(id) { + return await request({ + instanceName: 'request2', + url: `/repo/${id}`, + }); + }, +}; +``` + +## 异常处理 + +无论是拦截器里的错误参数,还是 `request` / `useRequest` 返回的错误对象,都符合以下类型: + +```js +const error = { + // 服务端返回错误状态码时则存在该字段 + response: { + data: {}, + status: {}, + headers: {} + }, + // 服务端未返回结构时则存在该字段 + request: XMLHttpRequest, + // 一定存在,即 RequestConfig + config: { + }, + // 一定存在 + message: '' +} +``` + +## 高阶用法 + +### Mock 接口 + +项目开发初期,后端接口可能还没开发好或不够稳定,此时前端可以通过 Mock 的方式来模拟接口,参考文档 [本地 Mock 能力](../basic/mock.md)。 + +### 如何解决接口跨域问题 + +当访问页面地址和请求接口地址的域名或端口不一致时,就会因为浏览器的同源策略导致跨域问题,此时推荐后端接口通过 CORS 支持信任域名的跨域访问,具体请参考: + +- [HTTP 访问控制(CORS)](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS) +- [跨域资源共享 CORS 详解](https://www.ruanyifeng.com/blog/2016/04/cors.html) +- [Using CORS](https://www.html5rocks.com/en/tutorials/cors/) + +### 根据环境配置不同的 baseURL + +大部分情况下,前端代码里用到的后端接口写的都是相对路径如 `/api/getFoo.json`,然后访问不同环境时浏览器会根据当前域名发起对应的请求。如果域名跟实际请求的接口地址不一致,则需要通过 `request.baseURL` 来配置: + +```ts title="src/app.tsx" +import { defineRequestConfig } from '@ice/plugin-request/esm/types'; + +export const requestConfig = defineRequestConfig({ + baseURL: '//service.example.com/api', +}); +``` + +结合[构建配置](../basic/env.md)即可实现不同环境使用不同的 baseURL: + +```shell title=".env.local" +# The should not be committed. +BASEURL=http://localhost:9999/api +``` + +```shell title=".env.prod" +BASEURL=https://example.com/api +``` + +在 `src/app.tsx` 中配置 `request.baseURL`: + +```ts title="src/app.tsx" +import { defineRequestConfig } from '@ice/plugin-request/esm/types'; + +export const requestConfig = defineRequestConfig({ + baseURL: process.env.BASEURL, +}); +``` diff --git a/website/docs/guide/basic/env.md b/website/docs/guide/basic/env.md index 7a0be09f3..ca8a0e38a 100644 --- a/website/docs/guide/basic/env.md +++ b/website/docs/guide/basic/env.md @@ -3,7 +3,7 @@ title: 环境变量 order: 15 --- -ICE 内置通过环境变量实现给构建或运行时传递参数的功能。 +ice.js 内置通过环境变量实现给构建或运行时传递参数的功能。 - 使用 `.env` 文件来配置环境变量 - 配置 `ICE_` 开头的环境变量则会同时暴露到运行时环境中 diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 69e511f3b..ee8ddb465 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -15,11 +15,11 @@ const config = { organizationName: 'alibaba', projectName: 'ice', themeConfig: { - announcementBar: { - id: 'announcementBar-2', - content: 'ice.js 3,不仅是 PC,更适配移动端能力,更多', - isCloseable: true, - }, + // announcementBar: { + // id: 'announcementBar-2', + // content: 'ice.js 3,不仅是 PC,更适配移动端能力,更多', + // isCloseable: true, + // }, navbar, footer, // algolia: {