手写vite

1,411 阅读4分钟

介绍

Vite 是一个由原生 ESM 驱动的 Web 开发构建工具。在开发环境下基于浏览器原生 ES imports 开发,在生产环境下基于 Rollup 打包。一个基于 Vue3 单文件组件的非打包开发服务器,当遇见import依赖时,会直接发起http请求对应的模块文件。 源码地址 我自己手写的,路过的顺手给个start感激不尽

image.png

听说喊大哥的那兄弟是webpack的开发人员....

image.png

ESM

script module 是 ES 模块在浏览器端的实现,目前主流的浏览器都已经支持

image.png
其最大的特点是在浏览器端使用 export、import 的方式导入和导出模块,在 script 标签里设置 type="module"

<script type="module">
  import { createApp } from './main.js';
  createApp();
</script>

浏览器会识别添加** type="module"**的 元素,浏览器会把这段内联 script 或者外链 script 认为是 ECMAScript 模块,浏览器将对其内部的 import 引用发起 http 请求获取模块内容。 在 main.js 里,我们用 named export 导出 createApp 函数,在上面的 script 中能获取到该函数

// main.js
export function createApp(){
    console.log('create app!');
};

其实到这里,我们基本可以理解 vite 宣称的几个特性了。 • webpack 之类的打包工具为了在浏览器里加载各模块,会借助胶水代码用来组装各模块,比如 webpack 使用 map 存放模块 id 和路径,使用** webpack_require **方法获取模块导出,vite 利用浏览器原生支持模块化导入这一特性,省略了对模块的组装,也就不需要生成 bundle,所以 冷启动是非常快的 • 打包工具会将各模块提前打包进 bundle 里,但打包的过程是静态的——不管某个模块的代码是否执行到,这个模块都要打包到 bundle 里,这样的坏处就是随着项目越来越大打包后的 bundle 也越来越大。而 ESM 天生就是按需加载的,只有 import 的时候才会去按需加载

安装使用

安装

npm install create-vite-app
create-vite-app UprojecctName

进入项目

npm install
npm run dev

即启动了。第一次启动会稍微慢一些,因为要配置.vite_opt_cache。之后便不再需要配置。 我们可以看到main.js

import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

在浏览器中被解析为

import { createApp } from '/@modules/vue.js'
import App from '/src/App.vue'
createApp(App).mount('#app')

从这里我们可以看出vite对.js文件中没有路径的import做了路径标识。这符合ESM的思想,先将import的路径做标识,之后再来替换,换做http请求真实的文件地址。 下图是比较粗糙的流程:

image.png
生产环境打包用的还是rollup。

手写源码

建立一个index.js对各个中间件进行管理

const Koa = require('koa')
const {serveStaticPlugin} = require('./plugins/servePluginServeStaticPlugin')//第一步
const {moduleRewritePlugin} = require('./plugins/servePluginModuleRewirtePlugin')//第二步
const {moduleResolvePlugin} = require('./plugins/servePluginModuleResolvePlugin')//第三步
const {htmlRewritePlugin} = require('./plugins/servePluginHtmlRewritePlugin')//向html中插入NODE_ENV环境变量
const {vuePlugin} = require('./plugins/servePluginVue')//第四步
function createServer(){
    const app  = new Koa()
    const root = process.cwd()//用来获取调用文件的根目录。
    const context = {
        app,
        root
    }
    const resolvePlugins = [//插件的集合.洋葱模型
        htmlRewritePlugin,
        moduleRewritePlugin,//解析import,进行重新@modules
        moduleResolvePlugin,
        //2)根据标识了@modules解析模块
        vuePlugin,
        serveStaticPlugin,//1)静态服务插件
    ]
    resolvePlugins.forEach(plugin => plugin(context))//一层一层来循环执行中间件
    return app
}
module.exports = createServer

一、利用koa-static读取静态文件

const root = process.cwd()
const static = require('koa-static')
app.use(static(root))

二、将js文件中不是以‘.或./或/’开头的import进行标记

