资讯专栏INFORMATION COLUMN

用100行代码,完成自己的前端构建工具!

haitiancoder / 1469人阅读

摘要:行代码,你将拥有一个现代化规范测试驱动高延展性的前端构建工具。在阅读前,给大家一个小悬念什么是链式操作中间件机制如何读取构建文件树如何实现批量模板渲染代码转译如何实现中间件间数据共享。函数将参数中的挂载到上,并返回以便于链式操作即可。

ES2017+,你不再需要纠结于复杂的构建工具技术选型。

也不再需要gulp,grunt,yeoman,metalsmith,fis3。

以上的这些构建工具,可以脑海中永远划掉。

100行代码,你将透视构建工具的本质。

100行代码,你将拥有一个现代化、规范、测试驱动、高延展性的前端构建工具。

在阅读前,给大家一个小悬念:

什么是链式操作、中间件机制?

如何读取、构建文件树?

如何实现批量模板渲染、代码转译?

如何实现中间件间数据共享。

相信学完这一课后,你会发现————这些专业术语,背后的原理实在。。。太简单了吧!

构建工具体验:弹窗+uglify+模板引擎+babel转码...

如果想立即体验它的强大功能,可以命令行输入npx mofast example,将会构建一个mofast-example文件夹。

进入文件后运行node compile,即可体验功能。

顺便说一句,npx mofast example命令行本身,也是用本课的构建工具实现的。——是不是不可思议?

本课程代码已在npm上进行发布,直接安装即可

npm i mofast -D即可在任何项目中使用mofast,替代gulp/grunt/yeoman/metalsmith/fis3进行安装使用。

本课程github地址为: https://github.com/wanthering... 在学完课程后,你就可以提交PR,一起维护这个库,使它的扩展性越来越强!

第一步:搭建github/npm标准开发栈

请搭建好以下环境:

jest 测试环境

eslint 格式标准化环境

babel es2017代码环境

或者直接使用npx lunz mofast

然后一路回车。

构建出的文件系统如下

├── .babelrc
├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── README.md
├── circle.yml
├── package.json
├── src
│   └── index.js
├── test
│   └── index.spec.js
└── yarn.lock
第二步: 搭建文件沙盒环境

构建工具,都需要进行文件系统的操作。

在测试时,常常污染本地的文件系统,造成一些重要文件的意外丢失和修改。

所以,我们往往会为测试做一个“沙盒环境”

在package.json同级目录下,输入命令

 mkdir __mocks__ && touch __mocks__/fs.js
 
 yarn add memfs -D
 yarn add fs-extra

创建__mocks__/fs.js文件后,写入:

const { fs } = require("memfs")
module.exports = fs

然后在测试文件index.spec.js的第一行写下:

jest.mock("fs")
import fs from "fs-extra"
解释一下: __mocks__中的文件将自动加载到测试的mock环境中,而通过jest.mock("fs"),将覆盖掉原来的fs操作,相当于整个测试都在沙盒环境中运行。
第三步:一个类的基础配置

src/index.js

import { EventEmitter } from "events"

class Mofast extends EventEmitter {
  constructor () {
    super()
    this.files = {}
    this.meta = {}
  }

  source (patterns, { baseDir = ".", dotFiles = true } = {}) {
    // TODO: parse the source files
  }

  async dest (dest, { baseDir = ".", clean = false } = {}) {
    // TODO: conduct to dest
  }
}

const mofast = () => new Mofast()

export default mofast

使用EventEmitter作为父类,是因为需要emit事件,以监控文件流的动作。

使用this.files保存文件链。

使用this.meta 保存数据。

在里面写入了source方法,和dest方法。使用方法如下:

test/index.spec.js

import fs from "fs-extra"
import mofast from "../src"
import path from "path"

jest.mock("fs")

// 准备原始模板文件
const templateDir = path.join(__dirname, "fixture/templates")
fs.ensureDirSync(templateDir)
fs.writeFileSync(path.join(templateDir, "add.js"), `const add = (a, b) => a + b`)


test("main", async ()=>{
  await mofast()
    .source("**", {baseDir: templateDir})
    .dest("./output", {baseDir: __dirname})

  const fileOutput = fs.readFileSync(path.resolve(__dirname, "output/tmp.js"), "utf-8")
  expect(fileOutput).toBe(`const add = (a, b) => a + b`)
})

现在,我们以跑通这个test为目标,完成Mofast类的初步编写。

第四步:类gulp,链式文件流操作实现。 source函数:

将参数中的patterns, baseDir, dotFiles挂载到this上,并返回this, 以便于链式操作即可。

dest函数:

dest函数,是一个异步函数。

它完成两个操作:

将源文件夹中所有文件读取出来,赋值给this.files对象上。

