面向將來的前端構建工具-vite

前言

若是近期你有關注 Vue 的動態,就能發現 Vue 做者最近一直在搗鼓的新工具 vite。vite 1.0 目前已經進入了 rc 版本,立刻就要正式發佈 1.0 的版本了。幾個月前,尤雨溪就已經在微博介紹過了 vite ,是一個基於瀏覽器原生 ESM 的開發服務器。css

尤雨溪微博

早期 Webpack 剛出來的時候,是爲了解決低版本瀏覽器不支持 ESM 模塊化的問題,將各個分散的 JavaScript 模塊合併成一個文件,同時將多個 JavaScript 腳本文件合併成一個文件,減小 HTTP 請求的數量,有助於提高頁面首次訪問的速度。後期 Webpack 乘勝追擊,引入了 Loader、Plugin 機制,提供了各類構建相關的能力(babel轉義、css合併、代碼壓縮),取代了同期的 Browserify、Gulp。html

現在,HTTP/2 的盛行,HTTP/3 也即將發行,再加上 5G 網絡的商用,減小 HTTP 請求數量起到的做用已經微乎其微,並且新版的瀏覽器基本已經支持了 ESM(<script module>)。前端

JavaScript modules

上手 vite

vite 帶着它的歷史使命隨之出現。因爲省略了打包的過程,首次啓動 vite 的時候可謂秒開。能夠看下我錄製的 Gif 圖,徹底無需等待就能進入開發。vue

啓動 vite

想要嘗試 vite ,能夠直接經過以下命令:node

$ npm init vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev

npm init vite-app 命令會執行 npx create-vite-app,從 npm 上拉取 create-vite-app 模塊,而後經過對應的模板生成模板文件到指定文件夾。react

{
  "name": "vite-app",
  "version": "0.0.1",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "vue": "^3.0.0-rc.1"
  },
  "devDependencies": {
    "vite": "^1.0.0-rc.1",
    "@vue/compiler-sfc": "^3.0.0-rc.1"
  }
}

目前 vite 都是和 vue 3 搭配使用,若是要在 vue 2 使用 vite 估計還得等正式版發佈。固然,能上 vue 3 仍是上 vue 3 吧,不管性能、包大小還有 ts 加持方面,vue 3 都遠優於 vue 2 。除了 vue,vite 還提供了 react、preat 相關的模板。webpack

其餘模板

生成的 vue 項目的目錄結構以下。git

目錄結構

項目的入口爲 index.html,html 文件中直接使用了瀏覽器原生的 ESM(type="module") 能力。關於瀏覽器 ESM 能力的介紹,能夠閱讀我以前的文章《前端模塊化的此生》github

<script type="module" src="/src/main.js"></script>

全部的 js 文件通過 vite 處理後,其 import 的模塊路徑都會被修改,在前面加上 /@modules/。當瀏覽器請求 import 模塊的時候,vite 會在 node_modules 中找到對應的文件進行返回。web

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

請求

這樣就省略了打包的過程,大大提高了開發效率。固然 vite 也提供了生產模式,利用 Rollup 進行構建。

談談 snowpack

首次提出利用瀏覽器原生 ESM 能力的工具並不是是 vite,而是一個叫作 snowpack 的工具。snowpack 在發佈 1.0 以前,名字還叫作 @pika/web

snowpack rename

pika 團隊之因此要作 snowpack ,是由於 pika 致力於爲 web 應用提速 90%。

pika

因爲當前許多 web 應用都是在不一樣開源模塊的基礎上進行構建的,而這些開源模塊都被 webpack 之類的打包工具打成了一個包,若是這些開源模塊都來源於同一個 CDN 地址,且支持跨域緩存,那麼這些開源模塊都只須要加載一次,其餘網站用到了一樣的開源模塊,就不須要從新在下載,直接讀取本地緩存。

舉個例子,淘寶和天貓都是基於 react + redux + antd + loadsh 進行開發的,當我打開過淘寶以後,進入天貓這些開源模塊都不用從新下載,只須要下載天貓頁面相關的一些業務代碼便可。爲此,pika 專門創建了一個 CDN(skypack) 用了下載 npm 上的一些 esm 模塊。

後來 snowpack 發佈的時候,pika 團隊順便發表了一篇名爲《A Future Without Webpack》 的文章,告訴你們能夠嘗試拋棄 webpack,革 webpack 的命。

snowpack

在 vite 的 README 中也提到了在某些方面參考了 snowpack,而且列舉了 vite 與 snowpack 的異同。

Different

snowpack 如今已經發布到 v2 了,咱們能夠找到 v1 時期的源碼看看 snowpack 的早期實現。

源碼解析

在 github 上,根據 git tag 能夠找到 snowpack v1.0.0 的版本,下載下來發現好像有點 bug ,建議你們閱讀源碼的時候能夠跳到 v1.2.0(https://github.com/pikapkg/snowpack/tree/v1.2.0)。

package.json 中能夠看到,snowpack 經過他們團隊的 @pika/pack 進行打包,這個工具將打包流程進行了管道化,有點相似與 gulp,感興趣能夠了解了解,這裏重點仍是 snowpack 的原理。

{
  "scripts": {
    "build": "pika build"
  },
  // snowpack 的構建工具
  "@pika/pack": {
    "pipeline": [
      [
        "@pika/plugin-ts-standard-pkg"
      ],
      [
        "@pika/plugin-copy-assets"
      ],
      [
        "@pika/plugin-build-node"
      ],
      [
        "@pika/plugin-simple-bin",
        {
          // 經過 snowpack 運行命令
          "bin": "snowpack"
        }
      ]
    ]
  }
}

這裏咱們以 vue 項目爲例,使用 snowpack 運行一個 vue 2 的項目。目錄結構以下:

目錄結構

若是要在項目中引入 snowpack,須要在項目的 package.json 中,添加 snowpack 相關的配置,配置中比較重要的就是這個 snowpack.webDependencies,表示當前項目的依賴項,這兩個文件會被 snowpack 打包到 web_modules 目錄。

{
  "scripts": {
    "build": "snowpack",
    "start": "serve ./"
  },
  "dependencies": {
    "http-vue-loader": "^1.4.2",
    "vue": "^2.6.12"
  },
  "devDependencies": {
    "serve": "^11.3.2",
    "snowpack": "~1.2.0"
  },
  "snowpack": {
    "webDependencies": [
      "http-vue-loader",
      "vue/dist/vue.esm.browser.js"
    ]
  }
}

運行 npm run build 以後,會新生成一個 web_modules 目錄,該目錄下的文件就是咱們在 snowpack.webDependencies 中聲明的兩個 js 文件。

npm run build

web_modules

snowpack 運行的時候,會調用源碼 src/index.ts 中的 cli 方法,該方法的代碼刪減版以下:

// 精簡了部分代碼,若是想看完整版建議去 github
// https://github.com/pikapkg/snowpack/blob/v1.2.0/src/index.ts
const cwd = process.cwd();

export async function cli(args: string[]) {
  // 解析命令行參數
  const { dest = 'web_modules' } = yargs(args);
  // esm 腳本文件的輸出目錄,默認爲 web_modules
  const destLoc = path.resolve(cwd, dest);
  // 獲取 pkg.json
  const pkgManifest: any = require(path.join(cwd, 'package.json'));
  // 獲取 pkg.json 中的依賴模塊
  const implicitDependencies = [
    ...Object.keys(pkgManifest.dependencies || {}),
    ...Object.keys(pkgManifest.peerDependencies || {}),
  ];
  // 獲取 pkg.json 中 snowpack 相關配置
  const { webDependencies } = pkgManifest['snowpack'] || {
    webDependencies: undefined
  };

  const installTargets = [];
  // 須要被安裝的模塊,若是沒有該配置,會嘗試安裝全部 dependencies 內的模塊
  if (webDependencies) {
    installTargets.push(...scanDepList(webDependencies, cwd));
  } else {
    installTargets.push(...scanDepList(implicitDependencies, cwd));
  }
  // 模塊安裝
  const result = await install(installTargets, installOptions);
}

該方法會讀取項目的 package.json 文件,若是有 snowpack.webDependencies 配置,會優先安裝 snowpack.webDependencies 中聲明的模塊,若是沒有該配置,會把 dependenciesdevDependencies 中的模塊都進行安裝。全部的模塊名都會經過 scanDepList,轉化爲特定格式,而且會把glob語法的模塊名,通過 glob 還原成單個的文件。

import path from 'path';

function createInstallTarget(specifier: string): InstallTarget {
  return {
    specifier,
    named: [],
  };
}

export function scanDepList(depList: string[], cwd: string): InstallTarget[] {
  // 獲取 node_modules 路徑
  const nodeModules = path.join(cwd, 'node_modules');
  return depList
    .map(whitelistItem => {
        // 判斷文件名是否爲 glob 語法 (e.g. `vue/*.js`)
      if (!glob.hasMagic(whitelistItem)) {
        return [createInstallTarget(whitelistItem)];
      } else {
        // 轉換 glob 路徑
        return scanDepList(glob.sync(whitelistItem,{cwd: nodeModules}), cwd);
      }
    })
      // 將全部文件合併成一個數組
    .reduce((flat, item) => flat.concat(item), []);
}

最後,全部的模塊會通過 install 進行安裝。

install

// 移除 .js、.mjs 後綴
function getWebDependencyName(dep: string): string {
  return dep.replace(/\.m?js$/i, '');
}

// 獲取模塊的類型以及絕對路徑
function resolveWebDependency(dep: string): {
  type: 'JS' | 'ASSET';
  loc: string;
} {
  var packagePattern = new RegExp('^(?:@([^/]+?)[/])?([^/]+?)$')
  // 若是帶有擴展名,且非 npm 模塊,直接返回
  if (path.extname(dep) && !packagePattern.test(dep)) {
    const isJSFile = ['.js', '.mjs', '.cjs'].includes(path.extname(dep));
    return {
      type: isJSFile ? 'JS' : 'ASSET',
      // 還原絕對路徑
      loc: require.resolve(dep, {paths: [cwd]}),
    };
  }
  // 若是是 npm 模塊,須要查找模塊對應的 package.json 文件
  const manifestPath = `${cwd}/node_modules/${dep}/package.json`;
  const manifestStr = fs.readFileSync(manifestPath, {encoding: 'utf8'});
  const depManifest = JSON.parse(manifestStr);
  // 而後讀取 package.json 中的 module屬性、browser屬性
  let foundEntrypoint: string =
    depManifest['browser:module'] || depManifest.module || depManifest.browser;
  if (!foundEntrypoint) {
    // 若是都不存在就取 main 屬性
    foundEntrypoint = depManifest.main || 'index.js';
  }
  return {
    type: 'JS',
    // 還原絕對路徑
    loc: path.join(`${cwd}/node_modules/${dep}`, foundEntrypoint),
  };
}

// 模塊安裝
function install(installTargets, installOptions) {
  const {
    destLoc
  } = installOptions;
  // 使用 set 將待安裝模塊進行一次去重
  const allInstallSpecifiers = new Set(installTargets.map(dep => dep.specifier));
  
  // 模塊查找轉化
  for (const installSpecifier of allInstallSpecifiers) {
    // 移除 .js、.mjs 後綴
    const targetName = getWebDependencyName(installSpecifier);
    // 獲取文件類型,以及文件絕對路徑
    const {type: targetType, loc: targetLoc} = resolveWebDependency(installSpecifier);
    if (targetType === 'JS') {
      // 腳本文件
      const hash = await generateHashFromFile(targetLoc);
      // 添加到腳本依賴對象
      depObject[targetName] = targetLoc;
      importMap[targetName] = `./${targetName}.js?rev=${hash}`;
      installResults.push([installSpecifier, true]);
    } else if (targetType === 'ASSET') {
      // 靜態資源
      // 添加到靜態資源對象
      assetObject[targetName] = targetLoc;
      installResults.push([installSpecifier, true]);
    }
  }
  
  if (Object.keys(depObject).length > 0) {
    // 經過 rollup 打包文件
    const packageBundle = await rollup.rollup({
        input: depObject,
      plugins: [
        // rollup 插件
        // 這裏能夠進行一些 babel 轉義、代碼壓縮之類的操做
        // 還能夠將一些 commonjs 的模塊轉化爲 ESM 模塊
      ]
    });
    // 文件輸出到 web_modules 目錄
    await packageBundle.write({
        dir: destLoc,
    });
  }

  // 拷貝靜態資源
  Object.entries(assetObject).forEach(([assetName, assetLoc]) => {
    mkdirp.sync(path.dirname(`${destLoc}/${assetName}`));
    fs.copyFileSync(assetLoc, `${destLoc}/${assetName}`);
  });

  return true;
}

基本原理已經分析完畢,下面看一看實際案例。咱們在 html 中經過 type="module" 的 script 標籤引入 index.js 做爲入口文件。

<!DOCTYPE html>
<html lang="en">
  <title>snowpack-vue-httpvueloader</title>
  <link rel="stylesheet" href="./assets/style.css">

  <body>
    <h1>snowpack - Vue Example</h1>
    <div id="app"></div>
    <script type="module" src="./js/index.js"></script>
  </body>
</html>

而後在 index.js 中, import 在 webDependenies 中聲明的兩個 js 文件,而且在以前加上 /web_modules

import Vue from '/web_modules/vue/dist/vue.esm.browser.js'
import httpVueLoader from '/web_modules/http-vue-loader.js'

Vue.use(httpVueLoader)

new Vue({
  el: '#app',
  components: {
    app: 'url:./components/app.vue',
  },
  template: '<app></app>',
})

最後經過 npm run start ,使用 serve 起一個 node 服務就能夠正常訪問了。

能夠看到 snowpack v1 的功能總體比較簡陋,只是將須要依賴的模塊從 node_modules 中提取到了 web_modules 中,中間經過 rollup 進行了一次編譯。這裏引入 rollup 主要是爲了對 js 代碼作一些壓縮優化,還有將某些 commonjs 的模塊轉化爲 ESM 的模塊。

可是最後還須要藉助第三方模塊來啓動 node 服務,當時官方還熱心的告訴你能夠選擇哪些第三方模塊來提供服務。

server

v2 版本已經支持內部啓用一個 node server 來開發,而不須要藉助,並且能夠進行熱更新。固然 v2 版本除了 js 模塊還提供了 css 模塊的支持。

vite 原理

在瞭解了 snowpack v1 的源碼後,再回過頭看看 vite 的原理。仍是按照以前的方式,追溯到 vite v0.1.1,代碼量較少的時候,看看 vite 的思路。

vite 在啓動時,內部會啓一個 http server,用於攔截頁面的腳本文件。

// 精簡了熱更新相關代碼,若是想看完整版建議去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/server.ts
import http, { Server } from 'http'
import serve from 'serve-handler'

import { vueMiddleware } from './vueCompiler'
import { resolveModule } from './moduleResolver'
import { rewrite } from './moduleRewriter'
import { sendJS } from './utils'

export async function createServer({
  port = 3000,
  cwd = process.cwd()
}: ServerConfig = {}): Promise<Server> {
  const server = http.createServer(async (req, res) => {
    const pathname = url.parse(req.url!).pathname!
    if (pathname.startsWith('/__modules/')) {
      // 返回 import 的模塊文件
      return resolveModule(pathname.replace('/__modules/', ''), cwd, res)
    } else if (pathname.endsWith('.vue')) {
      // 解析 vue 文件
      return vueMiddleware(cwd, req, res)
    } else if (pathname.endsWith('.js')) {
      // 讀取 js 文本內容,而後使用 rewrite 處理
      const filename = path.join(cwd, pathname.slice(1))
      const content = await fs.readFile(filename, 'utf-8')
      return sendJS(res, rewrite(content))
    }

    serve(req, res, {
      public: cwd,
      // 默認返回 index.html
      rewrites: [{ source: '**', destination: '/index.html' }]
    })
  })

  return new Promise((resolve, reject) => {
    server.on('listening', () => {
      console.log(`Running at http://localhost:${port}`)
      resolve(server)
    })

    server.listen(port)
  })
}

訪問 vite 服務的時候,默認會返回 index.html。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico" />
  <title>Vite App</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

處理 js 文件

html 文件會請求 /src/main.js, vite 服務在返回 js 文件的時候,會使用 rewrite 方法對 js 文件內容進行一次替換。

if (pathname.endsWith('.js')) {
  // 讀取 js 文本內容,而後使用 rewrite 處理
  const filename = path.join(cwd, pathname.slice(1))
  const content = await fs.readFile(filename, 'utf-8')
  return sendJS(res, rewrite(content))
}
// 精簡了部分代碼,若是想看完整版建議去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/moduleRewriter.ts
import { parse } from '@babel/parser'

export function rewrite(source: string, asSFCScript = false) {
  // 經過 babel 解析,找到 import from、export default 相關代碼
  const ast = parse(source, {
    sourceType: 'module',
    plugins: [
      'bigInt',
      'optionalChaining',
      'nullishCoalescingOperator'
    ]
  }).program.body

  let s = source
  ast.forEach((node) => {
    if (node.type === 'ImportDeclaration') {
      if (/^[^\.\/]/.test(node.source.value)) {
        // 在 import 模塊名稱前加上 /__modules/
        // import { foo } from 'vue' --> import { foo } from '/__modules/vue'
        s = s.slice(0, node.source.start) 
          + `"/__modules/${node.source.value}"`
            + s.slice(node.source.end) 
      }
    } else if (asSFCScript && node.type === 'ExportDefaultDeclaration') {
      // export default { xxx } -->
      // let __script; export default (__script = { xxx })
      s = s.slice(0, node.source.start)
        + `let __script; export default (__script = ${
              s.slice(node.source.start, node.declaration.start) 
               })`
        + s.slice(node.source.end) 
      s.overwrite(
        node.start!,
        node.declaration.start!,
        `let __script; export default (__script = `
      )
      s.appendRight(node.end!, `)`)
    }
  })

  return s.toString()
}

html 文件請求 /src/main.js, 通過 vite 處理後,結果以下:

- import { createApp } from 'vue'
+ import { createApp } from '/__modules/vue'
import App from './App.vue'

createApp(App).mount('#app')

main.js

處理 npm 模塊

瀏覽器解析完 main.js 以後,會讀取其中的 import 模塊,進行請求。請求的文件若是是 /__modules/ 開頭的話,代表是一個 npm 模塊,vite 會使用 resolveModule 方法進行處理。

// fetch /__modules/vue
if (pathname.startsWith('/__modules/')) {
  // 返回 import 的模塊文件
  return resolveModule(pathname.replace('/__modules/', ''), cwd, res)
}
// 精簡了部分代碼,若是想看完整版建議去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/moduleResolver.ts
import path from 'path'
import resolve from 'resolve-from'
import { sendJSStream } from './utils'
import { ServerResponse } from 'http'

export function resolveModule(id: string, cwd: string, res: ServerResponse) {
  let modulePath: string
  modulePath = resolve(cwd, 'node_modules', `${id}/package.json`)
  if (id === 'vue') {
    // 若是是 vue 模塊,返回 vue.runtime.esm-browser.js
    modulePath = path.join(
      path.dirname(modulePath),
      'dist/vue.runtime.esm-browser.js'
    )
  } else {
    // 經過 package.json 文件,找到須要返回的 js 文件
    const pkg = require(modulePath)
    modulePath = path.join(path.dirname(modulePath), pkg.module || pkg.main)
  }

  sendJSStream(res, modulePath)
}

處理 vue 文件

main.js 除了獲取框架代碼,還 import 了一個 vue 組件。若是是 .vue 結尾的文件,vite 會經過 vueMiddleware 方法進行處理。

if (pathname.endsWith('.vue')) {
  // 解析 vue 文件
  return vueMiddleware(cwd, req, res)
}
// 精簡了部分代碼,若是想看完整版建議去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/vueCompiler.ts

import url from 'url'
import path from 'path'
import { parse, SFCDescriptor } from '@vue/compiler-sfc'
import { rewrite } from './moduleRewriter'

export async function vueMiddleware(
  cwd: string, req, res
) {
  const { pathname, query } = url.parse(req.url, true)
  const filename = path.join(cwd, pathname.slice(1))
  const content = await fs.readFile(filename, 'utf-8')
  const { descriptor } = parse(content, { filename }) // vue 模板解析
  if (!query.type) {
    let code = ``
    if (descriptor.script) {
      code += rewrite(
        descriptor.script.content,
        true /* rewrite default export to `script` */
      )
    } else {
      code += `const __script = {}; export default __script`
    }
    if (descriptor.styles) {
      descriptor.styles.forEach((s, i) => {
        code += `\nimport ${JSON.stringify(
          pathname + `?type=style&index=${i}`
        )}`
      })
    }
    if (descriptor.template) {
      code += `\nimport { render as __render } from ${JSON.stringify(
        pathname + `?type=template`
      )}`
      code += `\n__script.render = __render`
    }
    sendJS(res, code)
    return
  }
  if (query.type === 'template') {
    // 返回模板
  }
  if (query.type === 'style') {
    // 返回樣式
  }
}

通過解析,.vue 文件返回的時候會被拆分紅三個部分:script、style、template。

// 解析前
<template>
  <div>
    <img alt="Vue logo" src="./assets/logo.png" />
    <HelloWorld msg="Hello Vue 3.0 + Vite" />
  </div>
</template>

<script>
import HelloWorld from "./components/HelloWorld.vue";

export default {
  name: "App",
  components: {
    HelloWorld
  }
};
</script>
// 解析後
import HelloWorld from "/src/components/HelloWorld.vue";

let __script;
export default (__script = {
    name: "App",
    components: {
        HelloWorld
    }
})

import {render as __render} from "/src/App.vue?type=template"
__script.render = __render

template 中的內容,會被 vue 解析成 render 方法。關於 vue 模板是如何編譯成 render 方法的,能夠看個人另外一篇文章:《Vue 模板編譯原理》

import {
  parse,
  SFCDescriptor,
  compileTemplate
} from '@vue/compiler-sfc'

export async function vueMiddleware(
  cwd: string, req, res
) {
  // ...
  if (query.type === 'template') {
    // 返回模板
    const { code } = compileTemplate({
      filename,
      source: template.content,
    })
    sendJS(res, code)
    return
  }
  if (query.type === 'style') {
    // 返回樣式
  }
}

模板

而 template 的樣式

import {
  parse,
  SFCDescriptor,
  compileStyle,
  compileTemplate
} from '@vue/compiler-sfc'

export async function vueMiddleware(
  cwd: string, req, res
) {
  // ...
  if (query.type === 'style') {
    // 返回樣式
    const index = Number(query.index)
    const style = descriptor.styles[index]
    const { code } = compileStyle({
      filename,
      source: style.content
    })
    sendJS(
      res,
      `
  const id = "vue-style-${index}"
  let style = document.getElementById(id)
  if (!style) {
    style = document.createElement('style')
    style.id = id
    document.head.appendChild(style)
  }
  style.textContent = ${JSON.stringify(code)}
    `.trim()
    )
  }
}

style 的處理也不復雜,拿到 style 標籤的內容,而後 js 經過建立一個 style 標籤,將樣式添加到 head 標籤中。

小結

這裏只是簡單的解析了 vite 是如何攔截請求,而後返回須要的文件的過程,省略了熱更新的代碼。並且待發布 vite v1 除了啓動服務用來開發,還支持了 rollup 打包,輸出生產環境代碼的能力。

總結

vite 剛剛發佈的時候,還只能作 vue 的配套工具使用,如今已經支持了 JSX、TypeScript、Web Assembly、PostCSS 等等一系列能力。咱們就靜靜的等待 vue3 和 vite 的正式版發佈吧,到底能不能革了 webpack 的命,就看天意了。

對了,vite 和 vue 同樣,來自法語,中文是「快」的意思。

vite翻譯

相關文章
相關標籤/搜索