Getting started with Vite starts with handwriting a beggar's version of Vite (Part 1)

"I signed up to participate in the 1st challenge of the Golden Stone Project - to share the 100,000 prize pool, this is my first article, click to view the details of the event "

I don’t need to say more about what ViteVue is . Friends who have used it must know it. In this article, I will Vitelearn about Vitethe basic implementation principle by writing a very simple beggar version. The reference is the Viteearliest version ( vite-1.0.0-rc.5version, Vueversion 3.0.0-rc.10). , it is already the 3.xcurrent version, why not directly refer to the latest version, because it is difficult to understand the source code of this relatively complete tool as soon as it comes up, anyway, the author can't, so we can first spy on the principle from the earliest version, Strong friends can be ignored~

This article will be divided into two parts, the first one mainly discusses how to successfully run the project, and the second one mainly discusses hot updates.

Front-end test project

The front-end test project structure is as follows:

VueThe component uses Options Apino csspreprocessing language, tsetc. js, so it is a very simple project. Our goal is very simple, that is, to write a Viteservice to make this project run!

Build basic services

viteThe basic structure of the service is as follows:

First let's start a service, the HTTPapplication framework we use connect :

// app.js
const connect = require("connect");
const http = require("http");

const app = connect();

app.use(function (req, res) {
  res.end("Hello from Connect!\n");
});

http.createServer(app).listen(3000);
复制代码

The next thing we need to do is to intercept various types of requests for different processing.

intercept html

The entry address of the project access is http://localhost:3000/index.html, so the first request received is the request for the htmlfile, we can directly return htmlthe content of the file for the time being:

// app.js
const path = require("path");
const fs = require("fs");

const basePath = path.join("../test/");
const typeAlias = {
  js: "application/javascript",
  css: "text/css",
  html: "text/html",
  json: "application/json",
};

app.use(function (req, res) {
  // 提供html页面
  if (req.url === "/index.html") {
    let html = fs.readFileSync(path.join(basePath, "index.html"), "utf-8");
    res.setHeader("Content-Type", typeAlias.html);
    res.statusCode = 200;
    res.end(html);
  } else {
    res.end('')
  }
});
复制代码

Now the access page is definitely still blank, because main.jswe have not processed the request initiated by the page, and main.jsthe content is as follows:

Intercept js requests

main.js请求需要做一点处理,因为浏览器是不支持裸导入的,所以我们要转换一下裸导入的语句,将import xxx from 'xxx'转换为import xxx from '/@module/xxx',然后再拦截/@module请求,从node_modules里获取要导入的模块进行返回。

解析导入语句我们使用es-module-lexer

// app.js
const { init, parse: parseEsModule } = require("es-module-lexer");

app.use(async function (req, res) {
    if (/\.js\??[^.]*$/.test(req.url)) {
        // js请求
        let js = fs.readFileSync(path.join(basePath, req.url), "utf-8");
        await init;
        let parseResult = parseEsModule(js);
        // ...
    }
});
复制代码

解析的结果为:

解析结果为一个数组,第一项也是个数组代表导入的数据,第二项代表导出,main.js没有,所以是空的。se代表导入来源的起止位置,ssse代表整个导入语句的起止位置。

接下来我们检查当导入来源不是./开头的就转换为/@module/xxx的形式:

// app.js
const MagicString = require("magic-string");

app.use(async function (req, res) {
    if (/\.js\??[^.]*$/.test(req.url)) {
        // js请求
        let js = fs.readFileSync(path.join(basePath, req.url), "utf-8");
        await init;
        let parseResult = parseEsModule(js);
        let s = new MagicString(js);
        // 遍历导入语句
        parseResult[0].forEach((item) => {
            // 不是裸导入则替换
            if (item.n[0] !== "." && item.n[0] !== "/") {
                s.overwrite(item.s, item.e, `/@module/${item.n}`);
            }
        });
        res.setHeader("Content-Type", typeAlias.js);
        res.statusCode = 200;
        res.end(s.toString());
    }
});
复制代码

