preface

Webpack has dominated the front-end packaging world for some time now, with new projects being packaged in WebPack except for older ones. However, The Times will always replace, like a dozen years ago with Nokia, MOTOROLA with such a keyboard mobile phone, now has been difficult to find traces. Now everyone has a smartphone with an entire screen on the front. With browser support for native ESM modules getting better and better, tools like Rollup and Vite, which use ESM modules directly, have become popular in the community in recent years and are expected to challenge webPack’s dominance. In this article we use a simple project to illustrate how Vite works and why it is so fast.

1. Initialize the project

Start by initializing a project

npm init -y
Copy the code

And install some dependencies

npm i react react-dom chokidar // Production environment dependency
npm i esno express ws chalk -D // Test environment dependencies
// esno is used to execute JS files written using the ESM module specification
// ws is the Websocket library on node
// chokidar is used to listen for file changes
// Chalk is used to set the style of console text
Copy the code

Then add an NPM script command to package.json

./package.json
{
...
  "scripts": {"dev":"esno src/opt-command.js && esno src/dev-command.js" // serial execution}... }Copy the code

This startup command will execute two js files in sequence, which we will create one by one in the following summary. Make a list of things to do.

  1. Start a local Node service
  2. An HTML template is returned when the service is accessed
  3. The header of this HTML is stuffed with a JS file that uses webSocket to receive file changes (i.e. hot updates) from the server.
  4. Handle other requests from clients on the server side
  5. The server listens for file changes and uses websocket to inform the client

It’s a little abstract, but let’s take it one step at a time

2. Set up the server

Create a SRC folder in the project root directory and create dev-command-js in SRC and import the required dependencies.

// /src/dev-command.js
import express from 'express';
import { createServer } from 'http';
import { join, extname, posix } from 'path'
import { readFileSync } from 'fs';
import chokidar from 'chokidar' // A library to listen for file changes
import WebSocket from 'ws'; // The server websocket library
import { transformCode, transformCss, transformJSX } from './transform'; // We need to write this document ourselves
Copy the code

Start a local service

// /src/dev-command.js
async function dev(){
  const app = express()
  ...
  const server = createServer(app) // createServer comes from the built-in HTTP module of Node
  const port = 3333
  server.listen(port, () = > {
    console.log('App is running at 127.0.0.1:', port)
  })
}
dev().catch(console.error)
Copy the code

When we type 127.0.0.1:3333 in the address bar to return an HTML file, the HTML file doesn’t bother to write itself. Create a React project with vite and use the generated HTML file directly. Execute in a new folder

yarn create vite
Copy the code

Select React in this step

Create a target folder in our project root directory and copy the HTML files generated by the Vite project and all files in the SRC directory.

// This is the newly generated Vite project
|-vite-project
  |-index.html
  |-package.json
  |-src
  |  |-App.css
  |  |-App.jsx
  |  |-favicon.svg
  |  |-index.css
  |  |-logo.svg
  |  |-main.jsx
  |-vite.config.js
  |-yarn.lock
Copy the code

Demonstrate the directory structure of the project

// This is the current directory structure of the project after the copy is completed
|-myvite
  |-package.json
  |-src
  |  |-dev.command.js
  |-target
  |  |-App.css
  |  |-App.jsx
  |  |-index.css
  |  |-index.html
  |  |-logo.svg
  |  |-main.jsx
  |-yarn.lock
Copy the code

The HTML file is then returned

// ./src/dev-command.js.async function dev(){
  const app = express()
  app.get('/'.(req, res) = > {
    res.set('Content-type'.'text/html') // Set the response header
    const htmlPath = join(__dirname, '.. /target'.'index.html') // Path to the HTML file
    let html = readFileSync(htmlPath, 'utf-8') // Read the HTML file
    html = html.replace('<head>'.'<head> \n <script type="module" src="/@myvite/client"></script>').trim()
    send(html)
  })
  ...
}
dev().catch(console.error)
Copy the code

When the browser parses the script tag, it sends a request to the SRC address, and we return it a JS file.

// ./src/dev-command.js.async function dev(){... app.get('/@myvite/client'.(req, res) = > {
    res.set('Content-Type'.'application/javascript')
    res.send(
      transformCode({
        code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
      }).code
    )
  })
