挑战21天手写前端框架 day10 手撕 esbuild 插件开发完成对 style 的支撑
阅读本文需要 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天手写前端框架》,关注的人会更多。哈哈哈,我没好意思放。
希望能够得到更多朋友的互动,我希望将这东西写成一个玩具,在写它的时候,能够学到更多的东西。
转载自:https://juejin.cn/post/7089083760901095455