my-webpack 实验,简单写一个webpack

my-webpack 实验,简单写一个webpack

个人学习作品,如有错误,欢迎指正!

首先,无论是哪个打包工具,核心内容都是:输入输出、依赖解析、文件处理,所以..

先创个空文件夹,跑个 npm init 搞个仪式感..

我的初始 package.json belike:

json 复制代码
{
  "name": "test",
  "version": "1.0.0",
  "description": "just for test",
  "main": "index.js",
  "scripts": {
    "build": "node config.mjs"
  },
  "keywords": [
    "test"
  ],
  "author": "Yanko1013",
  "license": "ISC"
}

再创个 config.js 到目录,它和执行、配置相关,就准备开始啦。

目录结构:

text 复制代码
my-webpack/
├── src/
│   ├── index.html
│   ├── main.js
│   └── utils.js
├── dist/
├── extensions/
│   ├── loader.mjs
│   ├── plugin.mjs
├── config.mjs
├── customize.json
└── package.json

config.mjs 将作为打包的配置文件和程序入口,所以记得在 package.json 里写入 "build": "node config.mjs"。

extensions/ 里放 loader 和 plugin。

我的参数 customize.json:

json 复制代码
{
  "baseDir": "./src",
  "entryFile": "./src/main.js",
  "outputPath": "./dist",
  "htmlTemplateFile": "./src/index.html",
  "bundleName": "index.js"
}

打包目标

打包前,开个服务器访问 xxx/src/index.html

功能很简单,就是 点击按钮输出 "Hello World"

此时,我们打开 Network 选项卡,会看到加载了

静态资源加载了 index.html, main.js, utils.js,那么我们先定一个小目标:只加载一个 js 文件。

main.js:

js 复制代码
import Hello from './utils.js'

export function runClick() {
  const hello = new Hello('World')
  document.querySelector('p').innerHTML =  hello.sayHello()
}
// 挂载 window
window.runClick = runClick

utils.js:

js 复制代码
class Hello {
  constructor(target) {
    this.target = target
  }

  sayHello() {
    return `Hello ${this.target}!`
  }
}

export default Hello

在 index.html 引入 main.js:

html 复制代码
<body>
  <button onclick="runClick()">click me</button>
  <p></p>
  <script type="module" src="./main.js"></script>
</body>

简单分析下要做什么

打包目标:只加载一个且经过压缩的 js 文件。

那么我们需要...

  • 从入口文件开始,找一共用了多少文件(依赖解析)
  • 对代码做压缩、转换
  • 结尾工作

上面是 loader 用武之处,那么再引入 plugin 的话,我选择模仿 HtmlWebpackPlugin,打包到最后创建一个新的 html 文件输出。

那么我的配置大概写成这样.. 完整代码可以看 源码 部分。

另外引入了 Proxy 封装生命周期对象,只是简单记录下时间。

js 复制代码
import fs from 'fs'
import path from 'path'
import esprima from 'esprima'
import esquery from 'esquery'
import { myHtmlWebpackPlugin } from './extensions/plugin.mjs'
import { tranCode } from './extensions/loader.mjs'
import customize from './customize.json' assert { type: 'json' }

class MyWebpack {
  constructor(baseDir, entryFile, outputPath, htmlTemplateFile, bundleName) {}

  initLifeCycle() {}

  getCostTime() {}

  // 主入口
  async run() {
    try {
      // 第一步 解析依赖
      const refMap = await this.getReference()

      // 第二步 扁平化依赖
      const refString = await this.flattenReference(refMap)

      // 第三步 代码转换、压缩。应用 Loader
      const codeString = await applyLoaders(refString)

      // 第四步 输出新 js
      await this.outputJs(codeString)

      // 第五步 收尾,应用 plugin
      await this.done()

    } catch (err) {
      console.error(err)
    }
  }
}

const myWebpack = new MyWebpack(baseDir, entryFile, outputPath, htmlTemplateFile, bundleName)
await myWebpack.run()

依赖解析

依赖解析需要从入口开始,递归到最后一层。那么就避免不了解析 import xxx from xxx 了。

这里我选用 esprima, esquery。前者把js代码解析成 AST,后者查询 AST。

最终把入口和它的依赖,作为 key-value 存在一个 map 里。

js 复制代码
// 递归找依赖
// 用了 esprima 解析 js -> ast ; esquery 查询 ast
async getRefByArray(filePath) {
  let refArray = new Array()
  refArray.push(filePath)

  const code = fs.readFileSync(filePath, 'utf-8'); // string
  const ast = esprima.parseModule(code); // object
  const importDeclarations = esquery(ast, 'ImportDeclaration'); // array

  // 本层的 import
  const imports = importDeclarations.map(declaration => declaration.source.value);
  // 递归
  for (let i = 0; i < imports.length; i++) {
    const importPath = imports[i]
    const fullPath = path.join(baseDir, path.basename(importPath))
    const result = await this.getRefByArray(fullPath)
    refArray.push(result)
  }

  return refArray
}

