Component phần 3: Áp dụng useState với UI Builder Elementor

Photo by Andrew Neel on Unsplash

Component phần 3: Áp dụng useState với UI Builder Elementor

Đây sẽ là 1 bài viết đi sâu vào sử dụng Elementor, theo hướng component.

Chuẩn bị

Đầu tiên chúng ta vào tọa 1 JS code đặt tên là common functions. Chúng ta sẽ viết các function dùng cho toàn bộ tất cả các trang ở đây:

Chèn đoạn code sau vào:

class StateRender {
   constructor(initialState) {
    this.state = initialState;
   }
   state = {};
   render;
   setState(obj) {
     if (this.state === obj) return;
     this.state = obj;
     if (!this.render) return;
     this.render(this.state);
   }

}

const useState = (initialState) => {
    const stateRender = new StateRender(initialState);
    const render = (fn) => {
        stateRender.render = fn;
    };
    const getState = () => stateRender.state;
    const setState = (obj) => {
        stateRender.setState(obj);
    };
    return [getState, setState, render];
}

const onAttributesChange = (element, fn) => {
    var observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            if (mutation.type === "attributes") {
                fn(mutation.target);
            }
        });
    });
    observer.observe(element, {
        attributes: true,
    });
}

const findElementorTemplateParent = () => {
    const me = document.currentScript;
    const parent = me.parentElement.parentElement.parentElement
        .parentElement.parentElement.parentElement
        .parentElement;
    return {
        currentElement: me,
        parent: parent,
        prev: (num = 1) => {
            let resultElement = me.parentElement.parentElement.previousElementSibling;
            let count = 1;
            while (count < num) {
                if (!!resultElement) {
                    resultElement = resultElement.previousElementSibling;
                    count++;
                }
            }
            return resultElement;
        },
        next: (num = 1) => {
            let resultElement = me.parentElement.parentElement.nextElementSibling;
            let count = 1;
            while (count < num) {
                if (!!resultElement) {
                    resultElement = resultElement.nextElementSibling;
                    count++;
                }
            }
            return resultElement;
        },
    };
}

const findElementorParent = () => {
    const me = document.currentScript;
    const parent = me.parentElement.parentElement.parentElement
        .parentElement.parentElement.parentElement;
    return {
        currentElement: me,
        parent: parent,
        prev: (num = 1) => {
            let resultElement = me.parentElement.parentElement.previousElementSibling;
            let count = 1;
            while (count < num) {
                if (!!resultElement) {
                    resultElement = resultElement.previousElementSibling;
                    count++;
                }
            }
            return resultElement;
        },
        next: (num = 1) => {
            let resultElement = me.parentElement.parentElement.nextElementSibling;
            let count = 1;
            while (count < num) {
                if (!!resultElement) {
                    resultElement = resultElement.nextElementSibling;
                    count++;
                }
            }
            return resultElement;
        },
    };
}

const appendHTML = (el, html) => {
    if (!el) return;
    const htmlString = String(html);
    const cloneId = Math.floor(Math.random() * 100000) + new Date().getTime();
    const htmlWithDataCloneId = htmlString.replace(" ", ` data-clone-id="${cloneId}" `);
    el.insertAdjacentHTML('beforeend', htmlWithDataCloneId);
    setTimeout(() => {
        const newElement = el.querySelector(`[data-clone-id="${cloneId}"]`);
        if (!newElement) {
            console.error('NEW ELEMENT NOT FOUND');
            return;
        }
        const findAllScripts = newElement.querySelectorAll('script');
        console.log('length', findAllScripts.length);
        findAllScripts.forEach(scriptEL => {
            const script = document.createElement('script');
            script.textContent = scriptEL.textContent;
            scriptEL.after(script);
            scriptEL.remove();
        });
    }, 300);

}

const onClick = (selectorOrElement, fn) => {
    if (!selectorOrElement) return;
    if (typeof selectorOrElement === 'string') {
        const element = document.querySelector(selectorOrElement);
        if (element) element.onclick = fn;
    } else {
        selectorOrElement.onclick = fn;
    }
}

