用普通的旧JS动态渲染DOM元素的好方法是什么?

What's a good way to dynamically render DOM elements with plain old JS?

本文关键字:元素 DOM 方法 是什么 JS 动态      更新时间:2023-09-26

我面临的挑战是用普通的旧Javascript构建一个单页应用程序,不允许使用库或框架。虽然在React和Angular中创建动态DOM元素相当简单,但我提出的香草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);
  });
}

这感觉不必要的笨拙,但我还没有找到一个更简单的方法来做到这一点。

提前感谢!

注意:我在这里所说的一切都是在概念证明层面上的,仅此而已。它不处理错误或异常情况,过去也是如此它在生产中进行了测试。请自行决定。

一个好的方法是创建一个函数来为你创建元素。像这样:
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 =中非常酷的语法。

如果你可以使用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>

在我看来,如果您不使用任何模板引擎,您希望尽可能多地控制如何将元素组合在一起。因此,合理的方法是抽象常见任务,并允许链接调用以避免额外的变量。所以我想这样写(不是很花哨):

 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);
  });
}

你得到的是你仍然可以很好地控制你的元素是如何组装的,什么连接到什么。但是你的代码更容易理解

出于同样的目的,我创建了一个库,您可以在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那样做…)

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

重构

  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);
    });
 }