前端 DSL 實踐指南(上)—— 內部 DSL

前言

近幾年,前端社區中 DSL 這個詞開始頻繁出鏡,這和環境的變化有很大關係:javascript

  1. React、Vue、Angular 等現代框架的表現層設計每每和 DSL 有較強的關聯,透過這些優秀做品咱們能夠獲得一些實踐指引。
  2. 前端相關語言的轉編譯工具鏈趨於成熟,如 babelpostcss 等工具能夠幫助開發者以擴展插件的方式低成本地參與到語言構建流程中。
  3. 社區的解析器生成工具開始普及,如 jisonPEG.js 等,能夠幫助開發者快速實現全新的編程語言(通常是模板等外部 DSL)。

雖然在「術」的實踐中咱們開始百花齊放,但同時也產生了一些誤區或迷思,好比會將 DSL 和轉編譯這種純技術議題劃上等號,好比會分不清內部 DSL 和庫(接口)的邊界等等,DSL 所以成了一我的人都在說但卻又很陌生的詞彙。css

同時市面上的權威著做如 Martin Fowler 的《領域特定語言》雖然會偏向於「道」的解答,但裏面充斥着諸如「格蘭特小姐的密室控制器」以及「蹦蹦高證券公司」等等對國內前端開發者而言會水土不服的晦澀案例。實際上前端的平常工做已經和 DSL 有着千絲萬縷的關係,做爲開發者已經不須要經過這些生澀案例來學習 DSL。html

本文做者因爲工做經歷上的特殊性,積累了一些關於前端 DSL 的實踐經驗(主要是外部 DSL),在所維護的開源項目中也有一些體現,同時做者在社區也有過一些不成體系的回答如《如何寫一個相似 LESS 的編譯工具》。此次我會嘗試從前端開發的視角來完整探討下 DSL 這個 「難以細說」 的議題。前端

因爲篇幅關係,本文會分爲兩個部分:java

  • 第一部分:DSL 初識 + 內部 DSL;
  • 第二部分:外部 DSL + 前端 DSL 實踐總結。

DSL 初識

和不少計算機領域的概念同樣,DSL 其實也算是先有實踐再有定義。

DSL 即「Domain Specific Language」,中文通常譯爲「領域特定語言」,在《領域特定語言》這本書中它有了一個定義:node

一種爲 特定領域設計的,具備 受限表達性編程語言

編程語言的發展實際上是一個不斷抽象的過程,好比從機器語言到彙編語言而後到 C 或 Ruby 這類高級語言:git

如上圖所示,彙編語言經過助記符代替機器指令操做碼,極大的加強了機器語言的可讀性和可維護性。但本質上它還是一門面向處理器和寄存器等硬件系統的低級編程語言。高級語言的出現解決了這個問題,真正脫離了對機器指令集的直接關聯,以上層抽象的語句(流程控制、循環等)和數據結構等更趨近天然語言和數學公式的方式完成編碼工做,大大提高了程序開發的效率。程序員

但在高級語言層面,抽象帶來的效率提高彷佛有了天花板。不管是從 C 到 Java,抑或是各類編程範式下衍生的抽象度更高的編程語言,解決的都是通用編程問題,它們都有充分的過程抽象和數據抽象,致使大量的概念產生,進而影響了編程效率。github

而在一些專有領域的任務處理上其實不須要那麼多語言特性,DSL 就是在這種矛盾中產生的破局方案,它是爲了解決特定任務的語言工具,好比文檔編寫有 markdown,字符串匹配有 RegExp,任務控制有 make、gradle,數據查找有 SQL,Web 樣式編碼有 CSS 等等。它的本質其實和咱們不少軟件工程問題的解決思路同樣,經過限定問題域邊界,從而鎖定複雜度,提升編程效率shell

咱們先來個簡單的例子,好比表示2周前的時間

解法一

new Date(Date.now() - 1000 * 60 * 60 * 24 * 7 * 2);

解法二

2 weeks ago

解法三

(2).weeks().ago();

解法一是符合通用編程思惟的解答,但即便做爲程序員的咱們也沒法一眼看出其含義。