const onClickAll = (selector, fn) => {
    const arrayOfElements = document.querySelector(selector);
    arrayOfElements.forEach(el => {
        onClick(el, fn);
    });
}

Trong đó:

  • useState: đã giới thiệu ở bài trước

  • findElementorParentfindElementorTemplateParent: dùng để tìm parent element, sẽ giải thích sau.

  • appendHTML: thêm HTML vào ở cuối của DOM đó. cái khác biệt với method có sẵn của DOM là nó sẽ chạy cả script html. (mặc định thì sẽ không chạy, do đó phải viết riêng function để xử lý).

  • onClickonClickAll: là function tiện ích để giảm bớt việc phải viết đi viết lại đoạn querySelect và check xem element có tồn tại hay không trước khi bắt đầu

  • onAttributesChange: mặc định các DOM không có sẵn listner báo tin khi attribute thay đổi, do đó phải viết thêm function này. Bạn có thể không cần hiểu, chỉ cần biết là khi attribute thay đổi thì nó chạy là đc. Attributes là các giá trị được viết kèm lên thẻ.

    VD: <h1 id="main-heading">Hello</h1>. thì idattribute, và nếu có biến h1Element chẳng hạn thì có thể gọi ra bằng h1Element.getAttribute('id'); Xem thêm về attributeđây

    Quan trọng hơn đó là data attributes, click vào link để đọc thêm, cơ bản bạn có thể đặt tên dạng data-* VD: <h1 data-abc="123" data-xyz="456" >Hello</h1>. Thì có thể tra cứu trực tiếp thông qua h1Element.dataset.abch1Element.dataset.xyz mà không cần dùng getAttribute.

Elementor Elements và Templates

Elements: là những phần kéo thả có sẵn (mình hay gọi là block), một số Element có sẵn quan trọng là Container, Button, Heading, Text Editor, Image, Icon, HTML.

Templates: là một Element của Elementor Pro, giúp bạn lưu cả cụm cha con vào, đây sẽ là phần chúng ta sử dụng chính để làm Component.

Giờ chúng ta sẽ học các phần trên bằng cách làm lại bài Todo List, lần này, để cho giao diện đẹp và dễ nhìn, chúng ta sẽ tìm 1 design free trên mạng và áp dụng vào.

UI UX hiện nay thì design thường được làm bằng Figma hoặc Adobe XD. Cả 2 đều free nên bạn cố gắng học cách dùng cả 2 (Adobe XD phải cài vào máy, Figma dùng qua web). Dùng theo cách của 1 coder sẽ đơn giản là đọc các thông số của giao diện và xuất ảnh/icon khi cần.

Ta sẽ dùng design này (free trên Figma Community).

Nhìn qua thì design này có những tính năng không có sẵn trong đề bài VD như subtask (task con), task có nhiều trạng thái, báo warning khi task chưa xong theo lịch, nhóm task theo các mục. Trước hết chúng ta sẽ tận dụng UI để làm theo ý đồ riêng của chúng ta đã.

Bước 1: setup font, color, size

Bước đầu là check qua các thông số về font

Bộ này dùng font Raleway, có trên Google Fonts. font size 20 thì không phải là bội của 8, do đó chúng ta sẽ dùng rem hệ 10.

Design này sử dụng Bold và Medium khá nhiều, nên chúng ta sẽ cần lưu ý chút và thiết lập để chỉnh lại toàn bộ 1 cách dễ dàng khi cần.

Vào Site Settings -> Global Fonts đổi tên Text thành Body Text và điền thông số.

Lần lượt khai báo các loại còn lại:

Tiếp đến là khai báo màu. Chúng ta sẽ chọn màu mà muốn dùng cho button làm màu primary, là màu xanh blue. Còn lại thì sẽ đặt theo tên màu cho dễ.