Copy the code

This js file that’s returned, and I’ll talk about that in a second. At the end of the body of the HTML there is also a JAVASCRIPT for rendering the page, so we’ll deal with that first.

<! DOCTYPEhtml>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />// Delete this line. This SVG can be deleted as well<meta name="viewport" content="Width = device - width, initial - scale = 1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>// change SRC to /target/main.jsx since we copied all files to target</body>
</html>
Copy the code

The browser will fetch main.jsx, but the browser doesn’t know what JSX is, so we need to convert JSX to JS and process all the other files along the way.

// /src/dev-command.js.async function dev(){...// All files under target are hit
  app.get('/target/*'.(req, res) = > {
    // Get the full path to the file
    // req.path >>> /target/main.jsx
    const filePath = join(__dirname, '.. ', req.path.slice(1))
    
    switch(extname(req.path)){ // extName is deconstructed from fs
      case '.svg': // Process SVG files
        res.set('Content-Type'.'image/svg+xml')
        res.send(
          readfileSync(filepath, 'utf-8'))break
      case '.css': // Process CSS files
        res.set('Content-Type'.'application/javascript')
        res.send(
          transformCss({
            path: req.path,
            code: readfileSync(filepath), 'utf-8')}))break
      default: // Process JSX files
        res.set('Content-Type'.'application/javascript')
        res.send(
          transformJSX({
            path: req.path,
            code: readfileSync(filepath), 'utf-8')}))break}... })Copy the code

The dev-command file will be written here first, and the rest of the logic will be added later.

3. Convert files of various types

When you parse the script tag in HTML, you go to the default branch of switch at the end of section 2

<script type="module" src="/target/main.jsx"></script>
Copy the code

Create a new transform.js file in the SRC directory and try to convert JSX to browser-aware js. The esbuild library was not installed when the project was initialized. Where did it come from? What does it do? It is not hard to imagine that some dependency of the installation must depend on this package. The answer is that ESno has introduced esBuild as a dependency. Esbuild, written in Golang, is better at compiling JS than js writing tools, and vite uses this library internally when translating JSX.

// /src/transform.js
import { transformSync } from 'esbuild';
import { extname, dirname, join } from 'path'
// Encapsulate a utility function that returns the converted content of esBuild
export function transformCode({code, loader}){
  return transformSync(code, { // jsx -> js
    loader: loader || 'js'.sourcemap: true.format: 'esm'})}function transformJSX(opts){
  const {appRoot, code, path} = opts
  const ext = extname(path).slice(1) // jsx
  const ret = transformCode({
    loader: ext, code }) ... }...export {
  transformJSX,
  transformCss,
  transformCode
}
Copy the code

Esbuild successfully converted JSX to JS, essentially converting Angle bracket tags to React. CreateElement.

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App.jsx";
ReactDOM.render(/* @__PURE__ */ React.createElement(React.StrictMode, null./* @__PURE__ */ React.createElement(App, null)), document.getElementById("root"));
Copy the code

Now the question is whether the top import can be successfully introduced. Remember that every import browser sends a request to retrieve the resource. For browsers, “react”, “react-dom”, “./index.css” means “. So the next step after converting to JS is to process these imports. take a broad pain re 😁 and match the import statement. Replace the second argument is a function. 😭

