<meter id="blnnv"></meter>

                  社區精選|Vite 入門,從手寫一個乞丐版的Vite開始(上)

                  業界 作者:SegmentFault 2023-02-28 12:29:20

                  今天小編為大家帶來的是社區作者?街角小林?的文章,讓我們一起來學習 Vite。


                  前言



                  Vite 是什么就不用筆者多說了,用過 Vue 的朋友肯定都知道,本文會通過手寫一個非常簡單的乞丐版 Vite 來了解一下 Vite 的基本實現原理,參考的是 Vite 最早的版本(vite-1.0.0-rc.5 版本,Vue 版本為 3.0.0-rc.10)實現的,現在已經是 3.x 的版本了,為什么不直接參考最新的版本呢,因為一上來就看這種比較完善的工具源碼比較難看懂,反正筆者不行,所以我們可以先從最早的版本來窺探一下原理,能力強的朋友可以忽略~

                  本文會分為上下兩篇,上篇主要討論如何成功運行項目,下篇主要討論熱更新。

                  前端測試項目


                  前端測試項目結構如下:


                  Vue 組件使用的是 Options Api ,不涉及到 css 預處理語言、ts 等 js 語言,所以是一個非常簡單的項目,我們的目標很簡單,就是要寫一個 Vite 服務讓這個項目能運行起來!

                  搭建基本服務


                  vite 服務的基本結構如下:


                  首先讓我們來起個服務,HTTP 應用框架我們使用 connect:https://github.com/senchalabs/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:https://github.com/guybedford/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 沒有,所以是空的。s、e 代表導入來源的起止位置,ss、se 代表整個導入語句的起止位置。


                  接下來我們檢查當導入來源不是.或/開頭的就轉換為/@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 https://esbuild.github.io/?進行轉換,當然 Vue 是有 ES 模塊的產物的,但是可能有的包沒有,所以直接就統一處理了。


                  攔截 css 請求


                  css 請求有兩種,一種來源于 link 標簽,一種來源于 import 方式,link 標簽的 css 請求我們直接返回 css 即可,但是 import 的 css 直接返回是不行的,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-sfc 的 3.0.0-rc.10 版本,首先需要把 Vue 單文件的 template、js、style 三部分解析出來:

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

                  然后再分別解析三部分,template 和 css 部分會轉換成一個 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~



                  點擊左下角閱讀原文,到?SegmentFault 思否社區?和文章作者展開更多互動和交流,“公眾號后臺回復“?入群?”即可加入我們的技術交流群,收獲更多的技術文章~

                  -?END -

                  關注公眾號:拾黑(shiheibook)了解更多

                  贊助鏈接:

                  關注數據與安全,洞悉企業級服務市場:https://www.ijiandao.com/
                  四季很好,只要有你,文娛排行榜:https://www.yaopaiming.com/
                  讓資訊觸達的更精準有趣:https://www.0xu.cn/

                  公眾號 關注網絡尖刀微信公眾號
                  隨時掌握互聯網精彩
                  贊助鏈接
                  一级毛片丰满奶头出奶水,国产成人精品午夜福利2010,亚洲欧美激情精品一区二区,色欲av无码蜜臀AV免费播放,夜夜爽夜夜叫夜夜高潮漏水,av无码aV高潮αV喷吹免费,无码vr熟妇人妻AV在线影片