解法二和解法三其實就是 DSL 的兩種不一樣類型——外部 DSL 和內部 DSL,它們的直觀性顯然更高(不信能夠問問你的女友),但它卻沒法直接運行,假如你嘗試在 JavaScript 環境下運行它,將會得到徹底不一樣的錯誤:

  • 2 weeks ago 會獲得 Uncaught SyntaxError: Unexpected identifier語法錯誤
  • (2).weeks().ago() 則會獲得一個 Uncaught TypeError: 2.weeks is not a function運行時類型錯誤
其實從錯誤類型上咱們就能夠看到它們是有本質不一樣的。

外部 DSL 簡述

解法二稱之爲外部 DSL ,它是一種獨立的編程語言,須要從解析器開始實現本身的編譯工具,實現成本較高。但它的語法的靈活性更高,更容易達到用戶的表現力需求。

外部 DSL 的直接對應就是 GPPL,因爲受限語法特性更少,通常不要求圖靈完備,因此它實現難度會低於 GPPL。

GPPL 即 「General Purpose Programming Language」,又稱通用編程語言,例如咱們經常使用的 JavaScript,它們被設計用來解決通用編程問題。

前端經常使用的模板引擎如 mustache 以及 React、Vue 支持的 JSX 語法都屬於外部 DSL。

mustache 的例子