Để copy màu chính xác nhất (mã Hex kèm % opacity) thì bật Dev mode ở figma r click vào màu để copy sang. 1 số màu kèm % opacity sẽ bằng 1 màu khác nhạt hơn nhưng solid (100% opacity), chúng ta có thể dùng mã đó thay thế.

Bước 2: Layout

Nhìn chung thì layout của design này khá đơn giản, chỉ là 1 box trắng sắp xếp nội dung theo dạng column.

layout đơn giản này có thể dùng Globals block được, chúng ta sẽ tạo 1 trang trắng, chèn 1 Container và style cho giống design, và lưu lại dưới dạng Globals

Lưu ý: khi chèn 1 block vào thì chúng ta có thể rename ở Navigator để cho dễ nhận biết hơn.

Hơi bực 1 chút là block Container không save as Global được, chúng ta phải dùng trick đó là save as template và khi nào cần dùng thì mở 1 cửa sổ template rồi copy qua. Vì khi đã lưu template, thì sẽ không thêm được block con vào bên trong.

Bước 3: Elements Component

Chúng ta sẽ làm các elements cơ bản như Button, Input. Trong design không đề cập đến Button và Input (thiếu luồng) nên chúng ta sẽ tự chế dựa cho phù hợp các phần khác

Button có thể dùng Save as default và lần sau khi kéo Button ra thì sẽ giống cái mình vừa lưu.

Elementor không có sẵn block "Input" nên chúng ta phải dùng HTML để tạo

Với cái này thì chúng ta có thể Save as Template để tạo thành 1 Element mới. Sau đó xóa nó đi và add lại bằng cách dùng template

Vào tab advance chỉnh lại width về 100%. Cơ bản khi tạo Template thì nó sẽ lại bọc các phần của mình trong các thẻ của template nữa, nên khi làm template cứ để hết width thành 100%, sau nếu muốn chỉnh sửa thì chỉnh sửa width của template ở tab Advance

Khi giao diện đã được đóng gói trong Template, thì những cái chúng ta có thể chỉnh sửa chỉ là những phần ở mục Advanced này. Ngoài mục width ra có 1 mục khác cũng rất quan trọng đó là Attributes. Mình sẽ dùng attribute để kết nối các Component với nhau.

Khi đã save as Template, b có thể mở ra sửa nó ở Admin -> Templates -> Saved Templates

Sau đó bạn mở ra sửa lại đoạn get parent như sau

Vì template sẽ bọc component của bạn thêm vài lớp, nên chúng ta cần target đến tận lớp ngoài cùng của template. Dùng findElementorTemplateParent đã lưu trong common functions từ trước. Giờ chúng ta sẽ phân tích đoạn code được viết:

const [getState, setState, render] = useState({
       text: '', 
    });
    const scriptElement = document.currentScript;
    const inputElement = scriptElement.previousElementSibling;
    const { parent } = findElementorTemplateParent();

    if (inputElement && parent) {

        inputElement.oninput = (e) => {
            parent.dataset.text = e.target.value;
        }

        onAttributesChange(parent, 'data-text', (newValue) => {
           setState({
               text: newValue,
           }) 
        });

        render((state) => {
            inputElement.value = state.text;
        });

        // initial render
        setState({ text: parent.dataset.text || '' });
    }
  • Khai báo state với state.text mặc định là string rỗng.

  • Đặt listener báo tin khi attribute data-text có thay đổi thì sẽ lấy giá trị mới đó để setState.

  • Đặt listner khi gõ vào input thì cũng update attribute data-text, thông qua dataset. Và như vậy sẽ trigger cái listener phía trên chạy, và sẽ setState.

  • Hàm bên trong render sẽ chạy mỗi khi được setState. và chúng ta sẽ update giá trị của ô input bằng với state.text. Kết quả sẽ được như này:

    %[youtube.com/watch?v=W-IEUthdasQ]

Bước 4: Big Component

Việc làm component là để tái sử dụng do đó việc kết hợp nhiều component nhỏ để thành 1 component to rất phổ biến. Các component sẽ to dần từ cấp Element sang cấp Widget và có thể lên cả cấp Page.

