2023年10月12日 星期四

npm 安裝不依賴 --legacy-peer-deps 解決 Conflicting Peer Dependency 錯誤

當執行 npm install 時,出現錯誤 Conflicting peer dependecy: XXX,並出現提示:Fix the upstream dependecy conflict, or retry this commaned with --force or --legacy-peer-deps

此時只要嘗試執行

npm install --legacy-peer-deps

確實能夠避免這個錯誤,然而卻無法保證套件運作都是正常的,這個方法只能當作緩解,更正確的作法是將套件更新以保證互相兼容,以避免未來引發其他潛在問題。


首先,列出目前套件版本落後的項目:

npm outdated

可以注意我們會得到哪些訊息,Current 為目前安裝的版本;Wanted則是 npm 自動安裝的版本,確保版號的第一碼一致,盡可能地安裝最新的版本;Lastest 則是目前最新的版本。例如下方這個設定,對照圖片上的訊息,npm會自動安裝版本 7.23.2。

{
  "devDependencies": {
    "@babel/core": "^7.16.12"
  }
}

因為下一步我們會將package-lock.json移除,因此若 Current 和 Wanted 不一致,必須確保 Wanted 顯示的版本並不會影響到目前的專案,根據版本的需求做出對應的調整。若確定不升級,請記得務必調整package.json,確保安裝正確的版本。

下一步,移除目錄node_modules/和檔案package-lock.json

最後重新安裝套件:

npm install

確認一下專案是否都正常,大功告成!


2023年8月26日 星期六

[Javascript] stopPropagation 停止事件捕捉及冒泡

近期工作上修正一個因為冒泡(bubbling) 導致的錯誤,神奇的是 chrome 異常但 safari 正常,因此想知道兩個瀏覽器為什麼會有這樣的差異,順便整理一下stopPropagation 的使用方法。

冒泡(bubbling) 是 Javacript 事件傳遞的方式,當一個 div 物件裡有個按鈕(button),也就是 div 為 button 的父元件,兩者都監聽點擊(click) 事件,當使用者點擊按鈕時,按鈕會先被觸發並進一步傳遞給 div 物件觸發點擊事件。

因此依據這樣的特性,可以將事件監聽 (addEventListener) 放在共同的父元件上,達到效能的優化,動態產生 DOM 元件時也不必額外處理事件監聽

<!-- 比較兩者差異 -->
<ul>
    <li onclick="doSomething()"> ... </li>
    <li onclick="doSomething()"> ... </li>
</ul>

<ul onclick="doSomething()">
    <li> ... </li>
    <li> ... </li>
</ul>

但總會有一些例外,例如只希望觸發目標元件(event.target) 的事件,而 stopPropagation 就是可以阻止事件傳播冒泡。

<div id="c">
    <div id="b">
        <button id="a">click</button>
    </div>
</div>

對每一層物件都加上監聽

<script>
document.querySelector("#a").addEventListener('click', event => {
    console.log('a');
});
document.querySelector("#b").addEventListener('click', event => {
    console.log('b');
});
document.querySelector("#c").addEventListener('click', event => {
    console.log('c');
});
</script>

若點擊按鈕,會依需印出 a b c


接下來,其他設定不變,我們稍微調整 #b 加上 stopPropagation

document.querySelector("#b").addEventListener('click', event => {
    event.stopPropagation(); // HERE!!!!

    console.log('b');
});

若點擊按鈕,會依需印出 a b,最外層並不會執行。


另外有 stopImmediatePropagation 極為相似,但不只是停止冒泡到父元件,同一個元件的相同事件也會被停止。舉例 #b 不只是一項 click 事件監聽:

document.querySelector("#b").addEventListener('click', event => {
    event.stopImmediatePropagation(); // HERE!!

    console.log('b');
});
document.querySelector("#b").addEventListener('click', event => {
    console.log('other');
});

若點擊按鈕,也是印出 a b,包含最外層 cother都不會執行。


最後,safari 冒泡的行為我參考 Mouse event bubbling in iOS 這篇文章,必須符合一些情境才會冒泡,ios chrome 也會是相同的實作,但不確定為什麼 116.0.5845.96 這個版本的 chrome 突然又會冒泡。