// /src/transform.js.function transformJSX(opts){...const {code} = ret
  // Reverse prelookup, non-capture grouping, backreference... Fill in a wave of regular knowledge
  code = code.replace(
    /\bimport(? ! \s+type)(? :[\w*{}\r\n\t, ]+from\s*)? \s*("([^']+)"|'([^"]+)')/gm.(a, b, c, d, e, f) = >{
    // This function is called every time there is a successful match. Because there are only three capture groups, there are six valid line parameters.
    // If there are no capture groups, there are three valid parameters (excluding b, c, d)
    // The return of the function will be used to replace the string matched by the re
    // A indicates the matched string
    // b is the content captured by the first capture group
    // c is the content captured by the second capture group
    // d is the content captured by the third capture group
    // e is the index position of the matched string in the original string
    // f is the original string. })}...Copy the code

Write a separate code block, after all, the above content needs to digest 😢. Ok next decide which files are inside the project such as ‘./index.css’ and which are third-party dependencies such as ‘react’. How do you tell? To be blunt, start with a ‘. ‘as an in-project file, otherwise as a third party dependency.

// /src/transform.js.function transformJSX(opts){...const { code } = ret
  code = code.replace(
    /\bimport(? ! \s+type)(? :[\w*{}\r\n\t, ]+from\s*)? \s*("([^']+)"|'([^"]+)')/gm.(a, b, c, d, e, f) = >{
      // For example
      // a import App from './App.jsx'
      // b './App.jsx'
      // c ./App.jsx
      
      let realFrom // Replace the content after the original from with this
      if(c.charAt(0) = = ='. ') {// Project files
        / / opts. The path is/target/main. The JSX
        // Return /target/ after calling dirname
        // Target/app.jsx after join
        realFrom = join(dirname(opts.path), c) 
        
        // If it is an SVG file, the purpose of the tag will be discussed below
        if(['svg'].includes(extname(realFrom).slice(1))){
          realFrom = `${realFrom}? import`}}else{ // Third-party dependencies
      
        // There is no.cache folder under taget
        realFrom = `/target/.cache/${c}/cjs/${c}.development.js`
      }
      
      // The content returned, for example
      // import App from '/target/App.jsx'
      return a.relace(b, ` '${realFrom}'`)})// Finally return all the js code strings that import has processed
  return code
}
Copy the code

Now that we have the JSX file function, we have the CSS file transformCss. This should be easy to understand, but what the returned JS code does is create a style tag, set the content to the CSS code, and stuff it into the head.

// /src/transform.js.function transformCss(opts){
  return `
    const css = "${opts.code.replace(/\n/g.' ')}"
    
    const styleTag = document.createElement('style')
    styleTag.setAttribute('type', 'text/css')
    styleTag.innerHTML = css
    
    document.head.appendChild(styleTag)
  `.trim()
}
Copy the code

Ok, now come back to why SVG files are marked.

// If it is an SVG file, the purpose of the tag will be discussed now
if(['svg'].includes(extname(realFrom).slice(1))){
  realFrom = `${realFrom}? import`
}
Copy the code

In the browser, we can only import js files. Importing SVG files generates the following error.

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "image/svg+xml". Strict MIME type checking is enforced for module scripts per HTML spec.
Copy the code

So we put it in the back, right? Import When the browser parses to the following line, it sends a request.

import logo from '/target/logo.svg? import'
Copy the code

When we receive this request, we simply return a JS file.

// /src/dev-command.js.async function dev(){... app.get('/target/*'.(req, res) = >{...// Capture the SVG file you just tagged here
    if('import' in req.query){
      res.set('Content-Type'.'application/javascript')
      res.send(`export default '${req.path}'`) 
      // Returns a string of js code
      return
      
    }
    
    switch(extname(req.path)){ // extName is deconstructed from fs. }... })Copy the code

Note that the js string we return to the browser is exported as a string ‘/target/logo.svg’. The following logo is actually the string.

// /target/App.jsx

import React from 'react';
import './App.css';
import logo from './logo.svg'; // Target /logo.svg' /target/logo.svg'

function App() {
  const [count, setCount] = React.useState(0);

  return( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p>Hello Vite  + React! </p> ...Copy the code

After the logo is passed in as the SRC attribute of img, the browser requests the resource and enters the case below. Then the page will display the image normally.

// /src/dev-command.js.async function dev(){... app.get('/target/*'.(req, res) = >{...switch(extname(req.path)){ // extName is deconstructed from fs
      case '.svg': // Process SVG files
        res.set('Content-Type'.'image/svg+xml')
        res.send(
          readfileSync(filepath, 'utf-8'))break. }... })Copy the code

