顯示具有 javascript 標籤的文章。 顯示所有文章
顯示具有 javascript 標籤的文章。 顯示所有文章

2024年3月28日 星期四

[Javascript] 四種併發 Promise 差異比較


Promise 代表非同步操作的物件,一個 Promise 可區分成三種狀態:

  • pending - 初始狀態,可以視為處理中
  • fulfilled - 表示操作成功
  • rejected - 表示操作失敗

Promise 的併發控制就是當我們傳入多個 Promise時,藉由達成不同的條件,返回不同狀態的 Promise。


Promise.all()

當輸入的所有 Promise 的狀態都為 fulfilled 時,回傳 Promise 也是 fulfilled、操作成功;可按照輸入 Promise 的順序取得其回傳值。若有任何一項 rejected,立即判定操作失敗。

Promise.all([requestA, requestB, requestC]).then(result => {
    const [resultA, resultB, resultC] = result;
    
    // 全部都成功
}).catch(err => {
    // 可取得第一個失敗原因 
    console.log(err);
})
全部成功才是成功

Promise.allSettled()

當輸入的所有 Promise 的執行完畢時,不論為 fulfilled、rejected,回傳 Promise 必為 fulfilled;可按照其輸入的順序,取得各別的狀態(status)、回傳值(value)或錯誤原因(reason)。

Promise.all([requestA, requestB, requestC]).then(result => {
    const [resultA, resultB, resultC] = result;
    
    // 操作成功時,可以取得回傳值
    // result = {status: 'fulfilled', value: 'Great Job!'}
    // 操作失敗時,可以取得原因
    // result = {status: 'rejected', reason: 'Because ....'}
})
全部完成比較重要,但誰輸誰贏很清楚

Promise.any()

當輸入的所有 Promise 有任一個操作成功時,回傳 Promise 狀態為 fulfilled,且可取得其回傳值。若沒有任何一項成功,則是 rejected,錯誤原因是AggregateError。

Promise.any([requestA, requestB, requestC]).then(result => {
  // 取得最快成功的回傳值
  console.log(result);
}).catch(err => {
  // AggregateError: No Promise in Promise.any was resolved
  console.log(err);
});
龜兔賽跑,爭取第一名

Promise.race()

當輸入的所有 Promise 有任一個操作完成時,根據其狀態決定回傳 Promise 狀態,若第一個操作結果為成功,則為回傳狀態為 fulfilled;反之是 rejected。

Promise.race([requestA, requestB, requestC]).then(result => {
  // 第一個人成功,走這條路
  console.log(result);
}).catch(err => {
  // 第一個人失敗,走這條路
  console.log(err);
});
成敗不是關鍵,速度才是絕對

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年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 套件


2022年9月28日 星期三

[E2E Testing] 如何從 cypress 9 升級 10

使用 cypress 進行 E2e Testing,針對比較常用的部分整理從 Cypress 9 升級到 10 一些異動,最大的差別是不再支援 cypress.json,並移除了 plugins 目錄。


安裝 cypress 10

npm install --save-dev cypress@10

如何升級至 cypress 10

主要針對 e2e 調整的部分進行說明,啟動 cypress 時,原本會讀取根目錄建立 cypress.json的設定,cypress 10不再支援。檔案格式可以支援 js/ts,底下範例以cypress.config.js為主,另外參數的設定也做出調整:

baseUrl

從設定在最上層,改成專屬 e2e 測試的設定,若只做 component testing 就不需要設定這個參數,讓 e2e / component testing 的設定更一目瞭然。

Before cypress.json
{
  'baseUrl': 'http://localhost:1234'
}
After cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:1234'
  }
});

pluginsFile

這個設定不需要,直接移除即可。

Before cypress.json
{
  'pluginsFile': 'cypress/plugins/index.js'
}

setupNodeEvents

移除 pluginsFile 的設定後,原本的內容也直接移到 cypress.config.js 進行設定,過去版本9若同時使用 component / e2e testing,可以將設定直接分開來寫。

Before cypress/plugins/index.js
module.exports = (on, config) => {
  if (config.testingType === 'component') {
    // component testing dev server setup code
    // component testing node events setup code
  } else {
    // e2e testing node events setup code
  }
};
After cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  component: {
    devServer(cypressConfig) {
      // component testing dev server setup code
    },
    setupNodeEvents(on, config) {
      // component testing node events setup code
    },
  },
  e2e: {
    setupNodeEvents(on, config) {
      // e2e testing node events setup code
    },
  },
});

完整內容請參考官網升級指引


相關文章:


2022年7月25日 星期一

Vue3.2 <script setup> 實作 TodoMVC