将this.files对象中的文件,写入到目标文件夹的位置。

可以这两个操作分别独立成两个异步函数:
process(),和writeFileTree()

process函数

使用fast-glob包,读取目标文件夹下的所有文件的状态stats,返回一个由文件的状态stats组成的数组

从stats.path中取得绝对路径,采用fs.readFile()读取绝对路径中的内容content。

将content, stats, path一起挂载到this.files上。

注意,因为是批量处理,需要采用Promise.all()同时执行。

假如/fixture/template/add.js文件的内容为const add = (a, b) => a + b

处理后的this.file对象示意:

{
    "add.js": {
        content: "const add = (a, b) => a + b",
        stats: {...},
        path: "/fixture/template/add.js"
    }
}
writeFileTree函数

遍历this.file,使用fs.ensureDir保证文件夹存在后, 将this.file[filename].content写入绝对路径。

import { EventEmitter } from "events"
import glob from "fast-glob"
import path from "path"
import fs from "fs-extra"

class Mofast extends EventEmitter {
  constructor () {
    super()
    this.files = {}
    this.meta = {}
  }

  /**
   * 将参数挂载到this上
   * @param patterns  glob匹配模式
   * @param baseDir   源文件根目录
   * @param dotFiles   是否识别隐藏文件
   * @returns this 返回this,以便链式操作
   */
  source (patterns, { baseDir = ".", dotFiles = true } = {}) {
    //
    this.sourcePatterns = patterns
    this.baseDir = baseDir
    this.dotFiles = dotFiles
    return this
  }

  /**
   * 将baseDir中的文件的内容、状态和绝对路径,挂载到this.files上
   */
  async process () {
    const allStats = await glob(this.sourcePatterns, {
      cwd: this.baseDir,
      dot: this.dotFiles,
      stats: true
    })

    this.files = {}
    await Promise.all(
      allStats.map(stats => {
        const absolutePath = path.resolve(this.baseDir, stats.path)
        return fs.readFile(absolutePath).then(contents => {
          this.files[stats.path] = { contents, stats, path: absolutePath }
        })
      })
    )
    return this
  }

  /**
   * 将this.files写入目标文件夹
   * @param destPath 目标路径
   */
  async writeFileTree(destPath){
    await Promise.all(
      Object.keys(this.files).map(filename => {
        const { contents } = this.files[filename]
        const target = path.join(destPath, filename)
        this.emit("write", filename, target)
        return fs.ensureDir(path.dirname(target))
          .then(() => fs.writeFile(target, contents))
      })
    )
  }

  /**
   *
   * @param dest   目标文件夹
   * @param baseDir  目标文件根目录
   * @param clean   是否清空目标文件夹
   */
  async dest (dest, { baseDir = ".", clean = false } = {}) {
    const destPath = path.resolve(baseDir, dest)
    await this.process()
    if(clean){
      await fs.remove(destPath)
    }
    await this.writeFileTree(destPath)
    return this
  }
}

const mofast = () => new Mofast()

export default mofast

执行yarn test,测试跑通。

第五步:中间件机制

如果说我们正在编写的类,是一把枪。

那么中间件,就是一颗颗子弹。

你需要一颗颗将子弹推入枪中,然后一次全部打出去。

写一个测试用例,将add.js文件中的const add = (a, b) => a + b修改为var add = (a, b) => a + b

test/index.spec.js

test("middleware", async () => {
  const stream = mofast()
    .source("**", { baseDir: templateDir })
    .use(({ files }) => {
      const contents = files["add.js"].contents.toString()
      files["add.js"].contents = Buffer.from(contents.replace(`const`, `var`))
    })

  await stream.process()
  expect(stream.fileContents("add.js")).toMatch(`var add = (a, b) => a + b`)
})

好,现在来实现middleware

在constructor里面初始化constructor数组

src/index.js > constructor

  constructor () {
    super()
    this.files = {}
    this.middlewares = []
  }

创建一个use函数,用来将中间件推入数组,就像一颗颗子弹推入弹夹。

src/index.js > constructor

  use(middleware){
    this.middlewares.push(middleware)
    return this
  }

在process异步函数中,处理完文件之后,立即执行中间件。 注意,中间件的参数应该是this,这样就可以取到挂载在主类上面的this.filesthis.baseDir等参数了。

src/index.js > process

async process () {
    const allStats = await glob(this.sourcePatterns, {
      cwd: this.baseDir,
      dot: this.dotFiles,
      stats: true
    })

    this.files = {}
    await Promise.all(
      allStats.map(stats => {
        const absolutePath = path.resolve(this.baseDir, stats.path)
        return fs.readFile(absolutePath).then(contents => {
          this.files[stats.path] = { contents, stats, path: absolutePath }
        })
      })
    )


    for(let middleware of this.middlewares){
      await middleware(this)
    }
    return this
  }