Ở design phía trên, vừa hay lại có 1 component cho chúng ta luyện tập. Figma có 1 cái gọi là Component Playground giúp chúng ta hình dung ra rõ hơn cách 1 component hoạt động.

Phân tích Tasks Component trong design:

  • Có nhiều trạng thái: Unfinished / In progress / Waiting (to test) / Done / Section title. (Cái Section title này không phải trạng thái, đáng ra không nên gộp vô, nhưng chúng ta sẽ cứ làm theo design)

  • Có 2 level task là Task và Subtask (Không áp dụng cho Section title)

  • Có 2 mức độ quan trọng: None và Important (Không áp dụng cho Section title)

  • Có text hiển thị nội dung task

Tính theo tổ hợp thì component này sẽ có tổng cộng 4x2x2 + 1 = 17 cách hiển thị. Về mặt nguyên tắc, tạo 17 giao diện khác nhau riêng lẻ sẽ tốt hơn là việc gộp hết vào 1 component. Tốt hơn ở chỗ nó được "care" nhiều hơn, ngoài ra cũng tránh nhầm lẫn, vì ít khi chúng ta ngồi nhân tổ hợp đếm số "biến thể" như này. Tuy nhiên, sẽ tốn khá nhiều thời gian và công sức để tạo ra 17 biến thể riêng biệt. Nên nếu gộp hết vào làm 1 mà vẫn xử lý được ổn thì nên làm để tiết kiệm công sức. Cái này cần có sự cân bằng giữa việc phân tách và khi có trải nghiệm rồi bạn sẽ hiểu về cái "sự cân bằng" này hơn.

Nhìn vào cột bên phải, đó chính là "hint" để chúng ta thiết kế cấu trúc dữ liệu của Component. Component sẽ có 4 data attribute

  • Type (data-type) (thay cho State vì trùng tên): Unfinished / In progress / Ready to Test / Done / Section title

  • Important (data-important): True / False

  • Subtask (data-subtask): True / False

  • Task (data-task): nội dung text

Về mặt giao diện, ta sẽ sắp xếp cấu trúc sau:

  • Tách riêng Section title ra khỏi các loại type khác, tức là vẽ 2 giao diện, khi type = Section title thì sẽ hiện giao diện Section title ẩn giao diện còn lại.

  • Với các loại Type khác thì sẽ là 1 row có 3 nội dung, sẽ ẩn hiện tuy theo các attribute khác

Tham khảo video:

Bước 4: Ghép các component lại với nhau thành 1 luồng hoàn chỉnh

Đến đây chúng ta sẽ chuẩn bị ghép lại các component lại với nhau. Tuy nhiên trước đó sẽ chỉnh lại cái Common functions 1 chút cho gọn gàng. Đầu tiên tách các function liên quan đến DOM ra 1 file, và các function liên quan đến useState ra 1 file.

Thêm function liên quan đến listener attribute để xử lý cho gọn hơn:

const onAttributesChange = (element, attributeName, fn) => {
    var observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            if (mutation.type === "attributes" && mutation.attributeName === attributeName) {
                fn(mutation.target.getAttribute(attributeName));
            }
        });
    });
    observer.observe(element, {
        attributes: true,
    });
}

// arrayOfHandlers: [ { name, fn } ]
const onAttributesDataChange = (element, arrayOfHandlers = []) => {
    var observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            if (mutation.type !== "attributes") return;
            const findHandler = arrayOfHandlers.find(h => 'data-' + h.name === mutation.attributeName);
            if (!findHandler) return;
            findHandler.fn(mutation.target.dataset[findHandler.name]);
        });
    });
    observer.observe(element, {
        attributes: true,
    });
}