<h2>Names</h2>
{{#names}}
  <strong>{{name}}</strong>
{{/names}}

這可比手動拼裝字符串高效多了。

內部 DSL 簡述

解法三咱們稱之爲 內部 DSL(Embedded DSL or Internal DSL) ,它是創建在其它宿主語言之上(通常爲 GPPL)的特殊 DSL,它與宿主語言共享編譯與調試工具等基礎設施,學習成本更低,也更容易被集成。他在語法上與宿主語言同源,但在運行時上須要作額外的封裝。

你也能夠將內部DSL視爲針對特定任務的特殊接口封裝風格,好比 jQuery 就能夠認爲是針對 DOM 操做的一種內部 DSL。

內部 DSL 的語法靈活度和語法噪音(syntactic noise)每每取決於宿主語言的選擇,本篇的例子咱們會圍繞 JavaScript 來展開。

syntactic noise is syntax within a programming language that makes the programming language more difficult to read and understand for humans.

簡而言之:看着蛋疼,寫着蛋疼。

最後咱們來看下內部 DSL 以及外部 DSL 與通常通用語言 GPPL 的關係:

其中內部 DSL 的定義一直是社區辯論的焦點,爲了理解內部 DSL 到底是什麼,咱們先來熟悉下內部 DSL 的典型構建風格。

內部 DSL 風格指南(JavaScript 描述)

結合 JavaScript 構建內部 DSL 其實有一些可套用的風格可循。

風格 1:級聯方法

級聯方法是內部 DSL 的最經常使用模式,咱們先以原生 DOM 操做做爲反面案例:

const userPanel = document.querySelector('#user_panel');

userPanel.addEventListener('click', hidePanel);

slideDown(userPanel); //假設這是一個已實現的動畫封裝

const followButtons = userPanel.querySelectorAll('button');

followButtons.forEach(node => {
  node.innerHTML = 'follow';
});

相信你們很難一眼看出作了什麼,但假如咱們使用遠古框架 jQuery 來實現等價效果:

$('#user_panel')
  .click(hidePanel)
  .slideDown()
  .find('button')
  .html('follow');

就很容易理解其中的含義:

  1. 找到 #user_panel 節點;
  2. 設置點擊後隱藏它;
  3. 向下動效展開;
  4. 而後找到它下面的全部 button 節點;
  5. 爲這些按鈕填充 follow 內容。

級聯方法等鏈式調用風格的核心在於調用再也不設計特定返回值,而是直接返回下一個上下文(一般是自身),從而實現級聯調用。

風格 2:級聯管道

級聯管道只是一種級聯方法的特殊應用,表明案例就是 gulp

gulp 是一種相似 make 構建任務管理工具,它將文件抽象爲一種叫 Vinyl(Virtual file format) 的類型,抽象文件使用 pipe 方法依次經過 transformer 從而完成任務。
gulp.src('./scss/**/*.scss')
  .pipe(plumber())
  .pipe(sass())
  .pipe(rename({ suffix: '.min' }))
  .pipe(postcss())
  .pipe(dest('./css'))

不少人會以爲 gulp 似曾相識,由於它的設計哲學是衍生自 Unix 命令行中的管道,上例能夠直接類比如下命令:

cat './scss/**/*.scss' | plumber | sass | rename --suffix '.min' | postcss | dest './css/'

上述針對 Pipeline 的抽象也有用常規級聯調用的方式來構建 DSL,好比 chajs

cha()
  .glob('./scss/**/*.scss')
  .plumber()
  .sass()
  .rename({ suffix: '.min' })
  .postcss()
  .dest('./css')
上述只是 DSL 的語法類比,chajs 不必定有 plumber 等功能模塊。

因爲減小了多個 pipe,代碼顯然是有減小的,但流暢度上並無更大的提高。

其次 chajs 的風格要求這些擴展方法都註冊到實例中,這就平添了集成成本,這些集成代碼也會影響到 DSL 的流暢度。

cha
  .in('glob', require('task-glob'))
  .in('combine', require('task-combine'))
  .in('replace', require('task-replace'))
  .in('writer', require('task-writer'))
  .in('uglifyjs', require('task-uglifyjs'))
  .in('copy', require('task-copy'))
  .in('request', require('task-request'))

相比之下,gulp 將擴展統一抽象爲一種外部 transformer,顯然設計的更加優雅。

風格 3:級聯屬性

級聯方法如文章開篇的 (2).weeks().ago() ,其實還不夠簡潔,存在明顯的語法噪音,(2).weeks.ago 顯然是個更好的方式,咱們能夠經過屬性靜態代理來實現,核心就是 Object.defineProperty(),它能夠劫持屬性的 settergetter

const hours = 1000 * 60 * 60;
const days = hours * 24;
const weeks = days * 7;
const UNIT_TO_NUM = { hours, days, weeks };

class Duration {
  constructor(num, unit) {
    this.number = num;
    this.unit = unit;
  }
  toNumber() {
    return UNIT_TO_NUM[this.unit] * this.number;
  }
  get ago() {
    return new Date(Date.now() - this.toNumber());
  }
  get later() {
    return new Date(Date.now() + this.toNumber());
  }
}
Object.keys(UNIT_TO_NUM).forEach(unit => {
  Object.defineProperty(Number.prototype, unit, {
    get() {
      return new Duration(this, unit);
    }
  });
});

將上述代碼粘貼到控制檯後,再輸入 (2).weeks.ago 試試吧,能夠看到級聯屬性能夠比級聯方法擁有更簡潔的表述,但同時也丟失了參數層面的靈活性。

可能有人會疑問爲什麼不是 2.weeks.ago,這就是 JavaScript 的一個「 Feature」了。惟一的解決方式就是去使用諸如 CoffeeScript 那些語法噪音更小的宿主語言吧。

在 DSL 風格中,不管是級聯方法、級聯管道仍是級聯屬性,本質都是鏈式調用風格,鏈式調用的核心是上下文傳遞,因此每一次調用的返回實體是否符合用戶的心智是 DSL 設計是否成功的重要依據。

風格 4:嵌套函數

開發中也存在一些層級抽象的場景,好比 DOM 樹的生成,如下是純粹命令式使用 DOM API 來構建的例子:

const container = document.createElement('div');
container.id = 'container';
const h1 = document.createElement('h1');
h1.innerHTML = 'This is hyperscript';
const list = document.createElement('ul');
list.setAttribute('title', title);
const item1 = document.createElement('li');
const link = document.createElement('a');
link.innerHTML = 'One list item';
link.href = href;
item1.appendChild(link1);
const item2 = document.createElement('li');
item2.innerHTML = 'Another list item';
list.appendChild(item1);
list.appendChild(item2);

container.appendChild(h1);
container.appendChild(list);

這種寫法略顯晦澀,很難一眼看出最終的 HTML 結構,那如何構建內部 DSL 來流暢解決這種層級抽象呢?

有人就嘗試用相似鏈式調用的方式去實現,好比 concat.js

builder(document.body)
  .div('#container')
    .h1().text('This is hyperscript').end()
    .ul({title})
      .li()
        .a({href:'abc.com'}).text('One list item').end()
      .end()
      .li().text('Another list item').end()
    .end()
  .end()

這彷佛比命令式的寫法好了很多,但構建這種 DSL 存在很多問題:

  1. 由於鏈式調用的關鍵是上下文傳遞,在層級抽象中需額外的 end() 出棧動做實現上下文切換。
  2. 可讀性強依賴於手動縮進,而每每編輯器的自動縮進每每會打破這種和諧。

因此通常層級結構抽象不多使用鏈式調用風格來構建 DSL,而會更多的使用基本的嵌套函數來實現。

咱們以另外一個骨灰開源項目 DOMBuilder 爲例:

這裏先拋開 with 自己的使用問題
with(DOMBuilder.dom) {
  const node =
    div('#container',
      h1('This is hyperscript'),
      ul({title},
        li(
            a({herf:'abc.com'}, 'One list item')
        ),
        li('Another list item')
    )
}

能夠看到層級結構抽象使用嵌套函數來實現會更流暢。

若是使用 CoffeeScript 來描述,語法噪音能夠降到更低,能夠接近 pug 這種外部 DSL 的語法:

div '#container',
  h1 'This is hyperscript'
  ul {title},
    li(
      a href:'abc.com', 'One list item'
    )
    li 'Another list item'
CoffeeScript 是一門編譯到 JavaScript 的語言,它旨在去除 JavaScript 語言設計上的糟粕,並增長了不少語法糖,影響了不少 JavaScript 後續標準的演進,目前完成了它的歷史任務,逐步銷聲匿跡中。

嵌套函數本質上是將在鏈式調用中須要處理的上下文切換隱含在了函數嵌套操做中,因此它在層級抽象場景是很是適用的。

另外,嵌套函數在 DSL 的應用相似解析樹,由於其符合語法樹生成思路,每每可直接映射轉換爲對應外部 DSL,好比 JSX:

<div id='container'>
  <h1 id='heading'> This is hyperscript </h1>
  <ul title={title} >
    <li><a href={href} > One list item </a></li>
    <li> Another list item </li>
  </ul>
</div>

嵌套函數並非萬金油,它自然不適合流程、時間等順序敏感的場景。

若是將風格 2 的級聯管道修改成嵌套函數:

執行邏輯與閱讀順序顯然不一致,而且會加劇書寫負擔(同時要關心開閉邏輯),極大影響讀寫流暢度。

風格 5:對象字面量

業界不少 DSL 都相似於配置文件,例如 JSON、YAML 等外部 DSL,它們在嵌套數據展示中有很強的表達力。

而 JavaScript 也有一個適合在此場景構建 DSL 的特性,那就是字面量對象,實際上,JSON(全稱 JavaScript Object Notation)正是衍生自它的這個特性,成爲了一種標準數據交換格式。

例如在項目 puer 中,路由配置文件選擇了 JS 的對象字面量而不是 JSON:

module.exports = {
    'GET /homepage': './view/static.html'
    'GET /blog': {
        title: 'Hello'
    }
    'GET /user/:id': (req, res)=>{
        res.render('user.vm')
    }
}

由於 JSON 有一個自然缺陷就是要求可序列化,這極大的限制了它的表達力(不過也使它成爲了最流行的跨語言數據交換格式),好比上例最後一條還引入了函數,雖然從 DSL 角度來講變得「不純粹」了,但功能性卻上了一個臺階。這也是爲何一些構建任務相關的 DSL(make、rake、cake、gradle 等)幾乎所有都是內部 DSL 的緣由。

除此以外,由於對象 key 值的存在,對象字面量也能提升參數可讀性,好比:

div({id: 'container', title: 'This is a tip' })

// CoffeeScript Version
div id: 'container', title: 'This is a tip'

顯然比用詞更少的下例可讀性更佳:

div('container', 'This is a tip')
構造 DSL 並不是越簡潔越好,提升流暢度纔是關鍵。

對象字面量的結構性較強,通常只用來作配置等數據抽象的場景,不適合用在過程抽象的場景。

風格 6:動態代理

以前所列舉內部 DSL 的構造方式有一個典型缺陷就是它們都是靜態定義的屬性或方法,沒有動態性。

如上節 [風格4: 嵌套函數] 中的提到 concat.js,它的全部相似 divp 等方法都是靜態具名定義的。而實際上由於 custom elements 特性的存在,這種靜態窮舉的方式顯然是有坑的,更別說 html 標準自己也在不斷增長新標籤。

而在外部 DSL,這個問題是不存在的,好比我早期寫的 regularjs/regular,它內置的模板引擎在詞法解析階段把相似/<(\w+)/的文本匹配爲統一的TAG 詞法元素,這樣就能夠避免窮舉。

內部 DSL 要實現這種特性,就強依賴宿主語言的元編程能力了。
Ruby 做爲典型宿主語言常常會用來證實其強大元編程能力的特性就是 method_missing,這個方法能夠動態接收全部未定義的方法,最直接功能就是動態命名方法(或元方法),這樣就能夠解決上面提到的內部 DSL 都是具名靜態定義的問題。

值得慶幸的是在 JavaScript 中也有了一個更強大的語言特性,就是 Proxy,它能夠代理屬性獲取,從而解決上文 concat.js 的窮舉問題。

如下並不是完整代碼,只是簡單演示
function tag(tagName){
    return {tag: tagName}
}

const builder = new Proxy(tag, {
  get (target, property) {
    return tag.bind(null, property)
  }
})

builder.h1() // {tag: 'h1'}
builder.tag_not_defined()  // {tag: 'tag_not_defined'}

Proxy 使得 JavaScript 具有了極強的元編程能力,它除了能夠輕鬆模擬出 Ruby 沾沾自喜的 method_missing 特性外,也能夠有不少其它動態代理能力,這些都是實現內部 DSL 的重要工具。

風格 7:Lambda 表達式

市面上有大量的查詢庫使用鏈式風格,它們很是接近 SQL 自己的寫法,好比:

const users = User.select('name') 
  .where('id==1');
  .where('age > 1');
  .sortBy('create_time')

爲了將 id==1 等表達式轉化爲可運行的過濾條件,咱們不得不去實現完整的表達式解析器,以最終編譯獲得等價函數

function(user){
    return user.id === 1
}

實現成本很是高,而使用 lambda 表達式能夠更低成本地解決這種需求

const users = User.select('name')
  .where(user => user.id === 1);
  .where(user => user.age > 20);
  .sortBy('create_time')

這種應用案例其實早就存在了,好比基於C#LINQ(Language-Integrated Query),這也是最常活躍在內部 DSL 技術圈的典型案例。

var result = products
    .Where(p => p.UnitPrice >= 20)
    .GroupBy(p => p.CategoryName)
    .OrderByDescending(g => g.Count())
    .Select(g => new { Name = g.Key, Count = g.Count() });

Lambda 表達式本質上是一種直觀易讀且延遲執行的邏輯表達能力,從而避免額外的解析工做,不過它強依託宿主的語言特性支持(匿名函數 + 箭頭表示),而且也會引入必定的語法噪音。

風格 8:天然語言抽象

天然語言抽象即以更貼近天然語言的方式去設計 DSL 的語法,它行得通的基本邏輯是領域專家基本都是和你我同樣的天然人,更容易接受天然語言的語法。

天然語言抽象的本質是一些語法糖,和通常 GPPL 的語法糖不同,
DSL 的語法糖並不必定是最簡潔的,反而會加入一些「冗餘」的非功能性語法詞彙。

舉個栗子,在雲音樂團隊開源的 svrx(Server-X) 項目(一個插件化 dev-server 平臺)中,路由是個高頻使用的功能,爲此咱們設計了一套內部 DSL 來方便開發者使用,以下例所示:

get('/blog/:id').to.send('Demo Blog')

put('/api/blog/:id').to.json({code: 200})

get('/(.*)').to.proxy('https://music.163.com')

其中 to 就是個非功能性詞彙,但卻使得整個語句更容易被天然人(固然也包括咱們程序員)所理解使用。

經過天然語言抽象,內部 DSL 的優點在單元測試場景中被髮揮的淋漓盡致,好比若是咱們裸用相似 assert 的斷言方法,單元測試用例多是這樣的:

var foo = '43';

assert(typeof foo === 'number', 'expect foo to be a number');
assert(
  tea.flavors && tea.flavors.length === 3,
  'c should have property flavors with length of 3'
)

有幾個顯著待優化的問題:

  1. 命令式的斷言語句閱讀不直觀;
  2. 爲了 report 的可讀性,須要傳入額外的提示語(如expect foo to be a number)。

若是這個 case 基於 chai 來書寫的話,可讀性會立立刻一個臺階:

var foo = '43'

// AssertionError: '43' should be a 'number'.
foo.should.be.a('number');

tea.should.have.property('flavors').with.lengthOf(3);

能夠發現測試用例變得更加易讀易寫了,並且當斷言失敗,也會自動根據鏈式調用產生的狀態,自動拼裝出更友好的錯誤信息,特別是當與 mocha 等測試框架結合時,能夠直接生成直觀的測試報告:

經過增長相似天然語言的輔助語法(動、名、介、副等),可使得程序語句更直觀易懂。

風格總結

本文並未囊括全部內部 DSL 實現風格(好比也有些基於 Decorator 裝飾器的玩法),且所列風格都不是銀彈,都有其適用場景,它們之間存在互補效應。

內部 DSL 的一些迷思

經過上面的一些慣用風格的介紹,咱們創建了對前端內部 DSL 的一些瞭解,本節會針對「Why」的問題作下深刻討論:

爲什麼選擇 JavaScript 做爲宿主語言

從風格案例能夠看到,宿主語言直接決定了內部 DSL 的「語法」優化的上限。正如 ROR 之於 Ruby、Gradle 之於 Groovy,典型的前期選擇大於後天努力。而前端開發最趁手的語言 JavaScript 其實在構建內部 DSL 時具有了很大的優點,由於它那些大雜燴般的語言特性:

  • 借鑑 Java 語言的數據類型和內存管理,抽象度高。
  • 基於對象,且擁有方便的對象字面量表示等,數據表達力一流。
  • 函數爲第一等公民(first class),能夠有一些泛 FP 的應用。
  • 使用基於原型(prototype)的繼承機制,而且可擴展原始類型如 Number。
  • Proxy、Reflect 等新特性加持下具有了極強的元編程能力

放蕩不羈的語言特性使得它幾乎能夠 Hold 住任何內部 DSL 的構建風格,另外它那活躍到離譜的社區也奠基了自然的開發者基礎。

JavaScript 存在的自然缺陷就是它那衍生自 C 的語法,致使噪音較強,使用一些變種語言(如 CoffeeScript)能夠扭轉一些這種劣勢。

庫(接口)仍是內部 DSL

外部 DSL 的邊界問題每每是 DSL 與 GPPL 的區別,這個在社區中的爭議並不算很大。而關於內部 DSL 的討論,特別是與庫(接口)的差別問題就一直都沒消停過,確實存在模糊的部分。

實際上 DSL 也有個別名叫流暢接口,因此它自己也屬於接口封裝或庫封裝的一種模式,目標是極限表達力。但它相較於傳統接口封裝,有幾個顯著設計差別點:

  • 語言性。
  • 不受傳統編程最佳實踐的束縛:如命令-查詢分離、迪米特法則等。

好比在內部 DSL 中,獲得代碼如 foo.should.be.a.number 就像是一個在既定語法下有關聯的整句,而不是命令式代碼的集合。而 jQuery 中 html 便是查詢方法(.html())也是命令方法(.html('content to set')),這顯然背離了命令查詢分離的原則。它們設計的首要目標是「極限流暢的表現力」,而非職責清晰、下降耦合度等傳統的封裝抽象準則。

其實本文更認同松本行弘先生在《代碼的將來》中引述的觀點,這也算最終解開了做者對於內部 DSL 的疑惑和心結:

庫設計就是語言設計

編程語言只肯定了基本語法框架和少許詞彙,庫設計應該將其與充當詞彙池的類、方法、屬性甚至變量相結合,並將它們按語義有機結合起來,最終真正實現「在限定任務下,編程工做者只須要關注 What,而無需關注 How」的設計目標。這也就是 2.weeks.ago 的魔力所在,編程(語言)的發展方向就應該如此,才能達到更高的抽象維度。

因此與其嘗試去爲內部 DSL 劃分一個明確的邊界,不如根據它的要求去改善你的接口設計。這裏引伸另外一個更激進的觀點:

Programming is a process of designing DSL for your own application.

內部 DSL 實踐的一些坑

除了因爲依賴於宿主語言,致使功能性缺失和額外的語法噪音以外,內部 DSL 也存在其它不可忽視的問題。

不友好的異常

[風格3: 級聯屬性] 案例中,其實咱們沒有定義 minutes 這個單位, 若是錯誤的使用(5).minutes.later,將獲得如下錯誤提示:

Uncaught TypeError: Cannot read property 'later' of undefined

而不是咱們預期的相似報錯信息:

Uncaught SyntaxError: Unexpected unit minutes

這是因爲異常處理機制也遵循宿主語言,在庫封裝層面作 DSL 抽象依然沒法逃脫這個限制,這也是外部 DSL 的優點所在,不過基於 [風格6:動態代理] 提到的 Proxy,咱們仍能作一些微不足道的小優化:

const UNITS = ['days','weeks','hours'];

const five = new Proxy(new Number(5), {
  get (target, property) {
    if(UNITS.indexOf(property) === -1){
        throw TypeError(`Invalid unit [${property}] after ${target}`)
    }else{
       // blablabla
    }
  }
})

粘貼到控制檯並輸入 five.minutes,你將看到更友好的錯誤提示:

Uncaught TypeError: invalid units [minutes] after 5

容易忽視冰山之下的設計

內部 DSL 的設計要點在於表現層是否流暢,而缺少對底層領域模型的抽象封裝要求,這可能致使 DSL 的「核」是缺少有效設計的。在實踐 DSL 時,咱們在領域模型這層仍然要遵循最佳編程實踐,好比本文 2.weeks.later 背後的 Duration 等領域模型實體。

做者曾爲內部一個歷史悠久的龐大前端框架擴展了一個相似 jQuery 的流暢 API 的接口(2012 年勿噴),去掉註釋僅僅花費了不到 500 行代碼,這個絕大部分歸功於框架底層的深厚設計功底和一致性,而非我上層的 DSL 語法糖包裝。

此外在 DSL 設計中,語法和語義一樣重要,上述諸多例子也證實了:語法的簡潔不必定帶來流暢性,必需要結合語義模型來設計。

關於語法與語義: a || ba or b 語法不一樣,但語義相同;而 a > b(Java)和 a > b(Shell)語法相同,但語義不一樣

這部分建議在外部 DSL 的設計工做中也一樣重要。

編輯器支持

有些內部 DSL 依賴排版來達到最佳表現,絕大部分語言(包括外部 DSL)的自動格式化引擎都是基於語法樹解析來實現的,但內部 DSL 就沒那麼幸運了,因爲它在實際語法層面並無定義,因此常常會發生在編輯器使用「Format Document」後前功盡棄的狀況,這類現象在基於縮進的語言中會比較少。

特殊的代碼高亮就更難了,即便是自動補全,也須要一些額外的工做才能被支持。

小結

常規編程解決思路下表達更多的是「How」即如何實現的細節,牽扯進的表達式、語句和數據結構等編程元素會影響到領域工做者對本源問題的理解。而 DSL 的祕訣在於它強調錶達是「What」,將本來的命令式編程轉化爲極致的聲明式表述,使得 DSL 具有強大的自解釋性(self-explanatory),從而提升編程效率,甚至能夠賦能給沒有編程經驗的用戶。

本文主要針對內部DSL這個重要分支在前端的實踐作了展開說明,並結合Javascript和前端領域的一些典型範例闡述了8種實現風格,而且強調這些風格並不是獨立的「銀彈」,而是互爲補充。

本文也對一些迷思展開了討論,咱們探討了 Javascript 作爲內部 DSL 宿主語言的可行性,並強調了「DSL的設計指引比它的邊界定義更應該受到關注」這一觀點,最後引出一些內部 DSL 設計過程當中的常見坑。

進一步閱讀

請關注本文的第二部分 —— 外部 DSL,同時如下書籍能夠幫助你進一步學習:

相關資料

> 本文發佈自 [網易雲音樂前端團隊](https://github.com/x-orpheus),文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 [加入咱們](mailto:grp.music-fe@corp.netease.com)!

相關文章
相關標籤/搜索