------
转载请标注:
转载自:网易云音乐前端团队
作者:郑海波(https://www.zhihu.com/people/leeluolee)
原文链接:https://zhuanlan.zhihu.com/p/107947462?utm_source=wechat_timeline&utm_medium=social&utm_oi=27020285181952&from=timeline&isappinstalled=0
------
近几年,前端社区中 DSL 这个词开始频繁出镜,这和环境的变化有很大关系:
React、Vue、Angular 等现代框架的表现层设计往往和 DSL 有较强的关联,透过这些优秀作品我们可以得到一些实践指引。
前端相关语言的转编译工具链趋于成熟,如 babel,postcss 等工具可以帮助开发者以扩展插件的方式低成本地参与到语言构建流程中。
社区的解析器生成工具开始普及,如 jison、PEG.js 等,可以帮助开发者快速实现全新的编程语言(一般是模板等外部 DSL)。
虽然在「术」的实践中我们开始百花齐放,但同时也产生了一些误区或迷思,比如会将 DSL 和转编译这种纯技术议题划上等号,比如会分不清内部 DSL 和库(接口)的边界等等,DSL 因此成了一个人人都在说但却又很陌生的词汇。
同时市面上的权威著作如 Martin Fowler 的《领域特定语言》虽然会偏向于「道」的解答,但里面充斥着诸如「格兰特小姐的密室控制器」以及「蹦蹦高证券公司」等等对国内前端开发者而言会水土不服的晦涩案例。实际上前端的日常工作已经和 DSL 有着千丝万缕的关系,作为开发者已经不需要通过这些生涩案例来学习 DSL。
本文作者由于工作经历上的特殊性,积累了一些关于前端 DSL 的实践经验(主要是外部 DSL),在所维护的开源项目中也有一些体现,同时作者在社区也有过一些不成体系的回答如《如何写一个类似 LESS 的编译工具》。这次我会尝试从前端开发的视角来完整探讨下 DSL 这个 「难以细说」 的议题。
由于篇幅关系,本文会分为两个部分:
第一部分:DSL 初识 + 内部 DSL;
第二部分:外部 DSL + 前端 DSL 实践总结。
和很多计算机领域的概念一样,DSL 其实也算是先有实践再有定义。
DSL 即「Domain Specific Language」,中文一般译为「领域特定语言」,在《领域特定语言》这本书中它有了一个定义:
一种为 特定领域设计的,具有 受限表达性的 编程语言
编程语言的发展其实是一个不断抽象的过程,比如从机器语言到汇编语言然后到 C 或 Ruby 这类高级语言:
编程语言的发展历程
如上图所示,汇编语言通过助记符代替机器指令操作码,极大的增强了机器语言的可读性和可维护性。但本质上它仍是一门面向处理器和寄存器等硬件系统的低级编程语言。高级语言的出现解决了这个问题,真正脱离了对机器指令集的直接关联,以上层抽象的语句(流程控制、循环等)和数据结构等更趋近自然语言和数学公式的方式完成编码工作,大大提升了程序开发的效率。
但在高级语言层面,抽象带来的效率提升似乎有了天花板。无论是从 C 到 Java,抑或是各种编程范式下衍生的抽象度更高的编程语言,解决的都是通用编程问题,它们都有充分的过程抽象和数据抽象,导致大量的概念产生,进而影响了编程效率。
而在一些专有领域的任务处理上其实不需要那么多语言特性,DSL 就是在这种矛盾中产生的破局方案,它是为了解决特定任务的语言工具,比如文档编写有 markdown,字符串匹配有 RegExp,任务控制有 make、gradle,数据查找有 SQL,Web 样式编码有 CSS 等等。它的本质其实和我们很多软件工程问题的解决思路一样,通过限定问题域边界,从而锁定复杂度,提高编程效率。
我们先来个简单的例子,比如表示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 的直接对应就是 GPPL,由于受限语法特性更少,一般不要求图灵完备,所以它实现难度会低于 GPPL。
GPPL 即 「General Purpose Programming Language」,又称通用编程语言,例如我们常用的 JavaScript,它们被设计用来解决通用编程问题。
前端常用的模板引擎如 mustache 以及 React、Vue 支持的 JSX 语法都属于外部 DSL。
mustache 的例子:
Namesh2>{
{#names}} {
{name}}strong>{
{/names}}
这可比手动拼装字符串高效多了。
解法三我们称之为 内部 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 的关系:
GPPL和内部DSL、外部DSL
其中内部 DSL 的定义一直是社区辩论的焦点,为了理解内部 DSL 究竟是什么,我们先来熟悉下内部 DSL 的典型构建风格。
结合 JavaScript 构建内部 DSL 其实有一些可套用的风格可循。
级联方法是内部 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');
就很容易理解其中的含义:
找到 #user_panel
节点;
设置点击后隐藏它;
向下动效展开;
然后找到它下面的所有 button 节点;
为这些按钮填充 follow 内容。
级联方法等链式调用风格的核心在于调用不再设计特定返回值,而是直接返回下一个上下文(通常是自身),从而实现级联调用。
级联管道只是一种级联方法的特殊应用,代表案例就是 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
,显然设计的更加优雅。
级联方法如文章开篇的 (2).weeks().ago()
,其实还不够简洁,存在明显的语法噪音,(2).weeks.ago
显然是个更好的方式,我们可以通过属性静态代理来实现,核心就是 Object.defineProperty()
,它可以劫持属性的 setter
与 getter
:
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; } getago(){
return new Date(Date.now()-this.toNumber()); } getlater(){
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 设计是否成功的重要依据。
开发中也存在一些层级抽象的场景,比如 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 存在不少问题:
因为链式调用的关键是上下文传递,在层级抽象中需额外的 end()
出栈动作实现上下文切换。
可读性强依赖于手动缩进,而往往编辑器的自动缩进往往会打破这种和谐。
所以一般层级结构抽象很少使用链式调用风格来构建 DSL,而会更多的使用基本的嵌套函数来实现。
我们以另一个骨灰开源项目 DOMBuilder 为例:
这里先抛开
with
本身的使用问题
with(DOMBuilder.dom){constnode=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: