如何使用纯JS动态渲染DOM元素?

8
我面临的挑战是使用纯JavaScript构建单页应用程序,不允许使用任何库或框架。在React和Angular中创建动态DOM元素相对简单,但我想出的vanilla JS解决方案似乎有些笨拙。我想知道是否有更简洁或高效的方法来构建动态渲染的DOM元素?
下面的函数接受从GET请求接收到的数组,并为每个项呈现一个div,传递值(就像您会在React中映射结果并呈现子元素一样)。
 function loadResults(array) {
  array.forEach(videoObject => {
    let videoData =  videoObject.snippet;
    let video = {
       title : videoData.title,
       img : videoData.thumbnails.default.url,
       description : videoData.description
    };
    let div = document.createElement("DIV");
    let img = document.createElement("IMG");
    img.src = video.img;
    let h4 = document.createElement("h4");
    let title = document.createTextNode(video.title);
    h4.appendChild(title);
    let p = document.createElement("p");
    let desc = document.createTextNode(video.description);
    p.appendChild(desc);

    div.appendChild(img);
    div.appendChild(h4);
    div.appendChild(p);
    document.getElementById('results')
      .appendChild(div);
  });
}

这种感觉显得有些复杂,但我还没有找到更简单的方法来做这件事。

提前感谢!


1
如果你不介意性能问题,你可以使用 innerHTML - Dai
1
你所拥有的看起来很好,虽然不是React或Angular,但仍然很好,并且是使用纯JS创建元素的正确方式。 - adeneo
1
这是正确的方法。你可以通过编写一个简短的hyperscript风格的函数来抽象出很多重复的部分 - function h(tagName, attributes, children) { /* 返回一个tagName元素 */ } - joews
2
React的创建是为了解决Facebook的一个问题,即在一个区域更改数据时会导致其他区域(例如聊天和通知栏)UI的更改。所有框架和库的目标都是使我们的生活更轻松,这就是抽象的全部意义。但这并不一定是因为JS很笨拙。笨拙性可以在50行代码或更少的代码中解决,而语言本身也越来越不笨拙。 - Madara's Ghost
1
@MadaraUchiha:好吧,我认输了。显然,我在胡说八道 :) 我最好回到我的安全港湾[ruby]。 :) - Sergio Tulentsev
显示剩余8条评论
5个回答

12

注意:我在这里说的一切都是概念验证级别的,仅此而已。它不处理错误或异常情况,也没有在生产环境中进行测试。请自行决定是否使用。

一个好的方式是创建一个为您创建元素的函数,类似于下面这样:

const crEl = (tagName, attributes = {}, text) => {
  const el = document.createElement(tagName);
  Object.assign(el, attributes);
  if (text) { el.appendChild(document.createTextNode(text)); }

  return el;
};

然后您可以像这样使用它:

results
  .map(item => crEl(div, whateverAttributes, item.text))
  .forEach(el => someParentElement.appendChild(el));

我看到的另一个很酷的概念实现是使用ES6代理作为一种模板引擎

const t = new Proxy({}, {
  get(target, property, receiver) {
    return (children, attrs) => {
      const el = document.createElement(property);
      for (let attr in attrs) {
        el.setAttribute(attr, attrs[attr]);
      }
      for (let child of(Array.isArray(children) ? children : [children])) {
        el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
      }
      return el;
    }
  }
})

const el = t.div([
  t.span(
    ["Hello ", t.b("world!")], {
      style: "background: red;"
    }
  )
])

document.body.appendChild(el);

代理对象会截获空目标对象上的get操作,并渲染一个具有被调用方法名称的元素。这就是你在const el =中看到的非常酷的语法。

1
style 属性是一个常见的特例,在现实世界中你需要处理它。 el.style.color = "red" 是有效的;而 el.style = { color: "red" } 则不行。 - joews

3
如果您能使用ES6,模板字符串是另一个想法 ->

var vids = [
  {
    snippet: {
      description: 'hello',
      title: 'test',
      img: '#',
      thumbnails: { default: {url: 'http://placehold.it/64x64'} }
    }
  }
];

