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. ...

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

相关推荐
牛奔34 分钟前
npm淘宝镜像证书过期
前端·npm·node.js
rgrgrwfe34 分钟前
前端内网开发npm 安装的几种方法
前端·npm·node.js
m0_748248773 小时前
【前端 Uniapp】使用Vant打造Uniapp项目(避坑版)
前端·uni-app
深海的鲸同学 luvi3 小时前
高德地图离线加载解决方案(内网部署)+本地地图瓦片加载
前端·javascript·html5
码字哥4 小时前
EasyExcel设置表头上面的那种大标题(前端传递来的大标题)
java·服务器·前端
GIS好难学6 小时前
《Vue进阶教程》第六课:computed()函数详解(上)
前端·javascript·vue.js
nyf_unknown6 小时前
(css)element中el-select下拉框整体样式修改
前端·css
m0_548514776 小时前
前端打印功能(vue +springboot)
前端·vue.js·spring boot
执键行天涯6 小时前
element-plus中的resetFields()方法
前端·javascript·vue.js
Days20506 小时前
uniapp小程序增加加载功能
开发语言·前端·javascript