关于前端:Vite入门从手写一个乞丐版的Vite开始上

36次阅读

共计 9339 个字符,预计需要花费 24 分钟才能阅读完成。

Vite 是什么就不必笔者多说了,用过 Vue 的敌人必定都晓得,本文会通过手写一个非常简单的乞丐版 Vite 来理解一下 Vite 的根本实现原理,参考的是 Vite 最早的版本(vite-1.0.0-rc.5版本,Vue版本为 3.0.0-rc.10)实现的,当初曾经是3.x 的版本了,为什么不间接参考最新的版本呢,因为一上来就看这种比较完善的工具源码比拟难看懂,反正笔者不行,所以咱们能够先从最早的版本来窥探一下原理,能力强的敌人能够疏忽~

本文会分为高低两篇,上篇次要探讨如何胜利运行我的项目,下篇次要探讨热更新。

前端测试项目

前端测试项目构造如下:

Vue组件应用的是 Options Api,不波及到css 预处理语言、tsjs 语言,所以是一个非常简单的我的项目,咱们的指标很简略,就是要写一个 Vite 服务让这个我的项目能运行起来!

搭建根本服务

vite服务的根本构造如下:

首先让咱们来起个服务,HTTP利用框架咱们应用 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);

接下来咱们须要做的就是拦挡各种类型的申请来进行不同的解决。

拦挡 html

我的项目拜访的入口地址是 http://localhost:3000/index.html,所以接到的第一个申请就是html 文件的申请,咱们临时间接返回 html 文件的内容即可:

// 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('')
  }
});

当初拜访页面必定还是一片空白,因为页面发动的 main.js 的申请咱们还没有解决,main.js的内容如下:

拦挡 js 申请

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 模块 只能导入 js 文件,所以动态文件的导入,响应后果也须要是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);
};

import导入的动态文件解决很简略,间接把动态文件的 url 字符串作为默认导出即可。

这样咱们又会收到两个动态文件的申请:

简略起见,没有匹配到以上任何规定的咱们都认为是动态文件,应用 serve-static 来提供动态文件服务即可:

// 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)));

动态文件服务的中间件放到最初,这样没有匹配到的路由就会走到这里,到这一步成果如下:

能够看到页面曾经被加载进去。

下一篇咱们会介绍一下热更新的实现,See you later~

正文完
 0