4. Handle third-party dependencies

After processing the various types of files in the project, there is a hole to fill, which is what the. Cache folder under target is for. Create the.cache folder manually under /target

}else{ // Third-party dependencies
      
// There is no.cache folder in taget
realFrom = `/target/.cache/${c}/cjs/${c}.development.js`
}
Copy the code

Remember the scripts in package.json, we configured them this way. Dev.mand. Js is done, opt.mand. Js is not. Opt, by the way, is short for optimize.

"scripts": {
  "dev": "esno src/opt.command.js && esno src/dev.command.js"
},
Copy the code

Create opt.command. Js under SRC with only one async iife.

// /src/opt.command.js
import { esbuild } from 'esbuild' // Enter again
import { join } form 'path'

const appRoot = join(__dirname, '.. ') // Get the project root directory
const cache = join(appRoot, 'target'.'.cache')

(function async (){
  const dep = ['react'.'react-dom'] // List of dependencies that need to be handled
  const ep = dep.reduce((a, b) = > {
    a.push(join(appRoot, 'node_modules', b, `cjs/${b}.development.js`))
    return a
  }, [])
  await esbuild({ // You should be able to guess the effect from the attribute name, refer to the esbuild website
    entryPoints: ep, 
    bundle: true.format: 'esm'.logLevel: 'error'.splitting: true.sourcemap: true.outdir: cache, 
    treeShaking: 'ignore-annotations'.metafile: true.define: {"process.env.NODE_ENV": JSON.stringify("development")}})}) ()Copy the code

After executing the js file, the.cache folder will generate the dependency files packaged according to the ESM specification.

5. Hot update

Hot update is a more practical function, this seemingly magic function, if you think about it is not difficult to consider. The first thing you have to do is listen for file changes, so how do you listen for file changes? That’s where the chokidar library comes in. Chokidar has been introduced in the dev.mand.js file and starts listening when the service is started.

// /src/dev.command.js.import chokidar from 'chokidar'

const targetRootPath = join(__dirname, '.. /target') // target Specifies the absolute path to the folder
(async function dev(){...const watcher = chokidar.watch(targetRootPath, {
    ignored: ['**/node_modules**'.'**/.catch/**'].// Ignore the directory
    ignoreInitial: true.ignorePremissionErrors: true.disableGlobbing: true
  })
  // When the file changes
  watcher.on('change'.(file) = >{
    // TODO
  })
})()
Copy the code

Ok so what do YOU do when your file changes? Note that you are now listening for file changes on the server side. But the code is ultimately running on the client, the browser, so how can the server actively inform the client when it hears changes? Websocket on the server we use the WS library. The following code creates an instance of webSocket.