function loadResults(array) {
  array.forEach(videoObject => {    
    let videoData =  videoObject.snippet;
    let video = {
       title : videoData.title,
       img : videoData.thumbnails.default.url,
       description : videoData.description
    };
    document.getElementById('results').innerHTML = `
<div>
  <img src="${video.img}"/>
  <h4>${video.title}</h4>
  <p>${video.description}</p>
</div>
`;
  });
}

loadResults(vids);
<div id="results"></div>


2

在我看来,如果您没有使用任何模板引擎,您希望尽可能地控制如何组合您的元素。因此,一个好的方法是抽象公共任务并允许链接调用以避免额外的变量。所以我会选择像这样的东西(不太花哨):

 function CE(el, target){
    let ne = document.createElement(el);
    if( target )
       target.appendChild(ne);
    return ne;
 }

  function CT(content, target){
    let ne = document.createTextNode(content);
    target.appendChild(ne);
    return ne;
 }


 function loadResults(array) {
  var results = document.getElementById('results');
  array.forEach(videoObject => {
    let videoData =  videoObject.snippet;
    let video = {
       title : videoData.title,
       img : videoData.thumbnails.default.url,
       description : videoData.description
    };
    let div = CE('div');
    let img = CE("IMG", div);
    img.src = video.img;
    CT(video.title, CE("H4", div));
    CT(video.description,  CE("p", div););

    results.appendChild(div);
  });
}

你所获得的是,你仍然可以精细控制元素如何组装,以及互相之间的链接关系,但你的代码更易于理解。

1
我为同样的目的创建了一个库,你可以在这里找到它:https://www.npmjs.com/package/object-to-html-renderer。我知道你不能在项目中使用任何库,但是由于这个库非常短(并且没有依赖关系),你可以只复制和调整代码,代码只有一个小文件: (请参见https://gitlab.com/kuadrado-software/object-to-html-renderer/-/blob/master/index.js)。
// object-to-html-renderer/index.js