我们现在得知了依赖信息,下一步就是整理了

整理代码/整理资源

因为最终是一个文件,所以我先把代码都弄到一块... 从依赖信息(数组)遍历,获取所有字符串拼接即可。

js 复制代码
// 扁平,代码全部归到一个文件
async flattenReference(refMap) {
  let refArr = refMap.get(this.entryFile)
  refArr = refArr.flat(refArr.length)
  // 反转一下,让依赖排前面,避免找不到
  refArr = refArr.reverse()

  let result = ''
  for (let i = 0; i < refArr.length; i++) {
    const code = fs.readFileSync(refArr[i], 'utf-8');
    result = result.concat('\n', code)
  }

  return result
}

现在所有代码都在一个字符串变量里了,开始清理无用代码啦

应用 loader

写一个 loader.mjs 放在 extensions/里,我的处理步骤:

  1. 先以换行符,切割整个字符串成数组
  2. 由于变成一个文件,自然 import, export 等语句要被删除和修改。
  3. 去除注释语句
  4. 去除多余空格、换行符
  5. 特殊处理
  6. 拼接返回

我的 loader.mjs:

js 复制代码
// 转成一个上下文;去除不必要符号和文本
export async function tranCode(refString) {
  let res = ""
  const arr = refString.split('\n')

  for(let i=0; i<arr.length; i++) {
    const trim = arr[i].trim()
    let line = trim
    if(trim.startsWith('import')) {
      line = ''
    }
    if(trim.startsWith('//')) {
      line = ''
    }
    if(trim.startsWith('export')) {
      if(!trim.includes('function')) {
        line = ''
      } else {
        line = trim.replace(/^export\s+/, '')
      }
    }
    // 特殊处理:如果是 "document",前面需要加个 ';'
    if(trim.startsWith('document')) {
      line = ';' + line
    }
    res += line.trim()
  }
  
  return res
}

然后就把得到的 js 用 fs 模块写到导出目录里即可。

大概结果是这样

js 复制代码
class Hello {constructor(target) {this.target = target}sayHello() {return `Hello ${this.target}!`}}function runClick() {const hello = new Hello('World');document.querySelector('p').innerHTML =  hello.sayHello()}window.runClick = runClick

结尾工作

应用 plugin

现在我们得到了一个 js 文件,自然也要有 html 文件啦。

写一个 plugin.mjs,放在 extensions/

替换本次打包实例的生命周期函数,读取一个模版,然后替换或者加入打包出来的 js 文件路径,再输出即可。

js 复制代码
import fs from 'fs'
import path from 'path'

// 模拟输出 html 文件
export async function myHtmlWebpackPlugin(myWebpackInstance) {
  // 用 bind 处理 this 指向
  myWebpackInstance.done = createHtml.bind(myWebpackInstance)
}

async function createHtml() {
  const originalHtml = fs.readFileSync(this.htmlTemplateFile, 'utf-8');
  const entryFileName = this.entryFile.split('/').at(-1)
  
  let handledHtml = originalHtml.replace(entryFileName, this.bundleName)
  const filePath = path.join(this.outputPath, 'index.html')

  fs.writeFile(filePath, handledHtml, 'utf8', (err) => {
    if (err) {
      console.error(err);
    } else {
      console.log('Succeed!');
    }
  })
}

打包结果

执行 npm run build

访问 xxx/dist/index.html

好!现在只加载一个 js 了,并且占用空间大大降低

时间还行,10毫秒内完成(毕竟文件少又小)

源码

GitHub_my-webpack

我的完全配置文件 config.mjs:

js 复制代码
import fs from 'fs'
import path from 'path'
import esprima from 'esprima'
import esquery from 'esquery'
import { myHtmlWebpackPlugin } from './extensions/plugin.mjs'
import { tranCode } from './extensions/loader.mjs'
import customize from './customize.json' assert { type: 'json' }

class MyWebpack {
  constructor(baseDir, entryFile, outputPath, htmlTemplateFile, bundleName) {
    this.baseDir = baseDir
    this.entryFile = entryFile
    this.outputPath = outputPath
    this.htmlTemplateFile = htmlTemplateFile
    this.bundleName = bundleName

    this.lifeCycle = this.initLifeCycle()
  }

  initLifeCycle() {
    const target = {
      status: 'pending',
      timeRecord: new Map()
    }
    const handler = {
      get(target, property) {
        return target[property]
      },
      // 记下各阶段开启时间
      set(target, property, value) {
        const curTime = performance.now()
        target.timeRecord.set(value, curTime)
        target[property] = value
        return true
      }
    }
    return new Proxy(target, handler)
  }

  getCostTime() {
    return Array.from(this.lifeCycle.timeRecord)
  }