修改js字符串我们使用了magic-string,从这个简单的示例上你应该能发现它的魔法之处,就是即使字符串已经变了,但使用原始字符串计算出来的索引修改它也还是正确的,因为索引还是相对于原始字符串。

可以看到vue已经成功被修改成/@module/vue了。

紧接着我们需要拦截一下/@module请求:

// app.js
const { buildSync } = require("esbuild");

app.use(async function (req, res) {
    if (/^\/@module\//.test(req.url)) {
        // 拦截/@module请求
        let pkg = req.url.slice(9);
        // 获取该模块的package.json
        let pkgJson = JSON.parse(
            fs.readFileSync(
                path.join(basePath, "node_modules", pkg, "package.json"),
                "utf8"
            )
        );
        // 找出该模块的入口文件
        let entry = pkgJson.module || pkgJson.main;
        // 使用esbuild编译
        let outfile = path.join(`./esbuild/${pkg}.js`);
        buildSync({
            entryPoints: [path.join(basePath, "node_modules", pkg, entry)],
            format: "esm",
            bundle: true,
            outfile,
        });
        let js = fs.readFileSync(outfile, "utf8");
        res.setHeader("Content-Type", typeAlias.js);
        res.statusCode = 200;
        res.end(js);
    }
})
复制代码

我们先获取了包的package.json文件,目的是找出它的入口文件,然后读取并使用esbuild进行转换,当然Vue是有ES模块的产物的,但是可能有的包没有,所以直接就统一处理了。

拦截css请求

css请求有两种,一种来源于link标签,一种来源于import方式,link标签的css请求我们直接返回css即可,但是importcss直接返回是不行的,ES模块只支持js,所以我们需要转成js类型,主要逻辑就是手动把css插入页面,所以这两种请求我们需要分开处理。

为了能区分import请求,我们修改一下前面拦截js的代码,把每个导入来源都加上?import查询参数:

// ...
	// 遍历导入语句
    parseResult[0].forEach((item) => {
      // 不是裸导入则替换
      if (item.n[0] !== "." && item.n[0] !== "/") {
        s.overwrite(item.s, item.e, `/@module/${item.n}?import`);
      } else {
        s.overwrite(item.s, item.e, `${item.n}?import`);
      }
    });
//...
复制代码

拦截/@module的地方也别忘了修改:

// ...
let pkg = removeQuery(req.url.slice(9));// 从/@module/vue?import中解析出vue
// ...

// 去除url的查询参数
const removeQuery = (url) => {
  return url.split("?")[0];
};
复制代码

这样import的请求就都会带上一个标志:

然后根据这个标志来分别处理css请求:

// app.js

app.use(async function (req, res) {
    if (/\.css\??[^.]*$/.test(req.url)) {
        // 拦截css请求
        let cssRes = fs.readFileSync(
            path.join(basePath, req.url.split("?")[0]),
            "utf-8"
        );
        if (checkQueryExist(req.url, "import")) {
            // import请求,返回js文件
            cssRes = `
                const insertStyle = (css) => {
                    let el = document.createElement('style')
                    el.setAttribute('type', 'text/css')
                    el.innerHTML = css
                    document.head.appendChild(el)
                }
                insertStyle(\`${cssRes}\`)
                export default insertStyle
            `;
            res.setHeader("Content-Type", typeAlias.js);
        } else {
            // link请求,返回css文件
            res.setHeader("Content-Type", typeAlias.css);
        }
        res.statusCode = 200;
        res.end(cssRes);
    }
})

// 判断url的某个query名是否存在
const checkQueryExist = (url, key) => {
  return new URL(path.resolve(basePath, url)).searchParams.has(key);
};
复制代码

如果是import导入的css那么就把它转换为js类型的响应,然后提供一个创建style标签并插入到页面的方法,并且立即执行,那么这个css就会被插入到页面中,一般这个方法会被提前注入页面。