module.exports = {
    register_key: "objectToHtmlRender",

    /**
     * Register "this" as a window scope accessible variable named by the given key, or default.
     * @param {String} key 
     */
    register(key) {
        const register_key = key || this.register_key;
        window[register_key] = this;
    },

    /**
     * This must be called before any other method in order to initialize the lib.
     * It provides the root of the rendering cycle as a Javascript object.
     * @param {Object} renderCycleRoot A JS component with a render method.
     */
    setRenderCycleRoot(renderCycleRoot) {
        this.renderCycleRoot = renderCycleRoot;
    },

    event_name: "objtohtml-render-cycle",

    /**
     * Set a custom event name for the event that is trigger on render cycle.
     * Default is "objtohtml-render-cycle".
     * @param {String} evt_name 
     */
    setEventName(evt_name) {
        this.event_name = evt_name;
    },

    /**
     * This is the core agorithm that read an javascript Object and convert it into an HTML element.
     * @param {Object} obj The object representing the html element must be formatted like:
     * {
     *      tag: String // The name of the html tag, Any valid html tag should work. div, section, br, ul, li...
     *      xmlns: String // This can replace the tag key if the element is an element with a namespace URI, for example an <svg> tag.
     *                      See https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS for more information
     *      style_rules: Object // a object providing css attributes. The attributes names must be in JS syntax,
     *                              like maxHeight: "500px", backgrouncColor: "#ff2d56",  margin: 0,  etc.
     *      contents: Array or String // This reprensents the contents that will be nested in the created html element.
     *                                   <div>{contents}</div>
     *                                   The contents can be an array of other objects reprenting elements (with tag, contents, etc)
     *                                   or it can be a simple string.
     *      // All other attributes will be parsed as html attributes. They can be anything like onclick, href, onchange, title...
     *      // or they can also define custom html5 attributes, like data, my_custom_attr or anything.
     * }
     * @returns {HTMLElement} The output html node.
     */
    objectToHtml(obj) {
        if (!obj) return document.createElement("span"); // in case of invalid input, don't block the whole process.
        const objectToHtml = this.objectToHtml.bind(this);
        const { tag, xmlns } = obj;
        const node = xmlns !== undefined ? document.createElementNS(xmlns, tag) : document.createElement(tag);
        const excludeKeys = ["tag", "contents", "style_rules", "state", "xmlns"];

        Object.keys(obj)
            .filter(attr => !excludeKeys.includes(attr))
            .forEach(attr => {
                switch (attr) {
                    case "class":
                        node.classList.add(...obj[attr].split(" ").filter(s => s !== ""));
                        break;
                    case "on_render":
                        if (!obj.id) {
                            node.id = `${btoa(JSON.stringify(obj).slice(0, 127)).replace(/\=/g, '')}${window.performance.now()}`;
                        }
                        if (typeof obj.on_render !== "function") {
                            console.error("The on_render attribute must be a function")
                        } else {
                            this.attach_on_render_callback(node, obj.on_render);
                        }
                        break;
                    default:
                        if (xmlns !== undefined) {
                            node.setAttributeNS(null, attr, obj[attr])
                        } else {
                            node[attr] = obj[attr];
                        }
                }
            });
        if (obj.contents && typeof obj.contents === "string") {
            node.innerHTML = obj.contents;
        } else {
            obj.contents &&
                obj.contents.length > 0 &&
                obj.contents.forEach(el => {
                    switch (typeof el) {
                        case "string":
                            node.innerHTML = el;
                            break;
                        case "object":
                            if (xmlns !== undefined) {
                                el = Object.assign(el, { xmlns })
                            }
                            node.appendChild(objectToHtml(el));
                            break;
                    }
                });
        }

        if (obj.style_rules) {
            Object.keys(obj.style_rules).forEach(rule => {
                node.style[rule] = obj.style_rules[rule];
            });
        }

        return node;
    },

    on_render_callbacks: [],

    /**
     * This is called if the on_render attribute of a component is set.
     * @param {HTMLElement} node The created html element
     * @param {Function} callback The callback defined in the js component to render
     */
    attach_on_render_callback(node, callback) {
        const callback_handler = {
            callback: e => {
                if (e.detail.outputNode === node || e.detail.outputNode.querySelector(`#${node.id}`)) {
                    callback(node);
                    const handler_index = this.on_render_callbacks.indexOf((this.on_render_callbacks.find(cb => cb.node === node)));
                    if (handler_index === -1) {
                        console.warn("A callback was registered for node with id " + node.id + " but callback handler is undefined.")
                    } else {
                        window.removeEventListener(this.event_name, this.on_render_callbacks[handler_index].callback)
                        this.on_render_callbacks.splice(handler_index, 1);
                    }
                }
            },
            node,
        };

        const len = this.on_render_callbacks.push(callback_handler);
        window.addEventListener(this.event_name, this.on_render_callbacks[len - 1].callback);
    },

    /**
     * If a main element exists in the html document, it will be used as rendering root.
     * If not, it will be created and inserted.
     */
    renderCycle: function () {
        const main_elmt = document.getElementsByTagName("main")[0] || (function () {
            const created_main = document.createElement("main");
            document.body.appendChild(created_main);
            return created_main;
        })();

        this.subRender(this.renderCycleRoot.render(), main_elmt, { mode: "replace" });
    },

    /**
     * This method behaves like the renderCycle() method, but rather that starting the rendering cycle from the root component,
    * it can start from any component of the tree. The root component must be given as the first argument, the second argument be
    * be a valid html element in the dom and will be used as the insertion target.
     * @param {Object} object An object providing a render method returning an object representation of the html to insert
     * @param {HTMLElement} htmlNode The htlm element to update
     * @param {Object} options can be used the define the insertion mode, default is set to "append" and can be set to "override",
         * "insert-before" (must be defined along with an insertIndex key (integer)),
         * "adjacent" (must be defined along with an insertLocation key (String)), "replace" or "remove".
         * In case of "remove", the first argument "object" is not used and can be set to null, undefined or {}...
     */
    subRender(object, htmlNode, options = { mode: "append" }) {
        let outputNode = null;

        const get_insert = () => {
            outputNode = this.objectToHtml(object);
            return outputNode;
        };

        switch (options.mode) {
            case "append":
                htmlNode.appendChild(get_insert());
                break;
            case "override":
                htmlNode.innerHTML = "";
                htmlNode.appendChild(get_insert());
                break;
            case "insert-before":
                htmlNode.insertBefore(get_insert(), htmlNode.childNodes[options.insertIndex]);
                break;
            case "adjacent":
                /**
                 * options.insertLocation must be one of:
                 *
                 * afterbegin
                 * afterend
                 * beforebegin
                 * beforeend
                 */
                htmlNode.insertAdjacentElement(options.insertLocation, get_insert());
                break;
            case "replace":
                htmlNode.parentNode.replaceChild(get_insert(), htmlNode);
                break;
            case "remove":
                htmlNode.remove();
                break;
        }
        const evt_name = this.event_name;
        const event = new CustomEvent(evt_name, {
            detail: {
                inputObject: object,
                outputNode,
                insertOptions: options,
                targetNode: htmlNode,
            }
        });

        window.dispatchEvent(event);
    },
};