app.use(async (ctx,next) => { await next()//需要等待读取完静态文件 if(ctx.body && ctx.response.is('js')){//判断一下从上一个传下来的body是否有内容,且返回的请求为js let content =await transStreamTostring(ctx.body)//把流转为String,异步,需要等待。在Koa中,所有的异步必须用promise,所以需要用promise包装一下transStreamTostring方法 let result = rewriteImports(content)//识别出来,并进行标识 ctx.body = result//将最终的结果赋值给body, } })

const { readBody } = require('./util.js')
const { parse } = require('es-module-lexer')
const MagicString = require('magic-string')
function rewriteImports(source) {
    let imports = parse(source)[0]//静态的再第一数组,动态的在第二个数组里
    /**
     * import xxx from 'xxx'静态
     * import()动态
     */
    let magicString = new MagicString(source)
    if (imports.length) {
        for (let i = 0; i < imports.length; i++) {
            let { s, e } = imports[i]
            let id = source.substring(s, e)
            if (/^[^\/\.]/.test(id)) {
                id = `/@modules/${id}`//标识模块
                magicString.overwrite(s, e, id)
            }
        }
    }
    return magicString.toString()//替换后的结果返回。
}
function moduleRewritePlugin({ app, root }) {
    app.use(async (ctx, next) => {
        await next()
        if (ctx.body && ctx.response.is('js')) {
            let content = await readBody(ctx.body)
            const result = rewriteImports(content)
            ctx.body = result//将重写的内容响应到body上面
        }
    })
}
exports.moduleRewritePlugin = moduleRewritePlugin

三、根据第二步的标记进行解析,引用正确的文件地址

const moduleReg = /^\/@modules\//
const fs = require('fs').promises
const {resolveVue} = require('./util')//这是根据vue3的特性,将各个文件的引用指向dist下的真实目录。
function moduleResolvePlugin({app,root}){
    const vueResolved = resolveVue(root)
    app.use(async (ctx,next) =>{
        if(!moduleReg.test(ctx.path)){
            return next()
        }
        const id = ctx.path.replace(moduleReg,'')
        //应该去当前项目下查找vue对应的真实文件
        ctx.type = 'js'//设置响应类型,响应的结果是JS类型。
        const content = await fs.readFile(vueResolved[id],'utf8')
        ctx.body = content
    })
}
exports.moduleResolvePlugin = moduleResolvePlugin

四、将.vue文件中的导出模块解析为对象导出,并引用template

判断路径是不是以.vue结束的,如果是的话才走,不然直接next 需要从vue中导出parse和compileTemplate两个方法。parse是解析内容的,compileTemplate是解析模板的。

const path = require('path')
const fs = require('fs').promises
const { resolveVue } = require('./util')
const defaultExportReg = /((?:^|\n|;)\s*)export default/
function vuePlugin({ app, root }) {
    app.use(async (ctx, next) => {
        if (!ctx.path.endsWith('.vue')) {
            return next()
        }
        //vue文件处理
        const filePath = path.join(root, ctx.path);
        const content = await fs.readFile(filePath, 'utf8')//.vue文件中的内容
        //获取文件内容
        //拿到模板编译模块进行编译
        let { parse, compileTemplate } = require(resolveVue(root).complier);//parse解析内容的,compileTemplate编辑模板的
        let { descriptor } = parse(content)//解析文件内容
        if (!ctx.query.type) {
            let code = ``
            if (descriptor.script) {//把export default变为const__script =
                let content = descriptor.script.content;
                let replaced = content.replace(defaultExportReg, '$1const __script =')
                code += replaced
            }
            if (descriptor.template) {
                const templateRequest = ctx.path + `?type=template`
                code += `\nimport { render as __render } from ${JSON.stringify(
                    templateRequest
                )}`
                code += `\n__script.render = __render`
            }
            ctx.type = 'js'
            code += `\nexport default __script`
            console.log(code)
            ctx.body = code
        }
        if (ctx.query.type == 'template') {
            ctx.type = 'js'
            let content = descriptor.template.content;
            const { code } = compileTemplate({ source: content })
            console.log(code)
            ctx.body = code
        }
    })
}
exports.vuePlugin = vuePlugin

• 在项目中配置本地启动命令(在全局环境可调用手写的vie进行打包)需要用到npm link • #! /usr/bin/env node 当前环境下,用node来执行此文件 • 项目中用到的包及其作用

作用
es-module-lexer 解析import语法为AST语法树。
magic-string 重写字符串的,将字符串转为对象再转为字符串
koa,koa-static 后端服务及静态文件导入

总结

大致这几步就可以实现一个简单的vite。可以去我git上拉下来运行跑一下,整体思路就是这样子的,尤大的中间件很多的,而且这个项目git上还在不断更新。在这里还要感谢一下珠峰的公开课,的确还是可以学到些东西的。