vite的前世今生及自制md插件

1,799 阅读8分钟

Vite 使用 native ES module imports 实现组件、样式、模板加载以及热更新,这会不会成为后续工具链的发展方向?

vite 是什么

vite —— 一个由 vue 作者尤雨溪开发的 web 开发工具,它具有以下特点:

  1. 快速的冷启动
  2. 即时的模块热更新
  3. 真正的按需编译

尤大是这样说的:

Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 rollup 打。虽然现在还比较粗糙,但这个方向我觉得是有潜力的,做得好可以彻底解决改一行代码等半天热更新的问题。

可以看出 vite 主要特点是基于浏览器 native 的 ES module (developer.mozilla.org/en-US/docs/…) 来开发,省略打包这个步骤,因为需要什么资源直接在浏览器里引入即可。

vite 的使用方式

同常见的开发工具一样,vite 提供了用 npm 或者 yarn 一键生成项目结构的方式,使用 yarn 在终端执行:

$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev

即可初始化一个 vite 项目(默认应用模板为 vue3.x),生成的项目结构十分简洁:

|____node_modules
|____App.vue // 应用入口
|____index.html // 页面入口
|____vite.config.js // 配置文件
|____package.json

执行 yarn dev 即可启动应用 。

server

这部分代码在 src/node/server/index.ts 里,主要暴露一个 createServer 方法。

vite 使用 koa 作 web server,使用 clmloader 创建了一个监听文件改动的 watcher,同时实现了一个插件机制,将 koa-app 和 watcher 以及其他必要工具组合成一个 context 对象注入到每个 plugin 中。

plugin 依次从 context 里获取上面这些组成部分,有的 plugin 在 koa 实例添加了几个 middleware,有的借助 watcher 实现对文件的改动监听,这种插件机制带来的好处是整个应用结构清晰,同时每个插件处理不同的事情,职责更分明,

plugin

上文我们说到 plugin,那么有哪些 plugin 呢?它们分别是:

  • 用户注入的 plugins —— 自定义 plugin
  • hmrPlugin —— 处理 hmr
  • htmlRewritePlugin —— 重写 html 内的 script 内容
  • moduleRewritePlugin —— 重写模块中的 import 导入
  • moduleResolvePlugin ——获取模块内容
  • vuePlugin —— 处理 vue 单文件组件
  • esbuildPlugin —— 使用 esbuild 处理资源
  • assetPathPlugin —— 处理静态资源
  • serveStaticPlugin —— 托管静态资源
  • cssPlugin —— 处理 css/less/sass 等引用
  • ...

我们来看 plugin 的实现方式,开发一个用来拦截 json 文件 plugin 可以这么实现:

interface ServerPluginContext {
  root: string
  app: Koa
  server: Server
  watcher: HMRWatcher
  resolver: InternalResolver
  config: ServerConfig
}
type ServerPlugin = (ctx:ServerPluginContext)=> void;
const JsonInterceptPlugin:ServerPlugin = ({app})=>{
    app.use(async (ctx, next) => {
      await next()
      if (ctx.path.endsWith('.json') && ctx.body) {
        ctx.type = 'js'
        ctx.body = `export default json`
      }
  })
}

vite 背后的原理都在 plugin 里,这里不再一一解释每个 plugin 的作用,会放在下文背后的原理中一并讨论。

build

这部分代码在 node/build/index.ts 中,build 目录的结构虽然与 server 相似,同样导出一个 build 方法,同样也有许多 plugin,不过这些 plugin 与 server 中的用途不一样,因为 build 使用了 rollup ,所以这些 plugin 也是为 rollup 打包的 plugin ,本文就不再多提。

vite 运行原理

ES module

要了解 vite 的运行原理,首先要知道什么是 ES module,目前流览器对其的支持如下:

imageimage

主流的浏览器(IE11除外)均已经支持,其最大的特点是在浏览器端使用exportimport的方式导入和导出模块,在 script 标签里设置type="module",然后使用模块内容。

<script type="module">
  import { bar } from './bar.js‘
</script>

当 html 里嵌入上面的 script 标签时候,浏览器会发起 http 请求,请求 htttp server 托管的 bar.js ,在 bar.js 里,我们用 named export 导出bar变量,在上面的 script 中能获取到 bar 的定义。

// bar.js 
export const bar = 'bar';

在 vite 中的作用

打开运行中的 vite 项目,访问 view-source 可以发现 html 里有段这样的代码:

