500 Lines or Less——視覺化程式設計工具(Blockcode)
Dethe 是一個極客老爸,具有審美趣味的程式設計師,導師,以及視覺化程式設計工具Waterbear的作者。他聯合創辦了溫哥華手工製作教育沙龍並且滿心希望機器紙折兔能火遍全球。
在基於塊(block-based)的程式語言中,你通過拖動和連線代表程式不同部分的塊來進行程式設計。而在一般的程式語言中,你是通過鍵入字元來程式設計的。
學習程式設計可能很困難,因為一般程式語言對於拼寫錯誤是零容忍的。大部分的程式語言都是大小寫敏感的,並且語法比較晦澀,哪怕是少寫一個分號都會拒絕執行程式。更有甚者,大部分的程式語言是基於英語的並且語法不能本地化。
相反,基於塊的語言可以完全消除語法錯誤,你的程式僅僅可能發生邏輯錯誤。塊語言也更加直觀,你可以在塊列表中看到所有的程式構件和語言庫。更有甚者,塊可以被本地化任意的人類語言而不用改變程式語言的含義。
本章的程式碼基於開源專案Waterbear,這不是一個語言,而是將其他現存語言包裝成塊語法的工具。該包裝器的作用包括以上提到的幾點:消除語法錯誤,方便本地化。除此之外,視覺化的程式碼有時更加容易閱讀和除錯,還不會打字的兒童也能使用塊。(可以更進一步地在塊上放置圖示,也可以加上文字,提供給學前兒童使用,然而這個功能我們先不考慮)。
該語言選擇使用的龜圖(turtle graphics)可以追溯到Logo語言,這是一個特地教導兒童程式設計的語言。許多基於塊的語言都包括了龜圖,該主題很適合用於一個類似被嚴格限制的專案。
如果想事先體驗一下基於塊的語言是怎麼樣的,可以到作者的Github進行實驗。
目標和結構
通過本章的程式碼我希望能實現幾點。首先,我要為龜圖(turtle graphics)實現一個塊語言,通過簡單的拖放塊,你可以編寫程式建立圖案,我想通過簡單的HTML,CSS和Javascript來實現。其次,我要展示如何將塊構想成為一個框架,服務於其他語言而不僅僅是簡單的龜語言(turtle language)。
為了做到這點,我將turtle language相關部分全部封裝到了一個檔案(turtle.js
),這樣我就可以輕易替換成其他檔案。除此之外的任何程式碼都不是特定於turtle language;其他的程式碼全部用來處理塊(blocks.js
和menu.js
)或者是通用的web工具(util.js
drag.js
,file.js
)。這是目標,然後為了使得工程儘量小型化,一些工具不是足夠通用而與塊相關。
在編寫一個塊語言時,讓我驚奇的是,語言就是它自己的IDE。你不能使用自己喜歡的編輯器;IDE的設計要和塊語言同步進行。這樣有利有弊。好處是所有人都使用一致的環境從而避免的關於編輯器優劣的爭論。壞處是會影響構建語言。
指令碼的本質
和任何其他語言的指令碼一樣,一個Blockcode指令碼就是一系列的操作。對於Blockcode指令碼來說,其中包含了一些HTML元素,指令碼迭代執行每個HTML元素對應的JavaScript函式。一些塊包含(負責執行)其他的塊,還有一些塊包含一些傳遞給函式的數值。
在大部分(基於文字)的語言中,一個指令碼的執行會經歷多個階段:一個詞法分析器將文字解析為tokens,語法分析器將tokens組織成抽象語法樹,然後根據語言的不同,可能會編譯為機器碼或者輸入到解析器中。這是一個簡化的描述;事實上可能會有更多步驟。對於Blockcode,塊的佈局本身就代表了抽象語法樹,因為我們可以免去詞法分析和語法分析階段。我們使用訪問者模式(Visitor pattern)來迭代每個塊並執行每個塊預定義的函式來執行整個程式。
我們完全可以新增額外的步驟來將Blockcode變得更像一般的語音。除了簡單的呼叫Javascript函式外,我們還可以將turtle.js
替換為一個能產生位元組碼的塊語言,運行於其他的虛擬機器。或者產生C++程式碼用以編譯執行。存在能夠生成Java位元組碼的塊語言(作為Waterbear專案的一部分),用於Arduino程式設計和為Raspberry Pi上執行的Minecraft編寫指令碼。
Web應用
為了讓更多的人使用該工具,我們使用了Web。該工具使用HTML,CSS和JavaScript編寫,因為可以執行在大部分的瀏覽器和平臺。
現代Web瀏覽器是一個強大的平臺,提供了構建偉大軟體的豐富工具。如果一些實現變得太過複雜,這就釋放了一種訊號,那就是我沒有按照web的方式來做,如果可能我就會試著使用瀏覽器工具來做得更好。
web應用和傳統桌面應用或者伺服器應用的一個重大的區別就是它沒有main函式或者其他的入口。也沒有顯式地迴圈,因為這些已經被瀏覽器內建了。我們所有的程式碼都在載入的時候被分析和執行,在這個過程中我們可以對感興趣的事件註冊監聽器用來和使用者互動。在初次執行後,所有後續的互動都在相應事件中註冊的回撥中進行,要麼是類似滑鼠移動的事件,或者是設定的定時器。瀏覽器並沒有暴露主要的執行緒(僅僅是共享的工作執行緒)。
程式碼分析
貫穿本專案始終,我都試著使用了最佳實踐。每個JavaScript檔案都被包含在一個函式中,從而避免變數洩露到全域性環境中。如果需要暴露變數給其他檔案,那麼每個檔案中根據檔名只定義單個global,所有需要暴露的函式都在其中。這些都在接近檔案尾部進行放置,接著就是該檔案定義的各種事件處理器,因而只需要看一眼檔案的末尾就能知道該檔案定義的事件處理器和匯出的函式。
程式碼是過程式的,沒有采用面向物件或者函式式。我們可以使用任意一種正規化來做同一件事,然而那些需要一更多的設定程式碼和包裝程式碼來進行本已存在於DOM的東西。最近有個專案Custom Elements使得你可以OO的方式操作DOM,還有很多關於Functional JavaScript的文章,然而這些都需要額外的工作,因此保持過程式使得問題更簡單。
專案中有八個原始檔,index.html
和blocks.css
是應用的基本結構和樣式因而不加討論。還有兩個JavaScript檔案也不過多討論:util.js
包含了一些工具函式,file.js
用於載入和儲存檔案並且序列化指令碼。
剩下這些檔案:
- blocks.js
是塊語言的抽象表示
- drag.js
實現了語言的關鍵互動:允許使用者從可選塊(選單)中拖拽塊並組裝成程式(指令碼)。
- menu.js
包含了一些工具程式碼並且負責實際地執行使用者程式。
- turtle.js
定義了塊語言的特定細節(turtle graphics)並且初始化特定的塊。如果需要定義不同的塊語言,那麼就替換該檔案。
blocks.js
每一個塊由一些HTML元素組成,由CSS設定樣式,由一些JavaScript時間處理器處理拖拽並且修改輸入引數。blocks.js
檔案用於建立並管理這些元素,並且將它們組成單一的物件。當塊被加入到選單中時,綁定了一個JavaScript函式用來實現語言,因而指令碼中的每個塊在指令碼執行的時候都要能找到其對應的函式並呼叫。
塊有兩種結構。一種擁有一個數值引數(具有預設值),還有一種作為其他塊的容器。這些貌似很有限制,然而在一個大的系統中可以改進。在Waterbear中還有表示式塊,可以作為引數進行傳遞;可以支援多個不同型別的值。在當前的狀況下,我們試試只有一種型別的引數能幹些什麼。
<!-- The HTML structure of a block -->
<div class="block" draggable="true" data-name="Right">
Right
<input type="number" value="5">
degrees
</div>
需要注意的是,指令碼中的塊和選單中的塊沒有區別。只有拖拽時會判斷塊是從哪兒拖出來的,指令碼只會執行指令碼區的塊,然而它們本質上是一樣的結構,這就意味著從選單中向指令碼區拖動塊的時候可以進行克隆。
createBlock(name, value, contents)
函式返回一個代表塊的DOM元素,並且在DOM中填充了各種內部元素,可以直接插入到document中。這可以用於向選單區新增塊,也可以用於從檔案或localStorage
中恢復塊到指令碼區。 這個函式是專為Blockcode語言編寫的,如果傳入的value引數有值,那麼就假定這是一個數值,並且建立一個number型別的input元素。該函式被限制用於Blockcode,如果要擴充套件塊以支援其他型別的引數,則需要更改程式碼。
function createBlock(name, value, contents){
var item = elem('div',
{'class': 'block', draggable: true, 'data-name': name},
[name]
);
if (value !== undefined && value !== null){
item.appendChild(elem('input', {type: 'number', value: value}));
}
if (Array.isArray(contents)){
item.appendChild(
elem('div', {'class': 'container'}, contents.map(function(block){
return createBlock.apply(null, block);
})));
}else if (typeof contents === 'string'){
// Add units (degrees, etc.) specifier
item.appendChild(document.createTextNode(' ' + contents));
}
return item;
}
我們有一些將塊作為DOM處理的工具函式:
- blockContents(block)
返回容器塊的子塊。如果引數是容器塊則以列表的形式返回子塊,否則返回null。
- blockValue(block)
如果塊中包含一個number型別的input則返回input的值,否則返回null。
- blockScript(block)
返回塊的JSON形式,便於序列化。其後方便恢復。
- runBlocks(blocks)
執行塊陣列中的所有塊。
function blockContents(block){
var container = block.querySelector('.container');
return container ? [].slice.call(container.children) : null;
}
function blockValue(block){
var input = block.querySelector('input');
return input ? Number(input.value) : null;
}
function blockUnits(block){
if (block.children.length > 1 &&
block.lastChild.nodeType === Node.TEXT_NODE &&
block.lastChild.textContent){
return block.lastChild.textContent.slice(1);
}
}
function blockScript(block){
var script = [block.dataset.name];
var value = blockValue(block);
if (value !== null){
script.push(blockValue(block));
}
var contents = blockContents(block);
var units = blockUnits(block);
if (contents){script.push(contents.map(blockScript));}
if (units){script.push(units);}
return script.filter(function(notNull){ return notNull !== null; });
}
function runBlocks(blocks){
blocks.forEach(function(block){ trigger('run', block); });
}
drag.js
drag.js
實現了選單區和指令碼區的互動,用於將靜態的HTML塊轉變為動態的程式語言。使用者從選單區拖動塊到指令碼區來建構程式,系統執行指令碼區的塊。
我們使用HTML5的拖拽功能;需要的JavaScript事件處理器在這兒定義。(關於HTML5的拖拽,詳情參考Eric Bidleman’s article.) 內建支援拖拽固然很棒,然而也有一些限制,例如移動端瀏覽器上基本不支援。
檔案開頭定義了一些變數。當我們拖動時,需要在拖動的不同階段的回撥中引用它們。
var dragTarget = null; // 正在拖動的塊
var dragType = null; // 從選單中還是指令碼中拖動?
var scriptBlocks = []; // 指令碼區中的塊
根據拖動的起始點和結束位置,drop
會有不同的效果。
* 從指令碼區拖放到選單區則刪除 dragTarget
(從指令碼區中刪除塊).
* 從指令碼區拖放到指令碼區則移動 dragTarget
(在指令碼區中移動現有塊).
* 從選單區拖放到指令碼區則複製 dragTarget
(向指令碼區中插入新塊).
* 從選單拖放到選單,不做任何事。
在dragStart(evt)
處理器中我們開始跟蹤塊是從選單拖放到指令碼區還是相反,或者在指令碼區內移動。我們還記錄下了指令碼區中所有沒有被拖動的塊,以便後來使用。evt.dataTransfer.setData
是用來處理瀏覽器和其他應用程式之間的拖放,這兒沒有用上,僅僅是為了繞開一個bug才使用的。
function dragStart(evt){
if (!matches(evt.target, '.block')) return;
if (matches(evt.target, '.menu .block')){
dragType = 'menu';
}else{
dragType = 'script';
}
evt.target.classList.add('dragging');
dragTarget = evt.target;
scriptBlocks = [].slice.call(
document.querySelectorAll('.script .block:not(.dragging)'));
// For dragging to take place in Firefox, we have to set this, even if
// we don't use it
evt.dataTransfer.setData('text/html', evt.target.outerHTML);
if (matches(evt.target, '.menu .block')){
evt.dataTransfer.effectAllowed = 'copy';
}else{
evt.dataTransfer.effectAllowed = 'move';
}
}
當我們正在拖動時, 可以在dragenter
, dragover
, 和 dragout
事件中新增一些視覺線索,例如高亮放置區等等。其中我們只使用了 dragover
。
function dragOver(evt){
if (!matches(evt.target, '.menu, .menu *, .script, .script *, .content')) {
return;
}
// Necessary. Allows us to drop.
if (evt.preventDefault) { evt.preventDefault(); }
if (dragType === 'menu'){
// See the section on the DataTransfer object.
evt.dataTransfer.dropEffect = 'copy';
}else{
evt.dataTransfer.dropEffect = 'move';
}
return false;
}
當我們鬆開滑鼠時會有一個 drop
事件,這就是見證奇蹟的時刻。我們需要檢查拖放的起始點,然後要麼複製塊,要麼移動塊,或者刪除塊。 我們使用trigger()
(定義在util.js中)啟動了一些自定義事件用來重新整理指令碼區。
function drop(evt){
if (!matches(evt.target, '.menu, .menu *, .script, .script *')) return;
var dropTarget = closest(
evt.target, '.script .container, .script .block, .menu, .script');
var dropType = 'script';
if (matches(dropTarget, '.menu')){ dropType = 'menu'; }
// stops the browser from redirecting.
if (evt.stopPropagation) { evt.stopPropagation(); }
if (dragType === 'script' && dropType === 'menu'){
trigger('blockRemoved', dragTarget.parentElement, dragTarget);
dragTarget.parentElement.removeChild(dragTarget);
}else if (dragType ==='script' && dropType === 'script'){
if (matches(dropTarget, '.block')){
dropTarget.parentElement.insertBefore(
dragTarget, dropTarget.nextSibling);
}else{
dropTarget.insertBefore(dragTarget, dropTarget.firstChildElement);
}
trigger('blockMoved', dropTarget, dragTarget);
}else if (dragType === 'menu' && dropType === 'script'){
var newNode = dragTarget.cloneNode(true);
newNode.classList.remove('dragging');
if (matches(dropTarget, '.block')){
dropTarget.parentElement.insertBefore(
newNode, dropTarget.nextSibling);
}else{
dropTarget.insertBefore(newNode, dropTarget.firstChildElement);
}
trigger('blockAdded', dropTarget, newNode);
}
}
dragEnd(evt)
在滑鼠鬆開時被呼叫,然而是在我們處理了drop
事件之後。這兒我們可以進行一些清理,刪除元素中的class,重置以便下次拖放。
function _findAndRemoveClass(klass){
var elem = document.querySelector('.' + klass);
if (elem){ elem.classList.remove(klass); }
}
function dragEnd(evt){
_findAndRemoveClass('dragging');
_findAndRemoveClass('over');
_findAndRemoveClass('next');
}
menu.js
在檔案menu.js
中,塊被綁定了執行時需要呼叫的函式,也包含了實際執行指令碼區塊的程式碼。每次指令碼被修改後,會自動重新執行。
這裡的選單不是下拉式或者彈出式的,而是一個塊的列表,從中你可以選擇塊,然後拖到指令碼區。該檔案就負責對選單區進行設定,選單區以一個提供迴圈功能的塊(這不是turtle language的一部分)開始。
使用一個檔案收集分散的函式很有用,特別是專案還在開發的時候。保持房屋整潔的祕訣就是給雜亂的東西指定特定的地方存放,構建程式也是如此。對於歸屬不明確的部分,應該使用一個檔案或者模組收集。當這個檔案變得越來越大的時候,你就要注意了,應當將相關的函式抽取出來歸併到一個單獨的模組中(或者整合成一個更加通用的函式)。你不應該任由這個雜貨區檔案變得龐大,而僅僅將它作為一個臨時放置區,直到你給其中的程式碼找到合理的歸置之所。
我們會較多的使用menu
和script
,因而保留它們的引用;沒有必要每次都查詢它們的DOM。我們也會用到scriptRegistry
,它儲存了選單中塊的指令碼。我們簡單的給選單區中的塊進行了命名,並進行了對映,不支援一個名字對應多個塊也不支援重新命名塊。這種策略如果用在複雜的指令碼環境中可能不夠健壯。
我們使用scriptDirty
來標識指令碼區是否已被修改過了,因而可以避免反覆執行指令碼區。
var menu = document.querySelector('.menu');
var script = document.querySelector('.script');
var scriptRegistry = {};
var scriptDirty = false;
當我們想通知系統在下一個frame處理器中執行指令碼,呼叫runSoon()
將scriptDirty
設定為true
。系統在每一個frame中呼叫run()
,除非scriptDirty
被設定,否則立即返回。當scriptDirty
被設定為true時,執行指令碼區中所有的塊,並且觸發事件使得特定的語言處理相關任務。這樣做將塊和turtle language進行了解耦,使得塊可以被重用(或者也可以說語言可插拔)。
在執行指令碼的時候,我們遍歷每個塊,呼叫它的runEach(evt)
,該方法會在塊上新增一個class(用於CSS),然後找到並呼叫與塊繫結的函式。如果我們減慢執行速度,你將看到每個塊在執行時會被高亮。
下面的requestAnimationFrame
是瀏覽器提供的用作動畫的函式。它接受一個函式作為引數,然後在渲染下一幀(每秒60幀)的時候使用。具體得到多少幀取決於我們能多塊的處理任務。
function runSoon(){ scriptDirty = true; }
function run(){
if (scriptDirty){
scriptDirty = false;
Block.trigger('beforeRun', script);
var blocks = [].slice.call(
document.querySelectorAll('.script > .block'));
Block.run(blocks);
Block.trigger('afterRun', script);
}else{
Block.trigger('everyFrame', script);
}
requestAnimationFrame(run);
}
requestAnimationFrame(run);
function runEach(evt){
var elem = evt.target;
if (!matches(elem, '.script .block')) return;
if (elem.dataset.name === 'Define block') return;
elem.classList.add('running');
scriptRegistry[elem.dataset.name](elem);
elem.classList.remove('running');
}
我們使用menuItem(name, fn, value, contents)
向選單中新增塊,該函式接受一個普通塊,然後給它繫結一個函式,並加入到選單欄。
function menuItem(name, fn, value, units){
var item = Block.create(name, value, units);
scriptRegistry[name] = fn;
menu.appendChild(item);
return item;
}
我們在此處定義repeat(block)
,而不是在turtle language中,因為我們希望這個函式可以在不同的語言中通用。如果我們有了if塊和讀寫變數,這些也應該放到這裡,或者是一個單獨的語言轉換(trans-language)模組,然而此時我們只有這一個通用的塊。
function repeat(block){
var count = Block.value(block);
var children = Block.contents(block);
for (var i = 0; i < count; i++){
Block.run(children);
}
}
menuItem('Repeat', repeat, 10, []);
turtle.js
turtle.js
是turtle塊語言的實現部分。它不被其他任何程式碼依賴。因而這一部分可以很輕易的替換。
Turtle programming是圖形程式設計的一種,由於Logo語言被大眾所熟悉,簡單的說就是一個攜帶著一隻筆的龜頭在螢幕上移動。你可以命令龜頭收起筆(不再畫線,繼續移動),放下筆(移動到哪兒,筆畫到哪兒),移動指定數量步,或者轉向多少度。僅僅依靠這些命令和迴圈,就可以畫出令人靜態的圖案。
在這個turtle graphics版本中,我們有一些額外的塊。技術上講,我們並不需要turn right
和turn left
同時存在,因為擁有其中一個,那麼另外一個可以使用負數完成。類似的move back
也可以使用move forward
加上負數解決。
上面的圖案指令碼,將兩個迴圈放入另一個迴圈之中,然後給每個迴圈新增一個move forward
和turn right
,然後調整引數,直到得到滿意的圖案。
var PIXEL_RATIO = window.devicePixelRatio || 1;
var canvasPlaceholder = document.querySelector('.canvas-placeholder');
var canvas = document.querySelector('.canvas');
var script = document.querySelector('.script');
var ctx = canvas.getContext('2d');
var cos = Math.cos, sin = Math.sin, sqrt = Math.sqrt, PI = Math.PI;
var DEGREE = PI / 180;
var WIDTH, HEIGHT, position, direction, visible, pen, color;
reset()
函式將所有的狀態變數恢復到預設狀態。如果我們需要支援多個龜頭,可以將這些變數封裝到一個物件中。deg2rad(deg)
函式用於將度轉化為弧度,因為作圖使用的是弧度。drawTurtle()
用於畫龜頭自身,預設是一個簡單的三角形。當然你可以自定義成更加華麗的龜頭。
注意drawTurtle
函式使用了我們在turtle drawing中定義的一些基本操作。有時你不想在不同的抽象層之間重用程式碼,然而如果定義明確,這對於程式碼大小和效能是至關重要的。
function reset(){
recenter();
direction = deg2rad(90); // facing "up"
visible = true;
pen = true; // when pen is true we draw, otherwise we move without drawing
color = 'black';
}
function deg2rad(degrees){ return DEGREE * degrees; }
function drawTurtle(){
var userPen = pen; // save pen state
if (visible){
penUp(); _moveForward(5); penDown();
_turn(-150); _moveForward(12);
_turn(-120); _moveForward(12);
_turn(-120); _moveForward(12);
_turn(30);
penUp(); _moveForward(-5);
if (userPen){
penDown(); // restore pen state
}
}
}
我們有個專門的塊用於畫一個特定半徑的圓。即使你可以使用move 1 right 1
然後迴圈360次來畫圓,畢竟這太麻煩了。
function drawCircle(radius){
// Math for this is from http://www.mathopenref.com/polygonradius.html
var userPen = pen; // save pen state
if (visible){
penUp(); _moveForward(-radius); penDown();
_turn(-90);
var steps = Math.min(Math.max(6, Math.floor(radius / 2)), 360);
var theta = 360 / steps;
var side = radius * 2 * Math.sin(Math.PI / steps);
_moveForward(side / 2);
for (var i = 1; i < steps; i++){
_turn(theta); _moveForward(side);
}
_turn(theta); _moveForward(side / 2);
_turn(90);
penUp(); _moveForward(radius); penDown();
if (userPen){
penDown(); // restore pen state
}
}
}
我們主要的基本函式是moveForward
,處理了一些三角函式的運算工作,並且判斷筆是否收起或放下。
function _moveForward(distance){
var start = position;
position = {
x: cos(direction) * distance * PIXEL_RATIO + start.x,
y: -sin(direction) * distance * PIXEL_RATIO + start.y
};
if (pen){
ctx.lineStyle = color;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(position.x, position.y);
ctx.stroke();
}
}
剩下大部分的龜頭命名都可以使用上面定義的函式來輕鬆的實現
function penUp(){ pen = false; }
function penDown(){ pen = true; }
function hideTurtle(){ visible = false; }
function showTurtle(){ visible = true; }
function forward(block){ _moveForward(Block.value(block)); }
function back(block){ _moveForward(-Block.value(block)); }
function circle(block){ drawCircle(Block.value(block)); }
function _turn(degrees){ direction += deg2rad(degrees); }
function left(block){ _turn(Block.value(block)); }
function right(block){ _turn(-Block.value(block)); }
function recenter(){ position = {x: WIDTH/2, y: HEIGHT/2}; }
當我們需要重新整理狀態,clear
函式可以恢復初始狀態。
function clear(){
ctx.save();
ctx.fillStyle = 'white';
ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.restore();
reset();
ctx.moveTo(position.x, position.y);
}
當指令碼初次載入時,我們使用reset
和clear
來初始化並畫出龜頭。
onResize();
clear();
drawTurtle();
現在可以在menu.js
檔案中通過Menu.item
函式使用上面定義的函式,從而構建使用者可以使用的塊。
Menu.item('Left', left, 5, 'degrees');
Menu.item('Right', right, 5, 'degrees');
Menu.item('Forward', forward, 10, 'steps');
Menu.item('Back', back, 10, 'steps');
Menu.item('Circle', circle, 20, 'radius');
Menu.item('Pen up', penUp);
Menu.item('Pen down', penDown);
Menu.item('Back to center', recenter);
Menu.item('Hide turtle', hideTurtle);
Menu.item('Show turtle', showTurtle);
總結
為何不使用MVC?
Model-View-Controller (MVC) 對於80年代的Smalltalk程式來說是一個好的設計選擇,現在也可以用於一些Web應用,然而它不是對於所有問題都是好的選擇。塊語言中所有的狀態(MVC中的“M”)都被塊物件所持有,因而將該模式複製到Javascript中實無必要,除非對於model有其他的需求(例如編輯共享的,分散式的程式碼)。
在Waterbear的早期版本中,我曾通過在JavaScript中維持model然後將之同步到DOM中,後來我發現有一半的程式碼和90%的bug都出於這裡。消除了這種重複之後,程式碼變得更加簡單和健壯,所有的狀態都被DOM元素所持有後,使用開發者工具可以輕鬆查出很多bug。因此在HTML/CSS/JavaScript的基礎上增加MVC的額外分層沒有什麼幫助。
細微更改引發大變更
構建一個我所從事的大型系統的小型而緊湊的版本是一個有趣的練習。在構建大型系統時,你對於改變總是很遲疑,因為這會影響到很多方面。然後在一個小型版本中,你可以盡情試驗然後將所學到的東西應用到大型系統。對於我來說,大型系統就是Waterbear,而這個功能對於Waterbear的構建很有建設意義。
小實驗使得失敗不再可怕
在這個微型的塊語言中,我進行了下面的試驗:
- 使用HTML5的拖拽
- 通過直接遍歷DOM並呼叫繫結函式的方式來執行塊
- 將實際執行的程式碼從HTML DOM中分離
- 簡化了拖動時的碰撞檢測
- 構建了我的微型向量(vector)和精靈(sprite)庫(用於遊戲塊)
- 改變指令碼時實時顯示結果
試驗不一定需要成功。我們總是傾向於將失敗看得很嚴重,而不是將它視為通向成功的路徑。雖然我搞定了HTML5的拖拽,然後由於在手機瀏覽器上不支援,所以在Waterbear中沒有采用。分離程式碼並且通過迭代塊來執行工作良好,因此我已經著手將它引入Waterbear了。
我們真正需要構建什麼
構建一個大型系統的小型版本可以使得我們更加專注於最重要的部分。使得我們思考,有哪些功能是歷史遺留而不再需要維護。總之,可以幫助大型系統進行重構。
構建程式之路還很漫長
在該專案中,我還有一些東西沒有試驗。比如新增一個能新增函式的塊。實現撤銷和重做功能。使塊能接受多個引數。通過網路分享指令碼。