  /**
   * 目标:
   * 1. 所有 js 变成一个 js
   * main.js     ├── index.js
   *  - utils.js │
   * 
   * 2. 创建新 html 文件,并应用新的 js 文件
   * 
   * 这只是我个人的想法哦,仅供参考
   */
  async run() {
    try {
      // 第一步 解析依赖
      this.lifeCycle.status = 'beforeGetReference'
      const refMap = await this.getReference()

      // 第二步 扁平化依赖
      this.lifeCycle.status = 'beforeFlattenReference'
      const refString = await this.flattenReference(refMap)

      // 第三步 代码转换、压缩。应用 Loader
      this.lifeCycle.status = 'beforeTranCode'
      const codeString = await applyLoaders(refString)

      // 第四步 输出新 js
      this.lifeCycle.status = 'beforeOutputJs'
      await this.outputJs(codeString)

      // 第五步 收尾,应用 plugin
      this.lifeCycle.status = 'beforeDone'
      await this.done()

      this.lifeCycle.status = 'done'

    } catch (err) {
      console.error(err)
    }
  }

  async getReference() {
    const map = new Map()
    // 想法是,以 entryFile 作为key,值为本文件引用的依赖
    // Map<string, string[]>
    const refArray = await this.getRefByArray(this.entryFile)
    map.set(this.entryFile, refArray)
    return map
  }

  // 递归找依赖
  // 用了 esprima 解析 js -> ast ; esquery 查询 ast
  async getRefByArray(filePath) {
    let refArray = new Array()
    refArray.push(filePath)

    const code = fs.readFileSync(filePath, 'utf-8'); // string
    const ast = esprima.parseModule(code); // object
    const importDeclarations = esquery(ast, 'ImportDeclaration'); // array

    // 本层的 import
    const imports = importDeclarations.map(declaration => declaration.source.value);
    // 递归
    for (let i = 0; i < imports.length; i++) {
      const importPath = imports[i]
      const fullPath = path.join(baseDir, path.basename(importPath))
      const result = await this.getRefByArray(fullPath)
      refArray.push(result)
    }

    return refArray
  }

  // 扁平,代码全部归到一个文件
  async flattenReference(refMap) {
    let refArr = refMap.get(this.entryFile)
    refArr = refArr.flat(refArr.length)
    // 反转一下,让依赖排前面,避免找不到
    refArr = refArr.reverse()

    let result = ''
    for (let i = 0; i < refArr.length; i++) {
      const code = fs.readFileSync(refArr[i], 'utf-8');
      result = result.concat('\n', code)
    }

    return result
  }

  // 创建 js 文件 把转换后的代码写入
  async outputJs(codeString) {
    const filePath = path.join(this.outputPath, this.bundleName)
    fs.writeFile(filePath, codeString, 'utf8', (err) => {
      if (err) {
        console.error(err);
      }
    })
  }

  async done() { }
}

// 导入配置
const {
  baseDir,
  entryFile,
  outputPath,
  htmlTemplateFile,
  bundleName
} = customize

const myWebpack = new MyWebpack(baseDir, entryFile, outputPath, htmlTemplateFile, bundleName)

// 装载 loader
// 目前只有一个 Loader,未来更新会试着写多几个。
async function applyLoaders(codeString) {
  return await tranCode(codeString)
  /* ...more loader... */
}

// 装载 plugin
myHtmlWebpackPlugin(myWebpack)

await myWebpack.run()

console.log(myWebpack.getCostTime())

未来计划

这玩具还挺好玩,我打算再多玩一下:

  1. Loader: Class 转成 Function
  2. 导入图片,仿 url-loader
  3. uglify JS
  4. ...

感谢阅读!如有错误,欢迎指正噢!

相关推荐
LV技术派5 分钟前
【成为 AI Native Builder · 02】什么是 AI Native Builder:一个人 + AI,交付完整闭环
前端·程序员·ai编程
#麻辣小龙虾#25 分钟前
网页布局css中flex+margin的组合布局技巧
开发语言·前端·javascript
叼烟扛炮28 分钟前
Linux 学习笔记(四十五):Web 安全基石——Cookie、Session 与 HTTPS 原理解析
linux·前端·网络·笔记·学习·tcp/ip·安全
环境栈笔记35 分钟前
指纹浏览器安卓怎么用?从运行位置到功能验收,先把三种方案分清
前端·人工智能·后端·自动化
计算机魔术师1 小时前
Anthropic IPO 招股文件披露近 9000 亿美元隐性债务
前端
chi11301 小时前
【无标题】
前端·javascript·python
IT_陈寒2 小时前
JavaScript实战技巧总结:深挖数组遍历的性能陷阱与实战解法
前端·人工智能·后端
步行cgn2 小时前
为什么既要 web.xml 又要 springmvc.xml?能不能只配一个?
xml·前端·hive
风骏时光牛马2 小时前
大模型综合能力评测:多维度性能与对齐效果对比
前端
winfredzhang2 小时前
从零手写一个毛玻璃风格的页面搭建台:单文件 HTML 实现组件放置、面板磁吸与图层管理
前端·html