<script type="module">
    import { createApp } from '/@modules/vue'
    import App from '/App.vue'
    createApp(App).mount('#app')
</script>

从这段代码中,我们能 get 到以下几点信息:

  • http://localhost:3000/@modules/vue 中获取 createApp 这个方法
  • http://localhost:3000/App.vue 中获取应用入口
  • 使用 createApp 创建应用并挂载节点

createApp 是 vue3.X 的 api,只需知道这是创建了 vue 应用即可,vite 利用 ES module,把 “构建 vue 应用” 这个本来需要通过 webpack 打包后才能执行的代码直接放在浏览器里执行,这么做是为了:

  1. 去掉打包步骤

  2. 实现按需加载

去掉打包步骤

打包的概念是开发者利用打包工具将应用各个模块集合在一起形成 bundle,以一定规则读取模块的代码——以便在不支持模块化的浏览器里使用。

为了在浏览器里加载各模块,打包工具会借助胶水代码用来组装各模块,比如 webpack 使用 map 存放模块 id 和路径,使用 __webpack_require__ 方法获取模块导出。

vite 利用浏览器原生支持模块化导入这一特性,省略了对模块的组装,也就不需要生成 bundle,所以打包这一步就可以省略了。

实现按需打包

前面说到,webpack 之类的打包工具会将各模块提前打包进 bundle 里,但打包的过程是静态的——不管某个模块的代码是否执行到,这个模块都要打包到 bundle 里,这样的坏处就是随着项目越来越大打包后的 bundle 也越来越大。

开发者为了减少 bundle 大小,会使用动态引入 import() 的方式异步的加载模块( 被引入模块依然需要提前打包),又或者使用 tree shaking 等方式尽力的去掉未引用的模块,然而这些方式都不如 vite 的优雅,vite 可以只在需要某个模块的时候动态(借助 import() )的引入它,而不需要提前打包,虽然只能用在开发环境,不过这就够了。

css

如果在 vite 项目里引入一个 sass 文件会怎么样?

最初 vite 只是为 vue 项目开发,所以并没有对 css 预编译的支持,不过随着后续的几次大更新,在 vite 项目里使用 sass/less 等也可以跟使用 webpack 的时候一样优雅了,只需要安装对应的 css 预处理器即可。

在 cssPlugin 中,通过正则:/(.+).(less|sass|scss|styl|stylus)$/ 判断路径是否需要 css 预编译,如果命中正则,就借助 cssUtils 里的方法借助 postcss 对要导入的 css 文件编译。

vite 热更新的实现

上文中出现了 vite/hmr ,这就是 vite 处理热更新的关键,在 serverPluginHmr plugin 中,对于 path 等于 vite/hmr 做了一次判断:

app.use(async (ctx, next) => {
  if (ctx.path === '/vite/hmr') {
      ctx.type = 'js'
      ctx.status = 200
      ctx.body = hmrClient
  }
 }

hmrClient 是 vite 热更新的客户端代码,需要在浏览器里执行,这里先来说说通用的热更新实现,热更新一般需要四个部分:

  1. 首先需要 web 框架支持模块的 rerender/reload
  2. 通过 watcher 监听文件改动
  3. 通过 server 端编译资源,并推送新模块内容给 client 。
  4. client 收到新的模块内容,执行 rerender/reload

vite 也不例外同样有这四个部分,其中客户端代码在:client.ts 里,服务端代码在 serverPluginHmr 里,对于 vue 组件的更新,通过 vue3.x 中的 HMRRuntime 处理的。

自制Vite插件

写代码宗旨:事不过三

vite支持插件

vite开发时使用浏览器原生能力,部署使用rollup,md.ts支持rollup

浏览器会将所有css ts等资源全部加载为js

1.根目录新建plugins/md.ts

命令:yarn add --dev marked 或 npm i -D marked

// @ts-nocheck
import path from 'path'
import fs from 'fs'
import marked from 'marked'

const mdToJs = str => {
  const content = JSON.stringify(marked(str))
  return `export default ${content}`
}

export function md() {
  return {
    configureServer: [ // 用于开发
      async ({ app }) => {
        app.use(async (ctx, next) => { // koa
          if (ctx.path.endsWith('.md')) {
            ctx.type = 'js'
            const filePath = path.join(process.cwd(), ctx.path)
            ctx.body = mdToJs(fs.readFileSync(filePath).toString())
          } else {
            await next()
          }
        })
      },
    ],
    transforms: [{  // 用于 rollup // 插件
      test: context => context.path.endsWith('.md'),
      transform: ({ code }) => mdToJs(code)
    }]
  }
}

2.根目录新建vite.config.js

// @ts-nocheck
import { md } from "./plugins/md";
import fs from 'fs'
import {baseParse} from '@vue/compiler-core'

export default {
  plugins: [md()],
  vueCustomBlockTransforms: {
    demo: (options) => {
      const { code, path } = options
      const file = fs.readFileSync(path).toString()
      const parsed = baseParse(file).children.find(n => n.tag === 'demo')
      const title = parsed.children[0].content
      const main = file.split(parsed.loc.source).join('').trim()
      return `export default function (Component) {
        Component.__sourceCode = ${
        JSON.stringify(main)
        }
        Component.__sourceCodeTitle = ${JSON.stringify(title)}
      }`.trim()
    }
  }
};

3.新建src/markdown/Intro.md

# 介绍

King UI 是一款基于 Vue 3 和 TypeScript 的 UI 组件库。

这款组件库其实是我为了总结自己这几年的技术经验而写的,全程亲手编写,尽量不采用第三方库,包括你现在看到的这个官网也几乎都是我自己写的。

所以如果强烈不建议你将此 UI 库用于生产环境。但如果你是抱着看源代码的目的来的,那么这个库还是值得一看的。源代码放在了 github.com/frankfang/xxxxxxx,历史提交信息非常规范,你可以按提交的顺序逐个查看;你也可以直接查看每个组件的源代码和示例,运行方法见 README.md。

下一节:[安装](#/doc/install)

新建src/markdown/Install.md

# 安装

打开终端运行下列命令:

```
npm install king-ui
``````
yarn add king-ui
```

下一节:[开始使用](#/doc/get-started)

新建src/markdown/GetStarted.md

# 开始使用
请先[安装](#/doc/install)本组件库。

然后在你的代码中写入下面的代码

```
import {Button, Tabs, Switch, Dialog} from "king-ui"
```

就可以使用我提供的组件了。

## Vue 单文件组件

代码示例:

```
<template>
  <div>
    <Button>按钮</Button>
  </div>
</template>
<script>
import {Button, Tabs, Switch, Dialog} from "king-ui"
export default {
  components: {Button}
}
</script>
```

3.根目录新建src/compontents/Markdown.vue

<template>
  <article class="markdown-body" v-html="content">
  </article>
</template>

<script lang="ts">
import { ref } from "vue";
export default {
  props: {
    path: {
      type: String,
      required: true,
    },
  },
  setup(props) {
    const content = ref<string>(null);
    //动态引入 异步加载
    import(props.path).then((result) => {
      content.value = result.default;
    });
    return {
      content,
    };
  },
};
</script>

4.src/router.ts引入 vue中的h函数 参数是组件及组件的属性

/**
 * 手机端路由切换之后关闭aside
 * 将router封装成单独的router.ts
 * 在App.vue中引入router.ts
 * router.afterEach监听到路由改变方法中改变menuVisible.value的值,即可在路由切换时将侧边栏收起(手机端)
 */
import { createWebHashHistory, createRouter } from 'vue-router'
import Home from './views/Home.vue'
import Doc from './views/Doc.vue'
import DocDemo from './components/DocDemo.vue'
import SwitchDemo from './components/SwitchDemo.vue'
import ButtonDemo from './components/ButtonDemo.vue'
import DialogDemo from './components/DialogDemo.vue'
import TabsDemo from './components/TabsDemo.vue'

import { h } from 'vue';
import Markdown from './components/Markdown.vue';
const md = filename => h(Markdown, { path: `../markdown/${filename}.md`, key: filename })  //key值用于路由切换不刷新有效

const history = createWebHashHistory()
export const router = createRouter({
  history: history,
  routes: [
    { path: '/', component: Home },
    {
      path: '/doc', component: Doc,
      children: [
        { path: '', component: DocDemo },
        { path: "intro", component: md('intro') },
        { path: "get-started", component: md('get-started') },
        { path: "install", component: md('install') },
        { path: 'switch', component: SwitchDemo },
        { path: 'button', component: ButtonDemo },
        { path: 'dialog', component: DialogDemo },
        { path: 'tabs', component: TabsDemo }
      ]
    }
  ]
});

另注:yarn build后的文件不可以双击打开

按如下方法将打包后文件运行

yarn global add http-server //开启服务器
hs dist/ -c-1   //地址:127.0.1:8080/index.html#/