这是整个库,可以像这样使用(还有更多功能,但至少对于基本用法):
// EXAMPLE - refresh a list after fetch data
const renderer = require("object-to-html-renderer");

class DataListComponent {
    constructor() {
        this.render_data = [];
        this.list_id = "my-data-list";
    }

    async fetchData() {
        const fetchData = await (await fetch(`some/json/data/url`)).json();
        return fetchData;
    }

    renderDataItem(item) {
        return {
            tag: "div",
            contents: [
                // Whatever you want to do to render your data item...
            ],
        };
    }

    renderDataList() {
        return {
            tag: "ul",
            id: this.list_id,
            contents: this.render_data.map(data_item => {
                return {
                    tag: "li",
                    contents: [this.renderDataItem(data_item)],
                };
            }),
        };
    }

    render() {
        return {
            tag: "div",
            contents: [
                {
                    tag: "button",
                    contents: "fetch some data !",
                    onclick: async () => {
                        const data = await this.fetchData();
                        this.render_data = data;
                        renderer.subRender(
                            this.renderDataList(),
                            document.getElementById(this.list_id),
                            { mode: "replace" },
                        );
                    },
                },
                this.renderDataList(),
            ],
        };
    }
}

class RootComponent {
    render() {
        return {
            tag: "main", // the tag for the root component must be <main>
            contents: [new DataListComponent().render()],
        };
    }
}

renderer.setRenderCycleRoot(new RootComponent());
renderer.renderCycle();

我仅使用这个工具就构建了整个Web应用程序,它的表现非常好。我认为它是React Vue等的一个很好的替代品。(当然,它比React更简单,并且不具备React的所有功能..)也许它对你或其他人有用。


请记住,最好分享一些实际的代码来回答问题,而不仅仅是链接,因为链接可能会随着时间的推移而过期或被删除。 - ruhnet

0

关于这个代码审查问题有两点观察:

通过以下两种方式进行重构:

  1. 添加一个帮助函数来创建一个具有可选文本内容的元素,以及
  2. 删除video对象抽象,因为它复制了三个属性中的两个相同名称的属性,

可以生成易读但普通类型的Javascript:

function loadResults(array) {
    function create (type,text) {
       let element = document.createElement(type);
       if( text)
           element.appendChild( document.createTextNode( text));
       return element;
    }
    array.forEach(videoObject => {
       let vid =  videoObject.snippet;
       let div = create("DIV");
       let img = create("IMG");
       img.src = vid.thumbnails.default.url;
       let h4 = create("H4", vid.title);
       let p = create("P", vid.description);

       div.appendChild(img);
       div.appendChild(h4);
       div.appendChild(p);
       document.getElementById('results').appendChild(div);
    });
 }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接