1. 程式人生 > 實用技巧 >用100行程式碼,完成自己的前端構建工具!

用100行程式碼,完成自己的前端構建工具!

ES2017+,你不再需要糾結於複雜的構建工具技術選型。也不再需要gulp,grunt,yeoman,metalsmith,fis3。以上的這些構建工具,可以腦海中永遠劃掉。100行程式碼,你將透視構建工具的本質。

100行程式碼,你將擁有一個現代化、規範、測試驅動、高延展性的前端構建工具。

在閱讀前,給大家一個小懸念:

  • 什麼是鏈式操作、中介軟體機制?
  • 如何讀取、構建檔案樹?
  • 如何實現批量模板渲染、程式碼轉譯?
  • 如何實現中介軟體間資料共享。

相信學完這一課後,你會發現————這些專業術語,背後的原理實在。。。太簡單了吧!

構建工具體驗:彈窗+uglify+模板引擎+babel轉碼...

如果想立即體驗它的強大功能,可以命令列輸入npx mofast example,將會構建一個mofast-example資料夾。

進入檔案後執行node compile,即可體驗功能。

順便說一句,npx mofast example命令列本身,也是用本課的構建工具實現的。——是不是不可思議?

本課程程式碼已在npm上進行釋出,直接安裝即可

npmi 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函式,是一個非同步函式。

它完成兩個操作:

  1. 將原始檔夾中所有檔案讀取出來,賦值給this.files物件上。
  2. 將this.files物件中的檔案,寫入到目標資料夾的位置。

可以這兩個操作分別獨立成兩個非同步函式:
process(),和writeFileTree()

process函式

  1. 使用fast-glob包,讀取目標資料夾下的所有檔案的狀態stats,返回一個由檔案的狀態stats組成的陣列
  2. 從stats.path中取得絕對路徑,採用fs.readFile()讀取絕對路徑中的內容content。
  3. 將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修改為varadd = (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.files、this.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() 實現原理:

通過nodejs的assert,確保type為ejs和handlebars之一

通過jstransformer+jstransformer-ejs和jstransformer-handlebars

判斷locals的型別,如果是函式,則傳入執行上下文,使得可以訪問files和meta等值。 如果是物件,則把meta值合併進去。

使用minimatch,匹配檔名是否符合給定的pattern,如果符合,則進行處理。 如果不輸入pattern,則處理全部檔案。

創立一箇中間件,在中介軟體中遍歷files,將單個檔案的contents取出來進行處理後,更新到原來位置。

將中介軟體推入陣列

babel()實現原理

通過nodejs的assert,確保type為ejs和handlebars之一

通過buble包(簡化版的bable),進行轉換程式碼轉換。

使用minimatch,匹配檔名是否符合給定的pattern,如果符合,則進行處理。 如果不輸入pattern,則處理所有js和jsx檔案。

創立一箇中間件,在中介軟體中遍歷files,將單個檔案的contents取出來轉化為es5程式碼後,更新到原來位置。


接下來,安裝依賴

yarnadd 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"],

然後使用

npmpublish

即可將包釋出在npm上。

資源搜尋網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com

總結:

好了,回答最開始的問題:

什麼是鏈式操作?

答: 返回this

什麼是中介軟體機制

答:就是將一個個非同步函式推入堆疊,最後遍歷執行。

如何讀取、構建檔案樹。

答:檔案樹,就是key為檔案相對路徑,value為檔案內容等資訊的物件this.files。

讀取檔案樹,就是取得相對路徑陣列後,採用Promise.all批量fs.readFile取檔案內容後掛載到this.files上去。

構建檔案樹,就是this.files採用Promise.all批量fs.writeFile到目標資料夾。

如何實現模板渲染、程式碼轉譯?

答:就是從檔案樹上取出檔案,ejs.render()或bable.transform()之後放回原處。

如何實現中介軟體間資料共享?

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

其實,前端構建工具背後的原理,遠比想像中更簡單。