這次的情境是子元件可以點擊編輯、父元件可以點擊拖曳,因為冒泡所以最後都會進入拖曳的狀態而無法編輯。之前因為大多使用 ios 瀏覽器操作系統,所以沒發現這個 bug,windows 系統下,若子元件未加上 stopPropagation 一樣會遇到無法編輯的問題。


2023年4月29日 星期六

[Javascript] 潛藏 setTimeout 的陷阱


setTimeoutsetInterval 常用在倒數計時或是提醒等,一些時間有關的實作,然而精準校時卻是不可能的任務,只能盡可能減少誤差。


使用方法

說明與範例
非同步函式(Asynchronous function)

setTimeout不會使得任何執行暫停

console.log(new Date());

setTimeout(() => {
  console.log(new Date());
}, 5000);
setTimeout(() => {
  console.log(new Date());
}, 3000);
setTimeout(() => {
  console.log(new Date());
}, 1000);

可以視同三個Timer同時開始倒數,而非停止等候5秒完成執行後再往下一項執行,因此輸出內容:

Sat Apr 29 2023 21:58:34
Sat Apr 29 2023 21:58:35
Sat Apr 29 2023 21:58:37
Sat Apr 29 2023 21:58:39


零延遲 (Zero deplay)

setTimout 參數第二位置傳入 delay 代表指定時間後執行第一個位置的 function,先看下面這個例子:

console.log("This is first line");

setTimeout(() => {
	console.log("This is second line");
}, 0);

console.log("This is third line");

輸出的結果是

This is first line
This is third line
This is second line

當設定成 0 時,預期是「馬上」執行,但更精準地說其實是下一個 event loop,也就是 setTimeout 會被放到 queue 裡等待下一次執行。

以這個例子來看,delay只能當作最短的執行時間。

const s = new Date().getSeconds();