Một số thay đổi:

  • chỉ listen data attribute. do đó lúc so sánh phải thêm 'data-' + h.name

  • đổi attributeNamefn thành arrayOfHandlers sẽ là 1 array để viết gộp các listener lại.

  • Lưu ý: dùng với array function thì khi có 1 parameter thì có thể bỏ dấu ngoặc.

    arrayOfHandlers.find(h => 'data-' + h.name === mutation.attributeName);

    \=

    arrayOfHandlers.find((h) => 'data-' + h.name === mutation.attributeName);

  • Lưu ý: find là một method của array, cái này cũng dùng nhiều. bên cạnh đó còn có findIndex

  • Lưu ý: đoạn code trên dùng spread operator, và bình thường để update 1 key của object thì thường viết trực tiếp dùng dấu . tuy nhiên trong trường hợp bạn chưa biết giá trị của key mà chỉ biết biến chứa nó thì có thể dùng [].

    VD: const a = { b: 1 }; a.b = 2;
    -> const unknownKey = 'b'; a[unknownKey] = 3;

Lúc này đoạn code sẽ trở thành:

// OLD
onAttributesChange(parent, 'data-type', (newValue) => {
            setState({
                ...getState(),
                type: newValue,
            })
        });
        onAttributesChange(parent, 'data-text', (newValue) => {
            setState({
                ...getState(),
                text: newValue,
            })
        });
        onAttributesChange(parent, 'data-important', (newValue) => {
            setState({
                ...getState(),
                important: newValue,
            })
        });
        onAttributesChange(parent, 'data-subtask', (newValue) => {
            setState({
                ...getState(),
                subtask: newValue,
            })
        });

// NEW
onAttributesDataChange(parent, [
    {
        name: 'type',
        fn: (newValue) => setState({ ...getState(), type: newValue })
    },
    {
        name: 'text',
        fn: (newValue) => setState({ ...getState(), text: newValue })
    },
    {
        name: 'important',
        fn: (newValue) => setState({ ...getState(), important: newValue })
    },
    {
        name: 'subtask',
        fn: (newValue) => setState({ ...getState(), subtask: newValue })
    },
]);

Để làm cho nó gọn hơn nữa, vì nhìn vẫn có nhiều đoạn lặp đi lặp lại, chúng ta sẽ truyền thẳng setState vào parameter.

const mapDataAttributeToState = (element, getState, setState) => {
    var observer = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
            if (mutation.type !== "attributes") return;
            setState({
                ...getState(),
                ...mutation.target.dataset,
            })
        });
    });
    observer.observe(element, {
        attributes: true,
    });
}

// IN USE
mapDataAttributeToState(parent, getState, setState);

Vậy là chúng ta đã thay toàn bộ đoạn listener bên trên thành 1 dòng code.

Bài tập: function mapDataAttributeToState bên trên sẽ lưu toàn bộ dataset vào state, đôi khi nó sẽ lưu thừa các biến khác mà mình không cần (trigger render không cần thiết). Hãy thêm parameter vào function mapDataAttributeToState để lọc và chỉ lấy các biến mình cần. Gợi ý, khi viết xong, thì sẽ dùng như sau: mapDataAttributeToState(parent, getState, setState, ['type', 'text', 'important', 'subtask']);

OK giờ chúng ta sẽ ghép hết tất cả vào thành 1 luồng:

Tóm tắt lại quá trình:

  • Chúng ta tạo 1 container và 1 task component để sẵn ở đó

  • Dùng JS để lưu code html của task component rồi xóa task đó đi

  • Loop qua array của state.tasks để chèn số lượng html tương ứng. Lưu ý, khi chèn html vào thì nó sẽ mặc định không chạy code JS trong thẻ <script />. Chúng ta phải dùng function rerunHTMLScript để chạy lại. Sau khi chạy code js thì component mới hoạt động đúng ý đồ được.

  • Các event của thành phần con của Task component sẽ không được gọi riêng lẻ tại app logic, mà được chuyển sang thành event-* của parent, và ở code logic sẽ lắng nghe thay đổi attribute event-* việc này mang tính chất đóng gói, app logic không cần biết đến sự tồn tại của các thành phần con bên trong component, mà chỉ giao tiếp với component thông qua attribute.