挑战21天手写前端框架 day10 手撕 esbuild 插件开发完成对 style 的支撑

简介: 挑战21天手写前端框架 day10 手撕 esbuild 插件开发完成对 style 的支撑

image.png


阅读本文需要 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;
复制代码


同理整理 UsersMe 页面,然后修改项目主入口 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>
复制代码

image.png

从效果上来看,我们的项目已经支持 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天手写前端框架》,关注的人会更多。哈哈哈,我没好意思放。


希望能够得到更多朋友的互动,我希望将这东西写成一个玩具,在写它的时候,能够学到更多的东西。


源码归档

目录
相关文章
|
10月前
|
前端开发
前端学习笔记202303学习笔记第五天-style节点的基本使用
前端学习笔记202303学习笔记第五天-style节点的基本使用
45 0
|
11月前
|
前端开发 开发者
前端祖传三件套HTML的常用属性之style
HTML是前端开发的基础知识之一,而style属性是其中一个常用的属性。在HTML中,style属性可以帮助我们为元素设置样式,使网页呈现出更好的视觉效果。本文将介绍style属性的定义、用法以及注意事项,帮助广大前端开发者更好地掌握style属性。
484 0
|
JavaScript 前端开发
前端-vue基础21-style样式处理
前端-vue基础21-style样式处理
108 0
前端-vue基础21-style样式处理
|
8月前
|
Web App开发 前端开发 JavaScript
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-fiber解决了什么问题
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-fiber解决了什么问题
95 0
|
8月前
|
前端开发 定位技术
前端学习笔记202305学习笔记第二十三天-地图单线程配置
前端学习笔记202305学习笔记第二十三天-地图单线程配置
66 0
前端学习笔记202305学习笔记第二十三天-地图单线程配置
|
8月前
|
前端开发 API
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-react-redux的工作流程
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-react-redux的工作流程
55 0
|
8月前
|
前端开发
前端学习笔记202306学习笔记第五十一天-工厂模式4
前端学习笔记202306学习笔记第五十一天-工厂模式
34 0
|
8月前
|
前端开发
前端学习笔记202305学习笔记第二十八天-数组结构之列表拖拽改变顺序4
前端学习笔记202305学习笔记第二十八天-数组结构之列表拖拽改变顺序4
32 0
|
4月前
|
前端开发 JavaScript
《Webpack5 核心原理与应用实践》学习笔记-> 构建微前端应用
《Webpack5 核心原理与应用实践》学习笔记-> 构建微前端应用
41 1
|
8月前
|
JavaScript 前端开发 调度
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-fiber和虚拟dom关系
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-fiber和虚拟dom关系
59 0