最后,我们新写了一个方法fileContents,用于读取文件对象上面的内容,以便进行测试

  fileContents(relativePath){
    return this.files[relativePath].contents.toString()
  }

执行一下yarn test,测试通过。

第六步: 模板引擎、babel转译

既然已经有了中间件机制.

我们可以封装一些常用的中间件,例如ejs / handlebars模板引擎

使用前的文件内容是:
my name is <%= name %>my name is {{ name }}

输入{name: "jack}

得出结果my name is jack

以及babel转译:

使用前文件内容是:
const add = (a, b) => a + b

转译后得到var add = function(a, b){ return a + b}

好, 我们来书写测试用例:

// 准备原始模板文件
fs.writeFileSync(path.join(templateDir, "ejstmp.txt"), `my name is <%= name %>`)
fs.writeFileSync(path.join(templateDir, "hbtmp.hbs"), `my name is {{name}}`)

test("ejs engine", async () => {
  await mofast()
    .source("**", { baseDir: templateDir })
    .engine("ejs", { name: "jack" }, "*.txt")
    .dest("./output", { baseDir: __dirname })
  const fileOutput = fs.readFileSync(path.resolve(__dirname, "output/ejstmp.txt"), "utf-8")
  expect(fileOutput).toBe(`my name is jack`)
})

test("handlebars engine", async () => {
  await mofast()
    .source("**", { baseDir: templateDir })
    .engine("handlebars", { name: "jack" }, "*.hbs")
    .dest("./output", { baseDir: __dirname })
  const fileOutput = fs.readFileSync(path.resolve(__dirname, "output/hbtmp.hbs"), "utf-8")
  expect(fileOutput).toBe(`my name is jack`)
})

test("babel", async () => {
  await mofast()
    .source("**", { baseDir: templateDir })
    .babel()
    .dest("./output", { baseDir: __dirname })
  const fileOutput = fs.readFileSync(path.resolve(__dirname, "output/add.js"), "utf-8")
  expect(fileOutput).toBe(`var add = function (a, b) { return a + b; }`)
})

engine()有三个参数

type: 指定模板类型

locals: 提供输入的参数

patterns: 指定匹配格式

babel()有一个参数

patterns: 指定匹配格式

engine() 实现原理:

通过nodejsassert,确保typeejshandlebars之一

通过jstransformer+jstransformer-ejsjstransformer-handlebars

判断locals的类型,如果是函数,则传入执行上下文,使得可以访问files和meta等值。 如果是对象,则把meta值合并进去。

使用minimatch,匹配文件名是否符合给定的pattern,如果符合,则进行处理。 如果不输入pattern,则处理全部文件。

创立一个中间件,在中间件中遍历files,将单个文件的contents取出来进行处理后,更新到原来位置。

将中间件推入数组

babel()实现原理

通过nodejsassert,确保typeejshandlebars之一

通过buble包(简化版的bable),进行转换代码转换。

使用minimatch,匹配文件名是否符合给定的pattern,如果符合,则进行处理。 如果不输入pattern,则处理所有jsjsx文件。

创立一个中间件,在中间件中遍历files,将单个文件的contents取出来转化为es5代码后,更新到原来位置。

接下来,安装依赖

yarn add jstransformer jstransformer-ejs jstransformer-handlebars minimatch buble 

并在头部进行引入

src/index.js

import assert from "assert"
import transformer from "jstransformer"
import minimatch from "minimatch"
import {transform as babelTransform} from "buble"

补充engine和bable方法

  engine (type, locals, pattern) {
    const supportedEngines = ["handlebars", "ejs"]
    assert(typeof (type) === "string" && supportedEngines.includes(type), `engine must be value of ${supportedEngines.join(",")}`)
    const Transform = transformer(require(`jstransformer-${type}`))
    const middleware = context => {
      const files = context.files

      let templateData
      if (typeof locals === "function") {
        templateData = locals(context)
      } else if (typeof locals === "object") {
        templateData = { ...locals, ...context.meta }
      }

      for (let filename in files) {
        if (pattern && !minimatch(filename, pattern)) continue
        const content = files[filename].contents.toString()
        files[filename].contents = Buffer.from(Transform.render(content, templateData).body)
      }
    }
    this.middlewares.push(middleware)
    return this
  }

  babel (pattern) {
    pattern = pattern || "*.js?(x)"
    const middleware = (context) => {
      const files = context.files
      for (let filename in files) {
        if (pattern && !minimatch(filename, pattern)) continue
        const content = files[filename].contents.toString()
        files[filename].contents = Buffer.from(babelTransform(content).code)
      }
    }
    this.middlewares.push(middleware)
    return this
  }
第七步: 过滤文件

书写测试用例

test/index.spec.js

test("filter", async () => {
  const stream = mofast()
  stream.source("**", { baseDir: templateDir })
    .filter(filepath => {
      return filepath !== "hbtmp.hbs"
    })

  await stream.process()

  expect(stream.fileList).toContain("add.js")
  expect(stream.fileList).not.toContain("hbtmp.hbs")
})

新增了一个fileList方法,可以从this.files中获取到全部的文件名数组。

依然,通过注入中间件的方法,创建filter()方法。

src/index.js

  filter (fn) {
    const middleware = ({files}) => {
      for (let filenames in files) {
        if (!fn(filenames, files[filenames])) {
          delete files[filenames]
        }
      }
    }
    this.middlewares.push(middleware)
    return this
  }

  get fileList () {
    return Object.keys(this.files).sort()
  }

跑一下yarn test,通过测试

第八步: 打包发布

这时,基本上一个小型构建工具的全部功能已经实现了。

这时输入yarn lint 统一文件格式。

再输入yarn build打包文件,这时出现dist/index.js即是npm使用的文件

在package.json中增加main字段,指向dist/index.js

增加files字段,指示npm包仅包含dist文件夹即可

  "main": "dist/index.js",
  "files": ["dist"],

然后使用

npm publish

即可将包发布在npm上。

总结:

好了,回答最开始的问题:

什么是链式操作?

答: 返回this

什么是中间件机制

答:就是将一个个异步函数推入堆栈,最后遍历执行。

如何读取、构建文件树。

答:文件树,就是key为文件相对路径,value为文件内容等信息的对象this.files。

读取文件树,就是取得相对路径数组后,采用Promise.all批量fs.readFile取文件内容后挂载到this.files上去。

构建文件树,就是this.files采用Promise.all批量fs.writeFile到目标文件夹。

如何实现模板渲染、代码转译?

答:就是从文件树上取出文件,ejs.render()或bable.transform()之后放回原处。

如何实现中间件间数据共享?

答:contructor中创建this.meta={}即可。

其实,前端构建工具背后的原理,远比想像中更简单。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/109768.html

相关文章

  • 从 0 到 1 再到 100, 搭建、编写、构建一个前端项目

    摘要:从到再到搭建编写构建一个前端项目选择现成的项目模板还是自己搭建项目骨架搭建一个前端项目的方式有两种选择现成的项目模板自己搭建项目骨架。使用版本控制系统管理源代码项目搭建好后,需要一个版本控制系统来管理源代码。 从 0 到 1 再到 100, 搭建、编写、构建一个前端项目 1. 选择现成的项目模板还是自己搭建项目骨架 搭建一个前端项目的方式有两种:选择现成的项目模板、自己搭建项目骨架。 ...

    call_me_R 评论0 收藏0
  • 从 0 到 1 再到 100, 搭建、编写、构建一个前端项目

    摘要:从到再到搭建编写构建一个前端项目选择现成的项目模板还是自己搭建项目骨架搭建一个前端项目的方式有两种选择现成的项目模板自己搭建项目骨架。使用版本控制系统管理源代码项目搭建好后,需要一个版本控制系统来管理源代码。 从 0 到 1 再到 100, 搭建、编写、构建一个前端项目 1. 选择现成的项目模板还是自己搭建项目骨架 搭建一个前端项目的方式有两种:选择现成的项目模板、自己搭建项目骨架。 ...

    wzyplus 评论0 收藏0
  • 从 0 到 1 再到 100, 搭建、编写、构建一个前端项目

    摘要:从到再到搭建编写构建一个前端项目选择现成的项目模板还是自己搭建项目骨架搭建一个前端项目的方式有两种选择现成的项目模板自己搭建项目骨架。使用版本控制系统管理源代码项目搭建好后,需要一个版本控制系统来管理源代码。 从 0 到 1 再到 100, 搭建、编写、构建一个前端项目 1. 选择现成的项目模板还是自己搭建项目骨架 搭建一个前端项目的方式有两种:选择现成的项目模板、自己搭建项目骨架。 ...

    aristark 评论0 收藏0
  • “流式”前端构建工具——gulp.js 简介

    摘要:流式构建改变了底层的流程控制,大大提高了构建工作的效率和性能,给用户的直观感觉就是更快。我的看法关于流式构建,短短几句话无法讲清它的来龙去脉,但是在的世界里,确实是至关重要的。 Grunt 一直是前端领域构建工具(任务运行器或许更准确一些,因为前端构建只是此类工具的一部分用途)的王者,然而它也不是毫无缺陷的,近期风头正劲的 gulp.js 隐隐有取而代之的态势。那么,究竟是什么使得 g...

    ShevaKuilin 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<