如果是link标签的css请求直接返回css即可。

拦截vue请求

最后,就是处理Vue单文件的请求了,这个会稍微复杂一点,处理Vue单文件我们使用@vue/compiler-sfc3.0.0-rc.10版本,首先需要把Vue单文件的templatejsstyle三部分解析出来:

// app.js
const { parse: parseVue } = require("@vue/compiler-sfc");

app.use(async function (req, res) {
    if (/\.vue\??[^.]*$/.test(req.url)) {
    // Vue单文件
    let vue = fs.readFileSync(
      path.join(basePath, removeQuery(req.url)),
      "utf-8"
    );
    let { descriptor } = parseVue(vue);
  }
})
复制代码

然后再分别解析三部分,templatecss部分会转换成一个import请求。

处理js部分

// ...
const { compileScript, rewriteDefault } = require("@vue/compiler-sfc");

let code = "";
// 处理js部分
let script = compileScript(descriptor);
if (script) {
    code += rewriteDefault(script.content, "__script");
}
复制代码

rewriteDefault方法用于将export default转换为一个新的变量定义,这样我们可以注入更多数据,比如:

// 转换前
let js = `
    export default {
        data() {
            return {}
        }
    }
`

// 转换后
let js = `
    const __script = {
        data() {
            return {}
        }
    }
`

//然后可以给__script添加更多属性,最后再手动添加到导出即可
js += `\n__script.xxx = xxx`
js += `\nexport default __script`
复制代码

处理template部分

// ...
// 处理模板
if (descriptor.template) {
    let templateRequest = removeQuery(req.url) + `?type=template`;
    code += `\nimport { render as __render } from ${JSON.stringify(
        templateRequest
    )}`;
    code += `\n__script.render = __render`;
}
复制代码

将模板转换成了一个import语句,然后获取导入的render函数挂载到__script上,后面我们会拦截这个type=template的请求,返回模板的编译结果。

处理style部分

// ...
// 处理样式
if (descriptor.styles) {
    descriptor.styles.forEach((s, i) => {
        const styleRequest = removeQuery(req.url) + `?type=style&index=${i}`;
        code += `\nimport ${JSON.stringify(styleRequest)}`
    })
}
复制代码

和模板一样,样式也转换成了一个单独的请求。

最后导出__script并返回数据:

// ...
// 导出
code += `\nexport default __script`;
res.setHeader("Content-Type", typeAlias.js);
res.statusCode = 200;
res.end(code);
复制代码

可以看到__script其实就是一个Vue的组件选项对象,模板部分编译的结果就是组件的渲染函数render,相当于把js和模板部分组合成一个完整的组件选项对象。

处理模板请求

Vue单文件的请求url存在type=template参数,我们就编译一下模板然后返回:

// app.js
const { compileTemplate } = require("@vue/compiler-sfc");

app.use(async function (req, res) {
    if (/\.vue\??[^.]*$/.test(req.url)) {
        // vue单文件
        // 处理模板请求
        if (getQuery(req.url, "type") === "template") {
            // 编译模板为渲染函数
            code = compileTemplate({
                source: descriptor.template.content,
            }).code;
            res.setHeader("Content-Type", typeAlias.js);
            res.statusCode = 200;
            res.end(code);
            return;
        }
        // ...
    }
})

// 获取url的某个query值
const getQuery = (url, key) => {
  return new URL(path.resolve(basePath, url)).searchParams.get(key);
};
复制代码

处理样式请求

样式和前面我们拦截样式请求一样,也需要转换成js然后手动插入到页面:

// app.js
const { compileTemplate } = require("@vue/compiler-sfc");

