大家好,我是微微笑的蝸牛,??。
今天將會開啟一個新的系列,如何打造自己的 React 框架。包括如下幾部分:
- dom 節點描述與創建
- jsx
- virtual dom
- component
這一篇文章主要講 dom 節點描述與創建。
dom api
我們首先來看一下如何使用 dom api 來創建節點。節點分為兩種類型:元素和文字。
對于元素來說,使用 createElement
創建,參數傳入類型。如下所示,創建了一個 input 節點,document
是一個全局對象。
const domInput = document.createElement("input");
可通過元素 id 獲取元素:
const domRoot = document.getElementById("root");
可設置元素屬性:
domInput["type"] = "text";
domInput["value"] = "Hi world";
domInput["className"] = "my-class";
可監聽事件:
domInput.addEventListener("change", e => alert(e.target.value));
對于文字來說,使用 createTextNode
創建,文字內容用屬性 nodeValue
填充。如下所示:
// Create a text node
const domText = document.createTextNode("");
// Set text node content
domText["nodeValue"] = "Foo";
// Append an element
domRoot.appendChild(domInput);
在了解這些 api 后,我們接下來就可以著手設計自己框架中的節點描述格式了。我將這個框架稱之為 SLReact,當然你也可以用你喜歡的名字。
節點描述
我們將使用 js 對象來描述一個節點信息。節點信息包括類型 type 和屬性 props。
- type 用來描述節點類型,是個字符串,比如
div/span
。 - props 是節點屬性信息。如果它有子節點的話,則會包含 children 字段。children 是一個數組,同樣包含描述信息。
舉個栗子:
const element = {
type: "div",
props: {
id: "container",
children: [
{ type: "input", props: { value: "foo", type: "text" } },
{ type: "a", props: { href: "/bar" } },
{ type: "span", props: {} }
]
}
};
上面這段信息,描述了如下的 dom 結構。div 節點中包含了 3 個子節點,input、a、span
。
<div id="container">
<input value="foo" type="text">
<a href="/bar"></a>
<span></span>
</div>
render
在有了節點描述信息之后,下一步就是如何將其轉換為真正的 dom 節點,并添加到 dom 樹上。這里我們將會實現自己的 render
方法。
元素節點
有了節點類型,很容易通過 createElement
這個 api 來創建 dom。
const { type, props } = element;
const dom = document.createElement(type);
若有子節點的話,遞歸調用 render 方法即可。如下所示:
const childElements = props.children || [];
childElements.forEach(childElement => render(childElement, dom));
但是需要注意的是,還有屬性需要處理。props 中包含屬性,同時也會包含事件信息。比如:
const element = {
type: "div",
props: {
onChange: () => {},
children: [],
other: 'xx'
}
};
- 以
on
開頭的是事件監聽,它的值是個方法。比如 onChange,表明我們想監聽 change 事件。 -
children
是子節點信息。 - 其余就是普通屬性。
事件監聽
將以 on
開頭的屬性過濾出來,以全小寫的方式取出事件名稱,最后使用 addEventListener
來監聽事件。
如下所示:
const isListener = name => name.startsWith("on");
Object.keys(props).filter(isListener).forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, props[name]);
});
添加屬性
在上一步,我們處理了事件監聽的屬性。這里再來處理普通屬性,過濾掉以 on
開頭的屬性和 children
即可,然后將屬性添加到 dom 中。
如下所示:
const isAttribute = name => !isListener(name) && name != "children";
Object.keys(props).filter(isAttribute).forEach(name => {
dom[name] = props[name];
});
文本節點
先來看下 createTextNode
函數的說明。其中參數 data 為文本內容,它指定了節點屬性 nodeValue
的值。下面會用到這個知識點。
/**
* Creates a text string from the specified value.
* @param data String that specifies the nodeValue property of the text node.
*/
createTextNode(data: string): Text;
文本的描述結構同元素,但是請注意:文本內容將被當做一個子節點。這跟《聽說你想寫個渲染引擎》中的處理是一樣的。
比如一段文本:<span>Foo</span>
。在 React 中,它的描述結構如下:
const reactElement = {
type: "span",
props: {
children: ["Foo"]
}
};
其中,文本內容 Foo
被當成了子節點,但它是一個字符串。
上面我們提到,children 中的結構也是一段描述信息。為了統一處理,這里將文本內容信息修改同樣的結構,使用 type + props 的描述方式。文本內容使用 nodeValue
屬性來描述。
如下所示:
const textElement = {
type: "span",
props: {
children: [
{
type: "text",
props: { nodeValue: "Foo" }
}
]
}
};
這樣,當我們遇到類型是 text
的節點時,便認為它是文本,而屬性 nodeValue
的值就是文本內容。所以將屬性塞給 dom 就好。
const { type, props } = element;
// 創建節點
const isTextElement = type === "text";
const dom = isTextElement
? document.createTextNode("")
: document.createElement(type);
完整的 render 方法如下所示:
function render(element, parentDom) {
const { type, props } = element;
// 創建節點
const isTextElement = type === "text";
const dom = isTextElement
? document.createTextNode("")
: document.createElement(type);
// 處理事件監聽
const isListener = (name) => name.startsWith("on");
Object.keys(props)
.filter(isListener)
.forEach((name) => {
const eventType = name.toLocaleLowerCase().substring(2);
dom.addEventListener(eventType, props[name]);
});
// 處理屬性
const isAttribute = (name) => !isListener(name) && name != "children";
Object.keys(props)
.filter(isAttribute)
.forEach((name) => {
dom[name] = props[name];
});
// 處理子節點
const childElements = props.children || [];
childElements.forEach((child) => render(child, dom));
// 添加父節點
parentDom.appendChild(dom);
}
測試代碼
function importFromBlow() {
function render(element, parentDom) {
// 省略實現
}
return { render };
}
const SLReact = importFromBlow();
const stories = [
{ name: "part1", url: "http://bit.ly/2pX7HNn" },
{ name: "part2", url: "http://bit.ly/2qCOejH" },
{ name: "part3", url: "http://bit.ly/2qGbw8S" },
{ name: "part4", url: "http://bit.ly/2q4A746" },
{ name: "part5", url: "http://bit.ly/2rE16nh" },
];
const appElement = {
type: "div",
props: {
children: [
{
type: "ul",
props: {
children: stories.map(storyElement),
},
},
],
},
};
// 生成 element 結構
function storyElement({ name, url }) {
const likes = Math.ceil(Math.random() * 100);
const buttonElement = {
type: "button",
props: {
children: [
{ type: "text", props: { nodeValue: likes } },
{ type: "text", props: { nodeValue: " ??" } },
],
},
};
const linkElement = {
type: "a",
props: {
href: url,
children: [{ type: "text", props: { nodeValue: name } }],
},
};
return {
type: "li",
props: {
children: [buttonElement, linkElement],
},
};
}
SLReact.render(appElement, document.getElementById("root"));
看看最后一句,是不是就很像 React 中的用法了?在 render 方法中傳入節點描述信息和 dom 根節點即可。
最后的效果圖如下所示(有自定義 css):
完整的代碼在此:https://github.com/silan-liu/slreact/tree/master/part1。
總結
這一篇文章主要介紹了如何定義節點描述信息,以及實現自己的 render 方法,來完成 dom 節點的創建和屬性設置。感謝閱讀~
下一篇文章將會講述 jsx 的實現,敬請期待。