Vue3.2開始將 <script setup> 移除experimental status,和setup()區別在有許多 option API 有了替代方案,props, emit 也可以寫在 setup`,variable 或 function 也不需要透過 return 才能讓 <template> 使用,哇!寫法怎麼好像有點既是感呢

下面會利用 TodoMVC 的練習,比較與統整 <script setup>setup() 常用的方法的差異。

TodoMVC 完整程式碼上傳至 Github (連結)。

data

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

export default {
    setup() {
        const newTodo = ref(undefined);

        return {
            newTodo,
        };
    },
}
</script>
<script setup>
宣告 ref 沒有差異,差在需不需要 return
<script setup>
import { ref } from 'vue';

const newTodo = ref(undefined);
</script>


component

<template>
    <TodoListEditor />
</template>
setup
<script>
import TodoListEditor from 'components/TodoListEditor.vue';

export default {
    components: {
        TodoListEditor,
    },
}
</script>
<script setup>
import 之後就可以直接在 <template> 使用
<script setup>
import TodoListEditor from 'components/TodoListEditor.vue';
</script>


props

setup
<script>
export default {
    props: {
        todoList: {
            required: true,
            type: Array,
        },
    },
}
</script>
<script setup>
defineProps 裡面內容與之前 props 相同
<script setup>
const props = defineProps({
    todoList: {
        required: true,
        type: Array,
    },
});
</script>


emit

setup
<script>
export default {
    emits: ['remove:todo', 'update:todo'],
    setupt(props, {emit}) {
        function removeTodoItem(id){
            emit('remove:todo', id);
        }
    },
}
</script>
<script setup>
defineEmits 裡面內容與之前 emits 相同
<script setup>
const emit = defineEmits(['remove:todo', 'update:todo']);

function removeTodoItem(id){
    emit('remove:todo', id);
}
</script>


directive

directive是所有寫法中我最不適應的,底下是頁面載入時可以有 autofocus 的效果,可以根據不同 lifecyle 定義,利如 mouted
<input
    v-model="newTodo"
    v-focus>
setup
<script>
const focus = {
    mounted: el => el.focus(),
};

export default {
    directives: {
        focus,
    },
}
</script>
<script setup>
<script setup>
const vFocus = {
    mounted: el => el.focus(),
};
</script>


lifecycle

基本上沒有什麼區別
setup
<script>
import {onMounted} from 'vue';

export default {
    setup() {
        onMounted(() => {
            // to something
        });
    },
}
</script>
<script setup>
<script setup>
import {onMounted} from 'vue';

onMounted(() => {
    // to something
});
</script>


2022年6月25日 星期六

【升版指南】Vue3 非兼容的特性 v-model 跟 Vue2 完全不一樣


vue2 v-model 的使用方式在這篇「[Vue.js] 如何在component自訂v-model」介紹,簡單說就是結合v-model其實綁定了名為 valuepropsinputemit事件,因此所謂的 v-model 就是一個父子元件的雙向溝通,在 vue2 兩者是相同的:

<input v-model="username">

<input :value="username"
       @input="(value)=>(username=value)">


但是!

vue3 props 預設名稱從 value 改為 modelValueemit 名稱則從 input 改為 update:modelValue,如果用錯了就無法達成雙向的綁定。

<input v-model="username">

<input :model-value="username"
       @update:modelValue="(value)=>(username=value)">

在 vue2 中可以用自訂 model 來自訂 propsemit 名稱,在 Vue3 移除不再支援,如果不想使用 modelValue 的話,在 Vue3 可以這樣寫:

<MyComponent v-model:username="username" />

<MyComponent
       :username="username"
       @update:username="(value)=>(username=value)" />

元件本身應該增加這個 props,更新時會呼叫 update:username

// MyComponent.vue
props: {
    username: {
        type: String,
        required: true,
    },
}

跟 vue2 .sync 有點相似,vue3不再支援 .sync ,寫法會是 v-model:[NAME] 後面 Name 就是想自訂的名稱,更新時update:[NAME],以事件名稱來看不算是完全客製,須按照規則觸發向上更新

但這個特性最棒的是支援多組 v-model ,你可以這樣寫:

<MyComponent
       v-model:username="username"
       v-model:password="password" />

總結將 vue2 升至 vue3,v-model 應該注意三個地方:

1. 不再使用 .sync

<MyComponent :username.sync="name" />

<!-- to be replaced with -->

<MyComponent v-model:username="name" />

2. 將所有的 value 和 input 和 modelValue 和 update:modelValue

<MyComponent v-model="name" />
// MyComponent.vue

export default {
  props: {
    modelValue: String, // "vaule" is replaced
  },
  emits: ['update:modelValue'],
  methods: {
    changeName(name) {
      this.$emit('update:modelValue', name);  // "input" is replaced
    },
  },
};

3. 不再使用 model

直接用 v-model ,v-model:[Name]


參考文章:https://v3-migration.vuejs.org/breaking-changes/v-model.html


2022年5月28日 星期六

Vue3+jest 測試 composable 範例


Vue3 的 composable 乍看下和 mixin 用途很類似,可以提供各個元件共用程式碼。但與 mixin 相比,composable 主要有三個優勢:

第一,元件可以很明確的區分使用的 composable 來源,當使用的 mixin 一多時,追朔來源相對困難,無法一眼看出由哪個 mixin 實作。

第二,多個 mixin 無法確保使用了相同的名稱,可能造成覆蓋,但 composable 即使有相同的名稱,也能透過結構式賦值、重新命名。

最後,多個 mixin 需要交互作用時,通常會使用相同的參數命名來達到這個目的,隱性的耦合使得辨識和debug難度增加,composable可藉由其一的回傳值,作為其他composable輸入的參數達到共享的目的。(參考資料)


安裝 Jest 測試 vue 時,首先要注意版本,請參考下面的對照表安裝套件:

Vue version Jest Version npm Package

Vue 2 Jest 26 and below vue-jest@4

Vue 3 Jest 26 and below vue-jest@5

Vue 2 Jest 27 and above @vue/vue2-jest@27

Vue 3 Jest 27 and above @vue/vue3-jest@27

Vue 2 Jest 28 and above @vue/vue2-jest@28

Vue 3 Jest 28 and above @vue/vue3-jest@28


composable 的測試主要分成兩種,第一種元件比較無關,可以當作普通的 js code 測試。

// counter.js
import {ref} from 'vue';

export function useCounter() {
  const count = ref(0);
  const increment = () => count.value++;

  return {
    count,
    increment,
  };
};
// counter.test.js
import {useCounter} from './counter.js';

test('useCounter', () => {
  const {count, increment} = useCounter()
  expect(count.value).toBe(0);

  increment();
  expect(count.value).toBe(1);
});

如果 composable 牽涉到元件 lifecyle hooks 或是 provide/inject 時,需要依附一個元件進行測試。

// test-utils.js
import {createApp} from 'vue';

export function withSetup(composable) {
  let result;

  const app = createApp({
    setup() {
      result = composable();
      // suppress missing template warning
      return () => {};
    },
  });

  app.mount(document.createElement('div'));
  // return the result and the app instance
  // for testing provide / unmount
  return [result, app];
};
// counter.js
import {ref, onMounted} from 'vue';

export function useCounter() {
  const count = ref(0);
  const increment = () => count.value++;
  
  onMounted(() => {
    increment();
  });

  return {
    count,
    increment,
  };
};

result是 composable 的回傳值(return),測試裡面 app.mount();,將執行 onMounted

// counter.test.js
import {withSetup} from './test-utils';
import {useCounter} from './counter.js'

test('useCounter', () => {
  const [result, app] = withSetup(() => useCounter());

  // run assertions
  expect(result.count.value).toBe(0);

  // trigger onMounted hook if needed
  app.mount();

  expect(result.count.value).toBe(1);
});


另外還有一個常犯的錯誤,先看 composable 程式碼,有一個 computed 使用 formatFn 轉換 list:

// useListFormatter.js
import {computed, unref} from 'vue';

export function useListFormatter(list, formatFn) {
  const formattedList = computed(() => (
    unref(list).map(item => formatFn(item))
  ));

  return {
    formattedList,
  };
};

測試時需要注意回傳值被使用之前,formattedList 不會被執行。

// useListFormatter.test.js
import {useListFormatter} from './useListFormatter.js';

test('useListFormatter', () => {
  const list = ['a', 'b'];
  const formatFn = jest.fn();

  formatFn.mockImplementation(value => `new-${value}`)

  const {formattedList} = useListFormatter();
  
  // Don't do it! formatFn won't be excuted until formattedList is called.
  // expect(formatFn).toHaveBeenCalled();
  
  expect(formatFn).not.toHaveBeenCalled();

  expect(formattedList.value).toEqual(['new-a', 'new-b']);
  expect(formatFn).toHaveBeenCalledTimes(2);
  expect(formatFn).toHaveBeenNthCalledWith(1, 'a');
  expect(formatFn).toHaveBeenNthCalledWith(2, 'b');
});


參考資料:

https://vuejs.org/guide/scaling-up/testing.html#testing-composables


2022年1月23日 星期日

webpack 常用 plugin 介紹 - HtmlWebpackPlugin 自動產生 Html


根據「[Vue.js] 如何建立 Vue3 + webpack5 專案範例」的內容,封裝時已經先建立目錄 dist/ ,新增 index.html,預先將 main.js 加到 script 中。

接著,以下要介紹 HtmlWebpackPlugin ,這個 webpack plugin 可以自動產生 Html,並自動將所有的 js 檔加入 script 中。下面的操作會用 [vue3-webpack5-template] 這個專案操作,可以先 clone下來並 npm install。

一、安裝
npm install html-webpack-plugin --save-dev


二、新增模板

新增 index.html ,並保留一些區域方便我們可以自訂。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>
            <%= htmlWebpackPlugin.options.title %>
        </title>
    </head>
    <body>
        <div id="<%= id %>"></div>
    </body>
</html>


三、新增 Webpack 設定

為了突顯 HtmlWebpackPlugin 功能的強大,首先刻意將輸出的 js 檔案名稱改為 [name].[contenthash].js,這時變得無法預期編譯後的檔案名稱。

再來將 HtmlWebpackPlugin 加入 plugin,完整檔案請點連結

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },

  ...

  plugins: [
    new HtmlWebpackPlugin({
      template: 'template/index.html',
      title: 'Getting Started',
      templateParameters: {
        id: 'app',
      },
    }),
  ],
};

上面共使用了三個參數:

1. template - 顧名思義是模板的路徑
2. title - html 檔案的 <title>
也說明為什麼在 index.html 有這一段

<%= htmlWebpackPlugin.options.title %>

3. templateParameters - 這裡傳入一個物件,將 id 訂為 app,因此模板這段程式碼,將會使用這裡的設定複寫

<div id="<%= id %>"></div>

若要了解更多參數的使用,可以參考文件



四、執行

執行下面的指令開始打包

npm run build

程式碼已經 push 到分支test-webpack-plugin,或者參考變更可以按這裡


2021年12月15日 星期三

取代 window resize 事件? 如何使用 ResizeObserver 偵測元素變化

ResizeObserver 用來監控元素大小的變動,與 window 的 resize 事件(如下)的用途相似,通常用在監控瀏覽器視窗大小的變動。ResizeObserver 使用範圍更廣,可以綁在 HTMLElement 上。

window.addEventListener('resize', callback);

首先要建立一個 ResizeObserver 物件

const resizeObserver = new ResizeObserver(callback);

callback 是定義當指定的元素偵測到變化時,需要執行的動作,分為:

  • entries:型態為陣列(array)。因為允許偵測多個變動元素,每個元素變動的大小、位置等資訊,會以 ResizeObserverEntry 物件裝在陣列內。
  • observer:ResizeObserver 物件本身
const resizeObserver = new ResizeObserver((entries, observer) => {
    entries.forEach(entry => {
        // Do something to each entry
        // and possibly something to the observer itself
    });
});

再來,使用 oberve()開始監看元素

resizeObserver.observe(target, options);

target為需要偵測的元素


unobserve() 取消追蹤指定的元素

resizeObserver.unobserve(target);

可以使用disconnect()取消所有元素的追蹤

resizeObserver.disconnect();

接著,請參考 window resize 事件的範例,範例將視窗大小顯示,若resize發生時,將異動更新到畫面上,如果使用 ResizeObserver 要怎麼改寫:

<p>Resize the browser window to fire the <code>resize</code> event.</p>
<p>Body height: <span id="height"></span></p>
<p>Body width: <span id="width"></span></p>
const heightOutput = document.querySelector('#height');
const widthOutput = document.querySelector('#width');
  
const resizeObserver = new ResizeObserver(entries => {
  entries.forEach(entry => {
    heightOutput.textContent = entry.contentRect.height;
    widthOutput.textContent = entry.contentRect.width;
  });
});

resizeObserver.observe(document.querySelector('body'));

Resize the browser window to fire the resize event.

Body height:

Body width:


底下實作一個範例,用slider控制 div 區域的寬度,當增測的 div 變化時,依據寬度的數值改變 div 背景的透明度。

See the Pen ResizeObserver Example by chenuin (@chenuin) on CodePen.


參考資料: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver

2021年8月9日 星期一

[Vue.js] 如何建立 Vue3 + webpack5 專案範例

以下說明如何使用 webpack5 打包 Vue3 專案,若尚未安裝 webpack5,可以參考「webpack5 安裝及基礎教學」。


一、安裝

需要安裝的套件如下,特別需要注意的是,在 Vue3 裡支援副檔名 .vue 的 single-file components 的套件,從 vue-template-compiler 變成 @vue/compiler-sfc

  • vue
  • @vue/compiler-sfc
  • vue-loader
  • webpack
  • webpack-cli


請先新增專案,並在目錄內新增 package.json,並 npm 執行安裝。

{
  "name": "vue3-webpack5-template",
  "version": "1.0.0",
  "description": "vue3-webpack5-template",
  "private": true,
  "scripts": {
    "build": "webpack"
  },
  "author": "chenuin",
  "license": "ISC",
  "devDependencies": {
    "@vue/compiler-sfc": "^3.1.5",
    "vue-loader": "^16.1.2",
    "webpack": "^5.49.0",
    "webpack-cli": "^4.7.2"
  },
  "dependencies": {
    "vue": "^3.1.5"
  }
}

執行指令安裝:

npm install



二、設定打包的設定檔

首先針對 Vue 專案所設定的,Loader 可分為testloader,前者是定義哪些檔案需要處理,像這裡就是副檔名為 .vue 的檔案;後者則是使用哪一個套件處理 :

module: {
    rules: [
        {
            test: /\.vue$/,
            loader: 'vue-loader'
        },
    ],
},
plugins: [
    new VueLoaderPlugin(),
],

當專案內import vue 可以使用別名(alias): 

resolve: {
    alias: {
        vue: 'vue/dist/vue.esm-bundler.js',
    },
},

因此完整的 webpack.config.js 如下:

const path = require('path');
const { VueLoaderPlugin } = require('vue-loader');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            },
        ],
    },
    resolve: {
        alias: {
            vue: 'vue/dist/vue.esm-bundler.js',
        },
    },
    plugins: [
        new VueLoaderPlugin(),
    ],
};



三、新增Entry, Ouput等內容

在目錄內 src/ 新增兩個檔案:

Vue3 主要元件 App.vue

<template>
  <div class="app">
    <p
      v-text="msg" />
  </div>
</template>

<script>
export default {
    name: 'App',
    setup() {
      return {
        msg: 'Hello World!',
      };
    },
};
</script>

新增 Entry File: index.js
設定別名的用途可以看第一行 import App from './App.vue';

import { createApp } from 'vue';
import App from './App.vue';

createApp(App)
    .mount('#app');


目錄 dist/ 則是新增 index.html,要注意的是先前 index.js 是綁定到 #app,所以 <div id="app"></div> 就是 vue render的地方。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Getting Started</title>
    </head>
    <body>
        <div id="app"></div>
        <script src="./main.js"></script>
    </body>
</html>

此時的專案架構應該會像是:

├── package.json
├── dist
│   └── index.html
└── src
    ├── App.vue
    └── index.js

專案已經上傳 Github [vue3-webpack5-template],可以下載使用。



四、執行

執行下面的指令開始打包

npm run build


完成後目錄 dist/ 下多了一個檔案 main.js,此時可以開啟 index.html ,看到 Hello World! 就代表打包成功囉!



2021年8月8日 星期日

webpack5 安裝及基礎教學

根據維基百科:「Webpack 是一個開源的前端打包工具。Webpack 提供了前端開發缺乏的模組化開發方式,將各種靜態資源視為模組,並從它生成優化過的程式碼。」,使用前必須安裝 Node.js。

webpack 從版本4.0.0開始,可以不需要設定設定檔(webpack.config.js),設定檔最基本的設定分別是:EntryOutput,前者代表 webpack 必須從哪邊開始進行打包,後者則是定義封裝輸出的路徑以及檔案名稱。


以下參考官網簡單的範例,執行前請先安裝 Node.js,可以參考「安裝node js」替換成需要的版本。

一、安裝

請先新增一個專案目錄,並安裝 webpackwebpack-cli

mkdir webpack-demo
cd webpack-demo>
cd webpack-demo>
npm init -y>
npm install webpack webpack-cli --save-dev

此時目錄內應該會多一個 package.jsonnode_module/


二、新增Entry module

新增目錄 src/,以及檔案 index.js

const element = document.createElement('div');

element.innerHTML = 'Hello World!';

document.body.appendChild(component());

三、新增Output

新增目錄 dist/,以及檔案 index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Getting Started</title>
  </head>
  <body>
    <script src="main.js"></script>
  </body>
</html>

此時的專案架構應該會像是:

webpack-demo
 |- package.json
 |- /dist
   |- index.html
 |- /src
   |- index.js

四、執行

執行下面的指令開始打包

npx webpack


完成後會發現目錄 dist/ 下多了一個檔案 main.js,此時可以開啟 index.html ,看到 Hello World! 就代表打包成功囉!


在沒有設定檔的情況下,webpack預設會尋找檔名 src 或是目錄 src/ 底下的 index,並將打包好的檔案放到目錄 dist/ 底下,預設檔名是 main.js,等同於:

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
;

可以透過指令來指定設定檔的路徑及名稱:

npx webpack --config webpack.config.js

範例還未使用LoaderPlugin等功能,在這個範例還未使用 css/image,也可以透過 Loader設定處理,webpack可以處理Javascript、Json,安裝loader可以讓webpack處理更多類型的檔案。另外在這個範例裡手動新增 index.html ,其實也可以透過相關的 Plugin 自動產生。

其他進階設定的功能可以參考 https://webpack.js.org/guides/getting-started/ 了解更多。



2021年2月21日 星期日

【升版指南】Vue 3 宣告事件 emits


Vue 3 新增 emits ,可以宣告需要傳遞到上層的事件名稱,而在 Vue2 時不需要宣告:
<template>
  <div>
    <p v-text="message" />
    <button v-on:click="$emit('accepted')">OK</button>
  </div>
</template>

<script>
  export default {
    props: ['message']
  }
</script>

在 Vue3 要在 emits 加上 accepted,寫法與 props 宣告的方式相似 :
<template>
  <div>
    <p v-text="message" />
    <button v-on:click="$emit('accepted')">
        OK
    </button>
  </div>
</template>

<script>
  export default {
    props: ['message'],
    emits: ['accepted'],
  }
</script>
雖然這個變動不大,但如果元件內忘記宣告,上層會收到兩次的事件觸發,造成非預期性的事件產生。

如同 props ,事件傳遞的資料可以加上驗證 (validator)。假設有個輸入訊息的 message 會透過 Button 點擊送出,期望送出的文字至少要10個字:
<template>
  <div>
    <input v-model="message">
    <button v-on:click="$emit('confirm', message)">
        OK
    </button>
  </div>
</template>

<script>
  export default {
    emits: {
    	confirm: message => message.length > 10,
    },
    data() {
        return {
            message: undefined,
        };
    },
  }
</script>


參考資料:https://v3.vuejs.org/guide/migration/emits-option.html

【升版指南】Vue 3 移除 $listeners 後如何跨元件傳遞事件

從 Vue2 到 Vue3 會有不兼容的特性,需要特別注意,其中跨元件傳遞資料和事件常用的 $attrs$listeners ,就是其中的一項。

在 Vue3 移除 $listeners ,全部改到 $attrs 裡,因此在 Vue2 裡可能會有這種情況:
<template>
  <label>
    <input
        type="text"
        v-bind="$attrs"
        v-on="$listeners">
  </label>
</template>

<script>
  export default {
    inheritAttrs: false
  }
</script>

在 Vue3 ,因為 $listeners 不存在,所有的綁定只要使用 $attrs
<template>
  <label>
    <input
        type="text"
        v-bind="$attrs">
  </label>
</template>

<script>
export default {
  inheritAttrs: false
}
</script>


假設只需要傳遞特定的事件,而非所有的事件,舉例要傳遞的事件是 update-name ,Vue 2 的寫法利用 $listeners 會是:
<template>
   <my-element
       :name="$attrs.name"
       @update-name="$listeners['update-name']" />
</template>

在 Vue3 裡需要加上 on 作為前綴,上層元件就能夠收到事件 update-name
<template>
   <my-element
       :name="$attrs.name"
       @update-name="$attrs['onUpdateName']" />
</template>

參考資料:https://v3.vuejs.org/guide/migration/listeners-removed.html

2020年11月2日 星期一

[Vue.js] vue-i18n 實現多語系


快速使用

假設你已經安裝 Vue cli 3.x,你可以直接到專案裡執行:

vue add i18n

接著會詢問你預設的語言、備用語系(fallbackLocale)及字典檔的目錄,所謂的備用語系指的是當使用者選擇語言的字典檔有缺時,會採用哪個語言的資料替代。

若按照預設值執行的話,可以確認一下專案是否出現目錄 /[Your Project]/src/locales/ ,裡面有個 en.json 英文的字典檔。

這個方法相關的設定已經自動完成,可以直接在 template 上使用:

{{ $t('message') }}


安裝及設定

Step 1: 安裝套件

npm install vue-i18n

Step 2: 新建目錄及字典檔

mkdir locales
新增目錄 /[Your Project]/src/locales/ ,加入第一個字典檔 en.json
{
  "hello": "Hello!"
}
接著 index.js
import en from './en';

export const messages = {
    en,
};
你也可以在 locales/ 下繼續新增其他語系

Step 3: 設定

新增 i18n.js 設定 locale 預設語言及 fallbackLocale 備用語言等,messages 是所有語系的字典檔。
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import {messages} from './locales';

Vue.use(VueI18n);

export default new VueI18n({
  locale: 'en', // set locale
  fallbackLocale: 'en',
  messages, // set locale messages
});
將設定加入 main.js
import Vue from 'vue'
import App from './App.vue'
import i18n from './i18n'

Vue.config.productionTip = false

new Vue({
  i18n,
  render: h => h(App),
}).$mount('#app')
試著在 template 上使用:

{{ $t('hello') }}

上述的檔案完成後,專案的架構如下列所示:

./src/
├── App.vue
├── i18n.js
├── locales
│   ├── en.json
│   └── index.js
└── main.js

更多的安裝資訊可以參考[文件]



進階使用

切換語系
在 root 更改設定值
$i18n.locale

備用語系
可依據各語言設定,可接受傳入字串、陣列、物件等
fallbackLocale


2020年10月16日 星期五

Ubuntu 20 安裝及建立 Angular 專案

請先安裝 node.js
sudo apt update
sudo apt install build-essential libssl-dev
curl https://raw.githubusercontent.com/creationix/nvm/v0.36.0/install.sh | sh
source ~/.profile
nvm install v12.19.0


安裝 Angular CLI 套件
npm install -g @angular/cli


建立專案
新增一個 my-app 的專案,執行指令後會詢問是否安裝 Angular routing 及樣式語言(stylesheet),不選擇的話可以直接按enter使用預設值。
ng new my-app
執行需要一點時間,完成後會在目前的目錄中出現一個 my-app/ 的資料夾。

執行
cd my-app
ng serve

請用瀏覽器開啟頁面
# run `ng serve --open` to open your browser automatically
http://localhost:4200/



相關文章:

2020年7月21日 星期二

[Vue.js] $ref 和 directive 操作 DOM 的兩種方式及範例

以下整理 Vue 兩種操作 DOM 的方法。程式碼內容是根據輸入的數字來決定 div 的高度,這些範例僅是方便了解使用的方式,不代表這類的需求適合使用。

一、自訂索引名 ($refs)
自訂索引名有點像是 id 一樣,假設在元件上設定 ref myBlock,就可以用 this.$refs.myBlock 取得 DOM 元素。
<template>
	<input
		v-model.number="heightModel"
		type="number">
	<div
		ref="myBlock"
		class="border border-danger">
		Block
	</div> 
</template>

<script>
export default {
	data() {
		return {
			height: 50,
		};
	},
	computed: {
		heightModel: {
			get() {
				return this.height;
			},
			set(height) {
				this.height = height;
				this.changeHeight(height);
			},
		},
	},
	mounted() {
		if (this.height) {
			this.changeHeight(this.height);
		}
	},
	methods: {
		changeHeight(height) {
			this.$refs.myBlock.style.height = `${height}px`;
		},
	}
}
</script>

二、自定義指令 (custom directive)
常用的 v-model 就是一種指令(directive),你也可以自訂指令方便全域使用。若定義 focus,在 template 上就使用 v-focus
// Register a global custom directive called `v-focus`
Vue.directive('focus', {
  // When the bound element is inserted into the DOM...
  inserted: function (el) {
    // Focus the element
    el.focus()
  }
})

同理,自定義一個 v-height,來改變元件的高度:
<template>
	<input
		v-model.number="height"
		type="number">
	<div
		v-height="height"
		class="border border-danger">
		Block
	</div> 
</template>

<script>
export default {
	directives: {
		height: (el, binding) => {
			el.style.height = `${binding.value}px`;
		},
	},
	data() {
		return {
			height: 50,
		};
	},
}
</script>

完整的範例請可以參考:

See the Pen Vue.js Reference ID VS Custom Directives by chenuin (@chenuin) on CodePen.



2020年2月27日 星期四

[JavaScript] 安裝Jest單元測試


Jest為javascript的前端測試工具,安裝快速且不需複雜的設定,執行速度快,所有測項可以同時進行讓效能最大化,並可以搭配用在vue.js、React等進行UI component的測試。

安裝指令

npm install --save-dev jest
npm的安裝教學可以參考「教學」。


實作

以簡單的JavaScript程式來測試執行,將傳入的陣列合併成字串回傳:
// combineArray.js
const combineArray = (array, separator = ',') => {
        if (!Array.isArray(array)) {
                return new Error('Invalid input array');
        }
        if (typeof separator !== 'string') {
                return new Error('Invalid input separator');
        }

        return array.join(separator)
};

module.exports = combineArray;
測試的副檔名 *.test.js
// combineArray.test.js
const combineArray = require('./combineArray');

test('Combine array: [1, 2]', () => {
        expect(combineArray([1,2])).toBe('1,2');
});
  • 每個一測試檔案都必須至少有一個test(),第一個參數是名稱;第二個參數是執行expect實際測試內容的function。[參考]
  • expect()常用的方式expect(A).toBe(B),也就是期望A會等於B。[參考]

package.json新增內容:
{
  "scripts": {
    "test": "jest"
  }
}

執行測試

npm run test
所有的*.test.js都會跑過一次,並顯示結果,包含有多少測試項目以及成功和失敗的數量。



根據程式碼的複雜程度可以寫出許多測試項目,為了方便辨識測試內容,describe()可以將測項分類、建立群組,.toThrow()判斷錯誤發生的情形等等:
const combineArray = require('./combineArray');

describe('Combine array', () => {
        const inputArray = [1, 2];

        test('Default', () => {
                expect(combineArray([])).toBe('');
                expect(combineArray(inputArray)).toBe('1,2');
                expect(combineArray(inputArray, undefined)).toBe('1,2');
        });
        test('With custom separator', () => {
                expect(combineArray(inputArray, ' ')).toBe('1 2');
                expect(combineArray(inputArray, '*')).toBe('1*2');
                expect(combineArray(inputArray, '-')).toBe('1-2');
        });

        describe('With invalid input', () => {
                test.each(
                        [undefined, null, 1, 'string', {}]
                )('Input array: %s', (item) => {
                        expect(() => {
                                combineArray(item);
                        }).toThrow(new Error('Invalid input array'));
                });
                test.each(
                        [null, 1, [], {}]
                )('Input separator: %s', (item) => {
                        expect(() => {
                                combineArray(inputArray, item);
                        }).toThrow(new Error('Invalid input separator'));
                });
        });
});
執行結果


若有任何不符合預期的結果,console上會寫出哪一個測項失敗,預期的輸出和輸入為何。測試項目寫得越完備越好,往後即使更新主程式,也可以再以指令執行測試,確保程式運作如你所預期。

更多的方法可以前往官網參考:
https://jestjs.io/

2020年2月25日 星期二

[Vue.js] computed的set()和get()使用方式


一般情況下,為了避免在 template 中寫入過度複雜的計算,可以選擇放到 computed
<template>
    <div v-text="colorList.join(',')" />
</template>

<script>
export default {
    data() {
        return {
            colorList: ['blue', 'green', 'red'],
        };
    },
};
</script>
// Recommend
<template>
    <div v-text="displayColorList" />
</template>

<script>
export default {
    data() {
        return {
            colorList: ['blue', 'green', 'red'],
        };
    },
    computed: {
        displayColorList() {
            return this.colorList.join(',');
        },
    },
};
</script>
預設的情況下computed只有 get() ,必須要有一個return值,會根據相依的值動態計算。computeddata很像,但如果要直接修改值,必須加上 set()

如果firstNamelastName被修改時,會觸發 set() ,第一個參數為這次觸發更新輸入的值。參考範例:
<template>
    <div>
        <input v-model="firstName">
        <input v-model="lastName">
        <input v-text="fullName" />
    </div>
</template>

<script>
export default {
    data() {
        return {
            fullName: '',
        };
    },
    computed: {
        firstName: {
            get() {
                return this.fullName.split(' ')[0] || '';
            },
            set(firstName) {
                this.fullName = `${firstName} ${this.lastName}`;
            },
        },
        lastName: {
            get() {
                return this.fullName.split(' ')[1] || '';
            },
            set(lastName) {
                this.fullName = `${this.firstName} ${lastName}`;
            },
        },
    },
};
</script>

2019年12月26日 星期四

[Vue.js] 跨元件的資料傳遞 Eventbus 簡易使用範例


Eventbus可以達成跨元件的資料傳遞,這個範例只是作為Eventbus效果的示範,依據實際的應用時,不一定使用eventbus是最合適的,可以考量使用props/emit父子元件的傳遞方式,或是vuex來需要共用的資源。

先參考一下整體的檔案結構:
├── App.vue
├── components
│   ├── Button.vue
│   └── DisplayBoard.vue
├── libs
│   └── eventbus.js
└── main.js

eventbus.js
import Vue from 'vue';
export const EventBus = new Vue();

MyButton.js
傳遞eventbus的元件,當按下按鈕時,會將目前的值傳出去,以$emit發出一個 "add-count" 的事件。
<template>
  <button @click="onClickCountAdd">Click</button>
</template>

<script>
// Import the EventBus we just created.
import { EventBus } from '../libs/eventbus.js';

export default {
  data() {
    return {
      clickCount: 0,
    }
  },
  methods: {
    onClickCountAdd() {
      this.clickCount++;
      // Send the event on a channel (add-count) with a payload (the click count.)
      EventBus.$emit('add-count', this.clickCount);
    }
  }
}
</script>

DisplayBoard.js
註冊eventbus的元件,$on是註冊;$off是取消,兩者必須成雙出現,可以避免產生重複綁定。display-board註冊 "add-count" 的事件,後面的參數是時間接收到事件時,負責處理的method。
<template>
  <div v-text="displayCount" />
</template>

<script>
// Import the EventBus we just created.
import { EventBus } from '../libs/eventbus.js';

export default {
  data() {
    return {
      displayCount: 0,
    }
  },
  created() {
    this.handleClick = this.handleClick.bind(this);
    // Listen to the event.
    EventBus.$on('add-count', this.handleClick);
  },
  destroyed() {
    // Stop listening.
    EventBus.$off('add-count', this.handleClick);
  },
  methods: {
    handleClick(clickCount) {
      this.displayCount = clickCount;
    }
  }
}
</script>

App.js
<template>
  <div>
    <my-button />
    <display-board />
  </div>
</template>

<script>
import MyButton from './components/MyButton'
import DisplayBoard from './components/DisplayBoard'

export default {
  components: {
    MyButton,
    DisplayBoard,
  }
}
</script>

最終成果,隨著my-button內按鍵點擊,display-board需呈現目前的點擊數。