![]() |
有情有义的枇杷 · Vue中引入jQuery - 永不言弃! ...· 7 月前 · |
![]() |
面冷心慈的牛腩 · 编程记录2_vscode + vcpkg ...· 1 年前 · |
![]() |
逃课的充电器 · 关于 electron-builder ...· 1 年前 · |
![]() |
酒量小的柳树 · jQuery insert ...· 2 年前 · |
距离 father 上一个 major 版本的发布已经过去了近 3 年,在这期间打包方案、NPM 社区、组件研发流程等方面都发生了不小的变化,加上 father 项目已经堆积了大量 issue、且有很大一部分是由于技术方案陈旧导致的,如果你还在使用 father,一定是深有体会。为了给开发者带来更好的打包体验,在经历几个月的研发工作后,我们终于带来了 father 4 的 RC 版,来看看它有哪些特性。双模式构建father 4 支持 Bundless 和 Bundle 两种构建模式。Bundless 指把所有源码文件单独编译、平行输出做发布。在 father 4 中,输出 ESModule 及 CommonJS 产物时会使用 Bundless 构建模式。 (来源:blog.slashgear.dev/should-snowpack-replace-webpack )Bundle 指把源码按 entry 打包成 1 个或多个文件做发布,也就是 Webpack 的打包模式。在 father 4 中,输出 UMD 及依赖预打包产物时会使用 Bundle 构建模式。 (来源:webpack.js.org )多 Bundless 编译核心在 Bundless 模式下,father 4 在 RC 版提供了两种编译核心,分别是 Babel 和 ESBuild。Babel 的特点是宽容度高、兼容性强,配合插件可以做很多事情,通常更适用于面向浏览器平台的源码构建,所以 father 4 会在 platform 配置项为 browser 或输出 ESModule 时默认使用 Babel 做源码编译。(来源:babeljs.io )ESBuild 的特点就是快、非常快,但产物最低能兼容到 ES6,通常更适用于面向 Node.js 平台的源码构建,所以 father 4 会在 platform 配置项为 node 或输出 CommonJS 时默认使用 ESBuild 做源码编译。 (来源:esbuild.github.io )开发者也可以结合项目实际情况、使用 transformer 配置项指定要使用的编译核心。除此之外,Bundless 的编译核心是可扩展的,未来 father 4 还计划支持 SWC 等编译核心,给开发者提供更多选择。依赖预打包流行的前端开发框架(比如 Umi、Next.js、Vite)都做了依赖预打包,father 4 基于 Vercel 的 ncc 和 Microsoft 的 api-extractor 带来了开箱即用的依赖预打包能力,它能给项目带来三大好处。一是 NPM 包发布后安装体积更小、速度更快。由于 NPM 包的依赖树往往十分复杂,我们依赖的只是 A 这棵树,安装的却可能是一片森林,但却不是每棵树都会被用到。而依赖预打包能将依赖打包成单个文件,让一片森林压缩成一棵大树,再跟随我们的项目一起发布 NPM,达到体积更小、速度更快的目的。二是不担心三方依赖更新引起 Bug。相信大家都碰到过『昨天还好好的,今天怎么就挂了』的情况,即便底层依赖的更新再小心翼翼,也无法验证上层项目的所有场景,更何况有些层依赖发版还不一定遵循 semver 的约定。而依赖预打包可以将『底层依赖更新』这件事由被动变为主动,需要更新的时候我们再重新预打包一份便是,从而大大提升项目稳定性。三是** NPM 包发布后安装 0 warning**。由于 NPM 安装会进行 peerDependencies 的校验,可能会产生大量的警告,依赖预打包后不需要安装,用户在使用我们的 NPM 包时也不会再看到警告信息了。而我们作为 NPM 包开发者,在开发阶段安装的时候仍然是可以审查这些警告信息的。不过,由于依赖中可能存在 dynamic require/import 等复杂的情况,现阶段不一定每个依赖都能顺利打包,father 4 会在 RC 阶段持续优化,将这项功能变得更加好用。试一试如果 father 4 RC 成功吸引了你的注意,不妨创建一个新项目玩一玩,感受一下 father 的新特性:$ mkdir test-father-4 $ npx create-father test-father-4 复制代码等待依赖安装完毕后,执行构建:# build watch $ npm run dev # build $ npm run build 复制代码如果你的新项目是框架或者工具,也可以安装某个三方依赖到 devDependencies,再参考 配置项文档 配置 prebundle,体验依赖预打包:$ npm run build:deps 复制代码后续规划RC 阶段只是 father 4 的第一波特性,在正式版发布之前,我们还将完成如下特性:SWC Bundless 编译核心father doctor 命令,自动诊断当前项目中存在的问题,给开发者提示(例如未把产物目录包含在 NPM 包发布目录中、分析产物大小等)father g 命令,提供工程原子化能力的生成,例如 test、commitlint 等插件接入能力,给项目提供定制化的入口,并发展插件生态……欢迎大家在项目中尝鲜 father 4 RC,也可以在 father 4 项目中与我们反馈问题、分享感受:github.com/umijs/fathe… 如果你也对组件/库打包有兴趣或有经验,欢迎 参与到 father 4 的建设中,一起将它变得更好 ❤️最后,感谢参与打造 father 4 的 Contributors:xiaohuoni、xierenyuan、cnyballk、zzcan、lylwanan、fz6m
在数据流章节中我有一段这样的描述:为什么都不是最佳实践了,我还要一直提 dva因为纯 hooks 的数据流方案,存在天然的局限性,因为 react hook 只能用在 react 的上下文环境中,但是在 Umi 中我们还有一些环节是不在 react 的上下文的,比如如果我们要前置判断用户登录情况,或者提前获取用户可访问菜单数据,或者其他的一些项目前置数据,我们都需要“上升”我们的数据流方案。其实这一段我是想描述在 Umi 项目中还存在不在 React 生命周期中的数据流管理时机,所以我们依旧需要 dva 来管理我们的项目数据,其实最主要的点还是 dva 是一个很流行的数据流管理方案,在我们的项目中有很长的使用情况,团队内对它都比较熟悉,因此就算出现了其他可替代的方案,我们依旧会选择使用较为“古老”的方案。西门吹风凉飕飕 提到的疑惑,其实在第 10 课使用 Umi 配置,定制化你自己的 Umi 框架中,我们讲解 Umi 中的运行时配置 - render 时,就已经演示过代码了。那时候,我们还没讲解到数据流和请求这些,今天我们就将这几个方案串联起来。升级插件将 @alita/plugins 升级到 3.0.3,因为我们添加了一个获取 Dva app 的 Api ,这使你能够在任何的 js 环境中继续使用 Dva"@alita/plugins": "3.0.3", 复制代码`@alita/plugins` 和 `@umijs/plugins` 中的 dva 插件有什么差别吗?其实这两个插件现在的功能是一致的,alita 中的 Dva 插件就是从 umijs 中复制出来的,唯一的不同是,alita 中的插件,添加了约定的 Dva module 类型定义。可以更加规范的在 Typescript 中使用 Dva。Mock 数据在第 16 课 Umi 项目中的菜单与权限 中,我们讲解了 Umi 项目中的菜单与权限,我们使用了unaccessible 数组来管理我们的菜单,所以我们想将它转移到本地的“服务端”。新建 Mock 文件 mock/accessible.tsexport default { "POST /api/rule": { success: true, data: ["/hooks", "/useEffect", "/usemodel", "/useState"], 复制代码如果你不知道这有什么用,请阅读第 18 课 Umi 中使用 mockjs 完善前后端分离增加配置import { defineConfig } from "umi"; export default defineConfig({ plugins: [ // 其他插件不用删除,这里只是简略展示 require.resolve("@alita/plugins/dist/dva"), // 其他配置不用删除,这里只是简略展示 dva: { enableModelsReExport: {}, 复制代码enableModelsReExport 配置就是 alita 中的 Dva 插件特有的,会将 module 文件中的 State 类型导出,这有个要求,每个 module 必须写明 State 的类型,不然程序就会报错。通过约定,我们可以很方便的解决问题。当然了如果你觉得这个功能你不需要,你可以不开启这个配置,或者直接使用 umijs/plugins 中的 Dva 插件。api.config.dva?.enableModelsReExport ? models .map((model: { file: string; namespace: string }) => { const { file, namespace } = model; // prettier-ignore // export type { IndexModelState } from '/Users/xiaohuoni/next-alita-app/src/models/index'; return `export type { ${namespace.replace(/( |^)[a-z]/g, (L) => L.toUpperCase())}ModelState } from '${winPath(file.replace(extname(file), ''))}';`; .join('\r\n') : '' 复制代码添加 Dva module 文件新建 Dva module 文件 src/models/global.tsimport { Reducer } from "umi"; export interface GlobalModelState { unaccessible: string[]; export interface GlobalModelType { namespace: "global"; state: GlobalModelState; reducers: { save: Reducer<GlobalModelState>; const GlobalModel: GlobalModelType = { namespace: "global", state: { unaccessible: [], reducers: { save(state, action) { return { ...state, ...action.payload, export default GlobalModel; 复制代码注意以上内容必须的是 GlobalModel 对象,剩余部分都是为了更好的用类型去定义和规范 GlobalModel 对象。在 render 中发起请求在运行时配置中 src/app.ts 的 render 中发起请求,如果你不知道 render 是啥,请翻阅第 10 课使用 Umi 配置,定制化你自己的 Umi 框架。import { request, getDvaApp } from "umi"; export function render(oldRender: any) { request("/api/accessible").then(({ data }) => { const app = getDvaApp(); app?._store.dispatch({ type: "global/save", payload: { unaccessible: data }, oldRender(); 复制代码这里我们通过 getDvaApp 获取到当前项目中的 Dva app,然后使用 _store 上的 dispatch 发起一个 action 将数据更新到 global modules 中。将 module 数据绑定到页面上将 global 的数据,绑定到全局布局上, src/layouts/index.tsx:import { connect } from "umi"; import type { ConnectProps, GlobalModelState } from "umi"; interface AppProps extends ConnectProps { global: GlobalModelState; const App: React.FC<AppProps> = ({ global }) => { const { unaccessible } = global; return (<></>) export default connect(({ global }: { global: GlobalModelState }) => ({ global, }))(App); 复制代码以上操作就将页面和 module 进行了双向绑定,只要 global 数据发生变化,就会促使页面进行重绘。我们只需要取出 global 中的 unaccessible 代替原来“写死”的 unaccessible 即可。总结以上操作,看起来比较繁琐,但是如果你对各个概念都有了一定了解,那阅读起来就会很轻松,觉得逻辑非常的清晰。如果你有任何疑问,可以去看看前面的课程,也可以在评论区和我互动。你应该可以从我的行文内容看出来,我是没有任何“存稿”的,跟这个系列文章,有点类似半直播的方式。我觉得这比我自己“埋头苦干”,要有趣的多,也希望你会喜欢。
上节课我们介绍了如何在 Umi 项目中引入 mockjs 来快速助力和完善前后端开发分离流程。但是 lucaslz 朋友还是觉得太麻烦了,我觉得可能有同样想法的朋友还不少。首先这里需要一个前提条件,服务端必须通过 swagger 提供给前端 api 的文档,如果你们的前端是通过 world 或者聊天窗口直接发接口文档给你的,那你用不了这个方案。在 Umi 的项目中,我们有一个最佳实践的推荐目录 src/service 目录,用来存放与服务端发起的请求,或者说就是接口文档的前端代码翻译。这在工作中,需要写很多重复的样板代码,虽然早起我们也通过微生成器等工具来简化了我们的工作,但是在真实的开发工作中,涉及到接口的变更,我们也需要人工的去迭代这些样板文件。这些工作其实会站到将近四分之一的工作量。因此我们就设想着能不能通过后端模型直接生成对应的数据,甚至生成前端需要的代码。其实早在3年前我们就意识到了这个问题,并且开始“偷懒”。我们建立了一个工具,叫做米莱狄,它能够通过 swagger 的 url 自动化生成 service 文件和 mock 文件。这很好用,但是由于那时候我们是开源社区小透明,所以知道这工具的人很少,其实在 alitajs 社区中还有很多类似好用的工具,以后有机会我会分享给更多的朋友。今天要分享的方案,是在 ant design pro 中使用到的 openAPI 插件。openAPI 对于后端是有一定要求的,但是工作量远远小于维护一个文档的成本,如果维护一个文档,那么每次更新完代码就需要重新编辑一遍文档。而使用 openAPI 的方式只要接入 swagger 然后做一些配置就可以生成一个界面,如果你使用的是 python 或者是 java,那么接入会变得异常简单。详细的接入步骤可以看 swagger 的官方文档。这里主要介绍前端如何使用。后端接入完成 swagger 之后,我们可以访问 swagger 生成的文档,一般来说都是 http://localhost:8080/swagger-ui.html,访问页面我们可以拿到一个 openAPI 的规范文件。使用安装插件pnpm i @umijs/max-plugin-openapi swagger-ui-react 复制代码配置然后在 config/config.ts 中配置 openAPI 的相关配置。plugins: [ // ... 其他插件 // 这是当前的目录,最近正在调整这个插件,如果你这么写报错了,请访问官网查看最新用法 require.resolve("@umijs/max-plugin-openapi/dist/openapi"), openAPI: { // 这里使用服务端提供的url schemaPath: "https://gw.alipayobjects.com/os/antfincdn/M%24jrzTTYJN/oneapi.json", mock: true, 复制代码配置命令然后在 package.json 中配置 scripts 命令。"scripts": { "start": "umi dev", "openapi": "umi openapi", "build": "cross-env ANALYZE=1 umi build" 复制代码执行命令pnpm openapi 复制代码效果执行日志如下:> umi4-course@1.0.0 openapi /Users/congxiaochen/Documents/umi4-course > umi openapi info - Using Request Plugin Using openapi Plugin [openAPI]: ✅ 成功生成 service 文件 [openAPI]: ✅ 生成 mock 文件成功 [openAPI]: execution complete 复制代码自动生成如下文件:不仅仅是代码是不是觉得很神奇了,开始心动了,但是其实这个插件给你的不仅仅是代码,还有文档,我相信有过真实开发经历的朋友,一定都遇到过一个问题,在本地连调的时候,服务端重启了,你需要等他重启,5-10分钟时最少的了。有些朋友回想说这不就是合理摸鱼时间吗?但是我觉得只有主动摸鱼才叫摸鱼,被动摸鱼,那就是“卡壳”。这时候你就可以在开发环境中访问 /umi/plugin/openapi 路由看到接口文档页面了。实现1、通过 http 请求 openAPI 配置中的 schemaPath 地址。openAPI: { schemaPath: "https://gw.alipayobjects.com/os/antfincdn/M%24jrzTTYJN/oneapi.json", 复制代码2、将请求回来的参数保存在临时文件 node_modules/umi_open_api/umi-plugins_openapi.json 中。umi-plugins_openapi.json{ "openapi": "3.0.1", "info": { "version": "1.0.0" "servers": [ "paths": { "/api/currentUser": { "get": { "tags": [ "api" "description": "获取当前的用户", "operationId": "currentUser", "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CurrentUser" "401": { "description": "Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" "x-swagger-router-controller": "api" "components": { "schemas": { 复制代码3、生成临时的页面文件 src/.umi/plugin-openapi/openapi.tsximport { useState } from "react"; import SwaggerUI from "swagger-ui-react"; import "swagger-ui-react/swagger-ui.css"; const App = () => { return (<SwaggerUI url={`/umi-plugins_openapi.json`} />); export default App; 复制代码4、通过中间件将 /umi-umi-plugins_openapi 映射到临时文件中api.addBeforeMiddlewares(() => { return [serveStatic('umi-plugins_openapi')]; 复制代码5、为 Umi 手动添加一个可访问的路由api.modifyRoutes((routes) => { routes['umi/plugin/openapi'] = { path: '/umi/plugin/openapi', absPath: '/umi/plugin/openapi', id: 'umi/plugin/openapi', file: 'src/.umi/plugin-openapi/openapi.tsx', return routes; 复制代码到此文档展示的功能完成。6、定义 openapi 命令api.registerCommand({ name: 'openapi', fn: async () => { const openAPIConfig = api.config.openAPI; genAllFiles(openAPIConfig); 复制代码这使得我们可以通过执行 umi openapi 来执行一些 node 操作,比如上面的 genAllFiles7、 定义 genAllFiles 函数找到生成文件的路径,比如 mock 文件和 services 文件的等,然后根绝 swagger 的内容生成对应的文件,实现较长,这里就不贴代码了,感兴趣的朋友可以查看umi-preset-pro,这是我为 ant design pro@next 的发布,创建的一个 presets。后续里面会有其他 pro 专用的插件收录,感兴趣的朋友可以关注一下。源码归档感谢阅读,关于 Umi 如果你有任何想了解的内容,可以多在评论区和我交流哦。
在现在习惯的开发流程中,特别是快速交付流程里面,我们通常会在开始动工之前通过约定好 API 接口,然后前端通过使用 Mock 数据在本地模拟出 API 返回的正确的数据结构,完成全部的前端逻辑开发,然后在项目上线前修改请求路径到后端,如果双方都严格按照最开始的约定进行,并且在此期间没有任何的业务流程修改的话,那前端可能只需要简单的请求路径,就可以完成前后端连调工作。在上一节课的练习中,我们采用了更原始的方法,直接在代码中写死数据,还要是一个列表,我们可以通过循环的方式来取巧生成。但是如果是一个对象,并且是一个大对象的话,那我们要么都用“11111”来渲染我们的整个页面,要么就要花大量的时间去编写“假数据”。Umi 提供了开箱即用的 Mock 功能,能够用方便简单的方式来完成 Mock 数据的设置。Mock 约定目录Umi 约定 /mock 目录下的所有文件为 Mock 文件,每一个 mock 文件都会返回一个 default 对象,例如这样的目录结构:. ├── mock ├── todos.ts ├── items.ts └── users.ts └── src └── pages └── index.tsx 复制代码则 /mock 目录中的 todos.ts, items.ts 和 users.ts 就会被 Umi 视为 Mock 文件 来处理。值得注意的是 mock 目录下的文件的文件名对真正的 mock 服务不会产生任何的影响,所以你可以仅仅从业务的分类的角度来组织他们,甚至你讲所有的 mock 服务放在同一个文件中存放也可以。Mock 文件Mock 文件默认导出一个对象,而对象的每个 Key 对应了一个 Mock 接口,值则是这个接口所对应的返回数据,例如这样的 Mock 文件:// ./mock/users.ts export default { // 返回值可以是数组形式 'GET /api/users': [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' } // 返回值也可以是对象形式 'GET /api/users/1': { id: 1, name: 'foo' }, 复制代码就声明了两个 Mock 接口,透过 GET /api/users 可以拿到一个带有两个用户数据的数组,通过 GET /api/users/1 可以拿到某个用户的模拟数据。请求方式当 Http 的请求方式是 GET 时,可以省略方法部分,只需要路径即可,例如:// ./mock/users.ts export default { '/api/users': [ { id: 1, name: 'foo' }, { id: 2, name: 'bar' } '/api/users/1': { id: 1, name: 'foo' }, 复制代码也可以用不同的请求方法,例如 POST,PUT,DELETE:// ./mock/users.ts export default { 'POST /api/users': { result: 'true' }, 'PUT /api/users/1': { id: 1, name: 'new-foo' }, 复制代码关闭 MockUmi 默认开启 Mock 功能,如果不需要的话可以从配置文件关闭:// .umirc.ts export default { mock: false, 复制代码或是用环境变量的方式关闭:MOCK=none umi dev 复制代码以上的内容大部分你可以在官网中找到,以下的内容是开发中的一些小 tip。如果你看到这个文章的时间较晚,那可能也能在官网中看到,因为这个系列的所有文档,后面会尽量整理到官网的内容中。取到请求的参数其实 Umi 中提供的 Mock 能力,本质上是一个 express 的中间件,你可以通过 req 取到请求的入参和 header 等信息。import { Request, Response } from 'express'; export default { 'POST /api/list': (req: Request, res: Response) => { const { body } = req; const { pageSize, offset } = body; // 从这里取出 pageSize, offset return res.json({ success:true 复制代码引入 Mock.js上节课中我们手写了一个数据,有时候很尴尬的是取名困难,要让数据看起来好看一点,就要让数据重复的更少。{ id: i, title: "卡片列表", description: "Umi@4 实战教程,专门针对中后台项目零基础的朋友,不管你是前端还是后端,看完这个系列你也有能力合理“抗雷”,“顶坑”", 复制代码这时候我们可以引入一些现在流行的 Mock 数据的生成工具,比如 Mock.js ,来帮我们方便的生成随机的模拟数据,会让我们的模拟数据看起来更加真实。类似上节课的练习数据,我们可以这么写。import mockjs from "mockjs"; export default { "GET /api/list": mockjs.mock({ "data|10": [{ id: "@id", title: "@name", description: "@cparagraph(2)" }], 复制代码会自动生成类似如下数据,太长了,我这里摘录了两段,实际上生成了 10 个数据,这是通过 data|10 来指定的,你在表格数据中,可以给一个比较大的值来渲染一个超长列表。{ "data": [ "id": "810000197712245720", "title": "David Lewis", "description": "理史率能厂响命热么克积深先片。每号公状志山织声具接度通满被准。" "id": "230000199101266590", "title": "Sharon White", "description": "是石来验关且公决器重调受白设。农队战社五点团持老了取装场。" 复制代码实战在上节课的技术上我们继续今天的实战。如果你不是每节课都看的朋友,你可以下载上节课的 源码归档安装依赖pnpm i mockjs @alita/plugins 复制代码安装类型包pnpm i @types/mockjs --D 复制代码因为今天 Umi@4 正式发布,所以我们同步升级到 Umi@4修改 packages.json 文件- "@umijs/plugins": "4.0.0-rc.20", + "@umijs/plugins": "4.0.0", - "umi": "4.0.0-rc.20" + "umi": "4.0.0" 复制代码修改后,重新执行 pnpm i。新建 Mock 文件新建 mock/list.ts,并写入如下内容:import mockjs from "mockjs"; export default { "GET /api/list": mockjs.mock({ success: true, "data|10": [ id: "@id", title: "@name", description: "@cparagraph(2)", 复制代码引入请求详细内容在 14 课中已讲解,这里直接写使用。修改配置文件 config/config.tsimport { defineConfig } from "umi"; export default defineConfig({ // 最终值在插件中设置,所以这里不用写 // title: "Hello Umi", plugins: [ require.resolve("@umijs/plugins/dist/model"), require.resolve("@umijs/plugins/dist/antd"), + require.resolve("@alita/plugins/dist/request"), model: {}, + request: {}, antd: {}, 复制代码在 src/pages/listcard/index.tsx 中引入请求,通过日志查看数据结构import { useRequest, request } from "umi"; // 文件中原有内容略 const ListCard = () => { const { data } = useRequest(() => request("/api/list")); console.log(data); return ( export default ListCard; 复制代码{ "data":[] 复制代码修改原有的代码将 dataSource={[{}, ...data?.data]} data 作为 List 的 dataSource。源码归档
Umi 4 现在可以在 npm 上使用了!详见:umijs.org。距离上一篇文章和大家介绍 Umi 4 RC 的发布已过去 5 个月,这段时间我们基本都保持了一周一个 RC 的节奏,目前是 RC.24。同时基于 Umi 4 的蚂蚁内网框架也已在 2 个月前发布,目前上线近 100 应用,Umi 4 的主体功能已非常稳定,这也是第一次我们先在内网发布后在社区正式发布。Umi 4 有什么新功能?相比 Umi 2 到 Umi 3,Umi 4 有着更宏大的目标,开发时间也长了很多,同时带来的变化是巨大的。多构建引擎。 Umi 4 同时支持 Vite 和 Webpack 两种构建方式,并尽量确保他们之间功能的一致性,让开发者可以通过一行配置进行切换。可能有些同学会喜欢 dev 用 vite,build 用 webpack 这样的组合。同时我们也在探索包括 ESMi 在内的其他构建方案的探索。export default { vite: {} 复制代码默认快。 默认快是多维度的,我们通过 MFSU V3 + Webpack 5 缓存解 Dev 时编译慢的问题;内网还有通过 Webpack 5 物理缓存和 CD 平台结合解 Build 时编译慢的问题;有使用 esbuild 做 js 和 css 的压缩器、配置和 MOCK 文件的读取、jest 的 transformer,让除构建之外的其他环节也飞快;此外还有运行时速度也有考虑。MFSU V3。 Umi 3 的 MFSU 大家可能多少有接触过,虽然有用,但 DX 不够好。用的时候会遇到一些坑,以至于很多同学都掌握了一项特殊技能,遇到问题时 rm -rf src/.umi。大家可能会遇到 monorepo 不支持、热更新导致 Tab 卡死、请求多导致页面打开慢、一些语法不支持的问题。以上问题在 MFSU V3 中全解!基于此,我们非常有信心地在 Umi 4 中默认开启 MFSU 功能。当然,如果你不喜欢,会保留手动配置 mfsu: false 关闭的口子。同时,MFSU V3 还可脱离 Umi 独立使用。Umi Max。 这是内部 Bigfish 框架的对外版本,解我们自己的问题,同时也给社区另一个集中化框架的选择,定位是中后台框架,包含了中后台相关的大量最佳实践的插件。如果有定制需求,大家可以参考他来实现内网框架的定制,比如快手团队就有基于 Umi 4 的框架定制,还有 Alita 也是基于 Umi 定制的面向移动端的框架。$ npm i @umijs/max -D 复制代码React Router 6。 我们升级了路由方案到 React Router 6,喜忧参半。好消息是,React Router 6 是 Remix 的基础库,面向框架层做了很多优化,路由实现层更优雅,Umi 得以删除大量路由渲染的代码;坏消息是,带来不少 Break Change,比如之前父路由渲染子路由用 children,得换成 。- { props.children } + <Outlet /> 复制代码支持 Vue。 Umi 4 中提供了 Vue 支持,记得我在 Umi 2 时画过一张架构图,其中就有 Vue 的一环,Umi 3 时也有过尝试,但那会 Vue 3 还不太成熟,接入时遇到不少坑,这个坑今天总算是补上了。此功能由社区同学操刀,只需装载一个 preset 即可切换到 Vue。export default { presets: ['@umijs/preset-vue'], 复制代码默认最快的 CSR 请求。 项目构建快解的是 DX 问题,但同时也应该关注 UX。Client Loader 的目的是让应用加载默认快,避免 React 项目经典的 Render-Then-Fetch 的加载瀑布流问题。效果见下图,示例项目的从 9s 降到 6s,这 6s 还是之前截的图,上了 Preload 功能之后其实已更快。export default function() { // 使用请求数据 useClientLoaderData() // 声明请求 export function clientLoader() {} 复制代码白盒文档的 Lint。 Umi 4 里内置了我们精挑细选的 lint 规则,只有质量类不开可能会导致项目问题的规则,不包含风格类的规则,不包含 TypeScript 中 type-aware 类的规则,这类规则需要跑整个项目,会导致性能问题;同时,我们通过 @rushstack/eslint-pach 的方式锁定了 config 里找 plugin 的规则,确保规则是长期稳定的。SSR。 Umi 4 重写了 SSR 功能,目前此功能还在 beta 阶段,请勿将其用于生产环境。Umi 4 的 SSR 有以下特点,1)server 代码的构建基于 esbuild,所以极快,2)请求的处理类似 next.js 的 getServerSideProps 和 remix 的 loader,只在服务端跑,3)基于 react 18 的 suspense 和 renderToPipeableStream。实现原因,部署层目前仅实现了 vercel 的 adapter。这里有个简单的 Todos 示例:test-vercel-chencheng.vercel.app/export default { ssr: { platform: 'vercel' } 复制代码API 路由。 Umi 4 约定 src/api 目录下存放的 Serverless Function 格式的文件即为 API 路由。这部分路由会打包成不同平台支持的 Serverless Function 产物。场景比如带 token 的 API 调用、动态数据源、基于 Notion API 的 Blog、Hackernews Clone 等等。基于此,Umi 能做的事的边界就大了很多。不再只是写写中后台,实现静态页面。export default { apiRoute: {}, 复制代码微生成器。 此概念来自 Modern.js。Modern.js 引入很多新概念,其中「微生成器」还是非常贴切的。他包含两个功能,1)小型脚手架,2)功能的开启与关闭。Umi 3 虽然也有 generate 命令,但只包含功能 1。Umi 4 拓展了下 generate(alias 为 g)命令。除了支持更多类型的小型脚手架生成,还支持功能的开启与关闭,以及比如 Monorepo、react 和 antd 版本等的功能切换。$ npx umi g ? Pick generator type › - Use arrow-keys. Return to submit. ❯ 创建页面 -- Create a umi page by page name 创建组件 -- . 创建 mock 代码 -- . 创建 model 代码 -- . 启用 Prettier -- Setup Prettier Configurations 启用 Jest -- Setup Jest Configuration 启用 E2E 测试 -- . 启用 Tailwind CSS -- Setup Tailwind CSS configuration 启用 SSR -- . 启用 Low Import 研发模式 -- . 启用权限方案 -- . 启用 Monaco 编辑器 -- . 关闭 Dva 数据流 -- Configuration, Dependencies, and Model Files for Dva 关闭 MFSU -- . 切换为 Monorepo 项目 -- . 切换 React 为 18 -- . 切换 Antd 为 5 -- . 复制代码除此之外,我们还有非常多小而美的 DX 改进。自动 https。 Umi 4 的 https dev server 的实现基于 mkcert,启动过程中会基于 hosts 自动生成对应的 key 和 cert。开发者除了安装前置的 mkcert,其他无需关心。浏览器里的构建进度条。 如果首次构建没有完成就在浏览器里打开,你会看到一个构建进度条,支持 webpack 多实例,支持 MFSU,完成初始构建后会自动跳转到项目页。Terminal 中的日志。 有些开发者会更希望在命令行里看到项目里通过 console 输出的日志,比如我。因为命令行日志不会随着刷新而失效,大家可能都经历过一些一闪而过的页面,想截屏都难;同时命令行日志还可以做物理存储,导出后可以方便他人排查。此功能复刻自 github.com/patak-dev/v…import { terminal } from 'umi'; terminal.log(`Some info from the app`); 复制代码然后就可以在命令行中看到日志,umi.js 产物调试。 不知大家是否会有这样的需求,开发项目时发现一些比较复杂的问题时,需要调整构建产物的代码。而 Umi 基于 webpack-dev-server,在 dev 阶段所有文件都存于内存中,没有物理文件的形式,并不方便直接修改后验证效果。如果大家用 Umi 4,可以把 umi.js 等产物文件保存到项目根目录,然后可以直接修改即生效。项目级插件:plugin.ts。 为进一步降低项目中使用插件的门槛,Umi 4 中约定项目根目录下的 plugin.ts 为插件,开发者可在此直接调用插件 API,无需注册,支持 TypeScript。有了这个文件,我们可以在项目级做很多事。比如,import { IApi } from 'umi'; export default (api: IApi) => { // 比如修改 HTML api.modifyHTML($ => { return $; // 比如在入口的 umi.ts 中添加代码 api.addEntryCodeAhead(() => [`console.log('entry code ahead')`]); api.addEntryCode(() => [`console.log('entry code')`]); // 比如在构建完成时做额外的事 api.onBuildComplete((opts) => {}); // 比如在启动阶段做额外的事 api.onStart((opts) => {}); // 比如校验每个 JavaScript/TypeScript 代码 api.onCheckCode((args) => {}); // 比如动态修改路由 api.modifyRoutes((routes) => {}); 复制代码deadCode 检测。 项目中通常会有未使用的文件或导出,Umi 4 中通过配置 deadCode: {} 即可在 build 阶段做检测。如有发现,会有类似信息抛出。Warning: There are 3 unused files: 1. /mock/a.ts 2. /mock/b.ts 3. /pages/index.module.less Please be careful if you want to remove them (¬º-°)¬. 复制代码Umi UI 卷土重来? 日常排查问题时,很多 Umi 框架的内部状态是看不到的,比如插件启用情况、appData 元数据、修改过的最终配置、修改过的最终 webpack 配置、修改过的最终路由、MFSU 的 module graph 信息等。Umi 4 提供了 /__umi/ 路由,dev 阶段可用,效果见下图。定位是开发辅助,大家也可以理解为是「丐版 Umi UI」。我可以做什么?Umi 4 发布之后,我可以做什么?① 升级项目到 Umi 4② 使用 Umi 4 开启新项目③ 参与贡献④ 订阅 NewsLetter⑤ 交流反馈群Ant Design Pro 已完成到 Umi 4 的升级,不日将发布新版本。除了使用 Umi,也非常欢迎大家参与贡献 Umi。你的贡献会直接影响到蚂蚁 10000+ 应用和 3000+ 开发者,以及非常多社区的项目和同学。感兴趣的同学可认领入门任务,1 个 PR 即可加入 Umi Contributor 群,和来自五湖四海的朋友们畅聊前沿技术。我们新增了 NewsLetter 服务,如果大家希望即时了解 Umi 的最新进展,可在新官网 umijs.org 右下角进行订阅。交流反馈群详见「feedback.umijs.org」。下一步?Umi 4 发布后,Umi 团队今年还会在以下方向发力。① 2022 版的最佳实践② MFSU V4:更快的 MFSU③ Father 4 和 dumi 2:新一代组件研发方案④ ESMi:面向未来的 Bundless 构建方案⑤ DX(开发体验)和速度技术方向有迭代才会更有生命力,今年最佳实践会出一个新的版本,包含现有方案的改进,以及新的 monorepo、icon、请求、数据流等方向的最佳实践;MFSU V4 已有思路,会在现有基础上进一步提升其稳定性和速度;Father 4 和 Dumi 2 是新一代的组件研发方案;面向未来的 ESMi Bundless 方案的性能问题已有曙光,预计也会在今年和大家见面;最后,DX 和速度(包括编译时和运行时)是 Umi 一直会关注的点。
经过前几天的介绍,我们现在已经对 Umi 部分体系有了一定的了解,今天我们就来编写一个中后台中比较简单的页面,先上效果图吧。需求先停下来思考 3 秒,如果是你拿到这个需求,你要开始编写这个页面,你会怎么做,大概是怎么编写页面的,样式布局之类的你会怎么设计?321新建页面新建文件 src/pages/listcard/index.tsximport React from "react"; const ListCard = () => { return <div>ListCard Page</div>; export default ListCard;全局布局中增加菜单配置修改 src/layouts.tsx 文件中的 menuHash 加上我们新增的页面“卡片列表”。- import { PieChartOutlined, UserOutlined, } from "@ant-design/icons"; + import { PieChartOutlined, UserOutlined, TableOutlined } from "@ant-design/icons"; const menuHash: any = { "/": { label: "首页", icon: <PieChartOutlined />, user: { label: "用户", icon: <UserOutlined />, + listcard: { + label: "卡片列表", + icon: <TableOutlined />, + }, };很容易看出来,就算我们没有修改上面的 menuHash 数据,我们的菜单也会出现 lastcard 的选项。这是我们上节课在菜单与权限上,将菜单与路由关联起来的好处。整个流程是新建页面文件,通过约定式路由的能力,自动生成了新的路由配置,路由配置通过我们的方法类转换成菜单数据绑定到 Layout 中的 Menu 组件。模拟页面数据我们通过简单的循环生成我们需要的数据。用于列表页面的渲染。const list: any[] = []; for (let i = 1; i < 10; i += 1) { list.push({ id: i, title: "卡片列表", description: "Umi@4 实战教程,专门针对中后台项目零基础的朋友,不管你是前端还是后端,看完这个系列你也有能力合理“抗雷”,“顶坑”", }重度使用 Antd 组件在中后台项目中,一定要非常重度的使用 Antd 的组件,因为这会让你少写非常多的样式,特别是在你的客户还没有审美疲劳的前提下,没改任何一处 Ant Design 的设计规范时。列表页面我们都可以通过使用 Antd 的 List 来渲染。<List rowKey="id" grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 3, xl: 4, xxl: 4, dataSource={[{}, ...list]} renderItem={(item) => { return <div>item</div> />这里需要注意的是 dataSource 我们并不是直接取 list,而是在 list 的前面放了一个空的对象,这是为了来渲染前面的 新增 卡片而添加的。然后我们就只要关注 renderItem 需要返回的内容了。Item 项我们需要返回的是一个卡片<Card hoverable actions={[<a key="option1">操作一</a>, <a key="option2">操作二</a>]} <Card.Meta avatar={ <Avatar size={48} src="https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png" /> title={<a>卡片列表</a>} description={ <Paragraph ellipsis={{ rows: 3 }}>Umi@4 实战教程,专门针对中后台项目零基础的朋友,不管你是前端还是后端,看完这个系列你也有能力合理“抗雷”,“顶坑”</Paragraph> /> </Card>;如果是第一个空对象的时候,我们要渲染成我们的新增卡片。<Button type="dashed" style={{ width: "100%", height: "201px" }}> <PlusOutlined /> 新增产品 </Button>;简单的整理之后,就可以得到我们的 renderItemrenderItem={(item) => { if (item && item.id) { return ( <List.Item key={item.id}> <Card hoverable actions={[ <a key="option1">操作一</a>, <a key="option2">操作二</a>, <Card.Meta avatar={ <Avatar size={48} src="https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png" /> title={<a>{item.title}</a>} description={ <Paragraph ellipsis={{ rows: 3 }}> {item.description} </Paragraph> /> </Card> </List.Item> return ( <List.Item> <Button type="dashed" style={{ width: "100%", height: "201px" }}> <PlusOutlined /> 新增产品 </Button> </List.Item> }}值得注意的是以上所有的代码,关于样式我们只写了 style={{ width: "100%", height: "201px" }}。甚至我们都没有新建样式文件。回过头去看整个需求和实现,是不是发现非常的简单了?所以有一个好的组件库对于能否早点下班是非常关键的,你一定要有一个你非常喜欢并且非常熟悉的组件库,你甚至可以不用理会它的实现,但是它长什么样子,有什么效果,你一定要熟记于心。当然并不一定非要是 Antd,你可以选择的你自己喜欢的组件库取深入了解,但是我很推荐 Antd ,他是我觉得 React 社区质量最高的组件库,没有之一。源码归档
上节课中我们完成了页面的大致布局的编写,今天我们主要把重点放在菜单配置中,为什么这么一个简单的菜单配置要单独写一篇文章来说明呢?因为他在后续的“动态菜单”,权限校验等环节都有很重要的作用。首先你要先把菜单数据上升到页面级数据,虽然它只是一个组件,但是它里面的数据需要和页面数据(主要是路由)关联上,几乎所有的导航组件,都需要有这一点的意识。首先我们需要获取到当前项目的所有路由信息,这有两种方式,一种是配置式,自己整理出一个清单,每增加一个页面都更新这个清单,有个好处就是后续菜单交由服务端管控的时候,可以直接将这份数据给他。坏处就是页面数是固定的,后续想做动态菜单,有点困难,因为你配置的路由信息需要是一个“最大值”,否则未配置的页面没有被引用则不会被编译。另一种就是约定式的,新建一个页面,即增加一个菜单信息,我们通过 Umi 提供的 API 获取到最新的页面路由信息,调用一些工具类,将他们转换成菜单数据,后面维护心智很低。约定式的方式,所有的页面都会被构建,只是通过菜单加权限来控制页面是否可访问,可以实现类似动态路由这样的需求。缺点就是需要独立维护一份菜单数据,主要是页面名称的“翻译文档“。比如首页 ”/home“ 在菜单中应该显示 “首页”。获取当前页面数据Umi@4 中要获取页面配置非常的简单,只需要使用 useAppData 即可,它返回全局的应用数据。declare function useAppData(): { routes: Record<id, Route>; routeComponents: Record<id, Promise<React.ReactComponent>>; clientRoutes: ClientRoute[]; pluginManager: any; rootElement: string; basename: string; clientLoaderData: { [routeKey: string]: any }; preloadRoute: (to: string) => void; 复制代码routes 和 clientRoutes 这两个数据都是路由数据,前者是对象,以 pathname 为 key,以 parentId 来标记层级和嵌套关系。后者是一个数组,以 children 来表示树形结构。const routes = { 'a':{ parentId: "b" path: "a" 'b':{ path: "b" 复制代码const clientRoutes = [{ path: "b", children:[{ path: "a" 复制代码以上两个数据“对等”。所以我们要取到当前的所有的路由配置信息,则import { useAppData } from "umi"; const App = ()=>{ const { clientRoutes } = useAppData(); const { children } = clientRoutes[0]; 复制代码将路由转化成菜单数据const clientRoutes = [{ path: "b", children:[{ path: "a" // 转化为 const menuData = [{ key:"/b", icon:<PieChartOutlined />, label:"首页", children:[{ key:"/a", icon:<UserOutlined />, label:"用户", 复制代码通过观察分析,我们发现,其实路由数据中,我们只有 path 和 children 数据有用,而菜单数据中,我们还需要 icon 和 label ,这时候就需要引入我们前面提到的 翻译文档 了。const menuHash: any = { "/": { label: "首页", icon: <PieChartOutlined />, user: { label: "用户", icon: <UserOutlined />, 复制代码至此我们的 路由转菜单的工具类 为:const getItem = (path: string, children?: MenuItem[]) => { const route = menuHash[path]; return { key: path.startsWith("/") ? path : `/${path}`, icon: route?.icon || <></>, children, label: route?.label || path, } as MenuItem; const routesToMenu = (routes: any[]): MenuItem[] => { return routes .map((route) => { const { path, children } = route; if (children) { return getItem(path, routesToMenu(children)); return getItem(path); 复制代码运行项目,访问 http://127.0.0.1:8888/这是你会发现,菜单中有很多我们之前写的 demo 页面,我们并不想让他们展示出来。所以我们需要增加一个访问权限的黑名单。const unaccessible = ["/hooks", "/useEffect", "/usemodel", "/useState"]; 复制代码只要简单的修改一下,我们的 routesToMenu 方法即可。const routesToMenu = (routes: any[]): MenuItem[] => { return routes .filter((i) => { const path = i.path.startsWith("/") ? i.path : `/${i.path}`; return !unaccessible.includes(path); .map((route) => { const { path, children } = route; if (children) { return getItem(path, routesToMenu(children)); return getItem(path); 复制代码保存代码,你讲看到菜单中只有两个数据了。增加页面权限但是这只是将路由入口隐藏了,如果用户知道你的路由信息,比如此时我们直接当问 http://127.0.0.1:8888/usemodel,虽然菜单已经过滤了但是我们依旧可以直达页面。其实原理也很简单,只要判断当前页面 pathname 在我们的不可访问清单就返回 403 页面即可,这个要看你们项目中的权限采用的是黑名单模式还是白名单模式了,黑名单模式匹配上拦截,白名单模式匹配上放行。import { Result, Button } from "antd"; if (unaccessible.includes(location.pathname)) { return ( <Result status="403" title="403" subTitle="抱歉,你没有权限访问这个页面!" extra={ <Button type="primary" onClick={() => navigate(-1)}> 返回上一个页面 </Button> /> 复制代码菜单与路由跳转需要实现的功能,点击菜单触发路由跳转,当前页面对应的菜单项需要高亮显示。import { useAppData, useNavigate, useLocation } from "umi"; const App = ()=>{ const navigate = useNavigate(); const location = useLocation(); const { clientRoutes } = useAppData(); const { children } = clientRoutes[0]; const items = routesToMenu(children); return <Menu theme="dark" onClick={(e) => { navigate(e?.key); defaultSelectedKeys={[location.pathname]} mode="inline" items={items} />; 复制代码至此,我们的菜单与权限部分的所有功能都开发完毕。这里面需要引申到项目的权限管控上,将 unaccessible 和用户的登录信息关联上,就可以了。如果有面试官问你,你们项目中的权限部分是怎么做的?如果用户知道你的页面url是否可以直接访问页面,如何拦截?你应该可以回答的很明白了。当然这节课只是为了讲明白原理,在实际项目开发中我们可以用上 layout 和 access 插件的组合来更合理的完成权限和菜单。源码归档
Umi@4、Ant Design Pro、多 Tabs 布局、状态保持、50 行代码、这些关键词能组合出一篇你感兴趣的文章吗?缘由在 Umi 社区的四年多,很多交流都是在聊天窗口中进行的,并没有留下太多有价值的内容,虽然我尽量整理了部分内容放到了官网的 FAQ 中,但其实还是有很多内容被遗忘了,所以一直想写这么一个系列。刚好在 Umi 4 开发实战小册 中网友 笑神堕落 问我 Umi 新版会不会支持多标签页?看最近 Umi 团队内的讨论,没有这个需求的讨论,因此我回复了应该不会,但我会做。其实多标签页这个需求,已经很久远了,在 pro issues #200 就有过这个讨论。题外话,截止本文撰写时,现在 pro 的 issues 已经 9919。如果你感兴趣,可以在 antd pro 的issues 上搜索 “tabs” 可以看到相当多的讨论,其实我在 umi@3 的时候,就实现过一个版本,因为和官方内置的 layout 插件,有点使用上的冲突,也因为用的人较少,我自己主要是在移动端h5上面的使用需求较多,所以 umi@3 中的多 tabs 实现,是从 keepalive 复制后修改的一个版本。有几个历史因素,导致 umi@3 多 tabs 插件比想象中的难用,其中最主要的一个点就是 umi@3 中的 keepalive 插件本身的实现,也是不够优秀的。但是到了 umi@4 ,react-route 更新到 6 版本之后,陈俊宇给我重写了一遍 keepalive demo,我修改后作为新版的 keepalive 插件,不论从代码的清晰度上还是方案的理想化上,都达到了让我很满意的程度。因此,对于新版本的 tabs 插件,也是在我原定计划之内会完成的内容,借着今天修改一个 alita 框架的 bug 的空档,顺便把这个需求做了。效果先直接上效果吧,这是在 pro 中的实现效果,对了,近期 umi 和 pro 都会发布新版,大家可以持续关注官网信息哦。思路1、为了避免一些非技术上的问题,新版的 tabs 插件和 keepalive 插件合并在一起实现。因为 keepalive 插件我们内部用到的很多,可以借场景验证整个方案的健壮性。2、实现上,只有被标记为“需要状态保持”的页面,才会被添加到多 tabs 标签中,可以解 “只有部分页面需要多 tabs 其他页面不需要” 的需求。3、在状态保持方案的基础上,尽可能少的改动和增加代码来实现想要的需求。4、为 keepalive 插件,增加一个新的配置用于开启多 tabs 布局实现因为 antd 的 Tabs 组件,本身是可以做到,Tabs 切换的时候保持页面数据不变更的。但是基本上平时我们在使用这个组件的时候,都是属于“组件级别”用法,就是在同一个页面中,做多 tabs 切换。因此我们需要将他提升到“页面级别”。实现也很简单,那就是不把页面放到 Tabs 的 TabPane 中。export function App() { return <> <div> <Tabs > {panels.map(() => ( <TabPane /> </Tabs> </div> {children} </> 复制代码要让页面和 Tabs 产生关联,只要将当前页面的路由 location.pathname 和当前所有被状态保持的页面 作为 Tabs 的 activeKey 和渲染 TabPane 的 panels 数据即可,将组件级别的 change activeKey 直接用页面跳转方法代替。export function App() { return <> <div> <Tabs onChange={(key: string) => { navigate(key); }} activeKey={location.pathname} > {Object.entries(keepElements.current).map(([pathname]: any) => ( <TabPane tab={`${local[pathname] || pathname}`} key={pathname} /> </Tabs> </div> {children} </> 复制代码将 Tabs 的关闭行为和状态保持中的清除缓存方法关联到一起 dropByCacheKey(targetKey); 共同维护一份数据。在 Tabs 中的 onEdit 事件中处理即可,要注意如果关闭的是当前页面,那要自动跳转到上一个页面,如果只剩最后一个页面,则提升用户,“必须保留一个窗口”。实现比较简单,感兴趣的直接看 alita 的仓库吧。需要注意的是我们清除缓存 dropByCacheKey 修改的是 React.useRef 的对象,直接修改它是不会导致页面重绘的,这会导致,我们的关闭 Tabs 事件虽然消除了缓存,但是需要在下一次进入页面的时候,Tab 才会被移除。这显然和我们的需求不符合。今天因为这个问题卡了我很久,最终只想到一个办法,能接问题,但写法很“丑”。如果有别的大哥有更好的解法,欢迎 PR 指教。以上问题,已被陈大哥修复。在多 Tabs 布局中放一个没用的 useState 当 keepElements.current 修改时,同步去 setState,虽然你没用到 state 但是当 state 被修改时页面还是会执行一次重绘,这就可以更新我们的页面了。最终实现的关键代码不到 50 行,并且逻辑非常的清晰,属于新手向非常友好的源码了,你看几遍就可以看懂的。import React, { useState } from 'react'; import { useOutlet, useLocation, matchPath, useNavigate } from 'react-router-dom' import { Tabs, message } from 'antd'; import { getPluginManager } from '../core/plugin'; export const KeepAliveContext = React.createContext({}); const { TabPane } = Tabs; export function useKeepOutlets() { const location = useLocation(); const element = useOutlet(); const navigate = useNavigate(); const [panel, setPanel] = useState(); const runtime = getPluginManager().applyPlugins({ key: 'tabsLayout', type: 'modify', initialValue: {} }); const { local } = runtime; const { keepElements, keepalive, dropByCacheKey } = React.useContext<any>(KeepAliveContext); const isKeep = isKeepPath(keepalive, location.pathname); if (isKeep) { keepElements.current[location.pathname] = element; return <> <div className="rumtime-keep-alive-tabs-layout" hidden={!isKeep} > <Tabs hideAdd onChange={(key: string) => { navigate(key); }} activeKey={location.pathname} type="editable-card" onEdit={(targetKey: string,) => { // 部分删除实现略 dropByCacheKey(targetKey); setPanel(Object.entries(keepElements.current)) }}> {Object.entries(keepElements.current).map(([pathname, element]: any) => ( <TabPane tab={`${local[pathname] || pathname}`} key={pathname} /> </Tabs> </div> Object.entries(keepElements.current).map(([pathname, children]: any) => ( <div key={pathname} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout" hidden={!matchPath(location.pathname, pathname)}> {children} </div> <div hidden={isKeep} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout-no"> {!isKeep && element} </div> </> 复制代码用法实现上如此的简单高效,用法上也是很简洁明了的。安装:pnpm i @umijs/plugins @alita/plugins // 版本需要大于'@alita/plugins': 3.0.0-rc.11 复制代码Umi 配置:import { defineConfig } from "umi"; export default defineConfig({ plugins: [ require.resolve("@umijs/plugins/dist/antd"), require.resolve("@alita/plugins/dist/keepalive"), require.resolve("@alita/plugins/dist/tabs-layout"), antd: {}, keepalive: [/users/, /foo/], tabsLayout: {}, 复制代码以上配置,需要非常注意的是 plugins 配置,因为这和你框架中使用到的 Umi 的 Preset you很大的关系,比如在 alita 中,上面的配置可以简化成import { defineConfig } from "alita"; export default defineConfig({ appType: 'pc', antd: {}, keepalive: [/users/, /foo/], tabsLayout: {}, 复制代码而在 Umi Max 中,则可以不而外引入 require.resolve("@umijs/plugins/dist/antd"), 配置,简化也好,不而外引入也好,其实并不是不需要引入了,只是因为这个插件在插件集中已经包含了。这个在使用 Umi 的时候,需要格外留意,当前项目所使用到的所有插件,有一个命令可以查,umi plugin list 可以列出你现在用到的所有的插件。keepalive 配置接收一个 字符串或者正则表达式的数组,有个取巧的值是配置 keepalive: [/./] 这样所有的页面都会被状态保持。因为使用到了 antd 的组件,所以需要使用 antd 的插件,并且 antd 包需要独立安装,因为新版的 Umi 没有内置 antd 了,这会让 Umi 包更小。你只需要执行 pnpm i antd 安装即可。如果你喜欢这个文章,请一键三连,并分享给需要的朋友。如果你也有一些其他“需求”需要支撑,也可以提供给我,我是一个很乐于帮助网友的人。后续6月20号更新 支持自定义渲染 Tabs用法1、更新 @alita/plugins@3.0.0-rc.122、配置(config/config.ts)中增加tabsLayout: { + hasCustomTabs: true, 复制代码3、增加运行态(src/app.tsx)配置 getCustomTabs会自动传入 isKeep, keepElements, navigate, dropByCacheKey, local, activeKey, 这几个属性用于页面渲染,以下是一个范例:import { message, Tabs } from 'antd'; import React from 'react'; const { TabPane } = Tabs; export const tabsLayout = { local: { '/': '首页', '/users': '用户', '/foo': '其他', export const getCustomTabs = () => { return ({ isKeep, keepElements, navigate, dropByCacheKey, local, activeKey, }: any) => { return ( <div className="rumtime-keep-alive-tabs-layout" hidden={!isKeep}> <Tabs hideAdd onChange={(key: string) => { navigate(key); activeKey={activeKey} type="editable-card" onEdit={(targetKey: string) => { let newActiveKey = activeKey; let lastIndex = -1; const newPanel = Object.keys(keepElements.current); for (let i = 0; i < newPanel.length; i++) { if (newPanel[i] === targetKey) { lastIndex = i - 1; const newPanes = newPanel.filter((pane) => pane !== targetKey); if (newPanes.length && newActiveKey === targetKey) { if (lastIndex >= 0) { newActiveKey = newPanes[lastIndex]; } else { newActiveKey = newPanes[0]; if (lastIndex === -1 && targetKey === location.pathname) { message.info('至少要保留一个窗口'); } else { dropByCacheKey(targetKey); if (newActiveKey !== location.pathname) { navigate(newActiveKey); {Object.entries(keepElements.current).map( ([pathname, element]: any) => ( <TabPane tab={`${local[pathname] || pathname}`} key={pathname} /> </Tabs> </div> 复制代码为什么要增加 hasCustomTabs 而不是直接判断 getCustomTabs 是否配置?因为使用 node 层的配置,可以在构建阶段生成不含有任何默认引入的模版,如果是判断 getCustomTabs 是否配置的话,那需要到运行时才能区分,这意味着,不论你有没有自定义,模版中都会有 import Tabs form antd。补充pro 已经升级到 Umi@4 了,你可以在 pro 直接使用多 tabs// 如果安装最新的 pro-preset 就无需配置 plugins plugins:[require.resolve('@alita/plugins/dist/keepalive'), require.resolve('@alita/plugins/dist/tabs-layout'),], export default {\ keepalive: [/./],\ tabsLayout: {},\
前面14天的内容,我们几乎都在谈论 Umi 的相关概念,从这节课开始,我们就会真正进入实战阶段,如果需要给它取一个小标题,那我大概率会用《如何手写 Ant Design Pro》这节课我们来实现页面级整体布局,所谓布局简单的理解就是网页的整体框框,也可以看成是,所有页面都共用的部分。 比如在 pc 上比较常见的上中下布局,在 app 上的底部 tabs 、全局浮动球等都属于布局需求。约定的全局布局约定式路由时的全局布局文件,实际上是在路由外面套了一层。比如,你的路由是:约定 src/layouts/index.tsx 为全局路由,实际上是在路由外面套了一层返回一个 React 组件,并通过 useOutlet hook 或者 Outlet 组件渲染子组件。比如以下目录结构,. └── src ├── layouts │ └── index.tsx └── pages ├── index.tsx └── users.tsx 复制代码会生成路由,[ { exact: false, path: '/', component: '@/layouts/index', routes: [ { exact: true, path: '/', component: '@/pages/index' }, { exact: true, path: '/users', component: '@/pages/users' }, 复制代码从组件角度可以简单的理解为如下关系:<layout> <page>index</page> <page>users</page> </layout> 复制代码一个自定义的全局 layout 如下:import React from "react"; import { useOutlet } from "umi"; const Layout = () => { const outlet = useOutlet(); return ( <div> Layout {outlet} </div> export default Layout; 复制代码不同的全局 layout你可能需要针对不同路由输出不同的全局 layout,Umi 不支持这样的配置,但你仍可以在 src/layouts/index.tsx 中对 location.path 做区分,渲染不同的 layout 。比如想要针对 /login 输出简单布局,import React from "react"; import { useOutlet } from "umi"; export default function(props) { const outlet = useOutlet(); if (props.location.pathname === '/login') { return <SimpleLayout>{ outlet }</SimpleLayout> return ( <> <Header /> { outlet } <Footer /> </> 复制代码使用 Ant Design 实现基本布局Ant Design 提供了好几种的布局方式,几乎中后台的所有布局都包括了。详细的范例可以参考 Ant Design 官网,这里我们用最常见的 顶部导航 Header、侧边栏 Sider、内容区 Content、底部区域 Footer 的布局来做演示。安装 Ant Design 和图标库pnpm i antd @ant-design/icons 复制代码使用 umi antd 插件config/config.ts 配置中,修改 plugins 配置import { defineConfig } from "umi"; export default defineConfig({ // 最终值在插件中设置,所以这里不用写 // title: "Hello Umi", plugins: [ require.resolve("@umijs/plugins/dist/model"), + require.resolve("@umijs/plugins/dist/antd"), model: {}, antd: {}, 复制代码开启 antd 插件功能config/config.ts 配置中,新增 antd 配置,这里是一次强调,添加完插件,要记得添加对应的配置。Umi 中部分插件是默认开启,就无须配置。正常的插件都是配置开关。所有的插件都可以通过配置值为 false,来关闭它。import { defineConfig } from "umi"; export default defineConfig({ // 最终值在插件中设置,所以这里不用写 // title: "Hello Umi", plugins: [ require.resolve("@umijs/plugins/dist/model"), require.resolve("@umijs/plugins/dist/antd"), model: {}, + antd: {}, 复制代码新建 Layout 页面新建页面文件 src/layouts/index.tsx,写出整体布局(用法来自 Ant Design 官网)import { Layout } from "antd"; import React from "react"; const { Header, Content, Footer, Sider } = Layout; const App: React.FC = () => { return ( <Layout style={{ minHeight: "100vh" }}> <Sider></Sider> <Layout> <Header style={{ padding: 0 }} /> <Content style={{ margin: "0 16px" }}></Content> <Footer style={{ textAlign: "center" }}></Footer> </Layout> </Layout> export default App; 复制代码编写 Sider 和 Menuimport { DesktopOutlined, FileOutlined, PieChartOutlined, TeamOutlined, UserOutlined, } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { Breadcrumb, Layout, Menu } from "antd"; import React, { useState } from "react"; const { Header, Content, Footer, Sider } = Layout; type MenuItem = Required<MenuProps>["items"][number]; function getItem( label: React.ReactNode, key: React.Key, icon?: React.ReactNode, children?: MenuItem[] ): MenuItem { return { icon, children, label, } as MenuItem; const items: MenuItem[] = [ getItem("Option 1", "1", <PieChartOutlined />), getItem("Option 2", "2", <DesktopOutlined />), getItem("User", "sub1", <UserOutlined />, [ getItem("Tom", "3"), getItem("Bill", "4"), getItem("Alex", "5"), getItem("Team", "sub2", <TeamOutlined />, [ getItem("Team 1", "6"), getItem("Team 2", "8"), getItem("Files", "9", <FileOutlined />), const App: React.FC = () => { const [collapsed, setCollapsed] = useState(false); return ( <Layout style={{ minHeight: "100vh" }}> <Sider collapsible collapsed={collapsed} onCollapse={(value) => setCollapsed(value)} <div style={{ height: "32px", margin: "16px", color: "#fff", textAlign: "center", fontSize: "16px", Umi 4 </div> <Menu theme="dark" defaultSelectedKeys={["1"]} mode="inline" items={items} /> </Sider> {/* Layout 略 */} </Layout> export default App; 复制代码编写 Footer<Footer style={{ textAlign: "center" }}> Umi@4 实战小册 Created by xiaohuoni </Footer> 复制代码编写 Content 和面包屑<Content style={{ margin: "0 16px" }}> <Breadcrumb style={{ margin: "16px 0" }}> <Breadcrumb.Item>User</Breadcrumb.Item> <Breadcrumb.Item>Bill</Breadcrumb.Item> </Breadcrumb> <div style={{ padding: 24, minHeight: 360 }}>Bill is a cat.</div> </Content> 复制代码渲染当前页面前面提到过,我们使用 useOutlet hook 或者 Outlet 组件渲染子组件。将上面 Content 中的 Bill is a cat. 替换成 outlet 即可。import { Outlet } from "umi"; // 其他内容略 <div style={{ padding: 24, minHeight: 360 }}> <Outlet /> </div> 复制代码运行效果执行 pnpm start 或者 npx umi dev,启动 umi 的开发服务,通过浏览器访问 http://127.0.0.1:8888/源码归档
request 请求数据Umi@4 中使用 axios 代替了 fetch,有一些使用上的差异,这节课我们提到的是 Umi 中的 fetch 方案前面我们花了大量的篇幅介绍项目中的数据流方案,其实他们是作为数据消费部分存在的。但是我们并没有真正的发起数据请求,在这里我们将详细的介绍在 umi 中如何规范的发起数据请求,即如何将数据传入我们之前介绍的数据流方案中。在很多其他项目中,我们都会单独维护一个 http 请求的工具类,它一般会在你的 utils 文件夹中,但是通过多个项目的代码比对,我们发现这个工具类有至少80%的代码是重复的,且不同人员的维护上也比较随意,代码维护上是比较乱的。因此我们将 request 请求内置到框架中。import { request } from 'umi'; const data = await request('/api/hello', { method: 'get', 复制代码直接使用,对服务端的返回数据模型,会有一些约定如果你的服务端返回数据格式不同,会在后面的配置中提到如何处理这种情况。interface ErrorInfoStructure { success: boolean; // if request is success data?: any; // response data 复制代码一般我们对服务端发起请求,最常见的会涉及到,统一的请求url、统一的head、默认的请求方式(默认 get或者默认post)等。这些内容,我们都在运行时配置中提供了修改方式。运行时配置顾名思义就是在项目运行时会根据一些条件或者时机来修改配置,常常用于某些需要动态配置的情况。在前面讲解 umi 中的配置时我们已经提过运行时配置。记住运行时配置只有一个地方 src/app.ts。某些功能会用到它。你需要按约定导出对象,不能导出不存在的对象。这些对象由你所使用的插件定义,包括内置插件和外部引用插件。export const request = { prefix: '', method: 'get', errorConfig: { adaptor: (resData) => { return { ...resData, success: resData.ok, errorMessage: resData.message, 复制代码在运行时配置中导出 request 是我们的请求功能要求的。request 配置参数说明类型可选值默认值method请求方式stringget , post , put ...getparamsurl 请求参数object 或 URLSearchParams 对象----data提交的数据any----headersfetch 原有参数object--{}timeout超时时长, 默认毫秒, 写操作慎用number----prefix前缀, 一般用于覆盖统一设置的 prefixstring----suffix后缀, 比如某些场景 api 需要统一加 .jsonstring----credentialsfetch 请求包含 cookies 信息string--credentials: 'same-origin'useCache是否使用缓存(仅支持浏览器客户端)boolean--falsevalidateCache缓存策略函数(url, options) => boolean--默认 get 请求做缓存ttl缓存时长, 0 为不过期number--60000maxCache最大缓存数number--无限requestTypepost 请求时数据类型stringjson , formjsonparseResponse是否对 response 做处理简化boolean--truecharset字符集stringutf8 , gbkutf8responseType如何解析返回的数据stringjson , text , blob , formData ...json , textthrowErrIfParseFail当 responseType 为 'json', 对请求结果做 JSON.parse 出错时是否抛出异常boolean--falsegetResponse是否获取源 response, 返回结果将包裹一层boolean--fasleerrorHandler异常处理, 或者覆盖统一的异常处理function(error)--cancelToken取消请求的 TokenCancelToken.token----fetch 原其他参数有效, 详见fetch 文档上面列出的是 request 支持的所有配置,看起来有些复杂,但是我们在实际使用中最经常修改的,其实只有 prefix,当你需要切换请求前缀时,会比较频繁的修改它。其他的数据应该是在首次接口连调的时候,和服务端约定好的配置。修改请求数据-加密或过滤一般我们还有有一个常用的需求,对我们发出的数据进行安全性的数据加密。或者仅针对某个接口对数据进行过滤。这时候就能用到我们的中间件功能了。这个在 express 项目中是很容易理解的概念。const middleware = async (ctx, next) => { // 这里是对请求数据的操作,比如加密或者过滤数据,我们可以在这里操作 // url 请求不包含 ‘abc’ 时,就做某些操作 if (!ctx.req.url.includes('abc')) { await next(); // next 执行之后,这部分我们一般是对请求结果做操作,比如统一的错误码处理,或者token失效这些,都可以在这里处理 // 以下代码只是模拟,正式的写法要根据服务端的约定 if (ctx.res.errors) { const { errorCode, fieldPath, message, value } = ctx.res.errors[0]; if (errorCode === '0000') { gotoLogin(message); } else { Toast.fail(`${message}` || '请求异常请稍后重试', 1); export const request = { prefix: '', // 统一的请求头 middlewares: [middleware], errorHandler: (error: ResponseError) => { // 集中处理错误 console.log(error); 复制代码ahooks useRequest理解了上面的概念,那么在我们之前提到的数据流中,应该在什么时机发起请求呢? 一般我们会在 useEffect 中,请求服务端接口,但是为了更加简洁的使用 request,我们引入了 ahooks 中的 useRequest。 这里我们只介绍两种最常见的用法默认用法默认用法最好理解,常常用来加载页面的初始化数据。比如列表的首屏展示。import { useRequest } from 'umi'; import Mock from 'mockjs'; import React from 'react'; function getUsername(): Promise<string> { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); export default () => { const { data, error, loading } = useRequest(getUsername); if (error) { return <div>failed to load</div>; if (loading) { return <div>loading...</div>; return <div>Username: {data}</div>; 复制代码值得注意的是,上面的代码看起来像是同步执行的,但其实 react 的 hooks 都是异步的,简单理解就是,数据修改时都会导致页面重绘,所以当你的自定义 hooks 很多的时候,可能你的页面会渲染好几次。手动触发如果设置了 options.manual = true,则 useRequest 不会默认执行,可以在合适的时候通过 run 来触发执行。比如常见的搜索查询,我们会在输入完条件之后,点击查询按钮才会调用服务端接口。import { message } from 'antd'; import React, { useState } from 'react'; import { useRequest } from 'umi'; function changeUsername(username: string): Promise<{ success: boolean }> { return new Promise((resolve) => { setTimeout(() => { resolve({ success: true }); }, 1000); export default () => { const [state, setState] = useState(''); const { loading, run } = useRequest(changeUsername, { manual: true, onSuccess: (result, params) => { if (result.success) { setState(''); message.success(`The username was changed to "${params[0]}" !`); return ( <div> <input onChange={(e) => setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} /> <button disabled={loading} type="button" onClick={() => run(state)}> {loading ? 'Loading' : 'Edit'} </button> </div> 复制代码感谢阅读,这个文章仅仅作为这个概念的讲解,实战在后续课程中,因此这节课没有源码归档。你不需要修改任何的文件。
在上节课中我们掌握了 React 的页面级数据流,我们知道了,在单个组件内使用 useState ,不同页面建的数据是没办法共享的。 但是在实际业务中我们经常会遇到两个或者多个页面之间需要同时维护一份数据的情况,因此就有了全局的 hooks 数据流方案。这节课我们会现将原理再讲最佳实践。组件间数据独立新建页面文件 src/pages/hooks.tsximport React, { useState } from "react"; const Count = () => { const [count, setCount] = useState(1); return <div onClick={() => setCount(count + 1)}>{count}</div>; export default function List() { return ( <div> <Count /> <br /> <Count /> <br /> <Count /> </div> 复制代码我们编写一个简单的计数器,在页面中使用了三次,运行页面你将看到三个计数器,之间的数据是独立的,相互没有影响的。让组件间数据同步简单的修改一下我们的代码,将数值和方法传到组件内部,这时候我们再运行我们的页面,就会看到所有的数值都保持一致了,因为他们共用的是同一个数据。import React, { useState } from "react"; const Count = ({ count, setCount }) => { return <div onClick={() => setCount(count + 1)}>{count}</div>; export default function List() { const [count, setCount] = useState(1); return ( <div> <Count count={count} setCount={setCount} /> <br /> <Count count={count} setCount={setCount} /> <br /> <Count count={count} setCount={setCount} /> </div> 复制代码但是上面的修改,我们需要将这两个属性一直往下传,如果组件层级很深的话,这么写非常的难维护。因此我们可以使用 React 的上下文来管理。import React, { useState, createContext, useContext } from "react"; const Context = createContext<any>(null); const Count = ({}) => { const { count, setCount } = useContext(Context); return <div onClick={() => setCount(count + 1)}>{count}</div>; export default function List() { const [count, setCount] = useState(1); return ( <Context.Provider value={{ count, setCount }}> <Count /> <br /> <Count /> <br /> <Count /> </Context.Provider> 复制代码这样不管我们的组件嵌套多深,它都可以直接取到页面的数据,这个小技巧在开发中可以选择性的使用。Umi 中的纯 hooks 数据流上面的例子,我们只是将数据提升到全局维护,但在 Umi 中,它做的更多。 约定存在 src/models 目录下面的文件,只要导出了自定义 hook ,会被识别为 model,添加到全局的 hooks 数据流中。可以在任何 React 上下文中,使用 useModel 取到你需要的数据。 比如,存在文件src/models/user.tsx,内容如下:import { useState, useCallback } from "react"; export default function useAuthModel() { const [user, setUser] = useState("umi"); const fetchUser = useCallback(() => { setUser("umi@4 实战教学"); }, []); return { user, fetchUser, 复制代码可以直接在页面中使用import React from "react"; import { useModel } from "umi"; export default () => { const { user, fetchUser } = useModel("user", (model) => ({ user: model.user, fetchUser: model.fetchUser, return <div onClick={() => fetchUser()}>hello {user}</div>; 复制代码useModel 有两个参数,useModel(namespace,updater)。namespace 就是 hooks model 文件的文件名,如上面例子里的 user。updater - 可选参数。在 hooks model 返回多个状态,但使用组件仅引用了其中部分状态,并且希望仅在这几个状态更新时 rerender 时使用(性能相关)。性能优化useModel() 方法可以接受可选的第二个参数 updater,当组件只需要使用 Model 中的部分参数,而对其它参数的变化不感兴趣时,可以传入一个函数进行过滤。以实现计数器的操作按钮为例:// src/components/CounterActions/index.tsx import { useModel } from 'umi'; export default () => { const { add, minus } = useModel('useCounterModel', (model) => ({ add: model.increment, minus: model.decrement, return ( <div> <button onClick={add}>add by 1</button> <button onClick={minus}>minus by 1</button> </div> 复制代码上面的组件并不关心计数器 Model 中的 counter 值,只需要使用 Model 提供的 increment() 和 decrement() 方法。于是我们传入了一个函数作为 useModel() 方法的第二个参数,该函数的返回值将作为 useModel() 方法的返回值。这样,我们过滤掉了 counter 这一频繁变化的值,避免了组件重复渲染带来的性能损失。插件实现略,插件实现会在我们讲解完 Umi 插件开发之后,会有专门的课程讲解实现细节。使用 Umi model 插件pnpm i @umijs/plugins@4.0.0-rc.20 复制代码注意指定版本号,为了这个课程在不久的将来仍然可用,我们锁定了 Umi 的版本。增加配置 config/config.ts,使用 model 插件,并且配置 model 来开启插件功能。import { defineConfig } from "umi"; export default defineConfig({ plugins: [require.resolve("@umijs/plugins/dist/model")], model: {}, 复制代码新建文件 src/models/user.tsx 写入上面 user.tsx 的演示代码。 新建文件 src/pages/usemodel.tsx 写入上面页面的演示代码。运行 pnpm start 你将在页面中看到 hello umi 当你点击它时,它会变成 hello umi@4 实战教学。源码归档
页面级的 hooks 数据流虽然我们假定当你阅读本文档的时候,你已经了解过 react hooks 相关的知识了,但是本次的文档希望面临的受众更广一些,所以依旧会很详细的讲解几个常用的 hooks。如果你无法很轻易的掌握本文档的内容,那你可以通过搜索阅读其他的文档来强化这些概念。此处不给出推荐链接,推荐进行内部的初级开发认证。本文档假定你是按照我们课程设计的顺序进行阅读的,即表示到达这里,你已经熟练掌握了 dva 的相关概念。因此我会直接使用 dva 的概念来类比介绍 react hooks 的概念。但事实上 react hooks 的概念是要比 dva 来的更容易掌握的。这么做的目的也是为了让你再次熟悉 dva。为什么都不是最佳实践了,我还要一直提 dvauseState我们经常在项目中看到如下的代码:import { useState } from 'react'; const [state, setstate] = useState(initialState) 复制代码这里我们以 dva 的概念类比理解:initialState 与 init state// initialState state: { name: 'learn umi', // 如果我们想像上面这样定义和初始化变量,我们可以使用如下 hooks const [name, setName] = useState('learn umi'); 复制代码name 与 select state在我们定义好变量之后,在后续的任何时候取值,都能取到最新的数据。// hooks const [name, setName] = useState('learn umi'); const newName = name + 1; // dva 中 const { name } = yield select(_=>_.global); const newName = name + 1; 复制代码setName 与 reducers save// hooks const [name, setName] = useState('learn umi'); setName('Umi 入门教程') // dva 中 yield put({ type: 'save', payload: { name: 'umi 入门教程' }, 复制代码注意:调用 setName 是要注意深浅拷贝的问题,你可以简单的记忆,当你 set 的数据是一个数组或者对象时,记得使用解构(...)返回一个新的对象。async/await 与 effectsconst delay = () => new Promise(resolve => { setTimeout(resolve, 1000); // hooks const [list, setList] = useState(['step1', 'step2', 'step3', 'step4']); const deleteItem = async item => { await delay() list.splice(list.findIndex(e => e === item), 1) setList([...list]) // dva 中 state: { name: 'learn umi', list: ['step1','step2','step3','step4'] effects: { *deleteItem({ payload }, { call, put,select }) { const { list } = yield select(_=>_.global); yield call(delay) list.splice(list.findIndex(e => e === payload), 1) yield put({ type: 'save', payload: { list }, 复制代码整理新建文件 src/pages/useState.tsximport React, { useState } from "react"; import { Link } from "umi"; export default function List() { const [list, setList] = useState(["step1", "step2", "step3", "step4"]); const [name, setName] = useState("learn umi"); const delay = () => new Promise((resolve) => { setTimeout(resolve, 1000); const deleteItem = async (item: any) => { await delay(); list.splice( list.findIndex((e) => e === item), setList([...list]); return ( <div> <h1>{name}</h1> <button onClick={() => { setName("Umi 入门教程"); Click Me! </button> <Link to="/">Go to index page</Link> <ul> {list.map((i) => ( <li key={i}> <button onClick={() => { deleteItem(i); 删除{i}{" "} </button> </li> </ul> </div> 复制代码访问 http://localhost:8888/useState,因为我们之前给 render 加了 3 秒延时渲染,所以你将需要等待 3 秒才能看到页面,点击 Click Me! 页面上的文字,从 learn umi 变为 Umi 入门教程。点击下方的按钮,被点击的按钮,将会在延迟1秒之后,从页面中被移除。到这里,我们就将 setState 的概念讲解完毕了,你很容易发现,在 react hooks 中就一行代码的逻辑,在 dva 中却需要跨越两个文件写比较多的模版代码。这也是我们现在不推荐在项目中重度使用 dva 的主要原因。由于 react hooks 无法在 组件之外使用,因此我们依旧需要保留 dva 用作一些全局的数据管理和在一些组件之外操作数据的场景。useEffect在 dva 中也有一个同名的概念 effects,之所以没有拿它和 useEffect 类比,是因为他们不太像是一个东西,放在一起反而容易混乱。比起 effects,useEffect 更像 dva 中的 subscription。你会发现在前面的概念和实战中,我们都没有提到这个概念。因为在实际的项目中,我们发现使用 useEffect 会比 subscription 逻辑更加清晰。如果你对 subscription 感兴趣,你可以查看 dva 的文档了解更多。useEffect 接收一个包含命令式、且可能有副作用代码的函数。useEffect(didUpdate); 复制代码当 useEffect 只接收一个函数时,表示函数在每一次页面渲染完成之后调用。在项目中我们常用的方法是当达成某一个条件之后,再执行某个函数这样的逻辑。因此我们在第二参数传入一个数组,数组里面是我们期望这些值变化的时候,触发函数调用。比如,当 name 值发生变化时调用:import { useState, useEffect } from 'react'; const [name, setName] = useState('learn umi'); useEffect(() => { console.log('name value change!'); }, [name]) 复制代码注意:name 值发生变化的时机包括 name 的初始化数据。即此时的 useEffect 的函数至少会被调用一次。当你希望页面初始化的时候,调用 effect 时,你可以在第二参数传入一个空数组([])。这就告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行。import { useState, useEffect } from 'react'; useEffect(() => { console.log('page init'); }, []) 复制代码当你需要在组件中绑定监听或者定时器时,你也可以在此时机中执行。但是请一定要记得在组件卸载时需要清除 effect 创建的诸如订阅或计时器 ID 等资源。要实现这一点,只需要在 effect 函数中,返回清除函数即可。比如,清除计时器 ID:import { useState, useEffect } from 'react'; useEffect(() => { console.log('page init'); const timekey = setInterval(() => { console.log('每秒调用一次'); }, 1000); return () => { // 清除 clearInterval(timekey); }, []) 复制代码比如,清除订阅:import { useState, useEffect } from 'react'; useEffect(() => { const subscription = props.source.subscribe(); return () => { // 清除订阅 subscription.unsubscribe(); 复制代码开发技巧一般情况下 name 都是能取到最新的值的,但如果你是在一个比较复杂的环境中使用它,并且你无法保证它一定是最新值的时候,在使用 setName 的时候,可以使用函数式的设置方式,如 setName(name=>name+1) 该用法会先接收最新的值,可以让他脱离外层的引用,这在 useEffect 回调中频繁修改数值的时候,会非常好用。 比如,我们每秒修改一次数值:import { useState, useEffect } from 'react'; const [count, setCount] = useState(0); useEffect(() => { console.log('page init'); const timekey = setInterval(() => { console.log('每秒调用一次'); setCount(count=>count+1); }, 1000); return () => { // 清除 clearInterval(timekey); }, []) 复制代码整理新建文件 src/pages/useEffect.tsximport React, { useState, useEffect } from "react"; export default function List() { const [name, setName] = useState("learn umi"); const [effect, setEffect] = useState("no"); const [count, setCount] = useState(0); useEffect(() => { setEffect(name === "umi 入门教程" ? "yes" : "no"); }, [name]); useEffect(() => { console.log("page init"); const timekey = setInterval(() => { console.log("每秒调用一次"); setCount((count) => count + 1); }, 1000); return () => { // 清除 clearInterval(timekey); }, []); return ( <div> <h1>count:{count}</h1> <h1>{name}</h1> <h1>name change:{effect}</h1> <button onClick={() => { setName("umi 入门教程"); Click Me! </button> </div> 复制代码访问 http://localhost:8888/useEffect,你将在页面上看到一个自动累加的计数器,点击 Click Me! 页面上的文字,从 learn umi 变为 Umi 入门教程。name change: 也从 no 变成 yes。总结在开发中我们最常用的 react 官方 hooks 就是 useState 和 useEffect。对于官方的其他 hooks,对我们的开发也有很大的帮助,当你熟练掌握 useState 和 useEffect 之后,你应该去官网全面的学习全部的 react hooks。到目前位置,我们详细的讲解了我们在项目中会使用到的 dva 数据流和页面级的 hooks 数据流。熟练的掌握这些概念,会够大幅度的提升你的开发体验。因为不管再复杂的页面,都可以拆成这样一传一传的数据流。可以说掌握这些概念,就已经可以让你很好的编写前端逻辑了。源码归档
因为 dva 不再是 Umi 数据流最佳实践方案,因此这篇你可以选择跳过,本文中的 demo 也不会出现在源码归档中。首先我们来看一下我们需要完成的需求。写一个列表,包含删除按钮,点删除按钮后延迟 1 秒执行删除。这里我们只关注 dva 的相关逻辑,页面样式不一定与上述一致。新建 models 文件在 Umi 中,我们约定当 src/models 下存在 dva 的 models 文件时,会被自动加载到项目中。 即约定了存在即生效,因此我们约定仅在这个文件夹下存放 dva 的 models 文件,虽然存放其他的文件会被框架自动过滤忽略,但是在这个目录存放其他文件会增加理解的心智负担,建议不要存放无关的文件。且为了管理方便,一般将文件名和 models 的 namespace 一一对应,不仅可以直观的找到相应的 models ,而且能够保证不会存在 models 冲突的问题,因为文件系统本身就不允许同名文件。新建文件 src/models/global.jsconst GlobalModel = { state: { name: 'learn umi', export default GlobalModel; 复制代码在页面绑定 model这里我们再次复习一下 connect 函数。import { connect } from 'umi'; connect(映射函数)(组件); 复制代码// 映射函数 const mapStateToProps = state => state; 复制代码connect 函数会自动给映射函数传入整个项目的 state,它包含了整个项目的所有的 models。 比如此时我们新建了一个 global 的 models ,那么这时候传入的 state 就是 { global }。映射函数返回的属性,会被自动传入被绑定的组件,比如我们将上面的映射函数修改为:// 映射函数 const mapStateToProps = state => { global: state.global; 复制代码就可以直接从组件的 props 里面取出 global 的属性。这就是 connect 函数做的事情。将上面的代码整理之后,我们就可以得到一下的页面绑定代码,以下的代码,用到了匿名函数还有一些 es6 的缩写方式,如果你不能直观的理解它们,你可以再看看上面的分析过程。这是一个必须要掌握且很容易被忽略的知识。请注意下方代码中出现三次的 global,我分别在注释中加了少量的说明,增加理解。不明白的,再看一下上述的分析过程。import { Link, Helmet, connect } from 'umi'; import { Button } from 'antd-mobile'; // 这里的 global 是 connect 向组件中注入的属性 const IndexPage = ({ global }) => { const { name } = global; return ( <div> <h1 style={{ color: 'white' }}>{name}</h1> <Helmet> <title>umi 入门教程</title> </Helmet> <Button type="primary">Click Me!</Button> <Link to="/list">Go to list page</Link> </div> // 第一个 global 是指从 state 中取出 global model // 第二个 global 是指返回一个 global 属性,因为是同名所以此处缩写,其实是 { global:global } export default connect(({ global }) => ({ global }))(IndexPage); 复制代码从 model 取数据我们可以关注到这个地方,我们从组件的 props 中取出了 global 属性,并从中取出了 name。const { name } = global; 复制代码这里的数据结构其实是你在 models 中定义的数据结构,即:state: { name: 'learn umi', 复制代码比如这里我们定义了这个 model 的 state 是一个对象,所以绑定到页面中时,global 就是一个对象,你可以根据自己的真实需要定义这里的数据类型。比如:// 这里定义了是一个数字类型 state: 0; // 因此在 page 中的 global就是一个数字类型,当你再次取 global.name 的时候就会导致程序出错 复制代码编写页面事件你可以简化的理解这一个过程,数据绑定是通过单项传递的方式绑定到页面中的,数据修改和变更是通过抛出事件的方式来修改的。这一整个数据流就是这么简单。(里面还有很多细节,但是我们先不理会。)所以我们需要修改当前页面的数据,我们就会调用到另一个非常常用的函数,dispatch 。其实通过 connect 函数绑定后的函数,不仅会被传入 model 的属性,还会被传入 dispatch 属性,即你可以通过组件的 props 取出 dispatch。const IndexPage = ({ global, dispatch }) => { return ( <button onClick={() => { dispatch({ type: 'global/changeName', payload: { name: 'umi 入门教程', click me! </button> 复制代码这里调用 dispatch 通常需要传入两个参数,type 和 payload。 你可以理解为,这里你发送一个短信,type 就是地址,payload 就是内容。 此处我们用到的 global/changeName 也是一个约定式的写法, / 前面的字符串代表了 models 的 namespace。即我们的 models 的文件名。 / 后面的字符串代表了 models 的 action,也就是定义好的事件,我们需要在 model 中的 effects 里写明。state: { name: 'learn umi', effects: { *changeName({ payload }, { call, put }) { console.log(payload); // {name: "umi 入门教程"} 复制代码到这里,dispatch 的工作就完成了。它只关心从页面上触发了一个什么事件,需要把什么内容发送给谁。 至于后续这个数据会有什么作用,会产生什么副作用,它都不再关心。副作用与更新数据这时候数据就有页面交接到了 model,我们在 effects 中处理这些副作用,我这里所谓的副作用包括页面的变化和数据的变化。 比如我们可以在这里请求服务端的接口,也可以直接在此处修改数据。最终我们将处理后的数据,返回到 reducers 事件中。在 Umi 中,我们建议只写一个 reducers 事件 save, 即reducers: { save(state, action) { return { ...state, ...action.payload, 复制代码你可以直接将它当作 model 里的 setState 使用。用法如下:const GlobalModel = { namespace: 'global', state: { name: 'learn umi', effects: { *changeName({ payload }, { call, put }) { console.log(payload); yield put({ type: 'save', payload: { name: payload.name }, reducers: { save(state, action) { return { ...state, ...action.payload, export default GlobalModel; 复制代码effects 中的 put 函数,作用和 dispatch 类似,不过它是表示向 reducers 抛出一个事件。 这里接受的 type 和 paylaod 与 dispatch 相同,不过在同一个 model 内部,type 可以省略 / 之前的 namespace。保存代码,查看 http://localhost:8000/#/,这时候当你点击 “Click Me!” 按钮时,页面上的文字,将从 “learn umi” 变为 “umi 入门教程”。我们回过头来看 effects ,每个 effects 定义时都有一个 * 标志,这表示它是一个 Generator 函数。如果你不太懂得什么是 Generator 函数,那你只需要记住,在编写 effects 时,不要遗漏这个符号即可。然后它在函数内部的每一个步骤都使用 yield 来定义,你可以使用 async/await 的概念来做类比,帮助理解。但其实此处更加简单,当你调用函数之外的函数时,你就使用 yield 做一个标记,而无需理会这个调用函数的同步还是异步。effects 函数的第二参数中除了 put ,常用的还有 call 和 select 即: { call, put, select }call 就是调用其他函数,这个比较好理解。select 的作用是查找 state,比如如果你想获得最新的 global 的 state。 可以这么写:const globalState = yield select(_=>_.global);如果你理解了上面的概念,那现在回到我们的问题上:写一个列表,包含删除按钮,点删除按钮后延迟 1 秒执行删除。 对于现在的你是不是有了一点概念了。step1:先在 state 中增加一个数组数据 list:['step1','step2','step3','step4']step2:将数据渲染到页面中step3:点击删除按钮,向 effects 发送一个事件step4:调用睡眠函数,等待一秒钟step5:查询先有 global state 并删除我们点击的对象对应的数据step6:将修改后的数据同步到 gloabl state 中(会自动触发页面数据同步)demo 演示// src/models/global const GlobalModel = { namespace: 'global', state: { name: 'learn umi', list: ['step1', 'step2', 'step3', 'step4'], effects: { *changeName({ payload }, { call, put }) { yield put({ type: 'save', payload: { name: payload.name }, *deleteItem({ payload }, { call, put, select }) { const { list } = yield select(_ => _.global); yield call( () => new Promise(resolve => { setTimeout(resolve, 1000); list.splice( list.findIndex(e => e === payload), yield put({ type: 'save', payload: { list }, reducers: { save(state, action) { return { ...state, ...action.payload, export default GlobalModel; 复制代码// src/pages/index import { Link, Helmet, connect } from 'umi'; import { Button } from 'antd-mobile'; const IndexPage = ({ global, dispatch }) => { const { name, list = [] } = global; return ( <div> <h1 style={{ color: 'white' }}>{name}</h1> <Helmet> <title>umi 入门教程</title> </Helmet> <Button type="primary" onClick={() => { dispatch({ type: 'global/changeName', payload: { name: 'umi 入门教程', Click Me! </Button> <Link to="/list">Go to list page</Link> <ul> {list.map(i => ( <li key={i}> <Button onClick={() => { dispatch({ type: 'global/deleteItem', payload: i, 删除{i}{' '} </Button> </li> </ul> </div> export default connect(({ global }) => ({ global }))(IndexPage); 复制代码按照本教程的预期,你应该能够清晰的理解以上的代码片段,如果你在阅读上存在任何的疑问,你可以反复的阅读,直到你真正掌握为止。总结当你理解了 dva 你会觉得在项目中使用它会很清晰和舒服,但是我们现在不推荐在项目中重度的使用它。因为页面的私有数据流如果暴露到全局的数据流当中,反而会增加页面的维护难度。我们会在下一节课《纯 hooks 的数据流》中,介绍如何在页面中维护页面私有的数据。
一般你会在官网上看到 Umi 的配置分为普通配置和运行时配置。在本文中我们都会详细说明,并且还会聊一聊其他的官网没有明说的配置。普通配置是在构建时读取的,你可以理解为传给 webpack 的配置。(当然实际上,它最终也给传给了其他的工具或者组件)这意味着它是运行在 node 环境中的,所以你可以在普通配置文件中,去使用一些 node 的 api.而运行时配置时是跑在浏览器端的,这意味着你可以在运行时配置的文件中写函数、jsx还有其他的一些浏览器端的依赖。注意在这里使用 node 的 api 会导致程序崩溃。配置Umi 的配置文件两个 .umirc.ts 和 config/config.ts。Umi 中约定的文件都支持 ts 和 js。你可以根据自己项目中使用的语言做出合理的选择,因为这个课程推荐全程使用 typescript 开发,所以在说明的时候,我会直接写明文件后缀是 ts|tsx。.umirc.ts与 config/config.ts 文件功能相同,2 选 1 。.umirc.ts 文件优先级较高配置文件,包含 Umi 内置功能和插件的配置。config/config.ts与 .umirc.ts 文件功能相同,2 选 1 。.umirc.ts 文件优先级较高配置文件,包含 Umi 内置功能和插件的配置。配置文件实践新建 config/config.tsexport default { 复制代码为了获得更好的开发体验,我们引入 Umi 导出的配置文件定义方法 defineConfig。import { defineConfig } from "umi"; export default defineConfig({ 复制代码以上两种写法,在功能上是没有区别的,但是有个好处是,我们在编写配置项的时候,会有“联想”推荐和类型校验,比如,你输入一个字母 t,就会自动联想推荐 Umi 支持的所有配置,这个配置项还会根据你引用的插件数量而发生变化。import { defineConfig } from "umi"; export default defineConfig({ // targets // (property) targets?: { // [key: string]: any; // } | undefined // terminal // (property) IConfigFromPlugins.terminal?: {} | undefined // theme // (property) IConfigFromPlugins.theme?: {} | undefined // title // (property) IConfigFromPlugins.title?: string | undefined // tmpFiles // (property) IConfigFromPlugins.tmpFiles?: boolean | undefined 复制代码并且在你的值写错时 VS Code 就会直接给出标红提示,而不用等到运行构建时,才能暴露问题。这在开发前期你不熟悉 Umi 框架的时候,将会非常有用。在你觉得某个功能没有按照预期表现时,你可以很好的排查你的配置没有写错。import { defineConfig } from "umi"; export default defineConfig({ title:[123] // 此处标红,鼠标移上去,会有详细的错误说明 // 不能将类型“number[]”分配给类型“string”。ts(2322) // pluginConfig.d.ts(9, 1): 所需类型来自属性 "title",在此处的 "ConfigType" 类型上声明该属性 复制代码将上面的 title 配置修改为正确的 string 类型:"Hello Umi"。运行项目(pnpm start),你将在浏览器中看到也看的 title 从 http://127.0.0.1:8000/ 变成了 Hello Umi。其他的配置,我们将会在后续用到的时候逐个说明。运行时配置运行时配置,光从字面上理解,那就是为框架提供一些动态的配置数据,简单的概括成一句废话就是运行时配置是运行时使用的配置。这意味着其实我们可以在这里面执行一些异步的 Effect。首先运行时里面支持的所有配置和普通配置一样,都是有 Umi 插件扩展而来的。所以如果你遇到在使用一个配置时,Umi 告诉你这个配置不存在,那最大的可能就是你少用了某个插件。Umi 的运行时配置文件只有一个 src/app.ts|x。我们以 render 为例来简要说明,感受运行时配置的特性。新建 src/app.tsexport function render(oldRender: any) { fetch("/api/auth").then((auth) => { if (auth.isLogin) { oldRender(); } else { history.push("/login"); oldRender(); 复制代码render 是一个函数,会传入旧的页面 render 函数。最简单的理解这个配置,你可以把它当作一种阻断页面渲染的手段,就是当你配置了 render 你就暂停了页面的渲染,当你执行完所有你需要的代码逻辑之后,你调用 oldRender 就可以让页面继续渲染了。如上述的伪代码中,我们先请求了 /api/auth 接口,检测用户是否已登录,如果已登录(isLogin)就继续渲染页面,如果未登录,就跳转到登录页面("/login")。在这里你可以执行任意的逻辑,甚至如果你觉得你的页面白屏(或者loading)时间太短,你可以在这里倒数三秒再开始渲染页面。export function render(oldRender: any) { setTimeout(() => { oldRender(); }, 3000); 复制代码重新访问开发服务,刷新页面,你需要等待至少 3 秒,才能看到页面被正确渲染。其他配置除了官网上提到的普通配置和运行配置,其实我们在使用 Umi 开发项目的时候,还会用到一些其他的配置,比如环境变量,或者在插件中配置等手段,这些内容在官网的文档中都有,但是并不是被归类到 Umi 的配置中。环境变量定义环境变量有两个地方,一个是 .env 文件新建 .envPORT=8888 复制代码运行开发服务,服务端口号被正确修改为 8888ready - App listening at http://127.0.0.1:8888 复制代码另一个地方是启动 umi 命令的时候,一起传入.如在 package.json 中配置 ANALYZE=1{ "name": "umi4-course", "scripts": { "start": "umi dev", "build": "ANALYZE=1 umi build" 复制代码执行 pnpm build,你可以在项目构建介绍,访问 http://127.0.0.1:8888 查看构建产物中都包含哪些依赖。将构建产物添加到 gitignore 中,增加 dist。需要注意的是,在执行命令时配置环境变量,有平台的差异。# OS X, Linux $ PORT=3000 umi dev # Windows (cmd.exe) $ set PORT=3000&&umi dev 复制代码一般我们都使用 cross-env 来消除平台差异$ pnpm install cross-env -D $ cross-env PORT=3000 umi dev 复制代码{ "name": "umi4-course", "scripts": { "start": "umi dev", "build": "cross-env ANALYZE=1 umi build" 复制代码插件中配置 Umi在插件开发中使用 modifyConfig 修改用户的配置,这是我最喜欢用的一种方式,因为可以“黑盒”的修改一些 Umi 的配置,其实也相当于修改了 Umi 的默认行为,比如在同一个团队中,我们可以将一些共同的配置,放到插件中,这样每一个项目的配置文件就会非常的干净。同团队中,会大大减少配置文件的维护工作。像 alita 中,使用 modifyConfig 修改了 alita 项目中默认的一些配置信息,这样 alita 项目中的配置文件就可以很干净。加入说我们不使用 modifyConfig,而是要求用户严格编写如下配置,才能正常使用。这无疑会大大的增加框架的维护成本。const configDefaults: Record<string, any> = { history: { type: 'hash' }, title: false, // 默认内置了 Helmet targets: { ie: 9, hash: true, hd: api.userConfig.appType !== 'pc' ? {} : false, dva: { enableModelsReExport: true, model: {}, request: {}, displayName: 'alita-demo', conventionRoutes: { // 规定只有index文件会被识别成路由 exclude: [ /(?<!(index|\[index\]|404)(\.(js|jsx|ts|tsx)))$/, /model\.(j|t)sx?$/, /\.test\.(j|t)sx?$/, /service\.(j|t)sx?$/, /models\//, /components\//, /services\//, ...api.userConfig, api.modifyConfig((memo: any) => { Object.keys(configDefaults).forEach((key) => { memo[key] = configDefaults[key]; return memo; 复制代码在插件开发中设置环境变量会非常的简单,直接将环境变量设置成对应的值即可。编写项目中的插件新建 plugin.ts存在 plugin.ts 这个文件,会被当前项目加载为 Umi 插件,这属于 Umi 的约定行为,你可以在这里解一些需要插件级支撑的问题。出了默认加载这个插件,你也可以使用 plugins 配置,引入项目中的其他相对路径的插件文件。import { IApi } from "umi"; export default (api: IApi) => { // 通过插件设置环境变量 process.env.COMPRESS = "none"; // 通过插件修改配置 api.modifyConfig((memo: any) => { memo.title = "Hello Umi"; return memo; 复制代码通过上面的插件,我们定义了一个环境变量 COMPRESS = "none",它的作用时在执行 umi build 构建的时候不压缩代码,这在定位线上 bug 的时候非常的有用。当然我们这里只是演示,真实的项目中,肯定不能让这个构建一直保持不压缩,而是应该在线上出现 bug 时,临时使用一次这个变量,编译一次项目。我们还将用户的配置 title 修改成了 "Hello Umi",这样我们就可以删掉本地配置文件中的 title 配置。看起来就像是 Umi 默认的 title 就是 Hello Umi。当然我们也可以在这里写上其他的配置,修改 Umi 的其他默认行为,定制化属于自己的一个 Umi 框架。源码归档
虽然 Umi 中的 dva 已经不是官方推荐的最好的数据流管理方案了,但是学习 dva 的时候,其实更有利于我们后面熟悉纯 hooks 的数据流管理方案。在 Umi 中我们对请求方法做了高效的封装,对开发中遇到的请求相关的服务都做了内置功能。比如 mock 数据、请求代理、统一请求地址配置、接口文件组织等都有鲜明的 Umi 风格。在接下来的几个课程中,我们会详细的说明,Umi 在数据获取方面提供的能力和服务。Umi 将如何帮助你高效的完成数据获取和绑定的工作。学完这节课您将会掌握 dva 的基本入门。dva什么是 dva?dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架。通常在 dva 的项目中,你需要掌握 (6 个 API](dvajs.com/guide/conce… redux 的概念已经很少了,但是在 Umi 项目中,你需要掌握的 API 数是 0。 即一个也不需要掌握。因为在 Umi 中通过约定的方式组织代码,框架自动完成了相应 API 的执行。比如,在 src/models 中新建文件,就会被自动使用 app.model 绑定到 dva 中。为什么要用 dva经过一段时间的自学或培训,大家应该都能理解 redux 的概念,并认可这种数据流的控制可以让应用更可控,以及让逻辑更清晰。但随之而来通常会有这样的疑问:概念太多,并且 reducer, saga, action 都是分离的(分文件)。这带来的问题是:编辑成本高,需要在 reducer, saga, action 之间来回切换不便于组织业务模型 (或者叫 domain model) 。比如我们写了一个 userlist 之后,要写一个 productlist,需要复制很多文件。还有一些其他的:saga 书写太复杂,每监听一个 action 都需要走 fork -> watcher -> worker 的流程entry 书写麻烦...而 dva 正是用于解决这些问题。什么时候需要使用 dva在 react hooks 上线之后。在 Umi 项目中,我们建议轻量的使用 dva。仅仅在以下几种场景下推荐使用 dva:父子组件之间的数据互通多页面之间的数据传递(即,公共数据)非 react 组件的场景dva 入门课::: tip 内容来自之前为阿里内部同学准备的入门课。根据 Umi 中的使用情况,对无需关注的概念做了删减。 :::React 没有解决的问题React 本身只是一个 DOM 的抽象层,使用组件构建虚拟 DOM。如果开发大应用,还需要解决一个问题。通信:组件之间如何通信?数据流:数据如何和视图串联起来?路由和数据如何绑定?如何编写异步逻辑?等等通信问题组件会发生三种通信。向子组件发消息向父组件发消息向其他组件发消息React 只提供了一种通信手段:传参。对于大应用,很不方便。组件通信的例子步骤 1class Son extends React.Component { render() { return <input />; class Father extends React.Component { render() { return ( <div> <Son /> <p>这里显示 Son 组件的内容</p> </div> ReactDOM.render(<Father />, mountNode); 复制代码看这个例子,思考一下父组件如何拿到子组件的值。步骤 2class Son extends React.Component { render() { return <input onChange={this.props.onChange} />; class Father extends React.Component { constructor() { super(); this.state = { son: '', changeHandler(e) { this.setState({ son: e.target.value, render() { return ( <div> <Son onChange={this.changeHandler.bind(this)} /> <p>这里显示 Son 组件的内容:{this.state.son}</p> </div> ReactDOM.render(<Father />, mountNode); 复制代码看下这个例子,看懂源码,理解子组件如何通过父组件传入的函数,将自己的值再传回父组件。数据流图核心概念State:一个对象,保存整个应用状态View:React 组件构成的视图层Action:一个对象,描述事件connect 方法:一个函数,绑定 State 到 Viewdispatch 方法:一个函数,发送 Action 到 StateState 和 ViewState 是储存数据的地方,收到 Action 以后,会更新数据。View 就是 React 组件构成的 UI 层,从 State 取数据后,渲染成 HTML 代码。只要 State 有变化,View 就会自动更新。ActionAction 是用来描述 UI 层事件的一个对象。{ type: 'click-submit-button', payload: this.form.data 复制代码connect 方法connect 是一个函数,绑定 State 到 View。也支持高阶函数的用法。import { connect } from 'dva'; function mapStateToProps(state) { return { todos: state.todos }; connect(mapStateToProps)(App); 复制代码connect 方法返回的也是一个 React 组件,通常称为容器组件。因为它是原始 UI 组件的容器,即在外面包了一层 State。connect 方法传入的第一个参数是 mapStateToProps 函数,mapStateToProps 函数会返回一个对象,用于建立 State 到 Props 的映射关系。dispatch 方法dispatch 是一个函数方法,用来将 Action 发送给 State。dispatch({ type: 'click-submit-button', payload: {}, 复制代码dispatch 方法从哪里来?被 connect 的 Component 会自动在 props 中拥有 dispatch 方法。connect 的数据从哪里来? connect 方法传入的第一个参数是 mapStateToProps 函数,该函数默认传入一个参数 state 对应了整个应用的 state,你可以通过设置映射关系,将 state 中的某个值,绑定到页面组件的 props 里面。数据流图model 最简结构export default { namespace: 'count', state: 0, reducers: { add(state) { return state + 1; effects: { *addAfter1Second(action, { call, put }) { yield call(delay, 1000); yield put({ type: 'add' }); 复制代码Model 对象的属性namespace: 当前 Model 的名称。整个应用的 State,由多个小的 Model 的 State 以 namespace 为 key 合成state: 该 Model 当前的状态。数据保存在这里,直接决定了视图层的输出reducers: Action 处理器,处理同步动作,用来算出最新的 Stateeffects:Action 处理器,处理异步动作ReducerReducer 是 Action 处理器,用来处理同步操作,可以看做是 state 的计算器。它的作用是根据 Action,从上一个 State 算出当前 State。一些例子:// count +1 function add(state) { return state + 1; // 往 [] 里添加一个新 todo function addTodo(state, action) { return [...state, action.payload]; // 往 { todos: [], loading: true } 里添加一个新 todo,并标记 loading 为 false function addTodo(state, action) { return { ...state, todos: state.todos.concat(action.payload), loading: false, 复制代码EffectAction 处理器,处理异步动作,基于 Redux-saga 实现。Effect 指的是副作用。根据函数式编程,计算以外的操作都属于 Effect,典型的就是输入输出操作,全局 dom 变化,访问服务端数据等。function* addAfter1Second(action, { put, call }) { yield call(delay, 1000); yield put({ type: 'add' }); 复制代码Generator 函数Effect 是一个 Generator 函数,内部使用 yield 关键字,标识每一步的操作(不管是异步或同步)。call 和 putdva 提供多个 effect 函数内部的处理函数,比较常用的是 call 和 put。call:执行异步函数put:发出一个 Action,相当于 View 里面的 dispatch到这里,我们就将 dva 的基本概念讲解清楚了,如果你不是很理解,建议你多看几遍。如果你稍微有了一点概念,那就可以在后续的课程中慢慢掌握以上所有概念。这样你会更加清楚的了解到如何在项目中使用 dva。感谢阅读,今天不需要写任何的代码,只需要简单的搞懂 dva 的概念即可。我们会在后续手动创建 dva 插件来完成上述提到的,为什么 dva 中的 6 个概念,正在的业务开发中可以一个都不用掌握。
资产首先,让我们讨论一下 Umi 如何处理 静态资产 (如图像)。新建文件夹 src/assets ,值得注意的是,这并不是一个“约定目录”,仅仅是一个建议目录,我们希望能有一个目录统一管理我们的静态资源。将https://umijs.org/ 官网的 Umi logo 图片下载下来,放到这个文件夹下,即 src/assets/logo.pg。在组件中使用图片,我们可以将它当作一个模块直接引入,比如我们在 index 页面中使用它:import logoImg from '@/assets/logo.png'; // 剩余内容省略 export default () => { return ( <div> <img src={LogoImg} width={150} /> </div> 复制代码Umi 默认将 @ 映射到项目的 src 目录中,所以你可以在项目的任意位置使用 @/component 或 @/utils 之类的路径来引入模块和方法类。你将不再需要使用到如 ../../../componet 之类的相对路径。在 css 中同样支持别名,只是别忘了在 css 中使用别名需要增加 ~ 前缀。.logo { background: url(~@/foo.png); 复制代码通过路径引入图片的时候,如果图片小于 10K,会被编译为 Base64,否则会被构建为独立的图片文件输出到构建目录的 static 目录中。10K 这个阈值可以通过 inlineLimit 配置修改。如:{ inlineLimit: 10000; // 10K 复制代码如果你有一些希望原样被拷贝到构建目录的文件,如 robots.txt、Google 站点验证和其他任何静态资产希望被拷贝到构建目录中,则可以将他们放置在顶层的 public 目录中。注意:不能存在 public/index.html 和其他任何与构建产物同名的文件,不然构建产物将会被覆盖,导致不可预知的错误。修复资产类型错误引入 import logoImg from '@/assets/logo.png'; 之后,会有一个类型错误,这是因为 typescript 无法识别 png 后缀的文件名。因此我们可以在项目中添加一个 typings.d.ts 来修复这些不是别的文件后缀。当然如果有些库,你真的无法找到它对应的 types 库,那你也可以在这个文件中定义它或者忽略它。declare module '*.css'; declare module '*.less'; declare module '*.scss'; declare module '*.sass'; declare module '*.svg'; declare module '*.png'; declare module '*.jpg'; declare module '*.jpeg'; declare module '*.gif'; declare module '*.bmp'; declare module '*.tiff'; declare module '*.json'; 复制代码将 svg 直接当成组件使用CSS 样式接下来我们来聊一聊 CSS 样式。 打开我们首页的代码,即 src/pages/index。import React from "react"; import { Link, useSearchParams, createSearchParams, useLocation, } from "umi"; import logoImg from '@/assets/logo.png'; // 需要开启 svgr 配置之后才可用 // import UmiLogo from '@/assets/umi.svg'; export default () => { const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); console.log(location); const a = searchParams.get("a"); const b = searchParams.get("b"); return ( <div> Index Page <img src={logoImg} width={150}/> {/* <UmiLogo/> */} <p> <Link to="/user">Go to user page</Link> </p> <p> SearchParams ---- a:{a};b:{b} </p> <p>State ---- {JSON.stringify(location?.state)}</p> <button onClick={() => { setSearchParams(createSearchParams({ a: "123", b: "456" })); Change SearchParams </button> </div> 复制代码如你所见,我们在首页编写了 html 的 demo。你可以理解为我们搭建了页面的‘骨架’。但是往往我们需要页面按照我们的设计来呈现样式,因此我们还需要加上 CSS 样式,来声明 html 标签的渲染样式。编写和导入 CSSUmi 内置支持 CSS 和 Less 的支持,允许你导入 .css 和 .less 文件 。新建一个 src/pages/index.less 文件:.main{ font-size: 35px; 复制代码在首页中引入 import "./index.less"这样我们就可以在页面中使用我们定义好的样式了import './index.less'; // 剩余内容省略 export default () => { return ( <div className="main"> </div> 复制代码运行 umi dev,访问页面,你将会看到首页的 font-size 变成 35px。auto CSS ModulesCSS Modules一般我们在别的框架中使用 CSS Modules 是要引入如 index.module.less 之类的文件,Umi 则通过 babel 插件来实现,只要引用 index.less 就会自动使用 CSS Modules。我们使用 CSS Modules 的方式在 user 页面中使用样式import styles from './index.less'; // 剩余内容省略 export default () => { return ( <div className={styles.main}> </div> 复制代码通过浏览器开发者工具查看页面 dom ,你可以找到 user 页面的 div 的 class 的类名是如 main___us1GC 这样的带有一个随机 哈希值的类名。(index 页面中则是 main)所以这个也是我们使用 CSS Modules 的另一个目的,能够很好的隔离页面的样式,这样不管我们一个项目中有多少个人一起开发,正常样式编写的话,会极少的发生样式污染事件。源码归档
调整项目目录结构在项目中使用 TypeScript 并不仅仅是为了让代码中的类型更加明确,对于有些还没学习过 TypeScript 的部分朋友有些本能的排斥,这是很正常的现象。 但是在项目中使用 TypeScript 其实和框架中使用 TypeScript 是完全不一样的,对于不懂 TypeSctipt 的朋友(当然现在不懂的挺少的吧!嘻嘻嘻)你就当做 js 写,只是把文件后缀从 jsx 换成 tsx,剩下的事情交给 VS Code。给 Umi 项目添加 TypeScript 支持你可以参照 TypeScript 官方文档,手动添加配置和使用,但是在 Umi 最简单方式还是使用微生成器,上一节课中,我们介绍了很多微生成器,其实如果你记不住或者说懒得记。你可以直接执行 npx umi g,Umi 会给出当前项目中支持的微生成器。$ cd boilerplate $ npx umi g ? Pick generator type › - Use arrow-keys. Return to submit. ❯ Create Pages -- Create a umi page by page name Enable Prettier -- Setup Prettier Configurations Enable Typescript -- Setup tsconfig.json Enable Jest -- Setup Jest Configuration Enable Tailwind CSS -- Setup Tailwind CSS configuration Enable Dva -- Configuration, Dependencies, and Model Files for Dva Generate Component -- Generate component boilerplate code Generate mock -- Generate mock boilerplate code Generator api -- Generate api route boilerplate code 复制代码我们通过上下方向键,选中我们需要的微生成器,比如现在我们选择 Enable Typescript -- Setup tsconfig.json.✔ Pick generator type › Enable Typescript -- Setup tsconfig.json info - Write package.json info - Write tsconfig.json ... ... Progress: resolved 727, reused 701, downloaded 0, added 13, done devDependencies: + typescript 4.7.2 复制代码umi typescript 是否正确配置判断 Umi typescript 是否正确配置非常的简单,只要看所有从 umi import 的内容是否全部“未找到”。import { Link, useSearchParams, createSearchParams, useLocation } from "umi"; // 以上所有的引用都会标红,错误是 模块“"umi"”没有导出的成员“XXXXX” 复制代码如果未正确配置,则修改 tsconfig.json。修改 tsconfig.jsonUmi typescript 部分的生成器还没有完全完成,后续还有 LSP 之类的功能,因此此时生成的 tsconfig.json 文件中的 paths 针对于项目的话是有错误的。将 paths 改为正确的,能够指到 Umi 临时文件目录"paths": { "@/*": ["*"], "@@/*": [".umi/*"] 复制代码改成"paths": { "@/*": ["./src/*"], "@@/*": ["./src/.umi/*"] 复制代码如果你的 tsconfig 已经是正确的,那就不用修改。修改文件后缀修改文件名 src/pages/index.jsx 为 src/pages/index.tsx 这时你将看到项目标红,鼠标移到标红的位置上,可以看到错误提示:““React”指 UMD 全局,但当前文件是模块。请考虑改为添加导入。ts(2686)” 要修复这个错误需要我们在文件的头部,引入 import React from "react";引入之后发现文件中的错误修复了,但是 import react 这里又有一个新的错误,再次将鼠标移到标红的位置上,你讲看到一个新的错误提示:“找不到模块“react”或其相应的类型声明。ts(2307)”一般 ts 提示找不到模块,要么就是没有安装,要么就是安装的库没有类型。我们可以在项目中安装对应的类型库,如pnpm i @types/react --D 复制代码修复完之后,继续查看页面中存在标红的位置,你可以在 createSearchParams 看到标红,鼠标移上去看到一个错误:“类型“{ a: number; b: number; }”的参数不能赋给类型“URLSearchParamsInit | undefined”的参数。属性“a”的类型不兼容。不能将类型“number”分配给类型“string | string[] | undefined”。ts(2345)”setSearchParams(createSearchParams({ a: 123, b: 456 })); // ERROR: // 类型“{ a: number; b: number; }”的参数不能赋给类型“URLSearchParamsInit | undefined”的参数。 // 属性“a”的类型不兼容。 // 不能将类型“number”分配给类型“string | string[] | undefined”。ts(2345) 复制代码将 123 和 456 改成 '123' 和 '456'。自此,我们修复了当前页面的全部类型错误。看上面的行文,你会发现,我一直在强调“查看编辑器标红,然后鼠标移上去查看错误”,因为这个操作你要形成一种下意识的动作,一定要将页面中的标红全部清空,不能容忍哪怕一处“标红”,因为如果你容忍了一处“标红”,你就会下意识的忽略“标红”,后面你会发现,越复杂的页面你的“标红”错误更多。还有聊聊好处,我们从 url 中取下来的值,会是全部都是字符串的,如果你在传参的时候写的是数字,也会被“默认”转成字符串,这在理解的时候又要耗费心智成本,所以这里类型提醒,会促使我们在传参的时候,就传递字符串。以上操作重复一遍,修改 src/pages/user.jsx 为 src/pages/user.tsx智能提示和补全先聊聊使用 typescript 最直观的好处,那就是智能提示和补全了,当你需要从一个库导出一个 Api 时,可能你只需要输入首字母,VS Code 就会自动罗列出这个库导出的全部 Api,如import { u } from "umi"; // 当你输入一个 'u' // 你将会看到如下列表 // useAppData // useClientLoaderData // useLocation // useMatch // useNavigate // useOutlet // useOutletContext,useParams,useResolvedPath ... 复制代码毫无疑问,这样确实可以极大的提升开发者体验。不仅如此,Umi 还做的更多,Umi 是使用插件方式扩展框架能力的,意味着,很多能力和 Api 并不是内置集成在框架中的,所以 Umi 提供的所有 Api 的清单,和你当前项目使用到的所有插件直接相关。Umi 提供了 import all in umi 的能力,可以较少你少写很多的 import。这个的实现其实也很简单,在 umi 中导出 @@/exports 中的所有导出,我们前面配置 tsconfig.json 的时候,有写到 "@@/*": ["./src/.umi/*"],因此 @@/exports 就会被映射到 ./src/.umi/exports。插件中需要导出的 Api,通过通过 Umi 插件的 Api 写入 ./src/.umi/exports 文件中。所以如果后续你遇到一个 Api 无法工作或者提示找不到,你就可以直接先到这个文件下查看,“导出”是否被正确写入,这将有利于你定位和解决这一类问题。当然如果你在向其他人请教问题或者提交 Issues 的时候,能够提供这些信息,也会大大的减少沟通成本。这将极大的提升框架开发人员的开发体验。源码归档
在项目中的最好的提效方案就是复用,比如将常用模块封装,给到其他页面复用,就是我们经常提到的组件(复制使用的情况)。将项目中的方案进行封装,给到不同的项目中使用,就是我们说到的脚手架。而这些好的组件或者脚手架,要快速的被其他人员使用,就要用到生成器。简单的理解就是使用脚本帮你复制了一份,像很多项目中经常会执行的新建项目就是生成器的一种能力体现。npx create-xxx appName 复制代码Umi 中将生成器的目标定的更加细化,不是提供一整个脚手架,而是提供脚手架中的某一个方案,所以取名微生成器,其实灵感来源是 modern.js 的微生成器。Umi 的微生成器有统一的命令入口调用:umi generate (alias: umi g) [type] 复制代码内置微生成器列表页面生成器比如快速生成一个初始的简单页面,可以使用一下方法方式。交互式输入页面名称和文件生成方式:$ umi g page ? What is the name of page? › mypage ? How dou you want page files to be created? › - Use arrow-keys. Return to submit. ❯ mypage/index.{tsx,less} mypage.{tsx,less} 复制代码或者直接指明页面名称直接生成:$ umi g page foo Write: src/pages/foo.tsx Write: src/pages/foo.less 复制代码如果你的项目风格是使用目录方式,比如基于 Umi 构建的前端框架 alita 中,就只有目录下的 index 文件才会被识别成路由,所以就可以采用以目录方式生成页面,目录下为页面的组件和样式文件:$ umi g page bar --dir Write: src/pages/bar/index.less Write: src/pages/bar/index.tsx 复制代码批量生成多个页面:$ umi g page page1 page2 a/nested/page3 info - @local Write: src/pages/page1.tsx Write: src/pages/page1.less Write: src/pages/page2.tsx Write: src/pages/page2.less Write: src/pages/a/nested/page3.tsx Write: src/pages/a/nested/page3.less 复制代码剩余内置微生成器清单自定义微生成器当然官方提供的基本的微生成器,肯定无法满足我们特定的项目需求,我们可以通过简单的调用和约定来自定义微生成器。 比如官方提供的 g page 只生成了简单的页面。import React from 'react'; import styles from './abc.less'; export default function Page() { return ( <div> <h1 className={styles.title}>Page abc</h1> </div> 复制代码而我们的项目中需要的初始化页面内容可能会更加复杂,要带有请求等页面模版:import React from 'react'; import type { FC } from 'react'; import { useRequest } from 'umi'; import { query } from './service'; import styles from './index.less'; interface HomePageProps {} const HomePage: FC<HomePageProps> = () => { const { data } = useRequest(query); return <div className={styles.center}>Hello {data?.text}</div>; export default HomePage; 复制代码以上模版中提到的能力和内容会在后续的文章中体现,这里仅仅作为一个展示要自定义微生成器,只需要调用 generateFile 指定模版存放的路径,指定需要替换的模版中的变量,既可。generateFile({ path: join(__dirname, '../../../templates/generate/page'), target: join(api.paths.absPagesPath, name), data: { color: randomColor(), name: lodash.upperFirst(name), 复制代码generateFile 的目的是让开发人员在编写微生成器的时候,将精力更加聚焦于目标文件生成时的模版维护工作中。 不许花费而外的心力,去学习微生成器的功能开发。比如我们生成上面的页面,用到的模版如下:import React from 'react'; import type { FC } from 'react'; import { useRequest } from 'alita'; import { query } from './service'; import styles from './index.less'; interface {{{ name }}}PageProps {} const {{{ name }}}Page: FC<{{{ name }}}PageProps> = () => { const { data } = useRequest(query); return <div className={styles.center}>Hello {data?.text}</div>; export default {{{ name }}}Page; 复制代码在为用户提供微生成器时,有一些数据需要用户提供给我们,会有一些交互式的问答功能需要实现,在微生成器中,我们只需要关注问题本身。Umi 中提供了两种问答的手段,(底层是同一套方案)第一种就是在插件中使用的,配合上面提到的 generateFile 使用import { prompts } from '@umijs/utils'; if (!name) { const response = await prompts({ type: 'text', name: 'name', message: 'What is the name of page?', name = response.name; 复制代码使用 Umi 的基础微生成器工具第二种方式就是可以脱离 Umi 的声明周期使用的,是一个基础的生成模块,你可以将它用在任意的项目中,比如我们实现一个脚手架的生成器。const appPrompts = [ name: 'name', type: 'text', message: `What's the app name?`, default: name, name: 'author', type: 'text', message: `What's your name?`, const generator = new BaseGenerator({ path: join(__dirname, '..', 'templates', args.plugin ? 'plugin' : 'app'), target: name ? join(cwd, name) : cwd, data:{ version: require('../package').version, npmClient, registry, questions: appPrompts, await generator.run(); 复制代码我们的关注点就只在模版文件(templates)和问题(questions)这两点上,如果模版有修改或者问题有变更,我们也只需修改这两个地方,不用耗费大量的心力去处理当前复制和写入的是文件还是文件夹,目标文件夹是否为空等文件写入边界问题。以上提供的只是基本的模块使用方式,后续的课程中我们会展示以上代码的完整场景和实现。感谢阅读,今天教程仓库中没写一行代码,就不放源码归档链接了。今天更多的是微生成器的介绍,实际应用场景涉及整个最佳实践方案,我们会在后续的文章中一一体现。
这节课我们主要学习如何通过声明式和命令式的方式实现客户端页面间的导航。并运用 React Route 6 的 API 获取路由上的信息。上一节课之后,我们已经有了两个页面,首页和用户页面,这节课我们将在首页使用声明式方式跳转到用户页面,然后在用户页面使用命令式跳转到首页。声明式在网站的页面之间链接时,你通常使用 <a> HTML 标签。在 Umi 中,你使用了从 umi 导出的 <Link> 组件对应用程序中的不同页面进行客户端导航。首先,在 pages/index.js 中,从 umi 导入 Link 组件,方法是在顶部添加这一行:import { Link } from 'umi'; 复制代码然后修改 div 标记中的这一行:Index Page 复制代码修改为:import { Link } from 'umi'; export default () => <div>Index Page <p><Link to="/user">Go to user page</Link></p> </div>; 复制代码命令式(history、useNavigate)上述内容,我们在首页使用声明式的方法添加了一个跳转到 user 页面的方法,接下来我们通过命令式的方式,从 user 返回首页。接下来,将 pages/user/index.js 的内容改为:import { history, useNavigate } from 'umi'; export default function User() { const navigate = useNavigate(); return ( <div> <h1>User Page</h1> <button onClick={() => history.back()}>go back by history!</button> <button onClick={() => history.push('/')}>go to index by history!</button> <button onClick={() => navigate(-1)}>go back by navigate!</button> <button onClick={() => navigate('/')}>go to index by navigate!</button> </div> 复制代码上面的页面中,我们增加了四个按钮,使用 history 和 useNavigate 分别实现了回退方法,和页面跳转方法到达首页。虽然从效果是来看都是从列表页跳转到了主页,但需要注意的是,使用 back 方法,会撤回一次浏览器历史。也就是说,你无法使用浏览器上面的后退按钮(包括安卓设备上的返回键),返回 user 页面。而使用 push 返回,会增加一个浏览器历史。如果多次在两个页面之间 push ,会导致使用回退按钮返回页面时,会有一个很长的浏览器历史列表。back 方法完全依赖于项目的浏览历史,也就是说完全依赖于你的“前一个页面”。这意味着,当你在当前路由刷新页面时, back 有可能会失效。所以实际项目中,要根据具体的项目逻辑和场景来选择合适的方法。虽然命令式 history 和 useNavigate 看起来都可以实现在页面之间导航,但是需要注意的是 useNavigate 只能用在 React 存在的上下文中,简单的说就是他只能在组件的生命周期中使用,如果你在全局的文件,比如 app.ts 或者 global.ts 文件中就不能使用 useNavigate ,只能使用 history。使用 url 传参(SearchParams)url 传参就是 url 中的 search 对象,来进行页面之间的参数传递,是一种特别常用的前端传参手段,就是我们经常看到 url 中带有一个 ? 后面跟着一些 xx=yy&&dd=mm 之类的字符。 我们可以在页面中取出它们来进行页面逻辑编写。首先我们介绍一下 Umi 中如何取到 url 中的参数。import { useSearchParams } from 'umi'; export default () => { const [searchParams, setSearchParams] = useSearchParams(); const a = searchParams.get('a'); const b = searchParams.get('b'); return <div> <p>SearchParams ---- a:{a};b:{b}</p> </div> 复制代码我们使用 useSearchParams 来取到 url 中携带的参数,用法非常的便捷。 有读数据,当然也有写数据操作。刷新 SearchParams可以分为两种情况,当前页面可以使用 useSearchParams 返回的第二参数,刷新 SearchParams。import { Link, useSearchParams, createSearchParams } from 'umi'; export default () => { const [searchParams, setSearchParams] = useSearchParams(); const a = searchParams.get('a'); const b = searchParams.get('b'); return <div> <p>SearchParams ---- a:{a};b:{b}</p> <button onClick={() => { setSearchParams(createSearchParams({ a: 123, b: 456 })); }}>Change SearchParams</button> </div> 复制代码写入 SearchParams第二种情况是从别的页面跳转的时候携带 SearchParamsimport { useNavigate, createSearchParams } from 'umi'; export default function User() { const navigate = useNavigate(); return ( <div> <button onClick={() => { navigate(`/?${createSearchParams({ a: 1, b: 2 })}`) }}>go to index has SearchParams!</button> </div> 复制代码操作 SearchParams 我们都可以通过 Umi 提供的 createSearchParams API 来很轻松的完成。使用 SearchParams 最大的好处就是数据永久化,只要你将完整的链接发送给用户,那链接中都将会携带这些参数。坏处就是,参数都是显示存在的,对于一些安全性场景的敏感数据,可能太不友好。所以我们可以使用另外一种隐蔽性更强的方式实现参数传递。Route Stateimport { useNavigate } from 'umi'; export default function User() { const navigate = useNavigate(); return ( <div> <button onClick={() => { navigate('/', { state: { c: 987 }}>go to index has State!</button> </div> 复制代码在首页可以在 location 对象中取到 State,这个和 React 中的 State 类似,只不过它是一个临时性的数据,当你在全新的环境打开这个链接,将会丢失这个数据。可以使用 useLocation 获取到 locationimport { useLocation } from 'umi'; export default () => { const location = useLocation(); return <div> <p>State ---- {location.state}</p> </div> 复制代码两种参数传递的方式,都有自己的利弊,分别应对不用的项目交付场景,在真实的项目中可以充分分析需求,权衡利弊来使用不同的方式。当然以上提到的两种方式,是纯路由传参的手段,我们也可以通过前端数据流的方式来实现参数传递。当需要传递一个比较大的对象时,还可以借助服务端的能力,将数据保存到服务端,到新页面再通过接口获取。源码归档
前端开发中的“路由”可以说是每个开发框架中都必须掌握的一个点,但是对于这个教程来说,我们更加注重的实战阶段,我的目的就是希望这个教程能够与现有的官方文档进行“梦幻联动”,官方文档负责讲述清楚每一个概念和设计思路,而这个教程负责将这些概念串联到一起,任何一个看完本教程的朋友,但凡还有一个人不明白官网的这些概念在项目开发中如何使用,或者什么时候被用上的,那就算是这个教程的失败了。首先我们需要知道的是,在 Umi@4 中路由方案使用的是 React Route 6,如果你有 React Route 6 的使用经验,那你对于 Umi@4 中的路由相关 API 已经得心应手了。如果你重来没用过 React Route 6,也没有关系,接下来的课程我们就会讲清楚 Umi 的页面、如何在页面之间跳转及项目开发中常用到的几种页面之间传参方式。编辑页面上一节课,当我们启动了 umi dev 然后访问 http://127.0.0.1:8000 就可以访问到写有 Index Page 的页面。http://127.0.0.1:8000 是啥?先让我们尝试编辑一下上节课的页面,首先确保 umi 开发服务处在运行状态,浏览器能正常访问 http://127.0.0.1:8000umi dev然后打开 boilerplate/src/pages/index.js,在 <div> 标签下找到 “Index Page” 文字并把它修改为 “Hello Work” ,保存文件。此时你应该可以立马看到浏览器中已经自动将旧文本替换成新文本了。Umi 开发服务器具有热加载功能,当我们对文件进行修改时,Umi 会自动在浏览器中应用这些修改。无需刷新页面。这在开发中能够节省我们非常多的时间。Umi 中的页面在 Umi 中,页面是一个从 pages 目录中的文件导出的 React 组件。Umi 默认使用约定式路由来匹配文件。(约定式路由也叫文件路由,就是不需要手写配置,文件系统即路由,通过目录和文件及其命名分析出路由配置。)Umi 约定式路由页面与基于文件名的路由对应,例如,在开发中:pages/index.js 对应路由 /。pages/user.js 对应路由 /user 。我们已经有了 pages/index.js 文件,那么让我们创建 pages/user.js,看看它是如何运行的。新建 user 页面新建 boilerplate/src/pages/user.js 文件export default function User() { return <div>User Page</div>; 复制代码组件可以有任意名称,但必须将其导出为 default 。访问 http://127.0.0.1:8000/user。这里我们访问了路由 /user 而不再是最开始的可缺省路由 /你将在浏览器中看到文本 User Page。这就是在 Umi 中创建不同页面的方法。只需在 'pages' 目录下的任意目录下创建文件,该文件的路径就成为 URL 路径。还是要重复一遍,约定式路由是我最喜欢的 Umi 概念,因为它极大的提升了开发者体验,符合常理,所见即所得。快乐如此简单。源码归档感谢阅读,上一个系列的文章,是采用想到哪做到哪的方式展开的,完结之后,我个人觉得整体上会有一些混乱的,因为目标仅仅是强制我自己养成日更的习惯而已。这个系列的文章,采用先规划大纲,细节边调整边输出的方式,所以我很期待你能够在评论区和我互动,让这个系列的文章更加符合你的个人口味。
新建 git 仓库新建 umi4-course 仓库本系列的仓库我放在了 Umi 官方的组织下面了,你可以放到你的个人仓库下,名字我暂定为 umi4-course。将仓库同步到本地在你的工作目录下,执行 git clone you-git-url ,如 git clone https://github.com/umijs/umi4-course.git,将会新建一个文件夹,文件夹名称为你上面取的包名,umi4-course 。然后用 VS Code 打开这个空文件夹。打开 VS Code 的终端(点击 任务栏中的终端 - 新建终端)。mkdir boilerplate 复制代码创建之后的目录结构umi4-course └── boilerplate 复制代码初始化项目cd boilerplate npm init -y Wrote to /Users/umi4-course/boilerplate/package.json: "name": "boilerplate", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" "keywords": [], "author": "", "license": "ISC" 复制代码生成 boilerplate 目录下面的 package.json 文件,相当于我们近期使用的项目目录是 umi4-course/boilerplate,所以你在执行命令的时候,都要 cd 到 boilerplate 目录下,后续的执行命令时,只会在 bash 中写明,不会再特别提醒。安装 umi 模块在项目中安装 umi,请在刚刚的命令行窗口中,输入以下命令安装 umi。如果你已经关闭了刚刚的命令行窗口,那请你重新打开一个命令行窗口,并保证执行目录已经正确 cd boilerplate 到了项目目录。pnpm i umi@4.0.0-rc.20 复制代码安装 pnpm安装完 umi ,VS Code 会提示你“检测到过多更改。下面仅显示第10000个变更”,这是我们需要在跟路径(umi4-course)下新建 .gitignore 写入 node_modules 表示本次的 git 提交忽略 node_modules 文件夹下的所有变更。提交完,你将会看到本次提交变更文件数为 3。如果添加完 .gitignore 文件之后, VS Code 没有反应过来,可以选择重启 VS Code 窗口,mac 上快捷键 command + shift + p (window 上是 ctrl + shift + p),在弹出窗口中输入 >reload window 重启窗口。修改项目的启动命令在很多的前端框架中,你都会涉及到使用命令启动项目服务的情况,一般文档中会让你执行,如 yarn start 、 npm start 或 npm run dev 这样的命令。但可能你很少关注它是什么意思。其实它执行的是项目在 package.json 中定义好的 scripts 命令,你可以将它理解为一种别名,为了让你更加便利的执行命令。比如,如下所示,当你执行 yarn start 时,真正执行的是 umi dev。{ "scripts": { "start": "umi dev", 复制代码你也可以直接执行 umi dev 来完成同样的效果,但是这需要你保证你的全局变量中已经正确安装了 umi 命令,你也可以使用诸如 npx umi dev 这样的命令,来执行使用当前项目中的 umi 命令来启动项目。当你的命令拥有多个版本是,比如说全局版本是 3.x,项目中版本是 4.x 时, npx 就会非常好用。当你需要指定大量的环境变量或者同时执行多个命令时,scripts 这里的定义将会变得更高效。如下配置,项目会先执行编译,然后在产物目录中启动部署服务 serve,这样你就可以直接在 3000 端口的服务上预览你的项目。(这里只是举例说明,命令随手写的,无真实意义){ "scripts": { "review": "cross-env PATH=3000 DEV_UTILS=somekey umi build && cd dist && serve", 复制代码cross-env 是啥理解了上述这个基本概念之后,我们就可以着手修改我们的 package.json{ "name": "boilerplate", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "umi dev", + "build": "umi build" "keywords": [], "author": "", "license": "ISC", "dependencies": { "umi": "4.0.0-rc.20" 复制代码创建页面新建 boilerplate/src/pages/index.js 文件,并输入以下内容如:export default () => <div>Index Page</div>; 复制代码你也可以命令来完成,请打开你的终端,cd 进入你要在其中创建应用的目录,然后运行以下命令:cd boilerplate mkdir -p src/pages echo 'export default () => <div>Index Page</div>;' > src/pages/index.js 复制代码cd boilerplate 仅仅表示接下来的命令操作需要在 boilerplate 目录下执行,而不是真的需要执行 cd,后续整个教程中,都将沿用这个规则。运行开发服务器现在,运行以下命令启动开发服务器:cd boilerplate pnpm start 复制代码这将在 8000 端口上启动 Umi 应用程序的开发服务器。> boilerplate@1.0.0 start /Users/umi4-course/boilerplate > umi dev info - Umi v4.0.0-rc.20 ready - App listening at http://127.0.0.1:8000 event - Compiled successfully in 485 ms (266 modules) info - MFSU buildDeps since cacheDependency has changed wait - Compiling... event - Compiled successfully in 40 ms (266 modules) event - MFSU Compiled successfully in 749 ms (586 modules) info - MFSU write cache info - MFSU buildDepsAgain info - MFSU skip buildDeps info - Memory Usage: 233.66 MB (RSS: 659.39 MB) 复制代码让我们检查一下是否正常运行。在你的浏览器中打开 http://localhost:8000。现在,如果你在屏幕上看到大大的 “Index Page”,说明你以上所有的操作都成功了。仔细看上面的日志输出,我们可以看到开启 MFSU 之后,umi 的构建非常的快,umi@4 默认开启了 MFSU 。开启这个功能之后,虽然是使用 webpack,但是却比切换到 vite 构建要快。扩展阅读,比 Vite 还快的 MFSU next.umijs.org/blog/mfsu-f…Umi 缓存目录boilerplate/node_modules/.cacheUmi 构建时的缓存文件生成目录,这里面将会存放 babel 缓存,MFSU 缓存等如果 MFSU 构建异常,你可以删除 .cache 目录,它将会在再次构建的时候,被再次生成。不过 umi@4 中现在很少需要执行删除缓存文件的操作了boilerplate/src/.umidev 时的临时文件目录,比如入口文件、路由等,都会被临时生成到这里。不要提交 .umi 目录到 git 仓库,他们会在 umi dev 时被删除并重新生成。类似的,在 umi build 的时候会生成临时文件 .umi-production 。将 .umi 和 .umi-production 目录添加到 .gitignore 文件中。node_modules + .umi + .umi-production 复制代码最终本次提交,我们仅仅只有 4 次变更。.gitignore boilerplate/pnpm-lock.yaml boilerplate/package.json boilerplate/src/pages/index.js 复制代码源码归档感谢阅读,如果你觉得本文档对你有帮助,请为点赞,评论,收藏,并分享给你觉得同样需要的小伙伴。如果这个系列的课程感兴趣的朋友不多,可能会导致我断更。本系列教材归 umijs 开发团队所有,任何个人和组织在未经授权的情况下,搬运或部分搬运本系列文档,均属于违法行为。对以上声明的声明:最近天猪老哥的文档被恶意转载还被“挂”被批,让我对文档开源有些失望。所以以上声明只是希望未来的某一天,不要有人恶意转了我的文章还要来骂我。我玻璃心承受不起。
什么是UmiUmi,中文发音为「乌米」,是一个用于开发可扩展的企业级前端应用框架的开源项目(MIT)。它是云谦编写的一个和 next.js、remix、ice、modern.js 等同类型的元框架。它既是一个框架也是一个工具。简单的理解可以称它为一个类 next.js 的专注性能的前端框架。通过约定、自动生成和解析代码等方式来辅助开发,减少开发者要写的代码量。Umi是通用方案,几乎适用于现在所有的 web 环境。Umi 的优势Umi是一个以路由为基础,同时支持配置式路由和约定式路由,配以生命周期完善的插件体系,覆盖从源码到构建产物的每个生命周期,支持各种功能扩展和业务需求的前端框架,它的优势是:文件即路由,约定式的项目文件结构,自动将 pages 目录下的文件映射成路由配置。(并支持动态路由)自动代码拆分,提升页面加载速度内置 Less 支持。内置的大量性能优化多端,无缝支持容器和浏览器访问类 webpack 的插件机制,可拔插的插件设计,你可以完全的自定义你自己的 Umi 框架针对 antd 和 dva 有友好的支持我个人最喜欢的是 Umi 的文件即路由特性了,即在pages下面新建文件,就会自动产生配套的路由,对于我之前使用的其他前端框架,路由配置一直是一个很麻烦的事情,而且,对于多人协作开发的情况下,公共的配置文件,将面临着更多的文件冲突的可能。由于每个页面都是在一个单独的目录下,并且对于单个页面来说,不需要任何多余的配置,所以你甚至可以直接把整个页面交给一个零基础的实习生。Umi的可扩展性作者称“Umi有着类webpack般灵活的插件机制,他就是一个架子”。 Umi 把骨架搭好,把框架的生命周期钩子暴露出来,然后通过插件让功能丰富起来(包括现有的内部逻辑也是这么实现的)。我却更喜欢把它形容为一个高达玩具,对于刚入手的玩家,可以根据说明书,一步一步的组装出自己心爱的玩具。而对于高玩来说,官方提供了一个骨架,保证了高达的可动性,然后你自己可以随意的DIY,任意的使用材料和设计方式。对于 Umi 也是相同,对于刚接触前端的朋友,你可以很好的完成公司的业务需求。对于对前端有一定了解的朋友,你可以随意的修改,包括配置、编译、开发、模板、请求方式、数据流等等,几乎所有你能想到的前端工程化的内容,都允许你自定义。并且在一步步接触这些可配置项的时候,你也对前端工程化有了一步步的认识和理解。Umi的性能对于项目性能方面,Umi也做了很多的优化,包括尺寸,执行效率,首屏加载时间,用户体验等等方面,但这些对于开发者其实是无感知的,可以说有时候你就升级了一下插件版本,你的整个项目就优化了,你根本不需要进行任何的多余操作。作者称“你只管写业务代码,我会负责性能,并且随着 Umi 的迭代,我保证你的应用会越来越快”。简单的说,Umi做到了开箱即用,对于开发者和前端初学者是非常友好的。Umi 4 的特点1、体系化有体感的默认快Umi 默认快很多一部分是它基于 webpack 5 Module Federation 特性的构建的提速方案 MFSU。完成了既有 webpack 的功能和生态,又有 Vite 的速度的开发体验,实现了启动快、热更快、页面打开也快的效果。同时尽可能的用上缓存提速,避免了同样的事情做多次的窘境,比如使用了 webpack 5 的物理缓存,又用上了 babel 缓存,MFSU 中还试用了预编译缓存等;同时在构建环节还用上了 rust 和 go 语言编写的构建工具 swc 和 esbuild。通过这种比 js 更加贴近于原生的语言,在压缩和编译环节提速效果显著。2、依赖预打包让你的项目安全又稳定「中间商锁依赖,定期更新,并对此负责」依赖预打包前,Umi 通过 dependency 依赖 webpack、babel 和其他的一些第三方依赖,比如 colors 和 faker.js 等(如果不知道这两个库发生了什么事故的小伙伴,可以百度一下)。这时如果其中一个依赖出现异常,就会最终导致用户项目出现异常,这在 npm 包管理体系中是一个很普遍的问题。依赖预打包之后, Umi 通过使用框架中第三方依赖的构建产物,引用的是框架内的相对路径,因此底层包发生事故,就不会再影响到整个生态体系。又由 Umi 作为依赖包的中间商,定期更新底层依赖,可以再次锁定到最新可用的依赖版本,这就同时满足了,“锁包”和“升级”的矛盾需求。3、双构建引擎给用户更多选择同时支持 webpack 和 vite,用户可以通过简单的配置在不同模式之间切换,并且 Umi 尽可能保证功能的一致性。你可以在开发的时候用 vite 构建,体会现代框架的编译体验,然后在上生产构建的时候用 webpack 的生态,保证项目的兼容和稳定。题外话: Umi 的 webpack 方案 MFSU,比 vite 还快,可以看官网说明。比 Vite 还快的 MFSU4、技术栈最新把底层依赖升到最新,尤其是 react router 6使用 react router 6 来实现状态保持(keepalive),只需要不到 50 行的代码,这是我使用时最大的感受。5、微生成器。就是类似 modern.js 的微生成器,这功能从 modern.js 里学习了不少。举个例子,像页面模版、默认配置还有比如 prettier 功能,可能不是每个项目都需要,就比较适用于微生成器,按需启用、添加配置、安装依赖。这个在我们后续的课程中会用到不少,因为他确实能够最大程度的减少开发中的重复性工作,我们也会在教程中学会如何编写自己的微生成器,来满足项目的特定模版需求。6、强约束功能集成。Umi 4 提供 API 让强约束和代码校验变得非常容易。API 包括 api.onCheck、api.onCheckConfig、api.onCheckPkgJSON 和 api.onCheckCode,顾名思义,非常好理解他们分别是干嘛的,可以分别对依赖类、代码类和配置类的内容做校验和卡点,适用于团队。教程中我们用到这个 API 来做强约束的可能性不大,因为默认提供的约束方案,对于正常团队已经够用了。看完上面的介绍,不知道你对 Umi 是不是更感兴趣了呢?那么请在未来的一个月左右的时间内,紧跟此专栏的更新吧。要继续跟进后面的内容,你的电脑需要有现代前端开发需要的环境,安装了 node 和 git 并且有一个 IDE ,我推荐使用 VS Code。如果你是一个完全的前端新人,那你可以参照我的前一个专栏中的 day2 环境准备和技能要求 进行环境搭建。感谢阅读,同时也感谢你喜欢这个专栏。
首先开篇依旧是解决之前的遗留问题,这是网友在下月亮有何贵干指出来的一个写框架会遇到的典型的 winPath 问题,也是我第一次维护 umi 所修改的问题,本来在 day12 编写的时候,应该写上去的,但是我给忘记了。经典的 winPath 问题在使用 path join 做路径拼接之后,会导致在 window 上的路径错误随手用 winPath 处理一下。就是如图中这个朋友写的这样子。packages/malita/src/entry.tsimport { winPath } from '@umijs/utils'; importStr += `import A${count} from '${winPath(route.element)}';\n`; 复制代码接下里进入今天的主题,我们将多个项目的公共功能提取到框架层,形成开发脚手架。为了提效我们还可以将页面级别的内容整理成模版,用于快速创建相对应的资产,比如快速新建页面、快速新建服务、快速新建Mock等。生成器的实现是我上手开发的第一个 node 服务,我觉得它可以作为一个新手入门的最佳上手项目。这么适合新手,为什么放到最后实现呢?这里留一个悬念,文末说明。在没有生成器的时候,你如何在一个项目中新建一个新的路由页面,一般操作就是从另一个页面中复制,然后删除一些这个页面用不到的代码,然后再修改一些里面的内容。我们还是将这个过程分解出来,首先有一个地方存放了我们的模版文件,然后我们传入要修改的几个参数,替换模版文件中的数据,然后将它们写到我们指定的目录下。其实昨天我们在编写依赖预编译的时候,用到了 copyFileSync 也可以理解为一个最基本的生成器,就是不改任何参数(文件名也是参数)。fs.copyFileSync(path.join(pkgRoot, 'package.json'), path.join(target, 'package.json')) 复制代码配置 generate 命令program.command('generate').alias('g').description('微生成器').action(function(_, options) { const { generate } = require('../lib/generate'); generate(options.args); 复制代码我们就可以使用 malita g 类型 参数 这样的命令,来调用我们的微生成器了。 实现就是从 options.args 取到“类型”和“参数”,然后用 fs 提供的 api 一步一步的实现我们的需求,这种原始的实现方式,我们之前的19天中,已经写了不少了,我这里就不用这种方式实现了。这里介绍一种更加优雅的方式,是在 umi@4 中实现好的基础生成器,也是现在 umi@4 插件中推荐使用的微生成器最佳实践。当时的设计灵感其实还是聚焦在“约定式”,因为 umi@4 中会提供很多的微生成器支撑,因此就想着直接约定了模版的地址,然后提供统一的方法进行构建和生产,一些需要用户提供的数据,使用问答式的交互来获取。使用 @umijs/utils 的 generateFile 来快速完成生成器import { lodash, generateFile } from '@umijs/utils'; import path from 'path'; import fs from 'fs'; export const generate = async (args: string[]) => { const [type, name] = args; const absSrcPath = path.resolve(process.cwd(), 'src'); const absPagesPath = path.resolve(absSrcPath, 'pages'); if (fs.existsSync(absPagesPath) && type && name) { generateFile({ path: path.join(__dirname, `../templates/${type}`), target: path.join(absPagesPath, name), data: { name: lodash.upperFirst(name), 复制代码然后我们只要编写模版文件 packages/malita/templates/page/index.tsx.tpl 就好了。 只要关注需要动态替换的参数。import React from 'react'; import type { FC } from 'react'; interface {{{ name }}}PageProps {} const {{{ name }}}Page: FC<{{{ name }}}PageProps> = () => { return <div>Hello {{{ name }}}</div>; export default {{{ name }}}Page; 复制代码这样我们的生成器编写就只需要聚焦在模版文件的编写上。如果我们需要一些用户输入或者选择的数据,那么我们可以给 generateFile 传递一个 questions 对象。const questions = [ name: 'hi', type: 'text', message: `What's your name?`, ] as prompts.PromptObject[]; 复制代码然后修改模版文件return <div>Hello {{{ hi }}}</div>; 复制代码执行验证pnpm g page abc > @examples/app@1.0.0 g /Users/congxiaochen/Documents/malita/examples/app > malita g "page" "abc" ✔ What's your name? … malita Write: index.tsx 复制代码最终生成 examples/app/src/pages/abc/index.tsximport React from 'react'; import type { FC } from 'react'; interface AbcPageProps {} const AbcPage: FC<AbcPageProps> = () => { return <div>Hello malita</div>; export default AbcPage; 复制代码你可以用相同的方式实现各种各样的微生成器,比如 umi 中的微生成器列表感谢阅读,本来微生成器是要放在前面实现的,但是我看掘金上不少读者都是刚刚进入软件这个行业的,说来也巧,今天跟红尘炼心大佬聊天还说到这个。我觉得程序开发最重要的还是编程思维的培养,这一点不管是早期的快速入门还是后期的高P晋升都是非常重要的。很多时候我们在拿到需求的时候,立马想到的是程序上的实现,要写怎样的代码,用怎样的语法之类的。但其实我们更应该去思考的是这个需求的场景,和未来可以复用的情况,涉及的边界等等。这也是评判新手和高手的一个标准。像我们这个系列的解题思路,就是完全新手向的思维,看到问题解决问题。我在里面预留了很多的问题和边界的缺陷,我期待的是你能够发现它们,我觉得这是一件很有趣的事情,希望这个系列带给你的更多的是思考,而不像我的其他文章一样,仅仅是“告诉你有个什么东西,它怎么用”。源码归档
先讲结论:依赖预打包就是将框架里面用到的第三方开源的库的代码,拷贝一份到你的框架中保存起来。然后定期升级维护,好处就是项目中足够稳定,比如现在 npm 上经常出现的底层包挂码事件等超高的安全风险问题,就可以通过这个方法完美的解决。更多关于这部分的内容,可以查看 umi 作者云谦的星球日更28 依赖版本锁不锁。原理感觉原理没什么好讲的,就是将构建入口找到 node_modules 下的某个包指定的 main 入口,然后将它编译到我们指定的路径中。修改项目中的引用,从引用包的方式改成引用相对文件的方式。用法我们在需要预打包的子包目录下,执行 pnpm build:deps pkgName 来将子包依赖预编译到子包的 compiled。如:cd packages/malita pnpm build:deps express 复制代码将 malita 子包中的 express 进行预编译操作。实现这个实现也算是之前知识点的一个复习环节。首先我们要执行 build:deps 命令我们需要先定义它,在 malita 子包的 packages.json 中的 scripts 配置中定义。我们可以这么写。{ "name": "malita", "scripts": { "build:deps": "pnpm esno ../../scripts/bundleDeps.ts", 复制代码使用 esno 是希望直接执行 ts 类型的脚本,而不用将脚本转化成 js 语法。因为这个操作会在很多个子包中执行,因此我们将这个脚本写在最外层,供任意子包使用。// 最顶层目录下 pnpm i esno -w --D 复制代码获取到传入参数还记得我们第四天的内容,如何编写一个 cli 吗?我们那时候是使用 commander 来实现的,用 program.parse(process.argv) 取到命令行的参数。今天我们换一种用法,使用另一个编写命令行时常用的库:minimistpnpm i minimist @types/minimist -w --D 复制代码新建 scripts/bundleDeps.ts 文件,写入+ import minimist from 'minimist'; + const argv = minimist(process.argv.slice(2)); + console.log(argv); 复制代码运行测试cd packages/malita pnpm build:deps express // 日志 { _: [ 'express' ] } 复制代码取到入口和目标路径如果你忘了接下去该做什么,注意回看我们的需求:“将构建入口找到 node_modules 下的某个包指定的 main 入口,然后将它编译到我们指定的路径中”。import minimist from 'minimist'; import fs from 'fs-extra'; import path from 'path'; const argv = minimist(process.argv.slice(2)); // 找到 node_modules 下的 const nodeModulesPath = path.join(process.cwd(), 'node_modules'); // 某个包 const pkg = argv._[0]; // 的 main 入口 const entry = require.resolve(pkg, { paths: [nodeModulesPath], // 将它编译 build() writeFile() // 我们指定的路径中 const target = `compiled/${pkg}`; 复制代码然后问题的重点就回到了 build 和 writeFile 方法的实现上。采用这样的分解步骤,可以让我们的整个代码逻辑变得非常的清晰。然后先把我们的实现完成了,再去考虑代码的整洁性,改改 callback 到 promise ,逻辑调整啊之类的优化工作。我的理念就是不想太多,先做,先拿基础分。使用 @vercel/ncc 预编译代码// 没带 cd 的就是表示在最顶成根目录 pnpm i @vercel/ncc -w --D 复制代码import minimist from 'minimist'; import fs from 'fs-extra'; import path from 'path'; // @ts-ignore import ncc from '@vercel/ncc'; const argv = minimist(process.argv.slice(2)); const nodeModulesPath = path.join(process.cwd(), 'node_modules'); const pkg = argv._[0]; const entry = require.resolve(pkg, { paths: [nodeModulesPath], const target = `compiled/${pkg}`; ncc(entry, { minify: true, target: 'es5', assetBuilds: false, }).then(({ code }: any) => { // entry code fs.ensureDirSync(target); fs.writeFileSync(path.join(target, 'index.js'), code, 'utf-8'); 复制代码运行验证cd packages/malita pnpm build:deps express // 日志 > malita@0.0.2 build:deps /Users/congxiaochen/Documents/malita/packages/malita > pnpm esno ../../scripts/bundleDeps.ts "express" ncc: Version 0.33.4 ncc: Compiling file index.js into CJS 复制代码查看目标文件是否正确生成 packages/malita/compiled/express/index.js。修改项目中的引用- import express from 'express'; + import express from '../compiled/express'; 复制代码cd packages/malita pnpm build 复制代码cd examples/app pnpm dev // 日志 > @examples/app@1.0.0 dev /Users/congxiaochen/Documents/malita/examples/app > malita dev App listening at http://127.0.0.1:8888 [HPM] Proxy created: /api -> http://jsonplaceholder.typicode.com/ [HPM] Proxy rewrite rule created: "^/api" ~> "" 复制代码页面功能正常访问。添加类型定义从上面的操作,我们可以看出来 express 依赖已经预编译成功了。但是我们会收到一个类型错误。无法找到模块“../compiled/express”的声明文件。“/Users/congxiaochen/Documents/malita/packages/malita/compiled/express/index.js”隐式拥有 "any" 类型。pnpm i dts-packer -w --D 复制代码+ import { Package } from 'dts-packer'; + new Package({ + cwd: cwd, + name: pkg, + typesRoot: target, + }); 复制代码遗留问题cd packages/malita pnpm build:deps express // 日志 TypeError: Cannot read properties of undefined (reading 'uid') at isDirectory (/Users/congxiaochen/Documents/malita/node_modules/.pnpm/resolve@1.22.0/node_modules/resolve/lib/sync.js:31:23) at loadNodeModulesSync (/Users/congxiaochen/Documents/malita/node_modules/.pnpm/resolve@1.22.0/node_modules/resolve/lib/sync.js:200:17) at Function.resolveSync [as sync] (/Users/congxiaochen/Documents/malita/node_modules/.pnpm/resolve@1.22.0/node_modules/resolve/lib/sync.js:107:17) at Package.getEntryFile (/Users/congxiaochen/Documents/malita/node_modules/.pnpm/dts-packer@0.0.3/node_modules/dts-packer/dist/Package.js:88:56) at Package.init (/Users/congxiaochen/Documents/malita/node_modules/.pnpm/dts-packer@0.0.3/node_modules/dts-packer/dist/Package.js:37:32) at new Package (/Users/congxiaochen/Documents/malita/node_modules/.pnpm/dts-packer@0.0.3/node_modules/dts-packer/dist/Package.js:31:14) at null.build (/Users/congxiaochen/Documents/malita/scripts/bundleDeps.ts:52:5) 复制代码有一个很难懂的日志,我们可以根据报错堆栈,定位到是加载 @types/express 的时候找不到包。但是注释掉 ncc 单独执行 Package 又能顺利生成。从11点调试到12点半,太晚了,就没有继续定位这个问题,如果有知道原因的小伙伴,记得指导我一下。pnpm build:deps express > malita@0.0.2 build:deps /Users/congxiaochen/Documents/malita/packages/malita > pnpm esno ../../scripts/bundleDeps.ts "express" @types/express > index.d.ts /Users/congxiaochen/Documents/malita/node_modules/@types/express/index.d.ts >> dep import express 复制代码类型文件找不到完成了 express 的构建之后,我们继续构建其他的依赖,比如 commanderpnpm build:deps commander > malita@0.0.2 build:deps /Users/congxiaochen/Documents/malita/packages/malita > pnpm esno ../../scripts/bundleDeps.ts "commander" ncc: Version 0.33.4 ncc: Compiling file index.js into CJS > typings/index.d.ts /Users/congxiaochen/Documents/malita/packages/malita/node_modules/commander/typings/index.d.ts 复制代码我们会发现,commander 自己有 types 定义并且是在 typings/index.d.ts因此如果我们在项目中使用 import xx from '../compiled/commander'; 就会提示无法找到 commander 模块。因此我们还需要在构建的包下面生成一个虚拟的包,这个实现很简单,将原有的package.json 拷贝到目标目录下就行。const pkgRoot = path.dirname( resolve.sync(`${pkg}/package.json`, { basedir: cwd, if (fs.existsSync(path.join(pkgRoot, 'LICENSE'))) { fs.copyFileSync(path.join(pkgRoot, 'LICENSE'), path.join(target, 'LICENSE')) fs.copyFileSync(path.join(pkgRoot, 'package.json'), path.join(target, 'package.json')) 复制代码接下来就是将我们的其他依赖都进行预打包就可以了。这里只是讲解了如何实现和一点点注意事项,如果你想对这个内容了解的更清楚一点,包括上面用到的 dts-packer 包的详细实现和设计,请加入作者云谦的星球。感谢阅读,如果你觉得本文对你有一点点帮助,别忘了给我点赞加关注哦,感激不尽。如果文中有错漏的地方,欢迎指出,太晚了有点困了。一稿出了,抱歉抱歉。源码归档
昨天我们已经完成了移动端的适配,今天我们就引入 antd-mobile 组件,当作我们的基础组件,加快我们的开发效率。Ant Design Mobile 是由支付宝设计团队基于蚂蚁集团的众多业务实践,亿万用户的验证反馈,抽象构建出的移动端资产库。2020 年 9 月 18 日,antd-mobile 发布了 2.3.4 版本,也是 v2 的最后一个版本,时隔近一年半,官方终于发布了全新的 5.0(白杨)版本。又经过了大半年的维护,现在 antd-mobile 已经提供了非常多功能完善的组件供用户使用了。使用 2 倍组件的高清方案因为我们使用的是高清方案,根绝官网提供的两种方案,第一种方案是将 import 引导 2x 目录下,例如:import { Button } from 'antd-mobile' // ⬇️ import { Button } from 'antd-mobile/2x' import 'antd-mobile/es/global' // ⬇️ import 'antd-mobile/2x/es/global' 复制代码第二种方案使用 webpack 的别名设置,将 antd-mobile 映射到 antd-mobile/2x 上。先来说说这两种方案,在项目交付中都有什么问题吧,第一种,就是过于麻烦,也很难懂 /2x 是啥意思,某一个特殊版本吗? 第二种,在使用一些特殊加速手段缓存的时候,比如 umi 中很重要的 mfsu,别名映射会出现找不到包引入错误的问题。所以我们可以用一种更加“高效”有趣的方式来实现。在昨天的屏幕适配中我们提到,我们通过 postcss 的插件将 px 转换成 rem 单位,然后既然我们的 px 都要经过一次转化了,那是不是就可以直接通过它,将 antd-mobile 的 px 值直接放大一倍呢?根绝这个思路,我在 postcss-plugin-px2rem 的基础上增加了 selectorDoubleRemList: [/.adm-/] 。antd-mobile 的类名都是以 .adm- 开头的,因此会被匹配中,将 px 转化成两倍的 rem,比如按钮的默认 font-size 就是 34 px 即 0.68rem。这样即实现了,在不修改任何使用和新增任何环节的情况下,完成了使用高清组件的目的,这就是我所指的 “高效”。运行验证cd examples/app pnpm i antd-mobile 复制代码修改 examples/app/src/pages/home.tsximport { Button } from 'antd-mobile'; // 略,随便放到页面中 <Button color="primary">Click Me!</Button> 复制代码运行 dev 服务pnpm dev 复制代码使用谷歌浏览器的手机模拟器,Dimensions: iPhone 6/7/8 访问 http://127.0.0.1:8888,打开控制台查看 elements ,可以看到 adm-button 样式中的 px 都被转成 2 倍的 rem 了: padding: 0.14rem 0.24rem; font-size: 0.34rem;。2分钟画完两个页面好了既然引入了 antd-mobile 那我们随便写两行代码完成两个页面吧!编写全局 layout修改我们约定的全局 layout,要编写全局 layout 首先一定要分清楚那一部分的内容是所有页面公用的,而哪一部分的内容是页面私有的。这里我们将头部的 NavBar 和底部的 TabBar 放到,全局 layout 中编写。可以借助 @alita/flow 提供的几个布局组件,快速的将页面分为上中下结构。import { Page, Content, Header, Footer } from '@alita/flow'; import { Badge, TabBar, NavBar } from 'antd-mobile'; import { AppOutline, UnorderedListOutline } from 'antd-mobile-icons'; <Page className='malita-layout'> <Header><NavBar>{titleHash[activeKey]}</NavBar></Header> <Content> {element} </Content> <Footer> <TabBar onChange={value => { setActiveKey(value) navigate(value) }} activeKey={activeKey} > {tabs.map((item: any) => ( <TabBar.Item key={item.key} icon={item.icon} title={item.title} badge={item.badge} /> </TabBar> </Footer> </Page> 复制代码编写 home 页面我们随便仿照现在流行的一些页面布局,随手写下整体的页面,这真没什么好说的,只要你熟悉 antd-mobile 的组件用法就好了,再构造一点数据。import { Image, Swiper, Toast, Grid, Space, List } from 'antd-mobile'; <Swiper>{items}</Swiper> <Space></Space> <Grid columns={3} gap={8}> {grids} </Grid> <List header='用户列表'> {users.map(user => ( <List.Item key={user.name} prefix={ <Image src={user.avatar} style={{ borderRadius: 20 }} fit='cover' width={40} height={40} /> description={user.description} {user.name} </List.Item> </List> 复制代码编写列表页面import { IndexBar, List } from 'antd-mobile'; <IndexBar> {groups.map(group => { const { title, items } = group return ( <IndexBar.Panel index={title} title={`标题${title}`} key={`标题${title}`} <List> {items.map((item, index) => ( <List.Item key={index}>{item}</List.Item> </List> </IndexBar.Panel> </IndexBar> 复制代码可以通过上面的实现看出我们只是简单的引用了几个组件,便可以完成我们的需求,当然实际需求中页面设计的定制化会更高一点,实现起来也要复杂一点,但是却是比我们自己纯手写要快的多,特别在一些功能比较复杂的组件实现上。这就是引入比较好的第三方组件作为基础组件的优势。当然你可以基于这些基础组件进行二次封装开发,形成你们自己的基础组件或者业务组件,在同一个项目中的不同页面,你就可以仅仅引用你自己已经封装好的组件来高效的完成需求了,如果你们的设计师在多个项目中按统一的风格输出,那你还可以在多个项目中共用这些组件,来大大提高开发效率,这就是现在 React 开发中社区一直强调组件复用的原因。感谢阅读,今天的内容先对比较简单,但是希望我的整个阐述,能够给你提供一点思路,在工作中,只要你自己觉得麻烦,重复,枯燥,繁琐等等念头的时候,你就可以停下来,好好思考下,是不是有其他解法。新的解法实现之后,同步到你的整个团队中,那就是“提效”了。源码归档
原理先讲重点,我们使用的方案是在淘宝高清方案的基础上修改的方案,因为淘宝高清方案使用 meta 使用 0.5,这个设定在将移动端页面嵌套到其他平台的 iframe 中,会有内外 viewport 不一致导致的缩放问题,因此我们将 initial-scale 默认为 1。设置为1也会有内外不一致的问题,只是现在对接的系统多数都写1,跨域部署的时候,没办法获取到,所以这里我暂时没有更好的解法。我们的实现原理其实很简单,将项目中写的 px 值,通过 postcss 转换成 rem 单位。然后通过动态修改 html 上的 fontsize 大小,来实现在不同的屏幕上 px 的缩放比一致的效果。在讲的简单一点,比如我们在一个宽度为 50 的屏幕上,看到一个宽度为40的按钮。我们期望在宽度100的屏幕上,看到它时,它的宽度应该是 80.实现转换所有的 px 为 rem实现原理比较简单,就是通过 postcss 插件,匹配正则然后做一个值除与100的转化,比如 32px 转为 0.32rem。 为什么是除 100 而不是其他的值呢,是为了便于计算和直观表现做的约定。比如 css 文件中我们通过 postcss 转换,而在内联样式中,我们需要手动写明 rem 单位。这时候除与100,就只是简单的小数点缩进。上面的理由其实还是不够充分说明为什么是 100。这其实还和设备的像素点和像素倍率dpr等知识点有关,这里我不详细的展开,只做简要说明。在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rem =100px = 100物理像素。所以就相当于 iPhone6 上面取一整个屏幕宽度为 7.5rem 。设计师在输出设计稿的时候,只要以 iPhone6 的标准输出,我们就可以直接使用设计师标注的尺寸进行开发了。我们使用 postcss 转换我们之前使用 esbuild 构建后的样式产物。安装需要的模块cd packages/malita pnpm i postcss @alitajs/postcss-plugin-px2rem 复制代码@alitajs/postcss-plugin-px2rem 是在 postcss-plugin-px2rem 的基础上修改的,我增加了一个配置 selectorDoubleRemList: [/.adm-/, /.ant-/] 被匹配中的样式中 px 会被转化成两倍的 rem,比如 32px 转为 0.64rem。 这在使用一些没有使用高清方案编写的组件时非常有用。修改 packages/malita/src/styles.ts,以下仅仅摘录了本次的修改。我们将 esbuild 构建后的产物,使用 postcss 再构建一次。+ import postcss from 'postcss'; + // @ts-ignore + import px2rem from '@alitajs/postcss-plugin-px2rem'; const { errors, warnings, outputFiles } = await esbuild.build( entryPoints: [args.path], logLevel: 'silent', bundle: true, write: false, charset: 'utf8', minify: true, loader: { '.svg': 'dataurl', '.ttf': 'dataurl', + if (errors.length > 0) { + return { + errors, + warnings, + contents: outputFiles![0].text, + loader: 'text', + }; + } + try { + const result = await postcss( + [ + px2rem({ + rootValue: 100, + minPixelValue: 2, + selectorDoubleRemList: [/.adm-/, /.ant-/], + }), + ], + ).process(outputFiles![0].text, { + from: args.path, + to: args.path, + }); + return { + errors, + warnings, + contents: result.css, + loader: 'text', + }; + } catch (error){ + return { + errors, + warnings, + contents: outputFiles![0].text, + loader: 'text', + }; + } 复制代码运行验证cd examples/app pnpm dev > malita dev App listening at http://127.0.0.1:8888 复制代码查看产物文件 examples/app/dist/malita.js 全文搜索 malita-home,我们将会看到 font-size 被转换成 0.32rem。var home_default = ".malita-home{font-size:0.32rem;background:blue;width:1rem}\n"; 复制代码动态修改 html 的 font-size新建文件 packages/malita/src/hd.ts,实现复制了 alita 中的实现,这个在很多文章中都有体现,我这里就列出来的了。 主要需要注意的几个点如下:1、安卓dpr乱标 2、非淘宝高清方案,默认的 initial-scale 为 1 3、有些兼容环境下, fontSize为100px的时候, 结果1rem=86px; 需要纠正viewport 4、在 iframe 中打开 5、手机横屏需要使用高作为标准而不是宽 6、部分安卓手机转屏之后,需要延迟获取可视宽高 7、横屏模式时手机软件盘键盘弹起事件在框架中引入 hd 脚本,修改我们的主入口文件,添加 import '/Users/congxiaochen/Documents/malita/packages/malita/lib/hd';实现很简单就是修改我们之前的新建主入口文件的方法,在返回的 content 的 import 部分增加 import '${path.resolve(__dirname,'hd')}';。如果你没有想到这里的实现,可能你之前的 generateEntry 的逻辑还没有很理解。运行验证cd examples/app pnpm dev > malita dev App listening at http://127.0.0.1:8888 复制代码使用谷歌浏览器的手机模拟器,Dimensions: iPhone 6/7/8 访问 http://127.0.0.1:8888,打开控制台查看 elements ,需要关注的信息在 html 的上方1、 html 上的 style<html lang="en" style="font-size: 50px;" data-scale="true"> 复制代码2、meta 信息<meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,viewport-fit=cover"> 复制代码感谢阅读,今天的内容做了部分的简略,因为关于单位都是标准的文档,我就没有再拷贝一份了。你可以简单的理解,使用高清方案的时候,主要就是有 rem 、动态的 fontsize 和 meta 组合控制的,这样当你在项目中遇到整体的适配有误时,你就知道该从这些地方着手调查了。源码归档
原理代理也称网络代理,是一种特殊的网络服务,允许一个终端(一般为客户端)通过这个服务与另一个终端(一般为服务器)进行非直接的连接。- 维基百科在项目开发(dev)中,所有的网络请求(包括资源请求)都会通过本地的 server 做响应分发,我们通过使用 http-proxy-middleware 中间件,来代理指定的请求到另一个目标服务器上。如请求 featch('/api') 来取到远程http://jsonplaceholder.typicode.com/ 的数据。要实现上述的需求我们只需要在配置文件中使用 proxy 配置:export default { proxy: { '/api': { 'target': 'http://jsonplaceholder.typicode.com/', 'changeOrigin': true, 'pathRewrite': { '^/api' : '' }, 复制代码上述配置表示,将 /api 前缀的请求,代理到 http://jsonplaceholder.typicode.com/,替换请求地址中的 /api 为 '',并且将请求来源修改为目标url。如请求 /api/a,实际上是请求 http://jsonplaceholder.typicode.com/a。一般我们使用这个能力来解开发中的跨域访问问题。由于浏览器(或者 webview)存在同源策略,之前我们会让服务端配合使用 Cross-Origin Resource Sharing (CORS) 策略来绕过跨域访问问题。现在有了本地的 node 服务,我们就可以使用代理来解决这个问题。XMLHttpRequest cannot load api.example.com. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8000' is therefore not allowed access.原理其实很简单,就是浏览器上有跨域问题,但是服务端没有跨域问题。我们请求同源的本地服务,然后让本地服务去请求非同源的远程服务。需要注意的是,请求代理,代理的是请求的服务,不会直接修改发起的请求 url。它只是将目标服务器返回的数据传递到前端。所以你在浏览器上看到的请求地址还是 http://localhost:8000/api/a。值得注意的是 proxy 暂时只能解开发时(dev)的跨域访问问题,可以在部署时使用同源部署。如果在生产上(build)的发生跨域问题的话,可以将类似的配置转移到 Nginx 容器上。实现安装模块cd packages/malita pnpm i http-proxy-middleware 复制代码类型定义我们在 packages/malita/src/config.ts 定义一个配置 proxy 类型import type { Options as ProxyOptions } from 'http-proxy-middleware'; export interface UserConfig { title?: string; keepalive?: any[]; + proxy?: { [key: string]: ProxyOptions }; 复制代码使用 http proxy 中间件import { createProxyMiddleware } from 'http-proxy-middleware'; const buildMain = async ({ appData }: { appData: AppData }) => { // 获取用户数据 const userConfig = await getUserConfig({ appData, malitaServe // 略 getRoutes generateEntry generateHtml if (userConfig.proxy) { Object.keys(userConfig.proxy).forEach((key) => { const proxyConfig = userConfig.proxy![key]; const target = proxyConfig.target; if (target) { app.use( createProxyMiddleware(key, userConfig.proxy![key],), 复制代码我们判断如果用户设置了 proxy 就使用 http-proxy-middleware 中间件。项目中配置使用examples/app/malita.config.tsexport default { title: 'Hello', keepalive: [/./, '/users'], proxy: { '/api': { 'target': 'http://jsonplaceholder.typicode.com/', 'changeOrigin': true, 'pathRewrite': { '^/api': '' }, 复制代码运行验证cd examples/app pnpm dev > malita dev App listening at http://127.0.0.1:8888 [HPM] Proxy created: /api -> http://jsonplaceholder.typicode.com/ [HPM] Proxy rewrite rule created: "^/api" ~> "" 复制代码仔细看日志说明,就是我们前面提到的将 /api 前缀的请求,代理到 http://jsonplaceholder.typicode.com/,替换请求地址中的 /api 为 '',并且将请求来源修改为目标url。如请求 /api/a,实际上是请求 http://jsonplaceholder.typicode.com/a。浏览器中访问 http://127.0.0.1:8888/api/users[ "id": 1, "name": "Leanne Graham", "username": "Bret", "email": "Sincere@april.biz", "address": { "street": "Kulas Light", "suite": "Apt. 556", "city": "Gwenborough", "zipcode": "92998-3874", "geo": { "lat": "-37.3159", "lng": "81.1496" "phone": "1-770-736-8031 x56442", "website": "hildegard.org", "company": { "name": "Romaguera-Crona", "catchPhrase": "Multi-layered client-server neural-net", "bs": "harness real-time e-markets" 复制代码你会发现我们并没有写任何服务,但是却能够请求到真实的数据。表示我们的代理服务已经成功运行了。我建议你如果还不是很理解这个代理服务和这些配置的作用,什么代理不代理,替不替换前缀的,你可以多尝试着修改配置,看看真实代理的表现。感谢阅读,今天的内容就到这里了哦,嘻嘻嘻,记得给我点赞和关注哦。源码归档
标题来自云谦的星球阅读本文需要 5 分钟,编写本文耗时 1 小时极简的生命周期获取应用元数据获取路由配置(约定式路由)动态生成应用主入口文件动态生成 HTML执行构建昨天我们已经完成了 “获取应用元数据” 和 “获取路由配置(约定式路由)”,今天我们就直接进入主题,看看两个动态生成流程是如何实现的。动态生成应用主入口文件首先我们重新指定我们的项目主入口,因为是临时生成的,因此我们将它放到 absTmpPath 临时目录中。packages/malita/src/constants.ts- export const DEFAULT_ENTRY_POINT = 'malita.tsx'; + export const DEFAULT_ENTRY_POINT = 'src/index.tsx'; 复制代码其实我们的需求是非常清晰的,就是通过之前获取到的路由数据,生成我们现在的入口文件,即 examples/app/src/index.tsx。我们需要写一个工具,将 routes 配置,转换成真实可用的代码。[ path: '/', element: '/malita/examples/app/src/layouts/index' routes: [{ path: '/', element: '/malita/examples/app/src/pages/home' path: '/users', element: '/malita/examples/app/src/pages/users' 复制代码转换为import React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malitajs/keepalive'; import Layout from './layouts/index'; import Hello from './pages/home'; import Users from './pages/users'; const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> <Route path='/' element={<Layout />}> <Route path="/" element={<Hello />} /> <Route path="/users" element={<Users />} /> </Route> </Routes> </HashRouter> </KeepAliveLayout> const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); 复制代码仔细观察,其实我们需要关注的仅仅是与组件相关的这几行代码的动态生成。import Layout from './layouts/index'; import Hello from './pages/home'; import Users from './pages/users'; <Route path='/' element={<Layout />}> <Route path="/" element={<Hello />} /> <Route path="/users" element={<Users />} /> </Route> 复制代码只要根据配置生成对应的字符串即可。由于 esbuild 不转换 ast,所以我们这里做了一个简化,给导入页面随便写一个名字。import A1 from './layouts/index'; 复制代码简单是实现如下:let count = 1; const getRouteStr = (routes: IRoute[]) => { let routesStr = ''; let importStr = ''; routes.forEach(route => { count += 1; importStr += `import A${count} from '${route.element}';\n`; routesStr += `\n<Route path='${route.path}' element={<A${count} />}>`; if (route.routes) { const { routesStr: rs, importStr: is } = getRouteStr(route.routes); routesStr += rs; importStr += is; routesStr += '</Route>\n'; return { routesStr, importStr }; 复制代码最终我们就可以得到,整个文件的字符串如下:const { routesStr, importStr } = getRouteStr(routes); const content = ` import React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malitajs/keepalive'; ${importStr} const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> ${routesStr} </Routes> </HashRouter> </KeepAliveLayout> const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); `; 复制代码然后将字符串写到对应文件中即可writeFileSync(appData.paths.absEntryPath, content, 'utf-8');指的注意的是当目标文件的所在文件夹不存在的时候,是无法完成文件写入的,所以我们可以先创建目标文件所在的文件夹。这个在微生成器的实现部分,是一个非常需要注意的地方。import { mkdir, writeFileSync } from 'fs'; let count = 1; const getRouteStr = (routes: IRoute[]) => {} export const generateEntry = ({ appData, routes }: { appData: AppData; routes: IRoute[] }) => { return new Promise((resolve, rejects) => { count = 0; const { routesStr, importStr } = getRouteStr(routes); const content = ` import React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malitajs/keepalive'; ${importStr} const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> ${routesStr} </Routes> </HashRouter> </KeepAliveLayout> const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); `; try { mkdir(path.dirname(appData.paths.absEntryPath), { recursive: true }, (err) => { if (err) { rejects(err) writeFileSync(appData.paths.absEntryPath, content, 'utf-8'); resolve({}) } catch (error) { rejects({}) 复制代码动态生成 HTML还是一样的思路,动态生成,我们还是依照模版分析。我们要从之前获取到的数据,生成我们需要的 html 字符串。app.get('/', (_req, res) => { res.set('Content-Type', 'text/html'); res.send(`<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Malita</title> </head> <body> <div id="malita"> <span>loading...</span> </div> <script src="/${DEFAULT_OUTDIR}/index.js"></script> <script src="/malita/client.js"></script> </body> </html>`); 复制代码这里我们取 package.json 中的 name 作为页面的 title然后,修改引入 js 的真实路径,并将 html 写到根路径的 index.html。import { mkdir, writeFileSync } from 'fs'; import path from 'path'; import type { AppData } from './appData'; import { DEFAULT_FRAMEWORK_NAME, DEFAULT_OUTDIR } from './constants'; export const generateHtml = ({ appData }: { appData: AppData; }) => { return new Promise((resolve, rejects) => { const content = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>${appData.pkg.name ?? 'Malita'}</title> </head> <body> <div id="malita"> <span>loading...</span> </div> <script src="/${DEFAULT_OUTDIR}/${DEFAULT_FRAMEWORK_NAME}.js"></script> <script src="/malita/client.js"></script> </body> </html>`; try { const htmlPath = path.resolve(appData.paths.absOutputPath, 'index.html') mkdir(path.dirname(htmlPath), { recursive: true }, (err) => { if (err) { rejects(err) writeFileSync(htmlPath, content, 'utf-8'); resolve({}) } catch (error) { rejects({}) 复制代码执行构建单独看这几个生命周期,会觉得它们相互之间的关联性并不是太强烈,会有带着一个的疑问:为什么要这么写,为什么要生成这个文件?这我们在构建环节就会将这几个数据和临时文件串联到一起。malitaServe.listen(port, async () => { console.log(`App listening at http://${DEFAULT_HOST}:${port}`); try { // 生命周期 // 获取项目元信息 const appData = await getAppData({ // 获取 routes 配置 const routes = await getRoutes({ appData }); // 生成项目主入口 await generateEntry({ appData, routes }); // 生成 Html await generateHtml({ appData }); // 执行构建 await build({ // 没修改的配置,这里简略了,不是删除了哦 outdir: appData.paths.absOutputPath, entryPoints: [appData.paths.absEntryPath], } catch (e) { console.log(e); process.exit(1); 复制代码我们使用 esbuild 将新的项目主入口,构建到产物路径中,因此我们要同步的修改,我们的 html 获取方法。const output = path.resolve(cwd, DEFAULT_OUTDIR); app.get('/', (_req, res, next) => { res.set('Content-Type', 'text/html'); const htmlPath = path.join(output, 'index.html'); if (fs.existsSync(htmlPath)) { fs.createReadStream(htmlPath).on('error', next).pipe(res); } else { next(); 复制代码判断 html 是否生成成功,再返回 html。fs.createReadStream(htmlPath).on('error', next).pipe(res); 来自辟殊 (pshu)新的问题产生了我们将应用主入口从项目中,移到了框架中,当前的问题就是我们之前的 配置,被写死到了,生成的临时文件中。我们的页面 title,也是我们默认的取的 package.json 中的 name 这可能并不是用户想要的。因此这些数据应该从项目传到框架中,因此就出现了“用户配置”需求。这内容我们会在明天实现。感谢阅读,今天的内容主要是补充了昨天缺漏的实现。对于实现,也仅仅是实现我们此刻的需求和场景。但正是因为简单,反而更适合新手阅读。不知道这样的编写方式,你是觉得适合你呢,还是太简单啰嗦了呢?我期待你的反馈。源码归档
阅读本文需要 30 分钟,编写本文耗时 4 小时最佳实践的目录结构我们现在的页面还都写在 src/index.tsx 中,但是当我们项目变得复杂之后,仅用一个文件来管理整个项目显然是不行的,因此,我们借鉴一下 umi 的最佳实践将我们的页面按以下的目录结构重新调整一下。这是当前我们需要用到的文件目录,后续我们会不断的扩展我们需要的目录结构。. ├── dist ├── src │ ├── layout │ │ ├── inedx.tsx │ │ └── index.less │ └── pages │ ├── index.less │ └── index.tsx ├── node_modules ├── package.json ├── tsconfig.json └── typings.d.ts 复制代码dist 目录执行 malita build 后,产物默认会存放在这里。/src 目录layouts/index.tsx约定式路由时的全局布局文件,后续我们会默认用他来包裹我们的路由。比如,你的路由是:[ { path: '/', component: './pages/index' }, { path: '/users', component: './pages/users' }, 复制代码从组件角度可以简单的理解为如下关系:<layout> <page>1</page> <page>2</page> </layout> 复制代码pages 目录所有路由组件存放在这里。使用约定式路由时,约定 pages 下所有的 (j|t)sx? 文件即路由。使用约定式路由,意味着不需要维护,可怕的路由配置文件。约定式路由的实现我们会在明天完成。调整当前的文件将我们当前的 src/index.tsx 进行拆解。比如将 Layout 的内容存放到 src/layouts/index.tsximport React from 'react'; import { useLocation } from 'react-router-dom'; import { Page, Content, Header } from '@alita/flow'; import { useKeepOutlets } from '@malita/keepalive'; const Layout = () => { const { pathname } = useLocation(); const element = useKeepOutlets(); return ( <Page> <Header>当前路由: {pathname}</Header> <Content> {element} </Content> </Page> export default Layout; 复制代码Hello 放到首页 src/pages/home/tsximport React, { useState } from 'react'; import { Link } from 'react-router-dom'; const Hello = () => { const [text, setText] = React.useState('Hello Malita!'); const [count, setCount] = useState(0); return ( <> onClick={() => { setText('Hi!'); > {text} </p> <p>{count}</p> <p><button onClick={() => setCount(count => count + 1)}> Click Me! Add!</button></p> <Link to='/users'>go to Users</Link> </>); export default Hello; 复制代码同理整理 Users 和 Me 页面,然后修改项目主入口 src/index.tsximport React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malita/keepalive'; import Layout from './layouts/index'; import Hello from './pages/home'; import Users from './pages/users'; const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> <Route path='/' element={<Layout />}> <Route path="/" element={<Hello />} /> <Route path="/users" element={<Users />} /> </Route> </Routes> </HashRouter> </KeepAliveLayout> const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); 复制代码这样修改完之后,我们的整个项目的结构就会变得很清晰。这样开发人员就可以快速的上手开发,比如,维护一个老项目的时候,有个 bug 出现在 home 路由,你就知道,你只需要修改 home.tsx 而不用到处找文件。支持 css给我们的页面加一点简单的样式。我们这里加两个 css 文件。src/layouts/index.css.malita-layout { font-size: 24px; 复制代码src/pages/home.css.malita-home { font-size: 24px; 复制代码分别在 layout 和 home 页面中 import "./index.css" 和 import "./home.css";然后在页面中使用它们。随便用,你喜欢加哪就加哪,比如<p className='malita-home'>{count}</p> 复制代码执行 pnpm dev,发现在 www 目录下新增了一个 index.css/* src/layouts/index.css */ .malita-layout { font-size: 24px; /* src/pages/home.css */ .malita-home { font-size: 32px; background: blue; 复制代码此时我们只要将 index.css 文件挂载到 html 上就可以加载支持 css 了<link href="/${DEFAULT_OUTDIR}/index.css" rel="stylesheet"></link> 复制代码从效果上来看,我们的项目已经支持 css 了。但是,从感觉上不像那么一回事,有没有一种更加智能的东西能够帮助我们实现这个功能呢?esbuild 插件编写因为 esbuild 比较新,生态还是比较少的,所以遇到一些需求,我个人更倾向于自己实现插件。esbuild 的插件 api 还是比较简单的,只有 onResolve 和 onLoad,一个用来处理路径相关的问题,一个用来处理加载数据。onResolve可以用它来处理路径相关的需求。onResolve({ filter: filter }, async (args) => { return { path, 复制代码filter 表示路径的过滤条件,在 onLoad 中也是一样的用法,比如你要处理所有的 md 文件,你可以用 filter: /\.md$/,比如给所有的 md 文件添加前缀就是onResolve({ filter: /\.md$/ }, async (args) => { // 只是演示,给这个路径加前缀没什么实际作用 const path = `prefix/${args.path}`; return { path, 复制代码onResolve 还有一个使用的用法,是在 return 的时候指定 namespace,默认的namespace 一般是 file。你可以通过指定 namespace 把文件归类,这样在 onLoad 中可以针对这些文件做特殊处理。比如官网中的例子:onResolve({ filter: /^env$/ }, args => ({ path: args.path, namespace: 'env-ns', onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json', 复制代码这样你就可以在项目代码中使用 env,哪怕实际上并不存在这个模块和文件。import { PATH } from 'env' console.log(`PATH is ${PATH}`) 复制代码onLoad可以用它来处理内容相关的需求。一般是对文件内容有修改的时候,会用到它。 比如上面的例子,就是把本来是不存在内容,指定为 JSON.stringify(process.env) 所以就算本来这个文件不存在,在项目中也可以正常使用。我们可以用它来实现一些 esbuild 官方还不支持的 loader,比如处理 less 文件,使用 postcss 等。这里用 less 做个演示:onLoad({ filter: /\.less$/ }, async (args) => { let content = await fs.readFile(args.path, 'utf-8'); const dir = path.dirname(args.path); const filename = path.basename(args.path); const result = await less.render(content, { filename, rootpath: dir, paths: [...(lessOptions.paths || []), dir], return { contents: result.css, loader: 'css', resolveDir: dir, 复制代码因为 esbuild 不能识别 less 文件,所以上面的例子中我们用 less.render 将 less 文件转换成 esbuild 能够识别的 css 文件。当然 less 的实际处理要比上述的复杂的多,这里只是演示。实现 esbuild 的 styles 插件使用 onResolve 匹配所有 .css 路径的文件,将它的命名空间改到 style-stub 上。onResolve({ filter: /\.css$/, namespace: 'file' }, (args) => { return { path: args.path, namespace: 'style-stub' }; 复制代码使用 onLoad 修改 style-stub 中的返回内容onLoad({ filter: /.*/, namespace: 'style-stub' }, async (args) => ({ contents: ` import { injectStyle } from "__style_helper__" import css from ${JSON.stringify(args.path)} injectStyle(css) `, 复制代码这里又 import 两个文件,一个是 __style_helper__,另一个是它的原始路径。 因为我们项目根本就没有 __style_helper__ 文件,所以我们需要给这个路径加载的时候返回我们需要的代码。这就是我前面说过的“无中生有”。还是想用 onResolve 匹配路径,将它指向 style-helperonResolve( { filter: /^__style_helper__$/, namespace: 'style-stub' }, (args) => ({ path: args.path, namespace: 'style-helper', sideEffects: false, 复制代码然后使用 onLoad 返回我们需要的代码工具类,值得注意的是 onLoad 就是构建的最后一环了,所以我们需要返回正确的 es5 语法。onLoad({ filter: /.*/, namespace: 'style-helper' }, async () => ({ contents: ` export function injectStyle(text) { if (typeof document !== 'undefined') { var style = document.createElement('style') var node = document.createTextNode(text) style.appendChild(node) document.head.appendChild(style) `, 复制代码接下来我们就来处理,它的另一个 import 它原始路径的逻辑处理。根据我们上面提到的 esbuild 的插件编写,遇到路径处理,都用 onResolveonResolve({ filter: /\.css$/, namespace: 'style-stub' }, (args) => { return { path: args.path, namespace: 'style-content' }; 复制代码然后我们返回项目中真实的 css 文件,这时候就可以用上 esbuild 对 css 进行构建了。onLoad( filter: /.*/, namespace: 'style-content', async (args) => { const { errors, warnings, outputFiles } = await esbuild.build( entryPoints: [args.path], logLevel: 'silent', bundle: true, write: false, charset: 'utf8', minify: true, loader: { '.svg': 'dataurl', '.ttf': 'dataurl', return { errors, warnings, contents: outputFiles![0].text, loader: 'text', 复制代码整个过程非常的巧妙,逻辑简述的话,大概如下import "./home.css" 复制代码会被转换成// import css from "./css" var css = ".malita-home { font-size: 32px;background: blue;}"; function injectStyle(text) { if (typeof document !== 'undefined') { var style = document.createElement('style') var node = document.createTextNode(text) style.appendChild(node) document.head.appendChild(style) injectStyle(css); 复制代码这样只要我们加载了 js ,就会自动挂载我们用到的 css 文件了。上述的实现,是为了将如何编写 esbuild 插件将清除,里面有很多实现细节被我忽略了,真正的实现在 @umijs/bundler-esbuild 中,感兴趣的朋友,可以进一步去阅读 umi 的源码。明天我们会完成约定式路由的实现,到此 malita 就会成为一个真正的框架了。感谢关注,感谢阅读。今天的内容是比较新的 esbuild 的插件开发,相信熟悉的朋友不是很多。希望这篇文章能够帮到你们。最近的系列文章收到了一些朋友的反馈,也感谢朋友们指出文章中出现的错误,手误和概念缺失,我都会一一改正。还有朋友推荐我将标题改成《umi 核心开发人员带你21天手写前端框架》,关注的人会更多。哈哈哈,我没好意思放。希望能够得到更多朋友的互动,我希望将这东西写成一个玩具,在写它的时候,能够学到更多的东西。源码归档
阅读本文需要 15 分钟,编写本文耗时 2 小时什么是页面状态保持所谓的页面状态保持就是从 A 页面到 B 页面,再回到 A 页面的时候,希望 A 页面能够保持在离开前的状态。常见的业务场景就是从列表页进去详情页面,再回到列表页面时,期望页面的搜索查询状态,页面的滚动状态能够保持在原来的位置上。这个需求特别是在做移动端的时候,可以说是必备的需求,因为在 pc 端页面上我们还可以通过在 url 上保存分页信息来让返回体验保留在一个可以接受的状态上。但是在移动端,我们的长列表页面都是通过滚动加载更多的方式请求的数据,如果没有状态保持,那可能用户滚动了十几页然后进入详情页回来,又要从第一页开始重新滚动,这样的交互体验是极差的。还有一个比较常见的场景就是移动端填写长表单,其中的某一个值需要跳转一个新页面获取,回来还要将数据还原。如果没有状态保持,我们都是将临时的表单数据放到全局的数据流中,等到返回页面的时候,再从全局的数据流中加载数据。特别是有些数据复杂度很高的时候,这个开发非常的耗费精力,因为你要时刻关注用户什么时间点会离开。可能我们的交付项目多是在移动端,所以状态保持几乎都是每一个项目的强需求。这在 vue 中很早就提供了一个配置实现,而在 React 中缺迟迟没有官方提供。所以社区上也有不少的朋友在鼓捣这个方案。像我的好朋友CJY,就特别擅长和热衷于此。在 react-route@6 之前,我们提供了两种不同的方案在 umi 生态中使用。用的人还不少,反响也挺好。但是实现上还是比较复杂的,没有一点基础的朋友,要掌握这些是比较困难的。但是在 react-route@6 发布之后,我们有了一种更加简单优雅的方式实现,这就是我今天要介绍的内容。实现原理先简单的说一下实现原理吧。先来看两段简单的代码吧。他们的作用都是一样的,都是控制组件显隐。import React, { useState } from 'react'; const CountItem = () => { const [count, setCount] = useState(0); return <div onClick={() => setCount(count + 1)}>{count}</div> const Hello = () => { const [show, setShow] = useState(true); return <> {show && <CountItem />} </> 复制代码上面的逻辑是当 show 为 true 时,渲染 ,当 show 为 false 时, 会被销毁。import React, { useState } from 'react'; const CountItem = () => { const [count, setCount] = useState(0); return <div onClick={() => setCount(count + 1)}>{count}</div> const Hello = () => { const [show, setShow] = useState(true); return <> {<div hidden={!show}><CountItem /></div>} </> 复制代码上面的逻辑最大的差别就是 当 show 为 false 时, 的根节点 被隐藏,而不会被销毁。还有一个关键的知识点,在不使用 react-route@6 的情况下,它是必须的。那就是 key 在 React 中的作用。const Hello = () => { const [show, setShow] = useState(true); return <> {<div key="保持key不变"><CountItem /></div>} </> 复制代码只要在返回的 dom 中,保持 key 不变,就不会触发 React 的重绘。这也是为什么在 list map 的时候,一定要指定一个 key 的关键原因。这个内容不是今天的重点,这里就不深入展开,如果你的项目中用不上 react-route@6 但你有需要状态保持,那你可以参考 alita@2 中的实现。效果展示从上面的动图可以看出,我们在首页和用户页面,页面状态都得到了保持,在用户页面点击清除缓存之后,用户页面的状态被重置到初始状态。50行的源码实现上面提到的效果,完全符合我们的预期需求,并且实现也非常的简单和优雅。主要是使用了上下文来保存页面数据,通过调用 react-route@6 的 useOutlet 来取到真实的 demo。import React, { useRef, createContext, useContext } from 'react'; import { useOutlet, useLocation, matchPath } from 'react-router-dom' import type { FC } from 'react'; export const KeepAliveContext = createContext<KeepAliveLayoutProps>({ keepalive: [], keepElements: {} }); const isKeepPath = (aliveList: any[], path: string) => { let isKeep = false; aliveList.map(item => { if (item === path) { isKeep = true; if (item instanceof RegExp && item.test(path)) { isKeep = true; if (typeof item === 'string' && item.toLowerCase() === path) { isKeep = true; return isKeep; export function useKeepOutlets() { const location = useLocation(); const element = useOutlet(); const { keepElements, keepalive } = useContext<any>(KeepAliveContext); const isKeep = isKeepPath(keepalive, location.pathname); if (isKeep) { keepElements.current[location.pathname] = element; return <> Object.entries(keepElements.current).map(([pathname, element]: any) => ( <div key={pathname} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout" hidden={!matchPath(location.pathname, pathname)}> {element} </div> <div hidden={isKeep} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout-no"> {!isKeep && element} </div> </> interface KeepAliveLayoutProps { keepalive: any[]; keepElements?: any; dropByCacheKey?: (path: string) => void; const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => { const { keepalive, ...other } = props; const keepElements = React.useRef<any>({}) function dropByCacheKey(path: string) { keepElements.current[path] = null; return ( <KeepAliveContext.Provider value={{ keepalive, keepElements, dropByCacheKey }} {...other} /> export default KeepAliveLayout; 复制代码配置 keepalive配置 keepalive 支持字符串和正则,通过它来判断,当前页面是否需要状态保持,因为如果整个项目的页面都保持状态的话,对性能是很大的消耗。方法 isKeepPath 的实现也很简单。useKeepOutlets使用 useKeepOutlets 取到需要渲染的组件,它包含了当前页面的组件,和缓存中的组件。通过判断当前页面是否是需要保持的页面来对页面 DOM 做一个 hidden 显隐开关。指的注意的是所有被指定状态保持的页面在首次渲染之后,都会被挂载在页面 DOM 树上,仅仅是使用 !matchPath(location.pathname, pathname) 控制显隐。而没有被指定状态保持的页面,则是使用 {!isKeep && element} 控制,走 React 组件正常的生命周期。React.useRef({}) 与 {}const keepElements = React.useRef<any>({}) 复制代码使用 React.useRef({}) 来做页面数据保存的节点,是因为我们的上下文不被重新渲染的话 keepElements 就不会被重置。用它替代了 key 的特性。发布 @malitajs 组织下的包新建 packages/keepalive/src/index.tsx,将上面的代码放进去。修改包名 packages/keepalive/package.json 为 @malitajs/keepalive。修改 main 入口 "main": "lib/index.js", 和 types 文件路径 "types": "lib.index.d.ts",。添加发包配置 "publishConfig": { "access": "public" },。增加构建脚本 "scripts": { "build": "tsc", },增加 tsconfig packages/keepalive/tsconfig.json,值得注意的是 jsx 和 declaration 配置,"declaration": true 才会输出 .d.ts 文件。{ "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", "declaration": true, "outDir": "./lib", "rootDir": "./src" "include": ["src","client"] 复制代码构建后执行发包 npm publish ,发布成功之后大家就可以在项目中安装使用它了。这个包是在 alita@3 中经过 20 几个项目验证过生产环境的,比较靠谱的,可以酌情考虑是否用到生产上。授之以鱼 @malita/keepalive将今天的内容封装成独立的 React 中的状态保持组件安装yarn add @malita/keepalive 复制代码使用import KeepAliveLayout, { useKeepOutlets, KeepAliveContext } from '@malita/keepalive'; import { useLocation } from 'react-router-dom'; import React, { useState, useContext } from 'react'; // 使用 useKeepOutlets 取到当前渲染的页面内容,可能是缓存内容 const Layout = () => { const element = useKeepOutlets(); return ( {element} // 使用 KeepAliveLayout 包裹上下文 const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> // App </KeepAliveLayout> // 使用 useContext 取到 dropByCacheKey 清除缓存 const Home = () => { const { dropByCacheKey } = useContext<any>(KeepAliveContext); const { pathname } = useLocation(); return ( <button onClick={() => dropByCacheKey(pathname)}> Click Me! Clear Cache!</button> 复制代码感谢阅读,今天的内容实现事比较简单的,但是确实我们踩过好多坑之后才走出的比较舒服的一条路,如果你觉得对你有说帮助,别忘了给我点赞哦。感谢感谢。源码归档
阅读本文需要 5 分钟,编写本文耗时 1 小时经过一周的努力,我们已经完成了前端框架的工程化的基础部分,完成了一个能跑的前端框架工程。接下来我们会将需求实现转移到项目交付本身相关的内容上来。SPASPA 就是单页面 Web 应用,顾名思义就是只有一个页面,所有的用户访问都在这个页面中进行,只有一开始的时候加载了需要的 javascript 和 css 之后。(不考虑按需加载优化等情况的时候)用户的操作都将在当前页面中完成,有前端实现的 javascript 控制整个页面的交互逻辑。有个比较容易分辨是否是 SPA 的方法,是当你在页面中发生“页面跳转”的时候,页面是否重新刷新,重新加载了 js。如果是按需引入添加的 js 请求,是在你现有的基础上而外发起的请求,原来已加载的请求不会刷新。为什么现在基本上都选择使用 SPA除了部分比较传统的网站,有强烈 SEO 优化需求的项目之外,现在前端首选几乎都是 SPA。因为它除了首次加载页面耗时之外,其他的用户交互体验都比多页应用要快,部分内容的更改,不需要整个页面的刷新。前端的渲染也不用占用服务器资源。所以从用户体验和成本上来说,SPA 都是 Web 2.0 之后较好的选择。react-routerReact Router 是 React 的一个功能齐全的客户端和服务器端路由库,它是一个用于构建用户界面的 JavaScript库。React Router 可以运行在 React 运行的任何地方;在web上的实现是 react-router-dom。react-router@6 apireact-router@6 与 react-router@5 存在一定的差异,包含导出组件和导出 api 的使用,这在掘金上有很多的文章介绍,这里就不做过多的展开,我只罗列了本次文章中我们会使用到的几个简单的 api。导出作用说明<Routes>一组路由所有子路由都用基础的 Router 来表示,必须写<Route>基础路由Route 是可以嵌套的<Link>导航组件在实际页面中跳转使用<Outlet/>自适应渲染组件,你可以把它当作之前的 children根据实际路由 url 自动选择组件useLocation返回当前的location 对象-react-router@6 更新最让我惊喜的是 Outlet 组件,他还有一个配套的 useLocation hooks 供用户使用,用它来实现 keepalive 功能,比之前的实现要优雅简单的多,明天我们的主题就是手写一个 keepalive 组件。在项目中配置路由安装依赖cd examples/app pnpm i react-router react-router-dom 复制代码记得安装两个依赖 react-router 和 react-router-dom,使用的时候,我们只从 react-router-dom 中使用它导出的 API。 react-router 是 react-router-dom 的核心包,所以它是必须安装的。在项目中使用examples/app/src/index.ts首先我们先改一下我们页面的渲染入口组件。const App = () => { return (); const root = ReactDOM.createRoot(document.getElementById('malita')); - root.render(React.createElement(Hello)); + root.render(React.createElement(App)); 复制代码编写我们的 app 组件const App = () => { return ( <HashRouter> <Routes> <Route path='/' element={<Layout />}> <Route path="/" element={<Hello />} /> <Route path="/users" element={<Users />} /> <Route path="/me" element={<Me />} /> </Route> </Routes> </HashRouter> 复制代码仔细看上述的实现,我们使用 Route 嵌套了 Route 组件,这将会导致在渲染内层 Route 组件是会用外层的 Route 包裹,说起来有一点绕,简单的说就是常常被提到的 layout 功能。实现 layout 页面,layout 就是多个页面的公共部分的提取,这包括公共部分页面也包括公共逻辑和数据流。比如,你可以编写一个仅仅处理逻辑的 layout 页面。import { Outlet, useLocation } from 'react-router-dom'; const Layout = () => { const { pathname } = useLocation(); console.log(pathname); return (<Outlet />); 复制代码下面我们简单的编写一个我们本次教程需要的 layout 吧。 就是返回被嵌套的内部 Route 的渲染,简单理解就是之前的 {children}。import { Outlet, useLocation } from 'react-router-dom'; import { Page, Content, Header } from '@alita/flow'; const Layout = () => { const { pathname } = useLocation(); return (<Page> <Header>当前路由: {pathname}</Header> <Content> <Outlet /> </Content> </Page>) 复制代码简单的编写一下三个页面的组件,值得注意的是,我们使用 Link 组件来做页面跳转,他的功能就相当于元素上添加一个 onClick 事件,响应的是 history.push(to);。const Hello = () => { const [text, setText] = React.useState('Hello Malita!'); return ( <> onClick={() => { setText('Hi!') }}> {text} </p> <Link to='/users'>Users</Link> </>); const Users = () => { return ( <> <p> Users </p> <Link to='/me'>Me</Link> </>); const Me = () => { return (<><p> Me </p> <Link to='/'>go Home</Link></>); 复制代码上面的代码对于对 React Router 比较熟悉的朋友应该看一眼就清楚了,对于 React Router 不熟悉的朋友感兴趣的话可以再过一下官网的新手教程。因为在 malita 的设计中,我们会弱化掉这个概念,后续会沿用 umi 中的“文件即路由”的思路,毕竟这是我最喜欢的一个特性,所以你没有掌握 react-router 的知识,也可以正常继续后续的开发工作。效果展示仔细观察上面的操作,我们在进行页面切换的时候,页面并没有发生刷新。感谢阅读,今天的内容比较简单,仅仅作为对 react-router 的一个简单应用,同时作为明天 keepalive 实现的一个前置预告。因为路由部分是我们实现 keepalive 的基础。如果你对 react 状态保持感兴趣,在 react 官方支持这个功能之前,有强烈需求的,可以关注一下明天的文章内容。源码归档
阅读本文需要 20 分钟,编写本文耗时 5 小时。esbuild 生态不太完善,很多资料需要翻阅 Issues 甚至阅读源码,有一些实现需要反复的尝试。上一次我们遗留了两个问题。1、服务端口被占用2、每次修改项目都需要刷新页面自动检测端口可用性第一个问题比较简单,端口被占用或者端口不可用,那就自动找一个端口用呗。我用过两个包,第一个是用 umi@1 中用到的 detect-port,一个是 umi@4 中用到的 portfinder。这里我们随便用 portfinder 找一个可用的端口吧。import portfinder from 'portfinder'; import express from 'express'; import { DEFAULT_PORT } from './constants'; const app = express(); const port = await portfinder.getPortPromise({ port: DEFAULT_PORT, app.listen(port, ()=>{}); 复制代码热更新 hmr 原理因为我们的整个构建流程都没有使用到 webpack ,所以没办法使用 webpack 的 hmr 能力,要完全实现 hmr 和 react 的快速刷新功能还是挺复杂的。后面看看有机会的话我们再来完善它。现在我们只是简单的实现我们的需求,“项目文件被修改之后,自动刷新页面”。首先我们来分析一下 webpack 的 hmr 原理。1、项目页面(以下称之为客户端)下载 manifest 资源文件,你可以理解为需要加载的链接的清单列表2、客户端加载文件完成之后与 webpack 的开发服务器(以下称之为服务端),建立 Socket 通信3、webpack 监听文件变化,产生增量构建,并向客户端发送构建事件4、客户端接收到构建事件之后,向服务端请求 manifest 资源文件,比对文件变化,确认去要增量下载的文件5、客户端加载增量构建的模块6、webpack runtime 出发热更新回调,执行变更逻辑。如果你使用 webpack ,经常会在修改项目文件之后,发现浏览器发起了一个带有 hot 字样的链接,这个请求链接就是这么来的。因为 esbuild 没有办法做增量构建,所以我们结合上面的原理,完成我们的逻辑。1、项目加载完成,注入 Socket 客户端脚本2、与服务端建立 Socket 通信通道3、esbuild 监听事件变化,执行 onRebuild 事件4、向客户端发送 reload 事件5、客户端执行 window.location.reload() 刷新页面实现 Socket 服务端安装 ws 模块pnpm i ws 复制代码使用 ws 新建一个 WebSocketServerimport { WebSocketServer } from 'ws'; import { createServer } from 'http'; const server = createServer(); const wss = new WebSocketServer({ noServer: true, server.on('upgrade', function upgrade(request, socket, head) { wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit('connection', ws, request); server.listen(8080); 复制代码官网 ws 用法与 express 结合使用由于我们之前已经使用了 express 建立了我们的服务,所以结合上述需求,我们可以使用 http.createServer 来新建我们的 express 服务。import express from 'express'; import { DEFAULT_PORT } from './constants'; const app = express(); app.listen(DEFAULT_PORT, ()=>{}); 复制代码改为import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import express from 'express'; import { DEFAULT_PORT } from './constants'; const app = express(); const server = createServer(app); const wss = new WebSocketServer({ noServer: true, server.on('upgrade', function upgrade(request, socket, head) { wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit('connection', ws, request); server.listen(DEFAULT_PORT,()=>{}); 复制代码这样使用,我们还能保留 express 的中间件功能,可能是我写起来比较顺手吧。实现 Socket 客户端先给一段一眼就能看懂的代码吧,就是使用 window.WebSocket 链接我们的 Socket 服务端,在监听事件类型为 reload 时,执行刷新页面。if ('WebSocket' in window) { const socket = new window.WebSocket('ws://127.0.0.1:8888'); socket.onmessage = function (msg) { const data = JSON.parse(msg); if (data.type === 'reload') window.location.reload(); 复制代码Socket 保活给 Socket 增加心跳包(我不确定这个形容是否正确,实习做游戏的时候,带我的坛爷是这么讲的),就是定时给 Socket 服务端发送一个信息,告诉他你还“活着”。socket.onmessage = function (msg) { const data = JSON.parse(msg); if (data.type === 'connected') { console.log(`[malita] connected.`); // 心跳包 pingTimer = setInterval(() => socket.send('ping'), 30000); if (data.type === 'reload') window.location.reload(); 复制代码增加断线重连逻辑断线之后,先停止“心跳,因为链接已经中断了,你在一直发送信息,只会重复的报错。由于我们的服务还是一个 express 服务,所以我们可以写一个死循环,不断的向服务端发送请求,当服务端重启完成之后,只要刷新页面就可以重新连接 Socket,这个实现是从 vite 抄的,感觉实现很优雅,就拿到 umi@4 中使用了。async function waitForSuccessfulPing(ms = 1000) { while (true) { try { await fetch(`/__malita_ping`); break; } catch (e) { await new Promise((resolve) => setTimeout(resolve, ms)); const socket = new window.WebSocket('ws://127.0.0.1:8888'); socket.onclose = function (msg) { if (pingTimer) clearInterval(pingTimer); console.info('[malita] Dev server disconnected. Polling for restart...'); await waitForSuccessfulPing(); window.location.reload(); 复制代码整理一下我们上面的逻辑,最终实现我们的 Socket 客户端代码如下:function getSocketHost() { const url: any = location; const host = url.host; const isHttps = url.protocol === 'https:'; return `${isHttps ? 'wss' : 'ws'}://${host}`; if ('WebSocket' in window) { const socket = new WebSocket(getSocketHost(), 'malita-hmr'); let pingTimer: NodeJS.Timer | null = null; socket.addEventListener('message', async ({ data }) => { data = JSON.parse(data); if (data.type === 'connected') { console.log(`[malita] connected.`); // 心跳包 pingTimer = setInterval(() => socket.send('ping'), 30000); if (data.type === 'reload') window.location.reload(); async function waitForSuccessfulPing(ms = 1000) { while (true) { try { await fetch(`/__malita_ping`); break; } catch (e) { await new Promise((resolve) => setTimeout(resolve, ms)); socket.addEventListener('close', async () => { if (pingTimer) clearInterval(pingTimer); console.info('[malita] Dev server disconnected. Polling for restart...'); await waitForSuccessfulPing(); location.reload(); 复制代码使用 esbuild 构建浏览器端代码值得注意的是客户端代码,我们是在浏览器端使用的,而之前我们的 esbuild 构建项目的产物是 node 端使用的。所以我们要在 package.json 中增加一段构建客户端代码的脚本。(客户端代码放在 client/client.ts) 主要是修改了 --platform=node 和 outdir。"scripts": { "build": "pnpm esbuild ./src/** --bundle --outdir=lib --platform=node --external:esbuild", "build:client": "pnpm esbuild ./client/** --outdir=lib/client --bundle --external:esbuild", "dev": "pnpm build -- --watch" 复制代码编写框架的妥协现在是 2022 年 4 月 18 日 11 点,我查阅了很多资料,esbuild serve 不能响应 onRebuild, esbuild build 和 express 组合不能不写入文件,相关问题 Issues。esbuild 的作者不太想支持 live serve,并且 esbuild serve 在执行插件时无法响应 onEnd 事件(bug),所以以下实现,做了妥协。如果你是在很久远的未来,看到这篇文章,那你可能可以直接使用 esbuild serve 来实现以下的功能。只需在 onRebuild 或者 onEnd 事件中调用 sendMessage('reload') 即可。使用 esbuild build watch 模式替代 esbuild serve在监听服务启动的时候,构建我们的项目文件。将它们写入到 esbuildOutput(这个变量会在下面说明)。build 的配置就是我们之前使用的 esbuild.serve(serveConfig,buildConfig) 时用到的 buildConfig。只是增加了 watch 模式。malitaServe.listen(port, async () => { console.log(`App listening at http://${DEFAULT_HOST}:${port}`); try { await build({ outdir: esbuildOutput, platform: DEFAULT_PLATFORM, bundle: true, watch: { onRebuild: (err, res) => { if (err) { console.error(JSON.stringify(err)); return; sendMessage('reload') // ... other config } catch (e) { console.log(e); process.exit(1); 复制代码编写静态资源服务将 esbuild 的构建产物,放到静态资源服务中给客户端使用,这个使用 express 非常容易实现。import { DEFAULT_OUTDIR } from './constants'; const esbuildOutput = path.resolve(cwd, DEFAULT_OUTDIR); app.use(`/${DEFAULT_OUTDIR}`, express.static(esbuildOutput)); 复制代码这样当浏览器发起 /${DEFAULT_OUTDIR} 前缀的请求时,就会被重定向(或者称之为代理?)到 esbuildOutput。 我们上面构建中提到将产物输出到了 esbuildOutput 中。 之后修改我们的 html 返回。app.get('/', (_req, res) => { res.set('Content-Type', 'text/html'); res.send(`<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Malita</title> </head> <body> <div id="malita"> <span>loading...</span> </div> - <script src="http://${DEFAULT_HOST}:${DEFAULT_BUILD_PORT}/index.js"></script> + <script src="/${DEFAULT_OUTDIR}/index.js"></script> </body> </html>`); 复制代码注入 Socket 客户端脚本因为我们的客户端脚本是在框架中实现,并不在项目的文件中,因为我们可以用同样的静态资源服务器的方法,将 client 文件返回给浏览器。其实在 esbuild 体系中,有一种更加“有趣”的实现,就是可以使用插件无中生有,就是你 import 一个根本不存在的文件,然后通过插件中匹配你的引用路径,返回一个编译后的代码段,esbuild 插件开发我们会在后面的文章中体现,所以这里先不使用这种方式。//__dirname 文件所在路径 cwd 命令执行路径 app.use(`/malita`, express.static(path.resolve(__dirname, 'client'))); 复制代码然后在返回的 html 中添加引用<script src="/malita/client.js"></script> 复制代码感谢阅读,今天的文章需要一点点基础,如果你是完全零基础阅读这篇文章,那你可能需要反复阅读。你可以尝试着在上一次源码的基础上添加这些功能。只要实现了,修改项目文件 examples/app/src/index.tsx 保存,页面会自动刷新就说明成功了,如果对你来说这些功能添加不是很熟悉的话,你可以阅读下面的源码归档。多看几遍就能明白了。源码归档
阅读本文需要 4 分钟,编写本文耗时 1.5 小时引入 typescript在项目中使用 typescript 可以使用很多联想,还有对于函数调用出入参也有一个明显的提示作用,可以大大加快我们编写框架的速度,也能减少很多翻阅文档的时间。pnpm i typescript -w -D 复制代码使用 tsc 初始化项目npx tsc --init 复制代码将会自动生成一个 tsconfig.json 文件,我们会在后面需要的时候修改它。引入 esbuild 编译 ts使用 esbuild 编译我们的源码,讲 ts 文件转译为 js 文件,将 es6 语法转为 es5 的语法。构建工具现在流行的有很多种,使用 esbuild 的唯一一个原因就是它默认很快。pnpm i esbuild -w -D 复制代码修改构建命令,使得我们能够快捷的启动构建脚本。package.json"scripts": { "build": "pnpm -r --filter ./packages run build", "dev": "pnpm -r --filter ./packages --parallel run dev" 复制代码根目录下面这么编写的一样是当你执行 pnpm build 的时候,将会执行所有子包中的 build 脚本,这样我们就可以执行一次就构建全部的包。packages/malita/package.json"scripts": { "build": "pnpm esbuild src/** --bundle --outdir=lib", "dev": "pnpm build -- --watch" 复制代码子包中配置 build 脚本,来执行 esbuild 编译,将 src 目录下面的文件编译到 lib 文件中。配置完成之后,尝试执行 pnpm build。➜ malita git:(master) ✗ pnpm build > @ build /Users/congxiaochen/Documents/malita > pnpm -r --filter ./packages run build > malita@0.0.2 build /Users/congxiaochen/Documents/malita/packages/malita > pnpm esbuild src/index.ts --bundle --outdir=lib lib/index.js 100b ⚡ Done in 1ms 复制代码可以看到构建只花了 1ms。编写 dev 入口在 packages/malita/bin/malita.js 中添加 dev command,这里昨天介绍过了,这里不做多余阐述了。#!/usr/bin/env node const { Command } = require('commander'); const program = new Command(); // ... 略 program.command('dev').description('框架开发命令').action(function() { require('../lib/dev') program.parse(process.argv); 复制代码通过上面的命令我们完成了当用户执行 malita dev 的时候,会 require('../lib/dev'),我们只需要在 src/dev.ts 中编写我们需要的逻辑就可以了。那么我们该在 dev 里面写什么呢?或者说 dev 服务的功能是什么呢?极简的脚手架回想一下第三天时候,我们做的尝试让一个页面运行起来。最后我们使用了 jsx 的语法,编写了 react 项目,并且成功的运行起来了。所以我们先用我们新的目录结构,组织一下这个 demo。新建 examples/app/src/index.tsximport React from 'react'; import ReactDOM from 'react-dom'; const Hello = () => { const [text, setText] = React.useState('Hello Malita!'); return (<span onClick={() => { setText('Hi!') }}> {text} </span>); const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(Hello)); 复制代码新建 examples/app/www/index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Malita</title> </head> <body> <div id="malita"> <span>loading...</span> </div> <script src="./index.js"> </script> </body> </html> 复制代码配置执行脚本 scripts"scripts": { "build": "pnpm esbuild src/** --bundle --outdir=www", "dev": "pnpm build -- --watch" 复制代码执行编译 pnpm build,然后浏览器中打开 www/index.html 就能看到我们的页面了。 如果执行 pnpm dev,每一次更新代码之后,只要刷新页面就可以看到最新的内容了。直到现在,我们都是通过在浏览器中打开 html 的方法,访问我们的页面,如果你想通过类似 http://127.0.0.1:3000/ 的方式访问页面。那可以使用 serve 命令,这个在后面验证产物是否构建成功的时候,是一个很有帮助的服务。// cd examples/app pnpm i serve --D 复制代码配置执行脚本 scripts"scripts": { "build": "pnpm esbuild src/** --bundle --outdir=www", "dev": "pnpm build -- --watch", "serve": "cd www && serve --port=8000" 复制代码执行 pnpm serve 就可以 http://127.0.0.1:3000/ 了。总结通过上面的极简脚手架,我们就能分析出我们的极简框架的第一个需求了,执行 malita dev 提供一个可访链接能访问当前页面。我们会在明天完成这个需求。感谢阅读,如果你有任何问题,可以通过评论区和我互动。新人创作者,需要各位大哥点赞评论支持。谢谢。源码归档
事情的缘由来自于云谦在知识星球上发起的21天打卡任务,「21天不间断,每天250字以上有主题的短文」。而且至从开始跟 umi 项目之后,我就很少写文章了,整个工作重心都偏向于能在 umi 和 alita 上的代码产出。 但是其实我是一个很看重文档能力的人,这一点在我面试和培养新人的时候都有很好的体现跟着我的实习生整个实习期只有为一个要求,就是每个工作日一篇博文,可以发布在任何平台或者私人文档中,也可以不给我看,但是一定要写。面试的时候,如果有在线博客或者有相关平台的博文浏览量也会考虑到薪资定岗中。而且看着云谦的日更,其实也有点手痒。想着借由这个机会,能够重拾文档能力。毕竟偶像的号召也是一个很大的推进力。想着能写什么内容,刚好云谦也在他的知识星球上面,发过一个《云谦的极简框架课:手写 umi》的课程大纲。我就想如果是同样的题材,我会怎么去写,会写出一个什么样的东西。趁着他还没发布几篇,我还不能去抄作业,刚快规划一下。由于我周末都不工作,这点从我 github 的提交记录上就可以很清晰的体现,所以这个21天,我定为了21个工作日。刚好云谦这21个工作日的日更内容是 umi4 的特性介绍。也是打一个时间差,有一点乘老师还没公布答案之前,努力写作业的意思。大纲的话,基本会按着云谦给出的大纲输出。会有一点修改。因为整体会更偏向于移动端的框架需求,但是大体上应该是一致的。角度的话,就肯定是不一样的,毕竟偶像和粉丝就不是一个档次的选手,按我之前的文章定位,还是会更加面向前端新手。这是今年立下的第一个 flag,如果我断更的话,请无情的嘲讽我。最近在做零代码开发,项目中事情比较多,还夸下海口说这个月会有 50% 的精力投入到 umi 开发中,所以可能文章编写几乎都在深夜,所以请不要计较我是不是在过了12点之后才发的文章。欢迎围观。也欢迎更多的朋友参与到这个挑战之中。
背景0本地机器 MacBook Pro 芯片 Apple M1 Pro 线上机器 openjdk_java8_nodejs (不知道这个实际配置)背景1构建提速是 umi@4 的一个较大的特性,项目中使用 umi@4 构建,只需要12秒左右。➜ nocode-1 git:(master) pnpm build:app > @ build:app /Users/congxiaochen/Documents/nocode-1 > cross-env APP_ROOT=packages/app umi build info - Using Main Path Plugin info - Using Request Plugin info - Using Dva Plugin info - Using ClassNames Plugin info - Using UseModel Plugin info - Using Antd Plugin event - Compiled successfully in 12091 ms (4646 modules) info - Memory Usage: 501.55 MB (RSS: 1277.97 MB) event - Build index.html 复制代码如果使用传统的交付方式,我本地打包之后,通过 ftp 文件上传部署,可能整个上线流程只要几分钟时间。但是传统的交付部署模式,其实存在着很多的不可信任的人为操作问题,比如说包同步之后,线上没有同步,你很难断言说是人员操作失误,还是静态资源容器缓存。背景2项目中使用 menorepo(多包管理),构建项目之前需要先构建本地的子包。 使用的是 yarn 和 lerna 的管理方式。使用 build: lerna run build构建 10 个子包,每个子包构建时长为 10s-43s 不等。背景3所以整个 zcm 构建流程就是 容器启动(24s)、拉取代码(11s)、执行依赖安装(82s)、执行子包编译(231s)、执行子包编译(231s)、执行项目编译(52.71s)、云构建流程,将要回写数据到zcm中(5s)、构建镜像(16s)、推送上线(11s)#!/bin/bash # 安装 yarn # 避免重复执行 if [[ ! -h ~/.yarn/berry ]]; then curl -SsLf http://gitlab.iwhalecloud.com/rlc/cicd/raw/master/scripts/react/install_yarn.sh | bash - log_info "Start installing packages..." if [[ -f ".yarnrc.yml" ]]; then yarn install --immutable yarn install --frozen-lockfile log_info "Start building..." yarn build log_info "Start building app..." # 执行产物编译 yarn build:app 复制代码解决思路其实我一开始看到的是这个时间线切换 npm 源主要的时间是花在构建环节,和本地机器差异较大,所以我很想当然的以为是依赖安装问题,所以在本地切换了公司内部的源,并将锁定了源的 yarn.lock 上传到仓库中。这个对安装时间有提升,但是不明显。看了下构建日志,发现zcm 会自动把 npm 源设置到公司源。继续跟踪日志,我发现子包编译过程执行了两次,并且在子包编译结束时程序没有很良好的推出编译进入下一个环节。本地构建时也会偶发 lerna run build推出较慢的问题,(没有深入去跟踪问题)。使用 pnpm所以使用 pnpm 替换 lerna。- "build": "lerna run build", + "build": "pnpm -r --filter ./packages run build", 复制代码然后修改构建脚本#!/bin/bash # 安装 pnpm # 避免重复执行 if [[ ! -h ~/.pnpm-state/pnpm-state.json ]]; then curl -fsSL https://get.pnpm.io/install.sh | PNPM_VERSION=7.0.0-beta.2 sh - log_info "Start installing packages..." pnpm i log_info "Start building..." pnpm build log_info "Start building app..." # 执行产物编译 pnpm build:app 复制代码发现整个过程快了5分钟左右,有两方面的原因,一个是 pnpm 安装依赖更快,另一个是每一个构建流程完成之后都很顺利的进入下一个环节。最终得到了一个 6分25秒 构建的时间线使用 turbo既然依赖安装部分尽力了,那构建环节是不是还可以更快呢?刚好最近在 umi 项目开发中引入了 turbo 编译速度有很大的提升,也是抱着尝试的心态,将 turbo 引入到项目中(从 umi 抄作业)。1、只需要安装三个包"esno": "^0.14.1", "ts-node": "^10.7.0", "turbo": "^1.1.9", 复制代码2、加一个配置文件 turbo.json{ "$schema": "https://turborepo.org/schema.json", "baseBranch": "origin/master", "pipeline": { "build": { "dependsOn": ["^build"] "globalDependencies": [] 复制代码3、一个构建脚本 scripts/turbo.tsimport * as logger from '@umijs/utils/dist/logger'; import spawn from '@umijs/utils/compiled/cross-spawn'; import yArgs from '@umijs/utils/compiled/yargs-parser'; import { join } from 'path'; (async () => { const args = yArgs(process.argv.slice(2)); const scope = args.scope || '!@example/*'; const extra = (args._ || []).join(' '); await turbo({ cmd: args.cmd, scope, extra, cache: args.cache, parallel: args.parallel, })(); * Why not use zx ? * - `zx` not support color stdin on subprocess * - see https://github.com/google/zx/blob/main/docs/known-issues.md#colors-in-subprocess * https://github.com/google/zx/issues/212 async function cmd(command: string) { const result = spawn.sync(command, { stdio: 'inherit', shell: true, cwd: join(__dirname, '../'), if (result.status !== 0) { // sub package command don't stop when execute fail. // display exit logger.error(`Execute command error (${command})`); process.exit(1); return result; async function turbo(opts: { scope: string; cmd: string; extra?: string; cache?: boolean; parallel?: boolean; const extraCmd = opts.extra ? `-- -- ${opts.extra}` : ''; const cacheCmd = opts.cache === false ? '--no-cache --force' : ''; const parallelCmd = opts.parallel ? '--parallel' : ''; const options = [ opts.cmd, `--cache-dir=".turbo"`, `--scope="${opts.scope}"`, `--no-deps`, `--include-dependencies`, cacheCmd, parallelCmd, extraCmd, .filter(Boolean) .join(' '); return cmd(`turbo run ${options}`); 复制代码4、更换一下执行命令"build": "esno scripts/turbo.ts --cmd build", 复制代码最终得到一个我非常满意的 4分30秒 的时间线并且二次构建是有缓存的,如总结1、使用yarn2替换yarn1yarn2 会比 yarn1 要快,但是 yarn2 有些框架不支持,有些第三方包会存在异常。所以我本人不太喜欢 yarn22、使用 pnpm 替换 yarn + lerna 的组合3、使用 turbo 执行构建脚本备注其实主要信息都在总结,但是每个项目用到的框架和构建脚本都不一样,提供一下我的完整思路,希望能够引导你对你当前所在项目的构建提升作出优化。
前言console.log 对一个前端人,那是在熟悉不过了。我们最经常使用它在控制台输出信息然后进行代码调试,你肯定会发现输出的信息的颜色永远是黑色,当然还有一种我们最不喜欢的颜色 -- 红色,一旦出现了就意味BUG出现了。本文教你如何用 console 在控制台输出五颜六色的信息。console.log 输出单色的信息实现起来非常简单,就是 console[1] 能够解析占位符,总的就支持 5 个占位符 %o,%s,%s,%f,%c。其中 %c 指令将 CSS 样式应用于控制台输出,指令前的文本不会受到影响,但指令后的文本将使用参数中的 CSS 声明进行样式设置。比如想在控制台输出123456,其中456的颜色是红色的,可以在控制台中输入以下代码console.log('123%c456','color: red;')从上图中可以看到,我们的尝试是有效的,并且表现完全符合预期。把上面用法封装一下,形成一个“语法糖”,实现如下:const red = (str)=>{ console.log(`%c${str}`,'color: red') red('Hello world!')可以直接复制以上代码到控制台中执行,看是不是效果一样。red 方法就是一个在控制台输出蓝色的信息的语法糖。其它 4 个占位符,可以翻阅 mozilla console[2] 了解哈。更灵活的调用 console 方法通过观察上面的用法,我们发现如果我们需要给同一个字符串添加更多的 CSS 效果,我们需要编辑 log 的第二个参数,而如果需要把两个有颜色的字符串拼接到一起,则需要修改第一参数,并且添加一个第三参数。console.log(`%c123%c456`,'color: blue;','color: green;')如果再继续拼接的话,那传入参数更多。在传入参数不确定的时候,我们可以使用 arguments 来获取传入参数,使用 ES6 语法的话,只需要写解构就行。const log = (...args)=>{ console.log.apply(void 0, args); }console.log 输出多色的信息上面实现了单色的信息,那么多色的信息如何实现呢?console.log 在第一个参数后,还可以接受多个参数,每个参数可以给对应的信息设置不同的颜色,具体实现如下:console.log('%c123%c456','color: blue','color: green')那如何将 console.log 输出多色的信息封装一下,形成一个"语法糖"呢?可以这么做,把一个信息拆分成多段,再对应设置不同的颜色,再组合起来 console.log 输出。比如把 123456 拆分成 123 和 456,再对应不同的颜色,数据如下所示:['%c123','color: blue'] ['%c456','color: green']写一个 add 方法将上面数据拼接成 ['%c123%c456','color: red','color: blue'] ,然后利用 apply 调用 console.log ,如下所示:console.log.apply(void 0, ['%c123%c456','color: blue','color: green']);复制以上代码到控制台中执行,效果如下图所示:下面来实现一下 add 方法,并对 console.log 进行二次封装。const _console = console; const createlog = (util) => (...args) => { const fun = _console[util] ? _console[util] : _console.log; fun.apply(void 0, args); const add = (...arr) => { let fi = [[]]; for (let key = 0; key < arr.length; key++) { const [first, ...other] = arr[key]; fi[0] += first; fi = fi.concat(other); return fi; createlog('log')(...add(['%c123','color: blue',],['%c456','color: green']));复制以上代码到控制台中执行,看一下效果,效果如下图所示:预设一些颜色值我们将常用到的一些颜色值,都内置封装一下,方便使用,将上面实现的 red 方法的改造成按这样 chalk.log(chalk.red(123)) 调用。const _console = console; const createlog = (util) => (...args) => { const fun = _console[util] ? _console[util] : _console.log; fun.apply(void 0, args); const add = (...arr) => { let fi = [[]]; for (let key = 0; key < arr.length; key++) { const [first, ...other] = arr[key]; fi[0] += first; fi = fi.concat(other); return fi; const chalk = { log:createlog('log') const color = { black: '#00000', red: '#FF0000', green: '#008000', yellow: '#FFFF00', blue: '#0000FF', magenta: '#FF00FF', cyan: '#00FFFF', white: '#FFFFFF', Object.keys(color).forEach((key) => { chalk[key] = (str) => { // 用户会有两种用法 chalk.red('1231') if (typeof str === 'string' || typeof str === 'number') { return [`%c${str}`, `color:${color[key]}`]; // 用户第二种用法 chalk.red(chalk.bold('123')) for (let i = 1; i < str.length; i++) { str[i] += `;color:${color[key]}`; return str; chalk.log(...add( chalk.black('black'), chalk.red('red'), chalk.green('green'), chalk.yellow('yellow'), chalk.blue('blue'), chalk.magenta('magenta'), chalk.cyan('cyan'), chalk.white('white') )复制以上代码到控制台中执行,看一下效果,效果如下图所示:增加背景色通过观察我们发现背景色的方法名,其实就是颜色方法首字母大写再增加 bg 前缀。简单的修改上面的方法实现Object.keys(color).forEach((key) => { colorUtils[key] = (str: string | string[]) => { // 用户会有两种用法 chalk.red('1231') if (typeof str === 'string' || typeof str === 'number') { return [`%c${str}`, `color:${color[key]}`]; // 用户第二种用法 chalk.red(chalk.bold('123')) for (let i = 1; i < str.length; i++) { str[i] += `;color:${color[key]}`; return str; colorUtils[`bg${firstToUpperCase(key)}`] = (str: string | string[]) => { if (typeof str === 'string' || typeof str === 'number') { return [`%c${str}`, `padding: 2px 4px; border-radius: 3px; color: ${key === 'white' ? '#000' : '#fff'}; font-weight: bold; background:${color[key]};`]; for (let i = 1; i < str.length; i++) { str[i] += `;padding: 2px 4px; border-radius: 3px; font-weight: bold; background:${color[key]};`; return str; });给日志分级别为了进一步对日志输出作出更加合理的管控,也为了提供更多的默认颜色输出,因此我们给日志输出划分等级。const colorHash = { log: 'black', wait: 'cyan', error: 'red', warn: 'yellow', ready: 'green', info: 'blue', event: 'magenta', };使用这些分级会默认给输出日志添加前缀,如 [Error],后面的颜色是默认颜色。let chalk = {}; if (!window.chalk) { const _console = console; const color = { black: '#000000', red: '#FF0000', green: '#008000', yellow: '#FFFF00', blue: '#0000FF', magenta: '#FF00FF', cyan: '#00FFFF', white: '#FFFFFF', const add = (...arr) => { let fi = [ for (let key = 0; key < arr.length; key++) { const [first, ...other] = arr[key]; fi[0] += first; fi = fi.concat(other); return fi; const createlog = (util) => (...args) => { const fun = _console[util] ? _console[util] : _console.log; fun.apply(void 0, args); const colorUtils = { bold: (str) => { if (typeof str === 'string' || typeof str === 'number') { return `${str};font-weight: bold;`; for (let key = 1; key < str.length; key++) { str[key] += `;font-weight: bold;`; return str; const colorHash = { log: 'black', wait: 'cyan', error: 'red', warn: 'yellow', ready: 'green', info: 'blue', event: 'magenta', const createChalk = (name) => (...str) => { if (typeof str[0] === 'object') { createlog(name)(...add(colorUtils.bold(colorUtils[colorHash[name]](`[${firstToUpperCase(name "colorHash[name]")}] `)), ...str)); return; let strArr = str; if (typeof str === 'string' || typeof str === 'number') { strArr = colorUtils[colorHash[name]](str "colorHash[name]"); createlog(name)(...add(colorUtils.bold(colorUtils[colorHash[name]](`[${firstToUpperCase(name "colorHash[name]")}] `)), strArr)); const chalk = {}; Object.keys(colorHash).forEach(key => { chalk[key] = createChalk(key); const firstToUpperCase = (str) => str.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase()); Object.keys(color).forEach(key => { colorUtils[key] = (str) => { if (typeof str === 'string' || typeof str === 'number') { return [`%c${str}`, `color:${color[key]}`]; for (let i = 1; i < str.length; i++) { str[i] += `;color:${color[key]}`; return str; colorUtils[`bg${firstToUpperCase(key)}`] = (str) => { if (typeof str === 'string' || typeof str === 'number') { return [`%c${str}`, `padding: 2px 4px; border-radius: 3px; color: ${key === 'white' ? '#000' : '#fff'}; font-weight: bold; background:${color[key]};`]; for (let i = 1; i < str.length; i++) { str[i] += `;padding: 2px 4px; border-radius: 3px; font-weight: bold; background:${color[key]};`; return str; window.chalk = { ...chalk, ...colorUtils, chalk = window.chalk; chalk.log('log'); chalk.error('error'); chalk.warn('warn')自定义定制函数熟练的上述的方法之后,你就可以随意的添加你需要的函数了,比如常见的打印框架的版本号。const hello = (title: string, version: string) => createlog('log')( `%c ${title} %c V${version} `, 'padding: 2px 1px; border-radius: 3px 0 0 3px; color: #fff; background: #606060; font-weight: bold;', 'padding: 2px 1px; border-radius: 0 3px 3px 0; color: #fff; background: #42c02e; font-weight: bold;', hello('Malita','0.0.6');比如打印图片const image = (img: string) => createlog('log')(`%c `, `font-size: 1px; padding: 100px 100px; background-image: url(${img}); background-size: contain; background-repeat: no-repeat; color: transparent;`); image('https://logo.png')当然你也可以输出动图。只要你熟练掌握了这些方法,你就可以随意的发挥你的创意。灵感来源以上实现的灵感来源于一个很流行的 node 控制台美化仓库,chalk[3],它在 GitHub 上拥有 18.3k 的 star。import chalk from 'chalk'; console.log(chalk.blue('Hello world!'));在项目中使用为了方便大家和我自己使用,我将这个功能发布到了 npm 上,你可以在你的项目中使用。npm i @alita/chalkimport chalk from '@alita/chalk'; window.alitadebug = true; chalk.hello('Malita','0.0.6'); // 或者 import '@alita/chalk'; window.alitadebug = true; window.chalk.hello('Malita','0.0.6');我增加了一个日志开关 window.alitadebug = true;,好处就是部署上线的代码,正常情况下不打印日志,如果出现错误需要定位代码,可以直接通过控制台中输入 window.alitadebug = true; 来开启。源码开源[4]21天挑战手写前端框架系列[5]感谢阅读,如果你觉得这个东西很有趣,或者你有好的创意,欢迎在评论区和我互动。参考资料[1]console: https://developer.mozilla.org/en-US/docs/Web/API/console#examples[2]mozilla console: https://developer.mozilla.org/en-US/docs/Web/API/console#examples[3]chalk: https://github.com/chalk/chalk[4]源码开源: https://github.com/alitajs/alita/tree/master/packages/chalk[5]21天挑战手写前端框架系列: https://juejin.cn/column/7084812367116107789
esbuild 是一个新的 web 构建工具,它是使用 go 语言编写的。它最大的特点就是快。更多内容可以通过官网了解 https://esbuild.github.io/今天我们的重点在于如何编写 esbuild 插件。因为 esbuild 比较新,生态还是比较少的,所以遇到一些需求,我个人更倾向于自己实现插件。esbuild 的插件 api 还是比较简单的,只有 onResolve 和 onLoad,一个用来处理路径相关的问题,一个用来处理加载数据。onResolve可以用它来处理路径相关的需求。onResolve({ filter: filter }, async (args) => { return { path, });filter 表示路径的过滤条件,在 onLoad 中也是一样的用法,比如你要处理所有的 md 文件,你可以用 filter: /\.md$/,比如给所有的 md 文件添加前缀就是onResolve({ filter: /\.md$/ }, async (args) => { // 只是演示,给这个路径加前缀没什么实际作用 const path = `prefix/${args.path}`; return { path, });onResolve 还有一个使用的用法,是在 return 的时候指定 namespace,默认的namespace 一般是 file。你可以通过指定 namespace 把文件归类,这样在 onLoad 中可以针对这些文件做特殊处理。比如官网中的例子:onResolve({ filter: /^env$/ }, args => ({ path: args.path, namespace: 'env-ns', onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json', }))这样你就可以在项目代码中使用 env,哪怕实际上并不存在这个模块和文件。import { PATH } from 'env' console.log(`PATH is ${PATH}`)onLoad可以用它来处理内容相关的需求。一般是对文件内容有修改的时候,会用到它。比如上面的例子,就是把本来是不存在内容,指定为 JSON.stringify(process.env) 所以就算本来这个文件不存在,在项目中也可以正常使用。我们可以用它来实现一些 esbuild 官方还不支持的 loader,比如处理 less 文件,使用 postcss 等。这里用 less 做个演示:onLoad({ filter: /\.less$/ }, async (args) => { let content = await fs.readFile(args.path, 'utf-8'); const dir = path.dirname(args.path); const filename = path.basename(args.path); const result = await less.render(content, { filename, rootpath: dir, paths: [...(lessOptions.paths || []), dir], return { contents: result.css, loader: 'css', resolveDir: dir, });因为 esbuild 不能识别 less 文件,所以上面的例子中我们用 less.render 将 less 文件转换成 esbuild 能够识别的 css 文件。当然 less 的实际处理要比上述的复杂的多,这里只是演示,如果需要真正的例子,可以参考 umi-next 中的实现。
一、回顾 2.0前言为了解决从事移动端 h5 开发的小伙伴被长表单支配的恐惧,我们在 2020 年年初推出了 dform 的第一个版本。经过一年多的时间的沉淀,在公司内部数十个项目中得到锤炼,不断的完善优化。升级了稳定的 2.0 版本。基础功能介绍我们借鉴 antd@4 的 Form 组件,对底层表单库进行二次封装。补充了 radio 单选、checkbox 多选、rangeDatePicker 时间区间选择器 等 antd-mobile 库里没有的组件样式。实现一行代码实现单页面表单的取值赋值操作。做到一行配置代码统一修改整个项目的表单样式,在多人协同开发项目的情况下保证表单样式的统一。可视化 isDev 开发者模式方案。@alitajs/dform 可视化开发者模式方案涵盖:UI 的快速实现能实现一次性全部赋值表单一次性提交取值融合多类型组件表单支持动态表单一行代码配置项目表单样式二、强大的新功能经过数十个项目的积累,我们收集到了来自小伙伴们的新需求。至此 dform3 的新功能设想慢慢变得清晰:1、表单提交报错时的错误提示我们将 antd-mobile 的 List 组件从 dform 中抽离出来,提高了表单样式的灵活性。并在每次执行 submit() 的提交表单数据方法时获取错误信息,展示在页面上。2、级联开发过复杂表单的小伙伴一定有过级联的需求,对于字段固定的表单,可以通过自定义某个组件的 onChange 方法,自行触发其他组件的配置。如果是动态表单,就比较让人头疼。对此,我们给 dform 增加了 relatives 属性。用于配置整个表单的全部级联关系。四种级联规则包括:修改表单任意组件的值调整表单任意组件是否为必填项调整表单任意组件是否隐藏调整表单任意组件是否不可编辑有了可配置的级联规则,我们就可以从 onChange 的方法中解放出来。提高复杂表单的开发逻辑效率。const relatives = { sex: [ type: "changeFormValue", // 修改值 targetValue: ["woman"], // 当 sex 组件选中 `woman` targetSet: [ targetField: "username", // `username` 这个组件值修改为 `莉丝` targetValue: "莉丝", type: "required", // 是否必填 targetValue: ["woman"], // 当 sex 组件选中 `woman` targetSet: [ targetField: "date", // date 组件为必填 };来看下效果图:3、分组很多表单并不会以长表单的方式,直接展示在界面上,而是根据模块划分,比如个人信息、家庭信息、业务信息等。表单存放在各个模块中,小伙伴们没必要在一个页面上定义多个表单,但是分块后又难以实现样式和单个表单的统一处理。对此我们使用 const { Group } = DynamicForm 导出分组的组件。助力小伙伴自动实现卡片样式。代码的实现方式更是方便,还能支持多层卡片嵌套:<DynamicForm {...formProps}> <Group type="card" title="卡片一" required> <DformInput fieldProps="username" required placeholder="请输入" title="用户名" defaultValue="小红" /> <Group type="card" title="卡片二" required> <DformRadio fieldProps="sex" title="性别" required data={sexData} /> </Group> </Group> </DynamicForm>如果是以 json 的形式实现 dform 我们也提供多层卡片嵌套的能力:const data = [ type: "group", fieldProps: "group1", groupProps: { type: "card", title: "卡片一", required: true, children: [ type: "input", fieldProps: "username", required: true, placeholder: "请输入", title: "用户名", defaultValue: "小红", type: "group", fieldProps: "group2", groupProps: { type: "card", title: "卡片二", required: true, children: [ type: "radio", fieldProps: "sex", title: "性别", data: sexData, required: true, ];4、推广组件化方式开发在 2.0 的版本中,我们主要推广 json 的方式类似实现动态表单,但是在实际的业务开发中,我们能够发现,很多场景下会在表单内增加很多自定义的样式。所以表单并不一定是一行行顺序展示下来,很可能中间会嵌入自定义的样式,这里如果还是用 json 的开发形式,就带来了极大的不便利。所以我们强烈建议从 @alitajs/dform 里导出各个组件来实现业务。组件化开发和 json 开发的代码量和 api 都保持一致。5、单个组件默认赋值除了在 formsValues 赋值,还在每个组件下增加了 defaultValue 的默认值。formsValues 的赋值权限大于 defaultValue。及如果两处都有赋值的情况下,将优先使用 formsValues 的值。集训营 第 001 期参与人员陈小聪薛诗东雨陈书航林君源171 次提交9,598 行代码10 个工作日的深夜本次 dform@3 作为集训营第一期的内容,通过小伙伴们的共同努力,在最短的时间完成预定的所有需求,在此特别鸣谢他们对本次任务顺利完成的付出。结束语v3.0 的版本并不是结束,dform 的表单之路还在延续,欢迎小伙伴们能够提供更好的想法。欢迎给我们提供 pr 或者 issues。官网文档:https://dform.alitajs.com/
距离上次 Pro 发布已经过去了两年,这两年间前端的生态也发生了一些变化, Low-Code 大行其道,Bundleless 也随着 Snowpack,Vite 的发布越来越火热,前端在国际化,权限,数据流和布局方面已经有了最佳实践。Ant Design Pro 致力于提升中后台的开发体验,在这些领域我们也提出了自己的解决方案。我们基于以上的问题,分别提供了最佳实践,模板组件,编译提速,项目集成组件,** OpenAPI **五项重大功能更新,接下来我会详细介绍这些功能。🍣 最佳实践在 V5 中我们基于内外部的经验对中后台的常用领域做出了抽象,Ant Design Pro 研发框架基于 umi,在 V5 中我们通过一系列 umi 插件提供了一套中后台最佳实践。Pro 内置了以下的插件:plugin-access[1],权限管理plugin-antd[2],整合 antd UI 组件plugin-initial-state[3],初始化数据管理plugin-layout[4],配置启用 ant-design-pro 的布局plugin-locale[5],国际化能力plugin-model[6],基于 hooks 的简易数据流plugin-request[7],基于 umi-request 和 umi-hooks 的请求方案这些插件都支持快速关闭,方便我们组合这些能力。同时基于 umi 的运行时的能力,这些 API 都可以从 umi 中直接导出。// 从一个地方导出国际化,数据流,权限,网络请求 import { useModel, request, useAccess, getLocale, useIntl } from "umi";📯 数据流插件在过去的几年中,前端一直都使用 redux 来作为默认的数据流方案,但是 redux 系列一直存在样板代码多,代码提示效果差等问题,导致开发体验一直不是很好。虽然 redux 的功能很强大, 但是在中后台开发中全局公用数据较少,没有忒额复杂的数据流。借着 hooks 的东风我们在 V5 中提供了一个轻量的数据流方案 plugin-model[8] 。plugin-model[9] 提供了 hooks 方案的 API, 并且基于 umi 的运行时能力提供了实时的 TypeScript 提示。import { useModel } from "umi"; export default () => { const { user, fetchUser } = useModel("user"); return <div>{user.name}</div>; };🌎 国际化插件国际化一直是个很重要的功能,对于一部分来说用户并不需要,但是对于一部分用户来说是必不可少的。所以在 Pro 中的自带了国际化的功能。在 V5 中我们提供了更简单的 API, 我们可以从 umi 中导出 react-intl 的所有 API。import React, { useState } from "react"; import { useIntl } from "umi"; export default function () { const intl = useIntl(); return ( <button type="primary"> {intl.formatMessage({ id: "name", defaultMessage: "你好,旅行者", </button> };为了照顾不想只用国际化的用户,我们提供了i18n-remove 命令来删除所有国际化。npm run i18n-remove🖥️ 模板组件模板组件是我们去年工作的重点,早在 Pro 的第一个版本我们就提供了一系列的区块来帮助用户开发页面,在 2.0 中我们还提供了 umi ui 来管理区块。但是在实际的使用中我们发现区块上手成本高,耦合太强。并没有取得很好的反响。在 2.0 中我们增加了 ProLayout 在社区得到了很好的反馈,ProLayout 给了我们灵感,我们是不是可以用抽象 Layout 的方式来抽象我们的常见页面。而在中后台中我们最常用的就是 CURD。在 ProCompoents[10] 就是我们对于 CRUD 的抽象。ProCompoents[11] 以 valueType 为核心抽象了不同的 Field[12],而每个 Field[13] 都包含了编辑和只读两种模式,这样一套抽象可以同时用于表格和表单。所以我们可以根据 ProTable 的列配置生成查询表单。这样 valueType 就可以生成表单和表格,并且在此之上增加了Field 的布局功能,我们可以用一套 Field 配置不用的 Layout 来生成不同的表单,同时我们还提供了可编辑表格,FormList 等低频高复杂度的组件。对于开发者来说 ProTable 可以大大的减少代码量, 只需简单几行就可以得到一个全功能的表格。const columns: ProColumns<GithubIssueItem>[] = [ title: "标题", dataIndex: "title", copyable: true, ellipsis: true, title: "创建时间", key: "showTime", dataIndex: "created_at", valueType: "date", title: "操作", valueType: "option", render: (text, record, _, action) => [<a>编辑</a>, <a>查看</a>], const Page = () => { return ( <ProTable columns={columns} request={request("https://proapi.azurewebsites.net/github/issues", { params, rowKey="id" headerTitle="高级表格" /> };⚡ 编译提速随着前端工程化做的越来越深,前端的依赖越来越多,以 Pro 项目为例,一个初始化的项目包含了react, react-router,antd, ProComponents 。一个初始化的项目启动就需要 50s。Bundleless 就在此时应运而生。现代浏览器已经支持了 es6 的模块,那么我们就可以在项目中直接使用的 es6 ,而减少编译甚至于不编译。在首次生成 es 模块之后,获得极快的项目启动速度。umi 也提供了 Bundleless 方案 mfsu。mfsu **(Module Federation Speed Up)**[14]** **是一种基于 webpack5 新特性 Module Federation 的打包提速方案。核心原理是将应用的依赖构建为一个 Module Federation 的 remote 应用,以免去应用热更新时对依赖的编译。因此,开启 mfsu 可以大幅减少热更新所需的时间。在生产模式,也可以通过提前编译依赖,大幅提升部署效率。我们在 config.s 中配置 mfsu:{} 和 wepack5:{} 就能享受到编译性能提升的快乐。export default defineConfig({ + mfsu : {}, + webpack5: {}, + dynamicImport: {}, })这里我们用 Pro 的基础项目整理了一个对比列表,打开 webpack5 和 mfsu 的 Pro 在启动速度和热更新速度上有压倒性的优势。📚 项目集成模式组件文档我们在实际的项目开发中经常需要抽象一些组件出来,这些组件在应用中广泛使用,但是文档和测试缺失, TypeScript 可能解决部分问题。但是一些设计方面的考虑需要一份文档协同起来才会更加流畅。所以 Pro 中我们内置了 dumi 的项目集成模式,在登录之后看到业务组件文档的菜单。点击之后就可以看到 Pro 自带的业务组件文档,文档中包含了 Pro 所有内置组件的简单文档和 API .作为一个项目开发者,集成模式还给我提供了一个良好的 debug 环境。我们的写法与正常的文档写法并无区别。业务组件这里列举了 Pro 中所有用到的组件,这些组件不适合作为组件库,但是在业务中却真实需要。所以我们准备了这个文档,来指导大家是否需要使用这个组件。Footer 页脚组件这个组件自带了一些 Pro 的配置,你一般都需要改掉它的信息。/** * background: '#f0f2f5' import React from 'react'; import Footer from '@/components/Footer'; export default () => <Footer />;dumi 会将 md 文件渲染为一个页面,其中的代码块会渲染成一个组件。点击右下角还可以还可以在 CodeSandbox,快速打开 demo。想要了解更多可以查看 dumi 基础使用文档[15]。内置的文档在项目发布之后便会自动删除,不用担心文档出现在线上。🏷️ OpenAPI中后台开发中最重要的事情之一就是联调,在这期间后端一般都需要维护一份文档来告诉我们具体的 API 有什么功能,具体的字段信息有哪些,这些信息的维护成本相当高的。如果中途做了更改但是忘记更新文档就会造成信息不同步,在测试时很有可能会造成一个 bug。Pro V5 支持使用 OpenAPI 3.0.1 的接口描述文档,这份文档可以用 Swagger[16] 来生成,对于后端来说可能需要一点工作量,但是带来的收益是远远超出投入的。Pro 会根据 OpenAPI 来自动生成接口和基础类,配合 TypeScript 的使用,我们可以丝滑的接入后端的数据,并且与使用 tsc 来监控后端字段改变导致的字段不对齐问题。declare namespace API { type RuleListItem = { key?: number; disabled?: boolean; href?: string; avatar?: string; name?: string; owner?: string; desc?: string; callNo?: number; status?: number; updatedAt?: string; createdAt?: string; progress?: number; import { request } from 'umi'; /** 获取规则列表 GET /api/rule */ export async function rule(params: API.PageParams, options?: { [key: string]: any }) { return request<API.RuleList>('/api/rule', { method: 'GET', params: { ...params, ...(options || {}), }详细的接入流程可以查看 OpenApi 的接入文档[17] 。🧵 如何升级到 V5Pro 的 V4 与 V5 的底层架构是对齐,我们可以只选配自己喜欢的功能来使用新的特性。plugin-access[18],权限管理plugin-analytics[19],统计管理plugin-antd[20],整合 antd UI 组件plugin-crossorigin[21],通常用于 JS 出错统计plugin-dva[22],整合 dvaplugin-helmet[23],整合 react-helmet[24],管理 HTML 文档标签(如标题、描述等)plugin-initial-state[25],初始化数据管理plugin-layout[26],配置启用 ant-design-pro 的布局plugin-locale[27],国际化能力plugin-model[28],基于 hooks 的简易数据流plugin-request[29],基于 umi-request 和 umi-hooks 的请求方案plugin-esbuild[30], 使用 esbuild 作为压缩器,来获得火箭般的代码压缩速度mfsu 提速方案[31] ,浪费时间的是可耻的,打开 mfsu 可以获得极大的编译速度提升如果需要完全切换成我们的最佳实践,可以参考升级到 Pro V5[32] 进行操作。❤️ 特别鸣谢感谢所有提交错误、发起PR、回复问题、编写文档等的人!在这里特别要感谢 @kaoding[33] 同学承担了 v5 很大一部分开发工作。如果你在使用 Ant Design Pro 时遇到任何问题,可随时在 GitHub 提交问题[34]。感谢你的阅读,敬请安装、尝试。参考资料[1]plugin-access: https://umijs.org/zh-CN/plugins/plugin-access[2]plugin-antd: https://umijs.org/zh-CN/plugins/plugin-antd[3]plugin-initial-state: https://umijs.org/zh-CN/plugins/plugin-initial-state[4]plugin-layout: https://umijs.org/zh-CN/plugins/plugin-layout[5]plugin-locale: https://umijs.org/zh-CN/plugins/plugin-locale[6]plugin-model: https://umijs.org/zh-CN/plugins/plugin-model[7]plugin-request: https://umijs.org/zh-CN/plugins/plugin-request[8]plugin-model: https://umijs.org/zh-CN/plugins/plugin-model[9]plugin-model: https://umijs.org/zh-CN/plugins/plugin-model[10]ProCompoents: https://procomponents.ant.design/[11]ProCompoents: https://procomponents.ant.design/[12]Field: https://procomponents.ant.design/components/schema#valuetype-%E6%9F%A5%E7%9C%8B[13]Field: https://procomponents.ant.design/components/schema#valuetype-%E6%9F%A5%E7%9C%8B[14]mfsu (Module Federation Speed Up): https://zhuanlan.zhihu.com/p/385272270[15]dumi 基础使用文档: https://d.umijs.org/zh-CN/guide/basic[16]Swagger: https://swagger.io/[17]OpenApi 的接入文档: https://beta-pro.ant.design/docs/openapi-cn[18]plugin-access: https://umijs.org/plugins/plugin-access[19]plugin-analytics: https://umijs.org/plugins/plugin-analytics[20]plugin-antd: https://umijs.org/plugins/plugin-antd[21]plugin-crossorigin: https://umijs.org/plugins/plugin-crossorigin[22]plugin-dva: https://umijs.org/plugins/plugin-dva[23]plugin-helmet: https://umijs.org/plugins/plugin-helmet[24]react-helmet: https://github.com/nfl/react-helmet[25]plugin-initial-state: https://umijs.org/plugins/plugin-initial-state[26]plugin-layout: https://umijs.org/plugins/plugin-layout[27]plugin-locale: https://umijs.org/plugins/plugin-locale[28]plugin-model: https://umijs.org/plugins/plugin-model[29]plugin-request: https://umijs.org/plugins/plugin-request[30]plugin-esbuild: https://umijs.org/plugins/plugin-esbuild[31]mfsu 提速方案: https://umijs.org/zh-CN/docs/mfsu[32]升级到 Pro V5: https://beta-pro.ant.design/docs/upgrade-v5-cn[33]@kaoding: https://github.com/kaoding[34]提交问题: https://link.zhihu.com/?target=https%3A//github.com/ant-design/ant-design-pro/issues
(写这篇文章的时候,由React18 还没正式发布太多的文档,很多概念和内容是我从多个来源拼凑而来,里面包含了很多我个人的理解,可能到 React18 正式发布的时候,会有些许错误,写这个文章仅仅是满足一下猎奇心理。所以如果你是在未来的某个时间内看到这篇文章,你记得去阅读官网的内容,并以官网的内容为主。)其实 React18@alpha 已经发布有一段时间了,因为我最近分到一个调研-- “UMI 如何支持 react@18 alpha”。(要不然,我应该会继续蹲。)所以就开始看了看相关的文档和新闻。比较好的消息是,你可以非常平滑的升级到 React18。比如在 umi 中,抛开测试和兼容之类的代码,仅仅只需要修改一行代码就可以支持。- import { hydrate, render } from 'react-dom'; + import ReactDOM, { hydrate, render } from 'react-dom'; - if (window.g_useSSR) { - hydrate(rootContainer, rootElement, callback); - } else { - render(rootContainer, rootElement, callback); + reactRoot = (ReactDOM as any).createRoot(rootElement, { + hydrate: window.g_useSSR, + }); + reactRoot.render(rootContainer);并且改完从业务侧,页面代码中都无需做任何修改项目就可以正常的运行。接着你就可以通过选择性的添加 React18 的新特性到你的某些新页面或者优化某些场景。(看到这里不得不吐槽一下 React Native,发一个小版本都前后不兼容啊!)开箱即用当你简单的更改了类似上面的代码,你将直接享受到 React18 开箱即用的一些功能。自动批处理以减少渲染Suspense 的 SSR 支持(全新的 SSR 架构)自动批处理以减少渲染批处理使 React 将多个状态更新分组到单个重新渲染中以获得更好的性能。这个特性在现在的 React17 中就已经有了,但是在不同的上下文中交互的时候,将不支持批处理。现在 React18 中,增加了对网络请求,promise、setTimeout等事件的自动批处理支持。React17 - Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.function App() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); function handleClick() { fetchSomething().then(() => { // React 18 and later DOES batch these: setCount(c => c + 1); setFlag(f => !f); // React will only re-render once at the end (that's batching!) return ( <div> <button onClick={handleClick}>Next</button> <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1> </div> }Suspense 的 SSR 支持之前在服务端渲染上对 Suspense 的支持不是很好,现在你可以通过使用新的 API pipeToNodeWritable 来使用全新的 SSR 架构。比如我们期望最终打开的页面如下:(绿色代表用户可交互)(图片来自React18 工作组 discussions 37)当你没有使用 SSR 功能时,我们的页面都会在页面启动的时候,经历短暂的白屏阶段,这是由于此时浏览器发起了对 js 的请求和加载,页面之后等待这些 js 文件被下载完成之后,才会被执行,渲染出页面。当使用 SSR 时,React 会在服务端将组件渲染成 Html 并发送给用户,此时用户将会看到页面的基本框架,只能进行一些内置 Web 交互(如链接和表单输入)。(此处,灰色说明屏幕的这些部分尚未完全交互)之后浏览器照常加载 js 文件,当 js 文件加载完成之后,通过水合过程,将当前的 html 渲染成可交互的。这个过程的专业名词是 “Hydrate”,在 vue 中被翻译成“激活”,React 社区更期望称之为“水合”或者“注水”来描述这个过程,就相当于将“水”注入到“干”的 Html 中。这里也存在一个和不使用 SSR 一样的问题,当 js 被全部加载,页面组件被全部“水合”之前,页面研究是不可交互的。React18 就支持,让部分的页面优先加载完数据,优先执行水合。你的页面看起来大概是这样子的。并发渲染(concurrent rendering)当然你也可以选择性的使用 React18 的一些新功能,React18 加入了一个主要的可选机制,“并发渲染(concurrent rendering)”,这个是很多新功能的基础。值得注意的是这里的“并发”使用的依旧是单线程。但是这个单线程可以被中断的。因此渲染可以在多个渲染任务之间交错进行,如用户交互,网络请求,计时器,动画和浏览器布局/绘制等等。它的主要工作分配大致如下:当渲染任务遭遇到更高级的渲染任务时将会被中断,然后优先执行优先级更高的任务,再任务完成之后,再回到原来的渲染任务上。你可以通过一下一些新的 API 来告诉 React 哪些是优先级较高的任务。startTransitionuseDeferredValueSuspenseListstartTransition 过渡更新这是一个比较好理解的 API,通过使用 startTransition 来包裹一些 setState,声明他们是比较不重要的渲染行为。比如官方的例子里面提到的搜索场景。当用户在搜索框中输入时,搜索框需要实时的显示用户的输入字符,然后通过网络请求(或者本地过滤数据),获取到新的列表数据,再更新列表。这里面会有两个 setState,一个是 input 的 value 绑定。一个是搜索之后的页面数据绑定。import { startTransition } from 'react'; // 紧急:显示输入的内容 setInputValue(input); // 将内部的任何状态更新标记为转换 startTransition(() => { // Transition: 显示结果 setSearchQuery(input); });如果你需要在等待过渡渲染的时候执行一些表现,如 loading 操作之类的。你可以使用 useTransitionimport { useTransition } from 'react'; const [isPending , startTransition] = useTransition(); startTransition(() => { // Transition: 显示结果 setSearchQuery(input); { isPending && < Spinner /> }useDeferredValue推迟更新屏幕上不太重要的部分(这个还没有放出来文档)。SuspenseList协调加载指示器出现的顺序(这个还没有放出来文档)。但是从 Suspense 的用法来看,预计与懒加载的优先级有关,可能是指定哪些加载优先执行。(我猜的,毕竟新的文档几乎都是这个概念。)// 该组件是动态加载的 const OtherComponent = React.lazy(() => import('./OtherComponent')); function MyComponent() { return ( // 显示 <Spinner> 组件直至 OtherComponent 加载完成 <React.Suspense fallback={<Spinner />}> <div> <OtherComponent /> </div> </React.Suspense> }其他除了上面提到的这些和协作多任务、基于优先级的渲染、调度和中断等有关的功能之外,还有值得关注的特性。StrictMode 严格模式你的组件将会被多次调用加载-卸载,以确保他们的状态正确。React intentionally double-invokes effects (mount -> unmount -> mount) for newly mounted components.值得关注的是,这个特性是被默认开启的,其实现在的React中就已经有使用这个功能了。--- 快速刷新(Fast Refresh),开发时可以保持组件状态,同时编辑提供即时反馈。Offscreen新的 Offscreen API 允许 React 通过隐藏组件而不是卸载组件来保持这样的状态。为此,React 将调用与卸载时相同的生命周期钩子——但它也会保留 React 组件和 DOM 元素的状态。这就是 React 中的 keepalive 功能啊。这是我所期待的一个能力,现在是使用 实现。当然它还可以用作预渲染页面,提前渲染用户即将到达的页面,有点类似 Next 中用 Link 链接的页面,会被提前渲染。在优先级方面,Offscreen 是最低的,理论上它会被任何其他的渲染任务中断。it will not be in the initial 18.0 release, but may come in an 18.x minor.感谢阅读,有任何疑问可以通过评论一起讨论。喜欢这个文章的朋友,请给一个赞,喜欢我的朋友,可以关注一下我。感谢三连。参考链接https://zh-hans.reactjs.org/blog/2021/06/08/the-plan-for-react-18.html https://github.com/reactwg/react-18/discussions/4 https://github.com/reactwg/react-18/discussions/21 https://github.com/reactwg/react-18/discussions/22 https://github.com/reactwg/react-18/discussions/27 https://github.com/reactwg/react-18/discussions/37 https://www.youtube.com/watch?v=bpVRWrrfM1M
问题相信不少的朋友都遇到这样的场景,在我们的系统中嵌入别人的页面,和在别人的系统中嵌入我们的页面。这里的第一原则是,不管是我们嵌入别人还是别人嵌入我们,我们都只能修改我们自己的页面,而其他别人的页面,我们无权修改,毕竟会有“我们嵌入其他系统的时候,都是好好的啊”和“其他人嵌入我们的系统的时候,都是可以的。”解法这里需要分开讨论。我们的页面嵌入其他系统这里有一个 iframe 的特性需要事先知晓,当内外 viewport 不一致时,将会使用主页面的 viewport。比如,在 alita 项目中,我们使用的是现在比较稳定的高清方案。在项目中使用rem单位,通过获取设备的 dpr 即 window.devicePixelRatio,将项目中的 rem 值设置成真实的 px 值。doc.documentElement.style.fontSize = 100 / 2 * dpr + 'px';然后通过设置 viewport 的 initial-scale 来进行缩放页面达到高清显示页面的效果。比如 iphone6 中 dpr 是2,我们就会将 px 设置成 @2x,然后通过设置 initial-scale=0.5,来实现页面的适配。通常出现内外 viewport 不一致的情况,是主系统没有使用类似的高清方案,viewport 的 initial-scale 一般是设置为 1。其实就算设置的是其他的值也没有关系,我们都可以通过在代码中获取到主页面真实的 viewport。if (window != top ) { const meta = document.querySelector('meta[name="viewport"]'); const metaStr = meta.getAttribute('content') || ''; const viewport = getViewPort(metaStr); if (viewport['initial-scale']) { const dpr = window.devicePixelRatio || 1; const baseScale = 10 / dpr; window.alitaFontScale = baseScale / parseInt(`${parseFloat(viewport['initial-scale']) * 10}`, 10); }先判断一下,我们的页面是在 iframe 中打开,然后用 js 的方法获取到当前页面的 viewport。这里需要注意我们前面提到的“当内外 viewport 不一致时,将会使用主页面的 viewport”。所以在这里获取到的是主页面的 viewport。(这里的几个类型转换,纯属是我个人的强迫症,其实没有什么意义。)到此为止,我们就获得了我们也需要的缩放比例,将这个值放入我们最开始的 base fontsize 的计算中,就可以处理缩放问题了。doc.documentElement.style.fontSize = 100 / 2 * dpr * window.alitaFontScale + 'px';其他的页面嵌入我们的页面中。理解了上面的逻辑,那要处理这个问题就比较容易了。如果其他页面也使用类似的高清方案或者其他适配方案,理论上他们会处理这样的异常情况。在我们实际交互场景中,我们只遇到子系统 initial-scale=1 的情况,因此我这里也仅仅针对这个情况来解决问题。(这也仅仅是我个人的设计理念问题,做的少就是做的多。)比如在iphone 6 上,我们使用的是 @2x 的 px 值,但是子系统用的是 @1x 。因此我们只需要将 iframe 的大小设置成我们需要的 1 半,然后通过 transform scale 放大一倍就可以解决。const Iframe: FC<IframeProps> = (props) => { const stt = { border: 'none', width: `50vw`, height: `50vh`, transformOrigin: '0 0', transform: `scale(2)`, return <iframe style={stt} src={url}></iframe>; };套娃模式在有其他系统嵌入在我们的系统的情况下,我们的系统嵌入到其他系统中。(这里有点绕,反正就是嵌套里面又嵌套) 不知道你有没有留意到,在前面的实现中我们使用了一个全局变量,window.alitaFontScale。可以通过它来判断是否是套娃,和我们项目当前已经做出的修改情况。import React from 'react'; import type { FC, IframeHTMLAttributes } from 'react'; interface AlitaIframeProps extends IframeHTMLAttributes<HTMLIFrameElement> { * 0~100 width?: number; * 0~100 height?: number; const AlitaIframe: FC<AlitaIframeProps> = (props) => { const { width = 100, height = 100, style, ...rest } = props; (window as any).alitaFontScale = (window as any).alitaFontScale ?? 1; const defaultStyle = { border: 'none', ...style, width: `${1 / (window as any).alitaFontScale * 50 * width / 100}vw`, height: `${1 / (window as any).alitaFontScale * 50 * height / 100}vh`, transformOrigin: '0 0', transform: `scale(${(window as any).alitaFontScale / 0.5})`, return <iframe style={defaultStyle} {...rest}></iframe>; export default AlitaIframe;其他本着 alita 体系的设计理念,框架中做的越多,实际交付开发中就写的越少。我将第一种情况内收到了 hd 的插件中,适用于 alita 项目和其他使用了 @alitajs/hd 插件的 umi 项目。框架自己兼容,用户无需理会差异。将第二种情况,发布到了 @alitajs/iframe 组件。在项目中可以直接引入使用,用户无需理会过多的逻辑。$ yarn add @alitajs/iframe $ npm i @alitajs/iframeimport React from 'react'; import Iframe from '@alitajs/iframe'; const otherUrl = 'http://localhost:8000/#/'; export default () => <Iframe src={otherUrl} />;总结解法固然重要,更有趣的是过程。其实我们在实际开发中,都会遇到一些问题,或简单或复杂,或通用或局限,但是如果能够把问题梳理清楚,将代码保留在更高一个层级的位置,服务自己,服务他人,甚至能让整个团队受益。想象一下,以后在 React 项目中,只要使用 Iframe 打开其他人的页面,都不会再遇到缩放问题了,是不是很帅。当然这只是理想的情况,毕竟这是我昨天写的代码,我只能保证它在昨天能够正确运行。
我所知道的动态换肤方案有两种,一种是通过使用 less.modifyVars 修改 less 变量实现,一种是使用 var css 实现,由于 var css 很多浏览器都不支持。而且我们项目的主要场景在于移动端,所以能选择的就只有通过 less 实现的方案了。原理简述原理其实很好理解,将包含 less 变量的 css 类提取出来,通过修改变量的值重新生成新的 css 类,再添加到 dom 中。如在项目代码中,编写样式如下:@abcd-efg : #f3574c; .center { color: @abcd-efg; font-size: 26px; height: 50px; }在框架中编译之后,会产生这样的 css ,(如 umi.css):.center { color: #f3574c; font-size: 26px; height: 50px; }正常不需要动态换肤的场景下,这就是我们最终需要的 css 样式。如果需要动态换肤,那我们就可以保留下这些 less 变量,重新生成一个 less 文件(假设命名是 alita.less)。.center { color: #f3574c; font-size: 26px; height: 50px; }@abcd-efg : #f3574c; .center { color: @abcd-efg; }然后 less.js 会将这个 less 文件转化成 css 文件,放到 dom 上。最终我们部署的 html 文件大致如下:<!DOCTYPE html> <html lang="en"> <head> <link rel="stylesheet" href="/umi.css" /> </head> <body> <link rel="stylesheet/less" type="text/css" href="/alita.less" /> <script src="less.js"></script> </body> </html>当用户访问页面时,在浏览器端,less.js 将 dom 中的 less 文件编译成 css 样式,html 变化大致如下:<!DOCTYPE html> <html lang="en"> <head> <link rel="stylesheet" href="/umi.css" /> <!-- 这里解析之后是 .center { color: #f3574c; font-size: 26px; height: 50px; } --> </head> <body> <link rel="stylesheet/less" type="text/css" href="/alita.less" /> <style type="text/css" id="less:alita"> /* 这里就是上面的 less 文件编译而成 */ .center { color: #f3574c; </style> <script src="less.js"></script> </body> </html>当我们在项目中使用 less.modifyVars 修改变量会触发 less 文件重新编译。window.less.modifyVars({ 'abcd-efg': '#0000FF' })<style type="text/css" id="less:alita"> /* 这里就是重新生成的 css 样式 */ .center { color: #0000FF; </style>然后理解了原理,那么接下来就是实现了。实现变量提取看了一些实现,还是觉得 antd 的官网的实现最为靠谱(Ant Design Runtime Theme Update #10007),并且 mzohaibqc 已经写了一个很好的工具 (antd-theme-generator),看了下源码,里面写死了 antd 的目录路径和变量名称,但是提供的方法基本上都可以使用,因为我需要的是移动端的方案,即需要的是 antd-mobile,好在 antd-mobile@2 的结构和 antd 基本上一致,通过简单修改之后,就可以使用。但是发现一个问题,变量修改只能够修改 antd-mobile 组件中的变量,并无法修改项目中用到的变量名。import { Button } from 'antd'; import styles from './index.less'; // index.less // @import '~antd-mobile/lib/style/themes/default.less'; // .center { // .primary { // color: @brand-primary // } const Page: FC<PageProps> = () => { return ( <div className={styles.center} > <Button type="primary" >按钮</Button> <span className={styles.primary}>这里的颜色,在less文件中使用了主题色的变量。@brand-primary </span> </div> }修改变量之后发现按钮和其他用到主题色的组件都发生了变化,但是在项目中使用一样变量的类却无法改变,window.less.modifyVars({ 'brand-primary': '#FF0000' })初次猜测原因可能是自定义的 less 文件没有被编译到。通过查看最终的产物,发现 antd-theme-generator 编译的时候读取到的文件是原始文件,而 umi 项目中使用了 css module 之后,在 css-loader 的时候,类名会被默认加上后缀,导致 less 编译后的类名为 .center 而真实的类名为 .center__kjahd。因为 umi 生命周期中,并没有一个时机,能够获取到带有 less 变量和类名后缀的文件。因此直接从 webpack 构建环节中,读取了 umi 编译后的 css 文件。考虑到可能存在按需加载的情况,因此取了所有的 css 文件。class UmiThemePlugin { apply(compiler) { const options = this.options; compiler.hooks.emit.tapAsync('UmiThemePlugin', (compilation, callback) => { options.customCss = ''; Object.keys(compilation.assets).map((i) => { if (i.endsWith(".css")) { options.customCss = `${options.customCss}\n${compilation.assets[i].source()}` generateTheme(options) module.exports = UmiThemePlugin;既然 less 转 css 都通过 umi 编译了,那 antd-theme-generator 中就没有必要二次编译了。因此简单的删除了这里面编译 css 文件的内容。umi 插件本着框架中做的越多,项目交付中做的就越少的原则,将 antd-theme-generator 文档中要求的,手动引入的文件,和其他需要注意的事项通过 umi 插件的形式实现。最终完成 @alitajs/plugin-theme,安装完成后,在配置文件中配置使用:plugins:['@alitajs/plugin-theme'], dynamicTheme:{ type:'antd-mobile', varFile: path.join(__dirname, '../src/default.less'), themeVariables: ['@brand-primary','@abcd-efg'], }属性说明type声明是 antd 还是 antd-mobile ,会自动找到包的路径varFile声明 less 变量的文件路径,未提供的话,会默认找到 'style/themes/default.less'themeVariables需要提取的变量名,需要显示指明,才能在修改变量时使用,因为需要修改的变量越多,生成的 less 文件越大遗留的问题less 版本问题引入less@2.7 的用 window.less.modifyVars 的方式可以。但是在项目中使用了less@3 ,使用 import less from less;less.modifyVars 的方式,就算 javascriptEnabled 设置为 true ,也是不能使用.bezierEasingMixin();相关 Issues https://github.com/mzohaibqc/antd-theme-generator/issues/41#issuecomment-768734824less 变量的值必须唯一由于使用的 css 是由 umi 编译后的文件,中间未记录 less 变量,后续动作是采用值匹配来做反向绑定的。缺点就是如果两个变量名都指明了同一个颜色值,最终会被合并为一个。好处是就算在项目中写色值的时候忘记使用变量,也可以实现动态换肤,这对于遗留项目的功能跟进有着极大的好处。适用的项目理论上所有 umi 系的项目都可以使用,比如 umi、dumi、ant-design-pro、alita 等。目前测试了 umi 、alita 和 ant-design-pro 的项目。闭眼测试 ant-design-pro拉取当前 v5 分支代码yarn add @alitajs/plugin-themeconfig/config.ts 中添加配置src/pages/User/login/index.tsx 中,随便写了一个按钮config/config.tsexport default defineConfig({ + plugins: ['@alitajs/plugin-theme'], + dynamicTheme: { + type: 'antd', + themeVariables: ['@layout-body-background'], + }, });src/pages/User/login/index.tsx+ <Button type="primary" onClick={() => { + window.less.modifyVars({ + 'layout-body-background': '#FF0000' + }) + }}>点击背景色改变</Button>随意的效果总结我的水文总是不能缺了总结,这个方案还是挺有趣的,跑方案的时候,发现很多有趣的问题,写文章的时候,倒觉得都挺简单的了。这个方案从开始收到项目组需求到最终可用,总共花了4天时间,新发了三个包。欢迎大家试用,欢迎讨论。源码【umi 插件】: https://github.com/alitajs/plugin-theme【从 umi.css 中生成 less 文件】 : https://github.com/alitajs/umi-theme-generator【webpack 插件,主要作用是取到 umi.css 文件】: https://github.com/alitajs/umi-theme-webpack-plugin参考链接【Ant Design Runtime Theme Update 】: https://github.com/ant-design/ant-design/issues/10007【antd-theme-generator】 : https://github.com/mzohaibqc/antd-theme-generator【antd-theme-webpack-plugin】: https://github.com/mzohaibqc/antd-theme-webpack-plugin
默认情况下,Next.js 预渲染每个页面。这意味着 Next.js 为每个页面生成 HTML,而不是让它全部由客户端 JavaScript 完成。预渲染可以使项目拥有更好的性能和搜索引擎优化。每个生成 HTML 与该页所需的最小 JavaScript 代码相关联。当一个页面被浏览器加载时,它的 JavaScript 代码就会运行并使页面完全交互。(此过程称为hydration.)检查是否正在进行预渲染你可以通过以下步骤检查预呈现是否正在进行:禁用浏览器中的 JavaScript (这里是如何在 Chrome 浏览器中操作 https://developers.google.com/web/tools/chrome-devtools/javascript/disable)尝试访问此页面 (本教程的最终结果https://next-learn-starter.now.sh/)。你应该看到应用程序是在没有 JavaScript 的情况下呈现的。这是因为 Next.js 将应用程序预呈现为静态 HTML,允许你在不运行 JavaScript 的情况下查看应用程序 UI。注意: 你也可以在 localhost 上尝试上述步骤,但是如果禁用 JavaScript,CSS 将不会加载。如果你的应用程序是一个普通的 React.js 应用程序(没有 Next.js),就没有预呈现功能,所以如果禁用 JavaScript,你将无法看到该应用程序。例如:在浏览器中启用 JavaScript 并查看此页面。这是一个普通的 React.js 应用程序,由Create React App 构建。现在,禁用 JavaScript 并再次访问相同的页面。你不会再看到这个应用了 — 相反,它会说:“You need to enable JavaScript to run this app.”。这是因为应用程序没有预渲染为静态 HTML。总结: 预渲染与无预渲染这里是一个快速的图形化总结:Next.js 有两种预呈现形式: 静态生成 and 服务器端渲染。不同之处在于它 什么时机 生成了每一页的 HTML。静态生成 是在编译环节预生成 HTML 然后在每个请求上_重复使用_的预渲染方法。服务器端渲染 在每个请求时生成 HTML 的预渲染方法。在开发模式下(当你运行 npm run dev 或 yarn dev),每个请求都预渲染每一页——甚至对于使用静态生成的页面也是如此。每页标准重要的是,Next.js 允许你选择每一页使用的预渲染形式。你可以为大多数页面使用静态生成并为其他页面使用服务器端呈现来创建一个“混合的” Next.js 应用程序。何时使用静态生成 v.s. 服务器端渲染我们建议使用静态生成(带数据和不带数据),因为你的页面可以构建一次,由 CDN 提供服务,这使得它比让服务器在每个请求上呈现页面要快得多。你可以对许多类型的页面使用静态生成,包括:地推页面博客文章电子商贸产品目录协助和文件你应该问问自己:“我能在用户请求这个页面之前预渲染吗?”如果答案是肯定的,那么你应该选择静态生成。另一方面,如果你无法在用户请求之前预渲染页面,那么静态生成就不是一个好主意。也许你的页面显示频繁更新的数据,页面内容在每个请求上都会改变。在这种情况下,你可以使用服务器端渲染.它将会更慢,但预渲染的页面将永远是最新的。或者,你可以跳过预渲染,并使用客户端 JavaScript 来填充数据。我们将专注于静态生成在本课中,我们将重点讨论静态生成。在下一页中,我们将讨论在有或没有数据的情况下静态生成页面。有或无数据的静态生成静态生成可以使用和不使用数据来完成。到目前为止,我们创建的所有页面都不需要获取外部数据。这些页面将在应用程序编译的时候自动静态生成。然而,对于某些页面,你可能无法在不先获取一些外部数据时呈现 HTML。你可能需要访问文件系统、获取外部 API 或在构建时查询数据库。Next.js 支持这种情况 — 有数据的静态生成 — 开箱即用。使用 getStaticProps 的有数据的静态生成它是如何工作的?在 Next.js 中,当导出一个页面组件时,还可以导出一个名为getStaticProps 的 async 函数。如果你这样做,那么:在生产过程中,在构建时运行getStaticProps。在函数内部,你可以获取外部数据并将其作为页面的属性传递。export default function Home(props) { ... } export async function getStaticProps() { // Get external data from the file system, API, DB, etc. const data = ... // The value of the `props` key will be // passed to the `Home` component return { props: ... }本质上,getStaticProps允许你告诉 Next.js:“嘿,这个页面有一些数据依赖关系 — 所以当你在构建时预渲染这个页面时,一定要先处理它们!”注意: 在开发模式下,getStaticProps 在每个请求时运行。
前言我们都知道,目前传统的 SPA 网页在完成脚本加载后,通常还需要进行接口请求,拿到远端数据后才能进行完整地内容呈现 而在接口请求的过程中,为了过渡无数据的空白场景,并提示用户“数据请求中”,常用的方法为做一个 loading 动画效果而在用户胃口越来越刁的今天,一个简单的 loading 效果已经不太能安抚用户了,而骨架屏就是一种安抚用户的进阶方案最终成品链接(懒人用):auto-skeleton-plugin什么是骨架屏?简单来说,骨架屏就是在还未产生可阅读内容时,先将网页的大致结构框架呈现给用户,以达到安抚用户等待过程中的不耐烦心理、提升用户存留的效果骨架屏的实现,通常有两种方式手动书写骨架自动生成骨架手动写骨架的方式,好处是可以做出高定制性的骨架效果,缺点是开发成本大,效率低,但本文不对此方式进行展开那么如何实现自动骨架屏的效果呢?一个简单的方式是:将已有内容的样式进行调整,生成对应的骨架效果,例如以下代码,可以将所有文字内容,变成骨架条块function generateSkeleton() { // 文字节点 ;[...document.querySelectorAll('*')] .filter( (node) => !['script', 'style', 'html', 'body', 'head', 'title'].includes( node.tagName.toLowerCase() .map((node) => [...node.childNodes].filter((node) => node instanceof Text)) .flat(Infinity) .forEach((node) => { let span = document.createElement('span') node.parentNode.insertBefore(span, node) span.appendChild(node) span.style = ` background: #f2f2f2; color: transparent !important; }这样,只要我们完善不同内容如图片、图标等元素的骨架化过程,就可以得到一个相对可用的内容骨架化效果了自动骨架化的好处是,生成骨架的效率高,开发成本很低,但缺点是定制性相对较差,需要根据已有内容来确定骨架效果但这有一个问题,我们期望是在应用刚打开时,还未请求数据前就呈现骨架,目前显然是做不到的而我们可以借助“预渲染”来实现期望的效果什么是预渲染?预渲染类似服务端渲染,它的过程大概是这样的:在应用完成打包后,立刻启动一个 headless 浏览器进行页面访问,再将访问的结果输出成 html 文件的渲染过程通俗地说就是:打包完后本地先访问看一看,看到啥就“截个屏”存起来,然后输出一个 html 文件,覆盖原本构建生成的 index.html 这样,用户访问打包好的 index.html 时,看到的就是一个有内容的网页那么,借助预渲染,我们可以将上述自动骨架屏的过程,放在 headless 浏览器加载出网页内容后,具备内容后再将内容骨架化,再输出成 html,就可以实现用户访问时,还未请求数据前,先呈现骨架的效果自动骨架屏的过程实现我们可以参考一个常用的预渲染的 webpack 插件 prerender-spa-plugin 来实现这个过程查阅源码可知,这个插件并未实现核心渲染过程,其实只是将 prerenderer 包装成了 webpack 插件的形式,并承担了将最终结果输出成 html 产物文件的功能关键源码:https://github.com/chrisvfritz/prerender-spa-plugin/blob/master/es6/index.js#L65-L70 const Prerenderer = require('@prerenderer/prerenderer') function PrerenderSPAPlugin (...args) { const afterEmit = (compilation, done) => { const PrerendererInstance = new Prerenderer(this._options) PrerendererInstance.initialize() .then(() => { return PrerendererInstance.renderRoutes(this._options.routes || []) module.exports = PrerenderSPAPluginrerenderer 承担的则是使用 headless 浏览器访问网页,并输出访问结果的功能,其官方内置了两种可选的 headless 浏览器:puppeteer 和 jsdom由于 puppeteer 需要下载的内容较大,我们考虑使用较轻量的 jsdom 来完成这个效果在翻阅了部分 renderer-jsdom 的源码后,可以找到 headless 浏览器采集网页内容的部分关键源码:https://github.com/JoshTheDerf/prerenderer/blob/master/renderers/renderer-jsdom/es6/renderer.js#L25-L38我们只需要在采集网页内容前,对内容进行骨架化,就可以得到期望的效果 const JSDOM = require('jsdom/lib/old-api.js').jsdom const getPageContents = function (window, options, originalRoute) { return new Promise((resolve, reject) => { function captureDocument () { // 此处可在输出 html 结果前,先对网页内容进行骨架化 // generateSkeleton 就是上边咱们整理出来的 dom 操作实现自动骨架化过程 generateSkeleton(window) const result = { html: serializeDocument(window.document) return result class JSDOMRenderer { async renderRoutes (routes, Prerenderer) { const results = Promise.all(routes.map(route => limiter(() => { return new Promise((resolve, reject) => { JSDOM.env({ url: `http://127.0.0.1:${rootOptions.server.port}${route}`, .then(window => { return getPageContents(window, this._rendererOptions, route) return results module.exports = JSDOMRenderer至此,简易自动骨架屏效果的方案已经叙述完成,整个过程,需要我们自己动手的主要是骨架化过程的部分,其余之处,都可通过参考已有过程实现来完成,那么具体过程实现,此处就不再继续展开了,动手能力强的小伙伴,大概可以自己一把梭出来在 umi 中使用简单的用法在 config/config 中配置chainWebpack(async (config) => { config .plugin('auto-skeleton-plugin') .use(AutoSkeletonPlugin, [{ staticDir: 'xx/demo/dist/', routes: ['/'], return config; 这里需要注意的是,staticDir 需要写 build 之后的真实路径,并且需要是绝对路径,这在 umi 项目中和你的 outputPath 配置有关。编写插件使用在插件中使用 api.chainWebpack 来实现,写法基本上和上面一致。好处是可以获取真实的 outputPath ,因为它可能被配置修改,也可能被其他插件修改。api.chainWebpack(async (config) => { const { exportStatic } = api.config; const routes = await api.getRoutes() config .plugin('auto-skeleton-plugin') .use(AutoSkeletonPlugin, [{ staticDir: api?.paths?.absOutputPath!, routes: exportStatic ? getRotesPath(routes) : ['/'], return config; });我觉得这个插件最大的优势是在移动端应用中,一来现在的网络情况,pc页面已经较难看到白屏页面了。而在移动端应用中,首页白屏时间,依旧是困扰用户和开发的一大问题。如果用上骨架屏,在用户见到骨架屏的时候,从感官上项目已经启动了。但是对于程序来说,可能项目仅仅是进入页面,可能js都没有下载完成。在没有其他优化手段的前提下,用这种方式来优化用户体验,也是一个非常好的操作。结尾预渲染方案待展开的功能还是有不少的,例如如何内联样式?(这条比较容易做到,借助 jsdom 自身的 resourceLoader 足矣)如何保留关键样式,去除无用样式?(有一定难度,可参考 uncss,配合 postcss 实现)预渲染性能是否充足,能否用来做 SSR? (jsdom 渲染速度较快,此处进行了实践 santi)以下是上述方案的自动骨架插件实现,目前自动骨架化的过程比较简陋,只具备了基础的可用性,也希望能得到大家的帮助,共同完善自动骨架化的过程auto-skeleton-plugin
众所周知,(假设众所周知)dumi 是一款基于 Umi 打造、为组件开发场景而生的文档工具,与 father 一起为开发者提供一站式的组件开发体验,father 负责构建,而 dumi 负责组件开发及组件文档生成。它以易用美观的特点吸引了数百万的用户使用(我吹的)。可惜在当前的版本上,还不太支撑移动端的组件库开发。其中有两个问题移动端页面的解析和文档页面的解析,共用一套的 umi 配置,导致鱼与熊掌不可兼得。文档 demo 的展示还是偏向 pc 组件,对移动端组件的展示,不太友好。针对第一点,我最开始的想法是,通过指定路由的方式,让部分路由走 hd 插件,而剩余路由保持现有的解析方式,最后发现这个方案,不管从理论上还是实际上都无法实现。针对第二点,最开始的想法是,通过全局覆盖样式重写 __dumi-default-previewer-demo 的样式,使得移动端组件展示时,能够规定父级容器的宽度,以致于组件不变形太多。后来发现官方的某一个尝试方案就是这么实现的,而且效果他们很不满意。另启一个服务来编译移动端页面后来,项目临时需要,所以我又有了一个小的想法。既然需要两套 umi 的配置来实现,那干脆就启两个 umi 服务吧。文档部分,依旧保持着 dumi 的约定,在项目中新建文件夹 demo 并在其下面生成一个 umi 的 h5 应用,即 umi-preset-react 加 @alitajs/hd(我的私包,不知道官方为什么一直不发。)然后在 package.json 中配置启动命令。"scripts": { "start:docs": "dumi dev", "start:demo": "cross-env PORT=1123 APP_ROOT=demo umi dev" },为预览和开发是同时启动的,所以我希望能够执行一个命令就能启动两个服务,于是用上了 concurrently。scripts": { "start": "concurrently \"npm:start:docs\" \"npm:start:demo\"" },umi 项目如何引用 APP_ROOT 之外的组件这里遇到的第一个问题是,umi 项目如何引用 APP_ROOT 之外的组件。因为 umi 的特性,一般只编译 APP_ROOT 下的 src 目录,和可选择的编译 node_modules 。当你引入 APP_ROOT 上层目录下的组件时,就会提示组件未编译。并且通过 ../../../ 这样的相对方式引入,也不太符合真实的项目使用场景,我们期望的是 import {} from 'lib-name'//demo 移动端组件渲染页面 import { Foo } from '../../src'; // 组件开发 export { default as Foo } from './Foo';其实这个问题处理起来也很简单,只需要在 demo/config 里面配置就可以了。import { join } from 'path'; export default { chainWebpack: (config: any) => { config.module .rule('js') .test(/\.(js|mjs|jsx|ts|tsx)$/) .include.add(join(__dirname, '..', '..', 'src')).end() .exclude.add(/node_modules/).end() .use('babel-loader') alias: { 'dumi-theme-alita': join(__dirname, '..', '..', 'src') };dumi 中如何展示自定义的组件由于我们不对 dumi 做侵入式的修改,于是我们能够控制和编辑的就只有页面部分的内容了。好在 dumi 提供了 inline 来直接显示我们自定义的组件。因此我们可以自定义一个展示组件,用它来打开我们的 demo 页面。// 这里为了md 冲突,使用了注释,实际上不需要使用注释 // ```ts import React from 'react'; import { Device } from 'dumi-theme-alita'; export default () => <Device url="https://mobile.ant.design/kitchen-sink/" source="https://github.com/alitajs/dumi-theme-alita/tree/master/src/Device" />; // ```Device 的具体实现,看源码,效果大致如下。为了截取全部,页面做了缩放处理,组件有一点点变形。这里只是为了演示,他被包裹在 dumi 的 demo previewer组件中的效果。这也是 dumi 有魅力的地方,md 里面直接写组件,就能看到演示效果。但是现在它不是我们需要的,我们只是要这个预览窗口,于是我们给它加上 ts | inline。它就会以一般页面组件的方式展示在页面上。可以通过给它加一个 div ,添加上如 position: absolute; 等样式,让他定位到页面的右侧。// ```ts | inline import React from 'react'; import { Device } from 'dumi-theme-alita'; export default () => <Device url="https://mobile.ant.design/kitchen- sink/" source="https://github.com/alitajs/dumi-theme-alita/tree/master/src/Device" />; 最终我们得到需要的效果如下你可以通过修改 Device 组件的 src 属性,打开本地开发的服务,来试试预览和修改你的组件库。效果如下:此次方案,只是为了实现我的又一点小想法,而进行的实践。它也确实可行。如果你觉得又要改这又要改那的太麻烦了。那你可以关注一下 dumi 即将推出的 主题和实验室。届时你就可以很轻易的找到 dumi 的 mobile 主题。并获得 dumi 官方支撑最好的方案。[Device 源码] https://github.com/alitajs/dumi-theme-alita/tree/master/src/Device[dumi主题和实验室] https://d.umijs.org/lab
之前提到过,web 产物的优化问题。我就在想那能不能把产物包拆开,有些写到原生apk中?于是有了本次的方案尝试。特定方式编译 web 产物这里以 alita 的项目为例,因为在alita的项目中,初始项目只有一个 alita 的依赖。设想是将用户的 alita 依赖包,编译到一个 vendors 文件中,用户添加的其他第三方依赖,编译到另一个 micro 文件中。这样可以保证每个项目编译出来的 vendors 文件都是一样的,或者说,可以使得多个项目共用一个 vendors 文件。(经过多次尝试,虽然每次编译出来的 vendors 文件大小,不完全一致,但是交换着使用,却没有任何问题。)设置默认移除依赖我们读取用户项目的 package.json 文件,取得 dependencies 里面定义的第三方依赖。定义一个排除数组。这里是 'alita' 和 'alita' 中已经包含的常用依赖。如果在 umi 项目中,那可能是 umi、umi-preset-react 和 umi-plugin-xxx 等。const exclude = ['alita', 'classnames'];设置默认引入依赖由于项目中的 antd 和 antd-mobile 是按需加载的,即最终被引入到项目中的依赖库,如。import Button from 'antd/es/button';在编译的时候,webpack 以为的包名是 antd/es/button 而不是 antd。因此我们可以手动把 antd 相关的东西再加回来。定义一个需要导入的第三方依赖。const include = ['antd-mobile', 'antd', 'rc-', 'rmc-'];结合两次定义的数组,取得我们需要包含的真实的依赖。const dependencies = api.pkg.dependencies || {}; const pkgNames = Object.keys(dependencies) .filter((i) => !exclude.includes(i)) .concat(include);增加 chainWebpack 配置(这在上一个文章中,有较为详细的说明)。把满足我们定义的第三方依赖,打包到 micro 文件中,剩余的 打包到 vendors 中。 cacheGroups: { micro: { name: 'micro', chunks: 'all', enforce: true, test: (module: any, chunks: any) => { if (module.resource) { for (let key = 0; key <= pkgNames.length; key++) { module.resource.includes(`/node_modules/${pkgNames[key]}`) return true; return false; priority: -9, vendors: { name: 'vendors', chunks: 'all', test: /[\\/]node_modules[\\/]/, priority: -12, },这是的判断条件是包含,因为存在 rc-* 等库。执行编译之后,产物中就会包含三个js文件,umi.js,micro.js 和 vendors.js。增加 chunks 配置chunks: ['micro', 'umi']需要注意的是,我们产物是三个文件,但是我们只用了两个。并不包含 vendors.js。生成的index.html,如下所示:<body> <div id="root"></div> <script src="./micro.js"></script> <script src="./umi.js"></script> </body>webview 前置引入vendors.js上面我们提到,产物是三个js文件,但是我们在 html 中却只用了两个。还有一个我们放到原生app中。(我们安卓开发的同事提供的方法)WebView mWebView; // 取到 js 文件 final String jsStr = FileUtil.getJsStr(mActivity,"vendors.js"); BaseJavaScript javaScript = new BaseJavaScript(); mWebView.setWebViewClient(new WebViewClient(){ @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { view.loadUrl(url); Log.i("caicai", "shouldOverrideUrlLoading"); return true; @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { super.onPageStarted(view, url, favicon); Log.i("caicai", "onPageStarted"); @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); @Override public void onLoadResource(WebView view, String url) { super.onLoadResource(view, url); Log.i("caicai",url); view.evaluateJavascript(jsStr, new ValueCallback<String>() { @Override public void onReceiveValue(String value) { Log.i("caicai", "jsStr:"+value); });安卓中使用 view.evaluateJavascript 方法,将js文件前置添加到 webview 中。ios中有类似的方法(来自ios开发同事) WKUserContentController *userContentController = configuration.userContentController; [userContentController addUserScript:script];这种方式加载,可以减少大文件的下载时间,一个正常的项目,只需要下载100k左右的js文件。在对接到已知系统的情况,可以显著的提升用户体验。安卓添加腾讯X5内核同一个web应用,在ios上和安卓上,同样用webview打开的方式访问,在安卓上确实无法和iOS上对比。但是只要改动几个配置,引入腾讯X5内核,在安卓端的体验就可以有一个质的飞跃。接入方式非常简单,不会安卓原生开发的小白,就可以完成。比如我!app/build.gradle 增加模块// 这个不同的项目引入方式还不一样,注意看下上下文的引入方式,不要全信官方文档 implementation 'com.tencent.tbs.tbssdk:sdk:43903'app/src/main/AndroidManifest.xml 中增加访问权限 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" /> <application> // service 写在 application 里面 <service android:name="com.tencent.smtt.export.external.DexClassLoaderProviderService" android:label="dexopt" android:process=":dexopt" > </service> </application>替换所有引用的库把原生 android.webkit 的引用修改为 com.tencent.smtt.sdk ,根据官方文档,一个一个的全局覆盖就好了。开启预启动功能这个是需要搭配上面提到的 service 服务一起使用才会有效的。// app/src/main/java/com/example/minialita/MainActivity.java @Override protected void init() { HashMap map = new HashMap(); map.put(TbsCoreSettings.TBS_SETTINGS_USE_SPEEDY_CLASSLOADER, true); map.put(TbsCoreSettings.TBS_SETTINGS_USE_DEXLOADER_SERVICE, true); QbSdk.initTbsSettings(map); }其他地方都不用修改,还是使用保留之前的用法。但是 web 应用的滚动流畅性非常好,页面跳转切换,也有接近原生的感受。(还是差一点点)虽然说,这作为一个尝试方案,但是在真实项目中使用,却可以明显的提高用户体验。实践成本也很小。如果在你们有类似的使用场景,不妨尝试一下。
产物优化到这里终于要进入整体了,但是前面的操作都不是无用的,我们应该每个项目都通过上面的方式先分析一下,需要优化的部分。根据我们上面的分析和发现,我们可以先将一些常用的比较大的库,比如 react 、 react-dom 、 @antv/f2,使用 cdn 的方式引入。图片资源压缩这是最有效减小产物包大小的一步,却是被很多人忽略的一步,很多朋友都是直接下载了 UI 提供的切图,并没有对图片进行处理,其实适当的对图片进行压缩其实不会影响显示效果的。比如这里简单的处理一下就能从减少4MB的大小。压缩工具有很多比如 TinyPNG 或者 pngquant。配置 externals(配置 externals 还能减小编译消耗,使你项目的编译时间更短。)export default { externals: { 'react': 'window.React', 'react-dom': 'window.ReactDOM', '@antv/f2': 'window.F2', scripts: [ 'https://gw.alipayobjects.com/os/lib/react/16.13.1/umd/react.production.min.js', 'https://gw.alipayobjects.com/os/lib/react-dom/16.13.1/umd/react-dom.production.min.js', 'https://gw.alipayobjects.com/os/antv/assets/f2/3.4.2/f2.min.js', }我们通过上述的方法,再次查看请求,发现比之前多请求了三个远程的 js 文件,但是总耗时缺少了几秒,只有 18.55s。查看代码分析页面,我们发现 react 和 react-dom 已经被从产物包里面移除了,但是 @antv/f2 却还在包里面。这就导致了我们引入了两次的 @antv/f2 的库,虽然不会冲突,项目也可以正常运行,但是白白消耗的时间和性能,却是无法接受的。于是我全局搜索了一下项目中对 f2 的引用。发现项目中对f2 的引用,使用的是 import F2 from '@antv/f2/lib/index-all'; ,可能你不太理解为什么要这么引用,因为这个情况可能出现在其他的你不熟悉的库里面,我们就不对项目代码做任何的修改,这时我们只需要修改一下我们的配置文件就可以。export default { externals: { 'react': 'window.React', 'react-dom': 'window.ReactDOM', - '@antv/f2': 'window.F2', + '@antv/f2/lib/index-all': 'window.F2', }这种方式引入并不是越多越好的,浏览器对同一个 hostname 发起的请求数量是有限制的,特别在安卓的 webview 中,限制的更加明显,因此可以通过观察首次发起的请求数量,来酌情处理。当然,土豪组织也可以通过增加不同的 cdn 主机来解除限制。选用可替代的依赖库包名Stat SizeParsed SizeGzip Sizemonent659.12KB367/65KB77.78KB我们发现 monent 库比较大,刚好社区有一个 dayjs ,api 和用法都和 monent 一致,但是包大小却非常小。// package.json "dependencies": { "@alitajs/dform": "^1.5.3", "@alitajs/list-view": "^0.2.4", "@antv/f2": "^3.7.0-alpha.1", "alita": "2.5.5", "classnames": "^2.2.6", "copy-to-clipboard": "^3.3.1", "crypto-js": "^4.0.0", + "dayjs": "^1.8.29" - "monent": "^2.24.0" },这里我们使用一种不推荐但可能更高效的方式来修改,全局替换。然后有修改的页面再打开看一下,如果有 api 报错,再去添加 dayjs 相关的插件。查看代码分析页面,发现经过我们上面的简单配置,包已经变小了。包名Stat SizeParsed SizeGzip Size旧的 umi.js6.77MB2.75MB905.54KB新的 umi.js5.41MB2.16MB718.7KB如果你发现某一个较大的依赖包,你没有在项目中使用,那就是第三方依赖库中有使用到了,可以简单的翻一下 yarn.lock 文件,就可以发现它们的依赖关系。如果你有更好更小的替代库,可以直接给这些需要优化的第三方库提 Issues 或者 PR ,比如我们之前发现 @alitajs/dform 中使用了 monent,于是我们提交了 PR ,现在在最新的版本中,就没有使用 monent 了。调整 splitChunks 策略增加配置,如果你对 webpack 的配置不熟悉也不要紧,这里你只需要关注 cacheGroups 中的配置,尤其是每一项里面的 test ,比如这里的 /[\\/]node_modules[\\/]/ 表示将 node_modules 中的所有的库拆分到一个新的js文件中,文件名称为 vendors.js 。export default { chainWebpack(config:any) { config.merge({ optimization: { splitChunks: { chunks: 'all', automaticNameDelimiter: '.', name: true, minSize: 30000, minChunks: 1, cacheGroups: { vendors: { name: 'vendors', chunks: 'all', test: /[\\/]node_modules[\\/]/, priority: -12, }增加文件加载顺序声明 chunks ,因为我们增加了一个 js 文件,这是我们就要告诉项目,应该先加载哪个文件,如果你有增加其他的拆分文件,记得也要同步添加这个配置。export default { chunks: ['vendors', 'umi'], }查看分析页面,我们可以看到两个 js 文件,分别由 node_modules 文件夹的内容和 src 文件夹的内容组成。包名Stat SizeParsed SizeGzip Sizeumi.js1.45MB760.03KB230.05KBvendors.js3.96MB1.42MB454.66KB拆分前的 umi.js5.41MB2.16MB718.7KB移除非必要的请求从我们的请求日志中,可以发现一个外部js的引用,它不大但是却很耗时间。看了一下是高德地图需要引入的全局js文件。我们通过浏览器直接访问 https://webapi.amap.com/maps?v=1.4.15&key=demokey。可以得到一个js文件,我们将这个js下载到项目中,然后在 global.ts 中引入 import '@/utils/amap.js';,删除项目中对高德地图的引入,比如这里是在 src/pages/document.ejs 中删除对应的 script。虽然将文件打包到项目中,会使得文件变大。可能有些朋友看到这里会比较疑惑,我们上面一直在努力的减小我们的文件大小,为什么这里又选择增加了文件大小?其实如何做产物优化这回事,并没有什么最优解,具体问题具体分析,文件太大影响访问数据,就减小单文件大小,文件太多,引起访问受限,就合并文件数量,提供外链的cdn服务有问题,就将文件下载到本地,就像易经中常常提到的,这是一个平衡。减少将图片写成 Base64在我们的框架中,当图片大小小于 10KB ,会被转化成 Base64 写到 js 文件中,因此如果对js包有强制要求的情况下,可以选择不将图片写到 js 文件中,通过配置 inlineLimit 实现。port default { // Default: 10000 (10k) inlineLimit:10
相信很多人都有遇到这个问题,为什么 umi 打包完的产物这么大啊?首屏打开时间也太长了吧?特别是用 umi 开发移动端的同学,我们暂且认为首屏开启时间在 3s 内作为合格,5s 内勉强能接受。因为我们去年一年使用 umi 作为移动端的开发,是借助 cordova 将构建产物直接写在本地,所以包的大小对于我们的项目影响较小。但是今年开始有不少的同学将产物以 H5 的形式对外发布,或者以链接的形式,在其他应用中使用 webview 加载出来。于是包大小,首屏打开时间,就成为了我们项目的一个交互指标。这里我用一个被用户投诉的项目来做演示说明。当然只会展示产物信息,不会涉及任何用户的敏感信息。这里我们先看一下优化后的结果(同一个电脑同一个网络环境中)。比对项目原始数据优化后的数据dist 大小8.7MB4.5MBFast 3G20s16.94sOnline4.3s503ms默认打包使用 umi 执行打包,不增加任何修改和配置的情况下,所有的 js 代码会被编译到 umi.js 文件中。umi builddist 8.7MB 编译时间 21.02s包名Stat SizeParsed SizeGzip Sizeall6.77MB2.75MB905.54KBumi.js6.77MB2.75MB905.54KB增加按需加载觉得都打到一个文件 umi.js 里面文件太大的话,可以考虑在 config/config 中添加按需加载配置。export default { dynamicImport: { };页面切换的时候,会有一个默认的 loading ,觉得不是很好看,自己可以修改一下。export default { dynamicImport: { // @ 默认指到 src 目录 loading: '@/pages/Loading/index', };dist 18.7MB 编译时间 33.68s包名Stat SizeParsed SizeGzip Sizeall39.16MB10.76MB3.05MBumijs.js3.29MB1.44MB454.97KB添加完按需加载,每次在切换页面的时候,都会先走一个 loading 页面,看惯了原生应用的甲方说,不能接受。这里可以很明显的发现开启按需加载之后,产物包变大了很多,这个可以通过调整 splitChunks 策略,减少整体尺寸。接下来会提到 splitChunks 相关的配置,因此这里没有单独说明。包分析首先我们去掉刚刚添加的按需加载配置。export default { - dynamicImport: { - // @ 默认指到 src 目录 - loading: '@/pages/Loading/index', - }, };查看产物包的结构使用 ANALYZE=1 环境变量来查看 build 之后的产物包结构。注意环境变量的使用,mac 上可以直接使用,window 上需要使用 set ,因此在项目中,我们一般是通过安装 cross-env 来抹去平台差异。{ "scripts": { "build": "cross-env ANALYZE=1 umi build", }编译执行完成之后,你可以查看 http://127.0.0.1:8888 包分析页面。可以看到大致如下图所示的页面。可能第一次看这个页面,不是很明白什么意思,你可以简单的理解,页面上面积越大的库,就是包大小越大的。比如,现在所有的文件都在 umi.js 文件中,然后占体积最大的是 node_modules,其中体积最大的moment,其次是 @antv/f2,于是我们得到了下面的表格。包名Stat SizeParsed SizeGzip Sizeumi.js6.77MB2.75MB905.54KBmonent659.12KB367/65KB77.78KB@antv/f2526.87KB221.55KB60.51KB@alitajs/dform357.68KB102.51KB20.44KBsrc1.59MB811.24KB221.41KB查看真实请求耗时数据一般我将项目编译到 dist 目录之后,都会习惯性的用本地服务查看一次。arn global add serve cd dist serve Local: http://localhost:5000 这是我所知道的启动一个本地容器最简单的方式了。通过访问 http://localhost:5000 就能访问到我们的项目,而且表现更加接近于真实生产环境。在网络很快的情况下,我们很难体验到方案修改的差异,所以我们使用 3G 网络模拟一下对网站的请求,总耗时大约在 20s 左右。通过上述分析,我们发现,下载一个大的文件会比较耗时,所以我们先想办法减小 umi.js 的大小。
一、开发设想从事移动端 H5 开发的小伙伴有没有经历过被长表单支配的恐惧?是一个个表单项纯手写实现的吗?那一个页面你可能要做一天,欲哭无泪,心力憔悴。为什么表单实现这么难?是使用 antd-mobile 的 rc-form 吗?确实会让你提效 50%,但一个个的设置 getFieldProps 和 initialValue 的回填赋值,又感觉做了很多重复的动作。如果表单中存在 radio, check 这类 antd-mobile 库里没有的样式,那我们还要自己实现UI吗?再来一种情况,如果是后台提供的动态表单呢?心态崩了,还要自己遍历实现UI,提交取值,回填赋值等需求。那么能否有一种针对移动端长表单的快捷实现方案呢?比使用 rc-form 还要提效 50%,方案要覆盖:UI 的快速实现能实现一次性全部赋值表单提交取值融合多类型组件表单支持动态表单针对以上需求,我们开发 @alitajs/dform 支撑表单方案,并且在公司内部数十个项目中得到锤炼,不断优化完善。好了那么是骡子是马拉出来溜溜!二、基础使用我们借鉴了 antd@4 的 Form 组件,针对表单使用的 react-component/field-form 库进行二次封装。不了解的小伙伴也没事。想想表单需要哪些属性和事件?表单全部字段表单提交成功事件表单提交失败事件表单赋值回填的字段 import React from 'react'; import DynamicForm, { useForm } from '@alitajs/dform'; import { Button } from 'antd-mobile'; const Page = () => { const [form] = useForm(); // 定义 form const onFinish = values => console.log('Success:', values); const onFinishFailed = errorInfo => console.log('Failed:', errorInfo); const data = [ type: 'input', fieldProps: 'username', placeholder: '请输入', title: '用户名', required: true, const formProps = { form, // 表单定义 data, // 表单全部字段 formsValues: {}, // 表单赋值回填数据 onFinish, // 表单提交成功事件 onFinishFailed, // 表单提交失败事件 return ( <> <DynamicForm {...formProps} /> <Button onClick={() => { form.submit();}}>Submit</Button> </> export default Page;这个最简 demo 里核心代码应该就 10 行。data 是数组,要增加表单字段只需要简单的,往 data 里添加字段即可。dform 共提供 15 种组件。涵盖:文本展示类型: text输入类型: input 和 area选择类型: picker 和 select多选类型: multiplePicker开关类型: switch时间选择类型: date图片选择类型: image选择地址类型: addressPickerRadio按钮类型: radio 和 coverRadioCheck多选类型: check时间区间选择类型: rangeDatePicker高阶输入类型: extraInput如果这么多的组件还不能满足需求,不着急。我们还提供 自定义类型: custom 组件,让用户自己实现,并在文档中提供教程。或者给我们提个 issues,我们会根据评估结果进行开发和维护。三、提效点1、picker 组件:antd-mobile 提供的 Select 组件涵盖了及联的类型,所以 value 出参以 [] 的形式。但是在表单对象走接口时,每个字段的值很大情况下都是 stirng 或者 number 的形式进行传递,在 [] 情况下,还要对数据结构进行处理。dform 提供了四种选址组件:picker: 单选类型,出参为 string 或者 number,不再需要对数据结构进行多一层的转化。select: antd-mobile 上的 Select 组件,出参入参设值保持一直。multiplePicker: 多选,出参以 list 的形式提供。addressPicker: 选址,更是帮你大大的提效(舒服的写业务吧,剩下的事情交给我们)。2、一行代码配置样式不同的项目,不同的 ui设计师,针对表单的开发样式肯定不一样,比如:标题的颜色和大小值的颜色和大小placeholder 颜色...在 .umirc.ts 和 config.ts 下配置:theme3、不敲一行代码帮你配置 data 的JSON数据如果你连 JSON 格式的 data 也懒得写,那么 isDev 字段开启开发者模式,让你鼠标点一点就能编辑好一串 JSON
在这个文章中,将会简单的介绍如何使用 dumi 和 father-build 来创建组件库和维护组件库。最终我们会演示如何组织组件库的代码。还会输出一个简单的组件库。脚手架初始化新建一个空文件夹,然后使用脚手架初始化项目。mkdir myapp && cd myapp npx @umijs/create-dumi-lib yarn create @umijs/dumi-lib值得注意的是,项目中会有两个配置文件。.umirc.ts作为 umi 项目的配置文件,因为 dumi 其实是 umi 在组件库开发这个领域的一个最佳实践。但它本质上还是一个 umi 插件,这意味着,在 umi 上可用的插件,在 dumi 中仅可正常使用,但是是否能够对开发的组件库,产生影响,就需要看对应的插件,是否在这方面进行支持了.fatherrc.ts作为 father-build 的配置文件,这里将会配置,组件库被如何编译和编译产物的类型。一般我们都是针对组件库使用场景,进行简单的声明即可。也就是说,一般设置 esm: 'rollup', 就够用了。启动项目yarnyarn startdumi 的 Demo 理念dumi 只有一个理念——开发者应该像用户一样写 Demo。官网上是这么描述的,但是我比较喜欢从开发者的角度去阐述它,像写业务一样写组件。也就是官网上提到的“易于维护”,因为在开发者工具上,比如 VS Code,他的格式化工具,通常都是默认读取文件的后缀名来启用的,也就是说,你在文档的 md 文件中,编写代码,就没有很好的格式化和代码联想功能。(最新的VS Code,当你标记代码段时,可以使用相对应的代码片段,我这里只是一个举例。)所以 dumi 支持,直接从其他文件中引入代码的功,这样可以依旧保持编译器的能力。<code src="/path/to/Demo.tsx" />自动 linkdumi 项目会自动使用别名来对应当前的包名,效果很像是自动 link,就是说,你可以在文档中编写 import { Foo } from 'dumi-oni';,然后读者可以直接复制你的代码段,到真实的项目环境中,直接运行,而无需修改引入路径。比如在某些知名的组件库方案中的引入是 import { Foo } from './index';。用户复制代码段之后,还要修改 from 的路径。默认文档目录dumi 会默认使用 src 和 docs 目录下的 md 文件生成对应的文档。创建自己的组件我们参考 antd 的代码组织方式,来编写一个 button 组件 // src/Button/index import React, { FC } from 'react'; import './index.less'; interface ButtonProps { onClick?: React.MouseEventHandler<HTMLElement>; type?: 'default' | 'primary' | 'secondary'; disabled?: boolean; const prefixCls = 'dumi-oni-btn'; const Button: FC<ButtonProps> = ({ children, onClick, type = 'default', disabled }) => { const className = `${prefixCls} ${prefixCls}-button ${prefixCls}-${type}`; return ( <button className={className} onClick={onClick} disabled={disabled}> {children} </button> export default Button;对应的样式编写,如下。我们这里使用和 antd 一样的方式来实现。 @btn-prefix-cls: ~'dumi-oni-btn'; .@{btn-prefix-cls} { &-button { padding: 6px 16px; font-size: 12px; min-width: 64px; &-button[disabled] { color: rgba(0, 0, 0, 0.26); background-color: rgba(0, 0, 0, 0.12); &-default { &-primary { color: #fff; background-color: #1976d2; &-secondary { color: #fff; background-color: #dc004e; }为组件编写用例// src/Button/index.md import React from 'react'; import { Button } from 'dumi-oni'; export default () => <Button onClick= {()=>alert('onClick')}>default</Button>;dumi 会默认取文档的第一个标题作为菜单栏的名称,你也可以通过注视手动编写名称,如--- title: 按钮 ---运行查看效果$ npm start访问开发服务 http://localhost:8000。打包编译npm run build编译使用的是 father-build,有一个特别需要注意的地方,如果组件中使用到第三方的库,那么需要这些库在 package.json 中的 dependencies 或者 preDependencies 中,一般的打包出错问题,几乎都是这个原因引起的。注意 dependencies 或者 preDependencies 中的包是我们需要的依赖,注意将组件库不需要的依赖,移到 devDependencies 中,如脚手架创建的项目,最终修改为"dependencies": { "react": "^16.12.0" "devDependencies": { "@umijs/preset-react": "1.x", "@umijs/test": "^3.0.5", "dumi": "^1.0.5", "father-build": "^1.17.2", "lint-staged": "^10.0.7", "prettier": "^1.19.1", "yorkie": "^2.0.0" }发布组件库确定组件库名称,并确定名称未被其他人使用。如这里修改为 dumi-oni。然后执行 npm publish 将组件库发布到 npm 上。使用组件库yarn add dumi-oni在页面中使用import React from 'react'; import { Button } from 'dumi-oni'; export default () => <Button type="secondary">secondary</Button>;不难发现,其实在项目中使用,和我们 demo 中编写的方式是一致的,这也是我们前面提到的 dumi 的 demo 理念。