- 原文地址:Build a Drag and Drop layout builder with React and ImmutableJS
- 原文作者:Chris Kitson
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:fireairforce
- 校對者:Eternaldeath, portandbridge
使用 React 和 ImmutableJS 構建一個拖放(DnD)佈局構建器
『拖放』這一類的行為存在著巨大的使用者需求,例如構建網站(Wix)或互動式應用程式(Trello)。毫無疑問,這種型別的互動創造了非常酷的使用者體驗。如果再加上一些最新的 UI 技術,我們可以建立一些非常好的軟體。
這篇文章的最終目標是什麼?
我想構建一個能讓使用者使用一系列可定製 UI 元件來構建佈局的拖放佈局構建器,最終能構建出一個網站或者是 web 應用程式。
我們會用到哪些庫?
- React
- ImmutableJS
下面花一點時間來解釋它們在構建這個專案時所起的作用。
React
React 基於宣告式程式設計,這意味著它根據狀態來進行渲染。狀態(State)實際上只是一個 JSON 物件,它具有告訴 React 應該怎麼去渲染(樣式和功能)的屬性。與操作 DOM 的庫(例如 jQuery)不同,我們不直接改變 DOM,而是通過修改狀態(state)然後讓 React 去負責 DOM(稍後會介紹 DOM)。
在這個專案中,假設有一個父元件來儲存佈局的狀態(JSON 物件),並且這個狀態將被傳遞給其他的元件,這些元件都是 React 中的無狀態元件。
這些元件的作用是從父元件中獲取狀態,然後根據其屬性來渲染本身。
以下是一個具有三個 link 物件的狀態的簡單示例:
{
links: [{
name: "Link 1",
url: "http://link.one",
selected: false
}, {
name: "Link 2",
url: "http://link.two",
selected: true
}, {
name: "Link 3",
url: "http://link.three",
selected: false
}]
}
複製程式碼
通過上面的例子,我們可以遍歷 links 陣列來為每個元素建立一個無狀態元件:
interface ILink {
name: string;
url: string;
selected: boolean;
}
const LinkComponent = ({ name, url, selected }: ILink) =>
<a href={url} className={selected ? 'selected': ''}>{name}</a>;
複製程式碼
你可以看到我們如何根據狀態中儲存的選定屬性將 css 類『selected』應用到 links 陣列元件。下面是呈現給瀏覽器的內容:
<a href="http://link.two" class="selected">Link 2</a>
複製程式碼
ImmutableJS
我們已經瞭解了狀態在我們專案中的重要性,它是使 React 元件如何渲染的唯一真實的資料來源。React 中的狀態儲存在不可變的資料結構中。
簡而言之,這意味著一旦建立了資料物件,就不能直接去修改它。除非我們建立一個具有更改狀態的新物件。
讓我們用另外一個簡單的例子來說明不變性:
interface ILink {
name: string;
url: string;
selected: boolean;
}
const link: ILink = {
name: "Link 1",
url: "http://link.one",
selected: false
}
複製程式碼
在傳統的 JavaScript 中,你可以通過下面的操作來更新 link 物件:
link.name = 'New name';
複製程式碼
如果我們的狀態是不可變的,那麼上面操作不可能完成的,那麼我們必須要建立一個 name 屬性已經被修改的新物件。
link = {...link, name: 'New name' };
複製程式碼
注意:為了支援不變性,React 為我們提供了一個方法 this.setState()
,我們可以使用它來告訴元件狀態已經改變,並且元件還需要重新進行渲染如果狀態發生任何改變。
上面只是基本示例,但是如果想要在複雜的 JSON 狀態結構中更改巢狀了多層的屬性應該怎麼做?
ECMA Script 6 為我們提供了一些方便的操作符和方法來改變物件,但它們並不適用於複雜的資料結構,這就是我們需要 ImmutableJS 來簡化任務的原因。
稍後我們會使用 ImmutableJS,但是現在你只需要知道它具有給我們提供簡便的方法用來改變複雜的狀態方面的作用。
HTML5 拖放(DnD)
所以我們知道我們的狀態是一個不可變的 JSON 物件,而 React 來負責處理元件,但我們需要有趣的使用者互動體驗,對吧?
幸虧有了 HTML5 使得這實際上非常簡單,因為它提供了我們可以用來檢測拖動元件的時間和放置它們的位置的方法。由於 React 將原生 HTML 元素暴露給瀏覽器,因此我們可以使用原生的事件方法使我們的實現更加簡單。
注意:我得知使用 HTML5 實現的 DnD 可能存在一些問題但如果沒有其它的問題,這可能是一個探究課程,如果發現有問題的話,我們之後可以換掉它。
在這個專案中,我們擁有使用者可以拖動的元件(HTML divs),我稱他們為可拖動元件。
同時我們也擁有允許使用者放置元件的區域, 我稱他們為可放置元件。
使用原生 HTML5 事件如 onDragStart
、onDragOver
和 onDragDrop
,我們也應該擁有基於 DnD 互動更改應用程式狀態所需要的東西。
以下是一個可拖動元件的例項:
export interface IDraggableComponent {
name: string;
type: string;
draggable?: boolean;
onDragStart: (ev: React.DragEvent<HTMLDivElement>, name: string, type: string) => void;
}
export const DraggableComponent = ({
name,
type,
onDragStart,
draggable = true
}: IDraggableComponent) =>
<div className='draggable-component' draggable={draggable} onDragStart={(ev) => onDragStart(ev, name, type)}>{name}</div>;
複製程式碼
在上面的程式碼片段中,我們渲染了一個 React 元件,該元件使用 onDragStart
事件告訴父元件我們正開始拖動元件。我們還可以通過傳遞 draggable
屬性來切換拖動它的能力。
以下是一個可放置元件的例項:
export interface IDroppableComponent {
name: string;
onDragOver: (ev: React.DragEvent<HTMLDivElement>) => void;
onDrop: (ev: React.DragEvent<HTMLDivElement>, componentName: string) => void;
}
export const DroppableComponent = ({
name,
onDragOver,
onDrop
}: IDroppableComponent) =>
<div className='droppable-component'
onDragOver={(ev: React.DragEvent<HTMLDivElement>) => onDragOver(ev)}
onDrop={(ev: React.DragEvent<HTMLDivElement>) => onDrop(ev, name)}>
<span>Drop components here!</span>
</div>;
複製程式碼
在上面的元件中,我們正在監聽 onDrop
事件,因此我們可以根據放進可放置元件的新元件來更新狀態。
好的,是時候快速回顧一下,然後將他們全部放到一起:
我們將使用 React 中基於狀態物件的少量解耦無狀態元件來渲染整個佈局。使用者互動將由 HTML5 DnD 事件來處理,而時間會使用 ImmutableJS 來觸發對狀態物件的更改。
拖動全部
現在我們已經對要做的事情以及如何處理它們有了深刻的瞭解,讓我們考慮一下這個難題中的一些最重要的部分:
- 佈局狀態
- 拖放構建器元件
- 渲染網格內的巢狀元件
1. 佈局狀態
為了使我們的元件能表示無限的佈局組合,狀態需要靈活且可擴充。我們還需要記住的是,如果想要表示任何給定佈局的 DOM 樹,意味著需要很多令人愉快的遞迴來支援巢狀結構!
我們的狀態需要儲存大量元件,可以通過以下介面表示:
如果你不熟悉 JavaScript 中的介面,你應該看看 TypeScript — 你大概能看出我是它的粉絲。它很適用於 React。
export interface IComponent {
name: string;
type: string;
renderProps?: {
size?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
};
children?: IComponent[];
}
複製程式碼
我會使元件的定義最小化,但是你可以根據需要擴充它。我在 renderProps
這裡定義一個物件,所以我們可以為元件提供狀態來告訴它如何渲染,children
的屬性為我們提供了遞迴。
對於更高層次,我會建立一個物件陣列來儲存元件,它們將出現在狀態的根部。
為了說明這一點,我們建議將以下內容作為 HTML 中標記的有效佈局:
<div class="content-panel-1">
<div class="component">
Component 1
</div>
<div class="component">
Component 2
</div>
</div>
<div class="content-panel-2">
<div class="component">
Component 3
</div>
</div>
複製程式碼
為了在狀態中表示這一點,我們可以為內容皮膚定義如下所示的介面:
export interface IContent {
id: string;
cssClass: string;
components: IComponent[];
}
複製程式碼
然後我們的狀態將會成為一個像如下 IContent
陣列:
const state: IContent[] = [
{
id: 'content-panel-1',
cssClass: 'content-panel-1',
components: [{
type: 'component1',
renderProps: {},
children: []
},
{
type: 'component2',
renderProps: {},
children: []
}]
},
{
id: 'content-panel-2',
cssClass: 'content-panel-2',
components: [{
type: 'component3',
renderProps: {},
children: []
}]
}
];
複製程式碼
通過在 children
陣列屬性中推送其他元件,我們可以定義其他元件來建立巢狀的類似 DOM 的樹結構:
[0]
components:
[0]
children:
[0]
children:
[0]
...
複製程式碼
2. 拖放佈局構建器
佈局構建器元件將執行一系列功能,例如:
- 保持並更新元件狀態
- 渲染 可拖動元件 和 可放置元件
- 渲染巢狀佈局結構
- 觸發 DnD HTML5 事件
程式碼大概是這樣的:
export class BuilderLayout extends React.Component {
public state: IBuilderState = {
dashboardState: []
};
constructor(props: {}) {
super(props);
this.onDragStart = this.onDragStart.bind(this);
this.onDragDrop = this.onDragDrop.bind(this);
}
public render() {
}
private onDragStart(event: React.DragEvent <HTMLDivElement>, name: string, type: string): void {
event.dataTransfer.setData('id', name);
event.dataTransfer.setData('type', type);
}
private onDragOver(event: React.DragEvent<HTMLDivElement>): void {
event.preventDefault();
}
private onDragDrop(event: React.DragEvent <HTMLDivElement>, containerId: string): void {
}
}
複製程式碼
我們先暫時不用管 render()
函式,後面很快會再見到它。
我們有三個事件,我們將繫結它們到我們的『可拖動元件』和『可放置元件』上。
onDragStart()
——這個事件這裡我們設定一些關於 event
物件中元件的細節,即 name
和 type
。
onDragOver()
——我們現在不會對這個事件做任何事情,事實上我們通過 .preventDefault()
函式禁用瀏覽器的預設行為。
這就留下了 onDragDrop()
事件,這就是修改不可變狀態的神奇之處。為了改變狀態,我們需要幾條資訊:
- 要放置元件的名稱 ——
name
在event
物件中設定onDragStart()
。 - 要放置元件的型別 ——
type
在event
物件中設定onDragStart()
。 - 元件被放置的位置 ——
containerId
從可放置的元件中傳入這個方法。
在 containerId
中必須告訴我們,新的元件具體要放在狀態裡的什麼位置。可能有一種更簡潔的方法可以做到這一點,但為了描述這個位置,我將使用一個下劃線分隔的索引列表。
回顧我們的狀態模型:
[index]
components:
[index]
children:
[index]
children:
[index]
...
複製程式碼
用字串格式表示為 cb_index_index_index_index
。
此處的索引數描述了應該刪除元件的巢狀結構中的深度級別。
現在我們需要呼叫 immutableJS 中的強大功能來幫助我們改變應用程式的狀態。我們將在 onDragDrop()
方法中執行此操作,改方法可能如下所示:
private onDragDrop(event: React.DragEvent <HTMLDivElement>, containerId: string) {
const name = event.dataTransfer.getData('id');
const type = event.dataTransfer.getData('type');
const newComponent: IComponent = this.generateComponent(name, type);
const containerArray: string[] = containerId.split('_');
containerArray.shift(); // 忽略第一個引數,它是字串字首
const componentsPath: Array<number | string> = [] containerArray.forEach((id: string, index: number) => {
componentsPath.push(parseInt(id, INT_LENGTH));
componentsPath.push(index === 0 ? 'components' : 'children');
});
const { dashboardState } = this.state;
let componentState = fromJS(dashboardState);
componentState = componentState.setIn(componentsPath, componentState.getIn(componentsPath).push(newComponent));
this.setState({ dashboardState: componentState.toJS() });
}
複製程式碼
這裡的功能來自於 ImmutableJS 提供給我們的 .setIn()
和 .getIn()
方法。
它們採用一組字串/值以確定要在巢狀狀態模型中獲取或設定值的位置。這與我們生成可放置的 ids 方式很吻合。很酷吧?
fromJS()
和 toJS()
方法轉變 JSON 物件到 ImmutableJS 物件,然後再返回。
關於 ImmutableJS 有很多東西,我可能會在未來寫一篇關於它的專門的帖子。很抱歉,這次只是一次簡單的介紹!
3. 渲染網格內的巢狀元件
最後讓我們快速看一下前面提到的渲染方法。我想支援一個 CSS 網格系統類似於 Material responsive grid 來使我們的佈局更加靈活。它使用 12 列網格來規定 HTML 佈局,如下所示:
<div class="mdc-layout-grid">
<div class="mdc-layout-grid__inner">
<div class="mdc-layout-grid__cell mdc-layout-grid__cell--span-6">
Left column
</div>
<div class="mdc-layout-grid__cell mdc-layout-grid__cell--span-6">
Right column
</div>
</div>
</div>
複製程式碼
將它與我們的狀態所代表的巢狀結構相組合,我們可以得到一個非常強大的佈局構建器。
現在,我只是將網格的大小固定為兩列布局(即單個可放置元件中的兩列具有的遞迴)。
為了實現這一點,我們有一個可拖動元件的網格,它將包含兩個可放置的(每列一個)。
這是我之前建立的一個:
上面我有一個Grid,第一列中有一個Card,第二列中有一個Heading。
現在我在第一列中放置了另一個Grid,第一列中有一個Heading,第二列中有一個Card。
你明白了嗎?
舉個例子來說明如何使用 React 虛擬碼實現這個目的:
-
迴圈遍歷內容項(我們狀態的根)並且渲染一個
ContentBuilderDraggableComponent
和一個DroppableComponent
。 -
確定元件是否為 Grid 型別,然後渲染
ContentBuilderGridComponent
,否則渲染一個常規的DraggableComponent
。 -
渲染被 X 個子專案標記的 Grid 元件,每個子專案中都有一個
ContentBuilderDraggableComponent
和一個DroppableComponent
。
class ContentBuilderComponent... {
render() {
return (
<ContentComponent>
components.map(...) {
<ContentBuilderDraggableComponent... />
}
<DroppableComponent... />
</ContentComponent>
)
}
}
class ContentBuilderDraggableComponent... {
render() {
if (type === GRID) {
return <ContentBuilderGridComponent... />
} else {
return <DraggableComponent ... />
}
}
}
class ContentBuilderGridComponent... {
render() {
<GridComponent...>
children.map(...) {
<GridItemComponent...>
gridItemChildren.map(...) {
<ContentBuilderDraggableComponent... />
<DroppableComponent... />
}
</GridItemComponent>
}
</GridComponent>
}
}
複製程式碼
下一步是什麼?
我們已經完成了這篇文章,但我將來會對此進行一些擴充。這是一些想法:
- 配置元件的渲染道具
- 使網格元件可配置
- 使用服務端呈現從已儲存的狀態物件生成 HTML 佈局
希望你能 follow 我,如果你沒有,這是我在 GitHub 上的一個工作示例,希望你能欣賞它。 chriskitson/react拖放佈局構建器 使用React和ImmutableJS拖放(DnD)UI佈局構建器 - chriskitson/react拖放佈局構建器github.com
感謝您抽出寶貴時間閱讀我的文章。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。