// /src/dev.command.js.import WebSocket from 'ws'
import chalk from 'chalk'.function createWebSocketServer(server){
  const wss = new WebSoket.Server({noServer : true}) // Allow WebSocket servers to be completely detached from HTTP/S servers
  // Server is an instance of the service that listens for the upgrade event and processes the upgrade request
  server.on('upgrade'.(req, socket, head) = > {
    // Verify that the websocket matches the client. The websocket of the client will be created later
    if(req.headers['sec-websocket-protocol'= = ='vite-hmr'){
      wss.handleUpgrade(req, socket, head, (ws) = > { // Call handleUpgrade manually in noServer mode
        wss.emit('connection', ws, req)
      })
    }
    // Listen for connection setup events and register handlers
    wss.on('connection'.(websocket) = > {
      // Send data to the client
      websocket.send(JSON.stringify({type: 'connected'}})))// Listen for error events and register handlers
    wss.on('error'.(e) = > {
      if(e.code ! = ='EADDRINUSE') {console.log(chalk.red(`WebSocket server error: \n ${e.stack || e.message}`))}})// Finally return an object containing two methods: send to send data to the client and close to close the WebSocket
    return {
      send(payload){
        const stringified = JSON.stringify(payload)
        // Walk through all the clients
        wss.clients.forEach( client= > {
          client.send(stringified)
        })
      },
      close(){
        wss.close()
      }
    }
  })
}
...
Copy the code

Pass the server into createWebSocketServer and call handleHMRUpdate when you listen for changes to the file

// /src/dev.command.js. (async function dev(){...const server = createServer(app) // App is an instance of Express, and createServer is deconstructed from the HTTP module
  const wsMethods = createWebSocketServer(server)
  // When the file changes
  watcher.on('change'.(file) = >{
    handleHMRUpdate({file, wsMethods})
  })
  const port = '3333'
  server.listen(port, () = >{
    console.log('App is running at 127.0.0.1:' + port)
  })
})()
Copy the code

In the handleHMRUpdate, the updated information is sent to the client.

// /src/dev.command.js.const targetRootPath = join(__dirname, '.. /target')
function handleHMRUpdate({file, wsMethods}){
  // Get the file name
  const shortName = file.startsWith(targetRootPath + '/')? posix.relative(targetRootPath, file) :Posix is also deconstructed from the PATH module as a compatibility solution
    file
  ;const timestamp = Date.now()
  
  let updates
  if(shortName.endsWith('.css') || shortName.endsWith('.jsx')){
    updates = {
      type: 'js-update',
      timestamp,
      path: ` /${shortName}`.acceptedPath: ` /${shortName}`}}// Send to the client
  weMethods.send({
    type: 'update',
    updates
  })
}
...
Copy the code

We are finally at the end of this step, and we still need a logic to handle the hot updates returned by the server. Remember when we were dealing with the return of an HTML file, the head plug script tag

// ./src/dev-command.js.async function dev(){
  const app = express()
  app.get('/'.(req, res) = > {
    res.set('Content-type'.'text/html') // Set the response header
    const htmlPath = join(__dirname, '.. /target'.'index.html') // Path to the HTML file
    let html = readFileSync(htmlPath, 'utf-8') // Read the HTML file
    html = html.replace('<head>'.'<head> \n <script type="module" src="/@myvite/client"></script>').trim()
    send(html)
  })
  ...
}
dev().catch(console.error)
Copy the code

When the browser parses the script tag and requests resources from SRC, we return a JS file named Client.

// ./src/dev-command.js.async function dev(){
  app.get('/@myvite/client'.(req, res) = > {
    res.set('Content-Type'.'application/javascript')
    res.send(
      transformCode({
        code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
      }).code
    )
  })
  ...
}
Copy the code

We create a client.js file in the SRC directory.

// /src/client.js
// Create a websocket on the client with the protocol of vite-hMR to successfully establish a connection with the server.
// Why? You can search for vite-hMR on the page with CTRL/Command + F
const socket = new WebSocket(`ws://${location.host}`.'vite-hmr')
// Get the data
socket.addEventListener('message'.({data}) = >{
  handleMessage(JSON.parse(data)).catch(console.error)
})
// Process data
async function(payload){
  switch(payload.type){
    case 'connected':
      console.log('connected')
      break
    case 'update': // Process the updates
      payload.updates.forEach(async (update) => {
        if(update.type === 'js-update') {// if you don't understand the meaning of type, you can also search for js-update :)
          console.log('js update... ')
          await import(`/target/${update.path}? t=${update.timestamp}`) // Dynamic loading
          
          location.reload()
        }
      })
      break}}Copy the code

So that’s all the code we have here.

conclusion

As you can see, the entire page is finally refreshed with location.reload, without dynamically replacing the changed parts…… I need to learn more about this, but as I said in the beginning, I’m sure you have a good idea of why Vite is so fast.