setTimeout(function() {
	// prints out "2", meaning that the callback is not called immediately after 500 milliseconds.
	console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500)

while (true) {
	if (new Date().getSeconds() - s >= 2) {
		console.log("Good, looped for 2 seconds")
		break;
    }
}

可以當作 setTimeout 地位是最低的,因此即使設定的 delay 時間雖然到了,只能等待 queue 其他事件處理完成。

Good, looped for 2 seconds
Ran after 2 seconds

2023年3月11日 星期六

原始碼解析 create-vue 快速建立 Vue 專案


Vue 可以透過下面的指令,接著詢問一系列的問題,按照設定快速初始一個專案。(目前版本為 3.6.1)

npm init vue@latest

實際是執行這個 create-vue,想透過原始碼的解析,來了解怎麼樣做一個類似的功能,這個專案架構是如何、如何達成建立專案的功能。


1. playground

運用 git submodules的功能,主要是將各種條件下的樣板做snapshot,形成另一個 create-vue-templates 專案。


2. scripts

顧名思義分別對應到 package.json 的 scripts 所執行的檔案名。

  • build
  • prepublish
  • snapshot
  • test

3. template

將程式碼依照性質分成不同的目錄,根據使用者的設定將對應的檔案複製或合成等,形成 Vue 的專案。

  • base: 基本必備的程式碼,主要套件為 vue, vite
  • code: HelloWorld.vue 元件以及 vue-router 範例程式碼,會分成有無使用 ts 版本。
  • config: 裡面每個目錄依照套件名稱命名,目錄包含:cypress-ct, cypress, jsx, pinia, playwright, router, typescript, vitest,目錄裡是安裝套件的設定檔。主要會有 package.json 設定安裝那些套件,後續會將內容與現有設定合併,再來以 cypress 為例,會有 cypress.config.jscypress.config.ts,屆時會根據是否有用 ts 來複製對應的檔案。
  • entry: 指的是 vite 打包的 entry main.js,執行 createApp 的功能,總共有預設, pinia, router, pinia+router 這四種版本。
  • eslint
  • tsconfig

4. utils

目錄及檔案合併等方法都在這個目錄裡,檔名命名已經相當清楚。


5. index

主要邏輯在這個檔案

  • 讀 argv 指令
  • 使用 prompts 詢問一連串問題
  • 依照專案名稱等,產生 package.json
  • 複製 base
  • 複製 config
  • 複製 tsconfig
  • 產生 eslint
  • 複製 entry
  • 清除 js or ts 多餘的檔案或重新命名
  • 產生 README.md
  • 專案已完成,顯示指令提示

拆解過後,我覺得最重要的是如何整理出 template/,讓不同的安裝需求可以拆解出來,有秩序地組成這個初始的專案。


2023年1月27日 星期五

[Jest+Testing Library] Vue + storybook interaction 元件測試範例


storybook 可以獨立建立元件,並搭配 @storybook/addon-controls 控制 props 輸入及 @storybook/addon-actions 顯示 emit 輸出的內容,當然可以透過手動開啟 GUI 來測試元件是否符合規格,storybook 已經完成 render 的步驟,自動化的測試只差一步了!

storybook 可以搭配 interaction 套件進行元件測試,先按照「[Vue.js] storybook安裝方式與環境建置」完成 vue+storybook 的安裝,文內還是使用 Vue2,如果使用 Vue3 其實步驟差異不大。


安裝

執行指令

npm install --save-dev @storybook/addon-interactions @storybook/jest @storybook/testing-library


設定

請開啟 .storybook/main.js,註冊這個 addon interactions 套件,@storybook/addon-interactions 一定要放在 @storybook/addon-actions@storybook/addon-essentials 的後面。

module.exports = {
    addons: [
        '@storybook/addon-actions', // or '@storybook/addon-essentials'
        '@storybook/addon-interactions',
    ],
    features: {
        interactionsDebugger: true,
    },
};


撰寫測試

寫測試前,要先了解測試元件的重點是「邏輯!」

第一視覺的邏輯,經由 props 或 slots 改變元件顯示上的差異,例如文字內容或是決定是否顯示哪些區域等;第二行為的邏輯,當使用者輸入或點擊按鈕等,此時畫面產生的變化或是 emit 的事件內容等。

使用 jest + testing library 做測試,testing library底層為 vue test utils,兩者相比 testing library 可以更聚焦於元件測試,不容易因為非邏輯的變更,連同測試都要一起做調整。


簡單寫一個元件 NameEditor.vue,有兩個 props,按下按鈕可以發出 event。

<template>
    <div>
        <div
            v-text="message" />
        <input
            type="text"
            placeholder="name"
            v-model.trim="name">
        <button
            type="button"
            @click="onButtonClick"
            v-text="buttonText" />
    </div>
</template>

<script>
import {ref} from 'vue';

export default {
    props: {
        message: {
            type: String,
            required: true,
        },
        buttonText: {
            type: String,
            required: true,
        },
    },
    setup(props, {emit}) {
        const name = ref(undefined);

        const onButtonClick = () => {
            emit('subit', name);
        };

        return {
            name,
            onButtonClick,
        };
    },
};
</script>

接著,NameEditor.stories.js story基本的內容:

import {userEvent, screen} from '@storybook/testing-library';
import {expect} from '@storybook/jest';
import NameEditor from './NameEditor';

export default {
    title: 'NameEditor',
    argTypes: {
        message: {
            control: 'text',
        },
        buttonText: {
            control: 'text',
        },
        onNameSubmit: {
            action: 'submit',
        },
    },
};

const Template = (args, {argTypes}) => ({
    props: Object.keys(argTypes),
    components: {
        NameEditor,
    },
    template: `
        <NameEditor
            :message="message"
            :button-text="buttonText"
            @submit="onNameSubmit" />
    `,
});

export const Default = Template.bind({});

Default.args = {
    message: 'Input your name',
    buttonText: 'Submit',
};

interaction 的測試,必須寫在 play 裡面,測試流程是先輸入文字後,按下按鈕:

Default.play = async ({args}) => {
    const mockName = 'Chenuin';
    const button = screen.getByRole('button');

    await userEvent.type(screen.getByPlaceholderText('name'), mockName); // input name
    await userEvent.click(button); // click button

    const {message, buttonText, onNameSubmit} = args;

    expect(onNameSubmit).toHaveBeenCalledTimes(1);
    expect(onNameSubmit).toBeCalledWith(mockName);

    expect(screen.getByText(message)).toBeTruthy();
    expect(button.textContent).toBe(buttonText);
};

可以透過 args 取得 argTypes 的內容,發出的 event 自動 mock 可以直接驗證,只要把握 testing library獲取Dom的方式,需要注意 getBy...queryBy... 兩者差別,前者若取不到任何相符條件的element,會拋出exception,後者不會。其他部分就是 jest 語法。



執行

開啟 storybook 就會自動執行測試內容,如果有錯誤也會將訊息顯示畫面。

npm run storybook

(畫面如下)


到這邊就告一段落,如果希望能夠導入CI,可以使用 @storybook/test-runner

npm install --save-dev @storybook/test-runner

開啟 package.json 加入 npm script

{
  "scripts": {
    "test-storybook": "npx test-storybook"
  }
}

預設 test runner 會找到 http://localhost:6006 進行測試,可以加上 --url 來改變路徑。

npm run test-storybook

如果不是本地啟用storybook,可以利用 https://www.chromatic.com/ 來管理 storybook,就可以透過 internet 進行。Chromatic 可以用來發布 storybook、主要方便團隊 UI Review,藉此進行 CI 測試整合會更加容易。



參考資料

storybook 套件


2023年1月14日 星期六

第一個 Vue3 元件測試從 Vitest + Vue Test Utils 開始

延續「mocha + webpack 的 Vue3 元件單元測試」,既然使用 mocha 測試太卡關,這次安裝 Vitest 來試試看!

一、安裝

先安裝 Vitest,另外 Vitest 需要使用 Node >=v14,並安裝 Vite >=v3.0.0,還有 vue3 對應的測試套件

npm install --save-dev vite vitest @vue/test-utils @vitejs/plugin-vue

下面這幾個根據需求來選擇是否安裝

npm install --save-dev jsdom

提供UI介面,可參考 Test UI

npm install --save-dev @vitest/ui


二、設定

先到package.json設定npm指令

"scripts": {
    "test": "npx vitest",
}

在根目錄新增檔案 vitest.config.js

import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [
        Vue(),
    ],
    test: {
        globals: true,
        environment: 'jsdom',
    },
});