app.use(async function (req, res) {
    if (/\.vue\??[^.]*$/.test(req.url)) {
        // vue单文件
    }
    // 处理样式请求
    if (getQuery(req.url, "type") === "style") {
        // 获取样式块索引
        let index = getQuery(req.url, "index");
        let styleContent = descriptor.styles[index].content;
        code = `
            const insertStyle = (css) => {
                let el = document.createElement('style')
                el.setAttribute('type', 'text/css')
                el.innerHTML = css
                document.head.appendChild(el)
            }
            insertStyle(\`${styleContent}\`)
            export default insertStyle
        `;
        res.setHeader("Content-Type", typeAlias.js);
        res.statusCode = 200;
        res.end(code);
        return;
    }
})
复制代码

样式转换为js的这个逻辑因为有两个地方用到了,所以我们可以提取成一个函数:

// app.js
// css to js
const cssToJs = (css) => {
  return `
    const insertStyle = (css) => {
        let el = document.createElement('style')
        el.setAttribute('type', 'text/css')
        el.innerHTML = css
        document.head.appendChild(el)
    }
    insertStyle(\`${css}\`)
    export default insertStyle
  `;
};
复制代码

修复单文件的裸导入问题

单文件内的js部分也可以导入模块,所以也会存在裸导入的问题,前面介绍了裸导入的处理方法,那就是先替换导入来源,所以单文件的js部分解析出来以后我们也需要进行一个替换操作,我们先把替换的逻辑提取成一个公共方法:

// 处理裸导入
const parseBareImport = async (js) => {
  await init;
  let parseResult = parseEsModule(js);
  let s = new MagicString(js);
  // 遍历导入语句
  parseResult[0].forEach((item) => {
    // 不是裸导入则替换
    if (item.n[0] !== "." && item.n[0] !== "/") {
      s.overwrite(item.s, item.e, `/@module/${item.n}?import`);
    } else {
      s.overwrite(item.s, item.e, `${item.n}?import`);
    }
  });
  return s.toString();
};
复制代码

然后编译完js部分后立即处理一下:

// 处理js部分
let script = compileScript(descriptor);
if (script) {
    let scriptContent = await parseBareImport(script.content);// ++
    code += rewriteDefault(scriptContent, "__script");
}
复制代码

另外,编译后的模板部分代码也会存在一个裸导入Vue,也需要处理一下:

// 处理模板请求
if (
    new URL(path.resolve(basePath, req.url)).searchParams.get("type") ===
    "template"
) {
    code = compileTemplate({
        source: descriptor.template.content,
    }).code;
    code = await parseBareImport(code);// ++
    res.setHeader("Content-Type", typeAlias.js);
    res.statusCode = 200;
    res.end(code);
    return;
}
复制代码

处理静态文件

App.vue里面引入了两张图片:

编译后的结果为:

ES模块Only jsfiles can be imported, so the import of static files, the response result also needs to be js:

// vite/app.js
app.use(async function (req, res) {
    if (isStaticAsset(req.url) && checkQueryExist(req.url, "import")) {
        // import导入的静态文件
        res.setHeader("Content-Type", typeAlias.js);
        res.statusCode = 200;
        res.end(`export default ${JSON.stringify(removeQuery(req.url))}`);
    }
})

// 检查是否是静态文件
const imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/;
const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/;
const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/i;
const isStaticAsset = (file) => {
  return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file);
};
复制代码

importThe processing of imported static files is very simple. You can directly urlexport the strings of the static files as the default.

In this way, we will receive two requests for static files:

For simplicity, we consider static files that do not match any of the above rules, and use serve-static to serve static files:

// vite/app.js
const serveStatic = require("serve-static");

app.use(async function (req, res, next) {
    if (xxx) {
        // xxx
    } else if (xxx) {
        // xxx
        // ...
    } else {
        next();// ++
    }
})

// 静态文件服务
app.use(serveStatic(path.join(basePath, "public")));
app.use(serveStatic(path.join(basePath)));
复制代码

The middleware of the static file service is placed at the end, so that the routes that are not matched will come here. The effect of this step is as follows:

You can see that the page has been loaded.

In the next article, we will introduce the implementation of hot updates, See you later~

Guess you like

Origin juejin.im/post/7142878515380092959