阅读本文需要 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.tsx
import 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/tsx
import 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 ( <> <p 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.tsx
import 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-helper
onResolve( { 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 的插件编写,遇到路径处理,都用 onResolve
onResolve({ 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天手写前端框架》,关注的人会更多。哈哈哈,我没好意思放。
希望能够得到更多朋友的互动,我希望将这东西写成一个玩具,在写它的时候,能够学到更多的东西。