從0實現一個tiny react(一)
注: 首發於segmentfault, 現遷移到掘金。。。
學習一個庫的最好的方法就是實現一個, 實際react的程式碼可能相去甚遠。
支援JSX
react元件可以完全不用JSX, 用純js來寫。 JSX語法經過babel轉化就是純js程式碼, 譬如:
const hw = <div>Hello World</div>
const hw = React.createElement('div', null, "Hello World")
複製程式碼
這兩種是等效的。 babel 通過babylon 來把JSX轉化為js 配置如下(transform-react-jsx):
{
"presets": [
"es2015"
],
"plugins": [
["transform-react-jsx", {
"pragma": "createElement" // default pragma is React.createElement
}]
]
}
複製程式碼
所以對於react庫本身的, 是不需要關心jsx語法的
渲染
react 中virtual-dom的概念, 使用一個 js的結構vnode來描述DOM 節點。 然後, 從vnode渲染出DOM樹。 這個 vnode由3個屬性描述:nodeName(div, Son...), props, children(vnode 組成的陣列), 所以 createElement的最簡實現
function createElement(comp, props, ...args) {
let children = []
for(let i = 0; i< args.length;i++){
if(args[i] instanceof Array) {
children = children.concat(args[i])
} else {
children.push(args[i])
}
}
return {
nodeName: comp,
props: props || {},
children
}
}
複製程式碼
從vnode 怎麼渲染到dom? 先想一下我們在react裡面書寫下面的元件的時候
class Father extends Component {
render() {
return (<Son/>) // React.createElement(Son) --> {nodeName: Son, props:{}, children:[]}
}
}
class Son extends Component {
render() {
return (<Grandson/>) // React.createElement(Grandson) --> {nodeName: Grandson, props:{}, children:[]}
}
}
/**
*React.createElement(
* "div",
* null,
* "i",
* React.createElement(
* "div",
* null,
* "am"
* ),
* React.createElement(GrandText, null)
* );
*/
class Grandson extends Component {
render() {
return (
<div>
i
<div>am</div>
<GrandText/>
</div>
)
}
}
class GrandText extends Component {
render() {
return (
<div>grandson</div> // React.createElement(Grandson)
)
}
}
render(<Father/>, document.getElementById('root'))
複製程式碼
在react裡, 最終渲染出來的就是一個i am grandson。 渲染的過程就是: 渲染Father的Vnode -> 渲染Son的Vnode -> 渲染Grandson的Vnode -> 渲染div -> 渲染i -> 渲染am -> 渲染GrandText。 顯然這是一個遞迴的過程:遞迴的中止條件是 渲染html標籤。
- 當 nodeName 是 html標籤, 直接操作dom
- 當 nodeName 是 react元件 遞迴操作 元件render返回的vnode
暫時先不考慮 dom操作, 只考慮這個遞迴方法, 程式碼如下:
function renderVDOM(vnode) {
if(typeof vnode == "string") { // 字串 "i an grandson"
return vnode
} else if(typeof vnode.nodeName == "string") {
let result = {
nodeName: vnode.nodeName,
props: vnode.props,
children: []
}
for(let i = 0; i < vnode.children.length; i++) {
result.children.push(renderVDOM(vnode.children[i]))
}
return result
} else if (typeof vnode.nodeName == "function") { // 如果是function
let func = vnode.nodeName
let inst = new func(vnode.props)
let innerVnode = inst.render()
return renderVDOM(innerVnode)
}
複製程式碼
執行上面的結構將返回 (jsfiddle演示地址)):
{
"nodeName": "div",
"props": {},
"children": ["i", {"nodeName": "div", "props": {}, "children": ["am"]}, {
"nodeName": "div",
"props": {},
"children": ["grandson"]
}]
}
複製程式碼
加入實際DOM操作, 程式碼如下:
function render(vnode, parent) {
let dom
if(typeof vnode == "string") {
dom = document.createTextNode(vnode)
parent.appendChild(dom)
} else if(typeof vnode.nodeName == "string") {
dom = document.createElement(vnode.nodeName)
setAttrs(dom, vnode.props)
parent.appendChild(dom)
for(let i = 0; i < vnode.children.length; i++) {
render(vnode.children[i], dom)
}
} else if (typeof vnode.nodeName == "function") {
let func = vnode.nodeName
let inst = new func(vnode.props)
let innerVnode = inst.render()
render(innerVnode, parent)
}
}
function setAttrs(dom, props) {
const allKeys = Object.keys(props)
allKeys.forEach(k => {
const v = props[k]
if(k == "className") {
dom.setAttribute("class", v)
return
}
if(k == "style") {
if(typeof v == "string") {
dom.style.cssText = v
}
if(typeof v == "object") {
for (let i in v) {
dom.style[i] = v[i]
}
}
return
}
if(k[0] == "o" && k[1] == "n") {
const capture = (k.indexOf("Capture") != -1)
dom.addEventListener(k.substring(2).toLowerCase(), v, capture)
return
}
dom.setAttribute(k, v)
})
}
複製程式碼
渲染實際Hello World(jsfiddle演示地址) 總結一下:
- createElement 方法負責建立 vnode
- render 方法負責根據生成的vnode, 渲染到實際的dom的一個遞迴方法 (由於元件 最終一定會render html的標籤。 所以這個遞迴一定是能夠正常返回的)
- vnode是字串的是, 建立textNode節點
- 當vnode.nodeName是 字串的時候, 建立dom節點, 根據props設定節點屬性, 遍歷render children
- 當vnode.nodeName是 function的時候, 獲取render方法的返回值 vnode', 執行render(vnode')
props 和 state
v = f(props, state)。 元件的渲染結果由 render方法, props, state共同決定,之前只是討論了render, 現在引入 props, state。
對於props, 父元件傳遞過來, 不可變。 設定到屬性上面。 由基類Component 設定props
class Component {
constructor(props) {
this.props = props
}
}
複製程式碼
對於 state, 在元件的生命期內是可以修改的,當呼叫元件的setState方法的時候, 其實就是重新渲染 用一個新DOM樹替換老的DOM:
parent.replaceChild (newdom, olddom )
,
比如當我在 GrandText 上呼叫setState。 就是父div 把GrandText渲染出來的dom 替換一下。
所以
- 元件例項 必須有機制獲取到 olddom
- 同時 render方法的第二個引數是 parent。 元件例項必須有機制獲取到 parentDOM
這2個問題其實是一個問題。 parent = olddom.parentNode, 所以
olddom.parentNode.replaceChild (newdom, olddom )
。 現在的關鍵就是獲取到olddom, 這裡採用的機制是 每個元件例項 記住 直接渲染出的元件例項/DOM(通過__rendered屬性)。 下圖: 程式碼實現:
function render (vnode, parent, comp) {
let dom
if(typeof vnode == "string") {
const dom = ... // 建立文字節點
comp && (comp.__rendered = dom)
... // other op
} else if(typeof vnode.nodeName == "string") {
const dom = ... // 建立 dom節點
comp && (comp.__rendered = dom)
... // other op
} else if (typeof vnode.nodeName == "function") {
const inst = ... // 建立 元件例項
comp && (comp.__rendered = inst)
... // other op
}
}
複製程式碼
其中 comp 引數代表 "我是被誰渲染的"。 獲取olddom的程式碼實現:
function getDOM(comp) {
let rendered = comp.__rendered
while (rendered instanceof Component) { //判斷物件是否是dom
rendered = rendered.__rendered
}
return rendered
}
複製程式碼
呼叫 setState 使用olddom替換老的dom 程式碼如下:
function render(vnode, parent, comp, olddom) {
let dom
if(typeof vnode == "string") {
...
if(olddom) {
parent.replaceChild(dom, olddom)
} else {
parent.appendChild(dom)
}
...
} else if(typeof vnode.nodeName == "string") {
...
if(olddom) {
parent.replaceChild(dom, olddom)
} else {
parent.appendChild(dom)
}
...
} else if (typeof vnode.nodeName == "function") {
...
render(innerVnode, parent, inst, olddom)
}
}
複製程式碼
拼湊一下以上功能, 完整程式碼實現:
///Component
class Component {
constructor(props) {
this.props = props
}
setState(state) {
setTimeout(() => {
this.state = state
const vnode = this.render()
let olddom = getDOM(this)
render(vnode, olddom.parentNode, this, olddom)
}, 0)
}
}
function getDOM(comp) {
let rendered = comp.__rendered
while (rendered instanceof Component) { //判斷物件是否是dom
rendered = rendered.__rendered
}
return rendered
}
///render
function render (vnode, parent, comp, olddom) {
let dom
if(typeof vnode == "string" || typeof vnode == "number") {
dom = document.createTextNode(vnode)
comp && (comp.__rendered = dom)
parent.appendChild(dom)
if(olddom) {
parent.replaceChild(dom, olddom)
} else {
parent.appendChild(dom)
}
} else if(typeof vnode.nodeName == "string") {
dom = document.createElement(vnode.nodeName)
comp && (comp.__rendered = dom)
setAttrs(dom, vnode.props)
if(olddom) {
parent.replaceChild(dom, olddom)
} else {
parent.appendChild(dom)
}
for(let i = 0; i < vnode.children.length; i++) {
render(vnode.children[i], dom, null, null)
}
} else if (typeof vnode.nodeName == "function") {
let func = vnode.nodeName
let inst = new func(vnode.props)
comp && (comp.__rendered = inst)
let innerVnode = inst.render(inst)
render(innerVnode, parent, inst, olddom)
}
}
複製程式碼
有狀態元件 演示地址, have fun!
總結一下: render方法負責把vnode渲染到實際的DOM, 如果元件渲染的DOM已經存在, 就替換, 並且保持一個 __rendered的引用鏈
其他
程式碼託管在github。 覺得有幫助,點個star。哈哈哈。。。 本文所講的程式碼部分在 propsAndState 這個tag上:
git clone https://github.com/ykforerlang/tinyreact.git
git branch [yourbranchname] propsAndState
複製程式碼