常用的設定像是include指定測試檔案的目錄或名稱等,和exclude排除檔案

export default defineConfig({
    test: {
        include: ['**/*.test.js'],
        exclude: ['**/node_modules/**'],
    },
});

設定路徑的alias

export default defineConfig({
    resolve: {
        alias: {
            '@': path.resolve(__dirname, 'src/'),
        },
    },
});

或者可以設定 csssetupFiles來套用css style

export default defineConfig({
    test: {
        css: true,
        setupFiles: './src/vitest/setup.js',
    },
});

src/vitest/setup.js import css

// src/vitest/setup.js

import 'todomvc-app-css/index.css';
import 'todomvc-common/base.css';

其他設定可以參考官方文件

完成基本設定後,就可以開始寫測試囉!



三、測試

針對現有的元件 TodoListEditor 撰寫測試

import { mount } from '@vue/test-utils';
import { test, expect } from 'vitest';
import TodoListEditor from 'components/TodoListEditor.vue';

test('TodoListEditor', () => {
    expect(TodoListEditor).toBeTruthy()

    const wrapper = mount(TodoListEditor, {
        props: {
            todoList,
        },
    });

    expect(wrapper.vm.todoList).toStrictEqual(todoList);
    expect(wrapper.props('todoList')).toStrictEqual(todoList);
});

參考資料



四、執行
npm run test

執行後就可以看到所有的測試項目及結果,若有錯誤也會對應的訊息顯示

有安裝@vitest/ui,可以開啟ui試試看

npm run test -- --ui


五、CI/CD

有了測試之後,我們利用 github action 設定每次 push 分支時,執行 vitest 的測試,請新增檔案 .github/workflows/test.yml

name: Test

on:
  push:
    branches: main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Before srcipt
        run: npm install

      - name: Test
        run: npm run test -- --run

.github/workflows/gh-pages.yml內的設定:

on:
  workflow_run:
    workflows: ["Test"]
    types:
      - completed

jobs:
  build:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest

    steps:
      ....

我們的目標是當 Test 成功時,才會執行部屬,否則忽略。請注意 completed 指的是完成時,無論測試結果成功與否都會觸發。因此 if: ${{ github.event.workflow_run.conclusion == 'success' }} 的設定是必要的。

也可變化成 ${{ github.event.workflow_run.conclusion == 'failure' }},當測試結果失敗時可以發送通知,根據應用情境改寫。

workflows可以設定多項,只要有任何一項執行就會觸發。


安裝 Vitest 來進行 Vue3 測試十分容易。語法跟 jest 很相像,甚至原本的 mocha 寫的測試也一行都沒改,都能夠支援測試。以上的內容有上傳到 github 上(專案),可以點選下方連結了解執行的內容。