Framework design is far from as simple as you think. It is not to say that only the functional development is completed, but even if it is finished, there is still a lot of knowledge in it. For example, what build artifacts should our framework provide to the user? What is the module format of the product? Should appropriate warnings be printed when users are not using the framework in the intended way to improve the development experience and allow users to quickly locate problems? What is the difference between a development build and a production build? HMR (Hot Module Replacement) requires framework level support, should we consider that too? Also, when your framework provides multiple features, if the user only needs a few of them, can the user choose to turn off the other features to reduce the resource packaging volume? All of these issues will be discussed in this section.

This section requires some experience with the common module packaging tools, especially rollup.js and webpack. It doesn’t matter if you’ve only used or know one of them, because many of the concepts are similar. If you don’t use any of the module packaging tools you’ll need to do some research on your own, and it’s a good idea to come back to this section with at least a primer.

Improve user development experience

One of the best indicators of a good framework is its development experience. Take Vue3 for example:

createApp(App).mount('#not-exist')
Copy the code

We get a warning message when we create a Vue application and try to mount it to a DOM node that doesn’t exist:

warn

From this message we learn that the mount failed and explain why: Vue could not find the corresponding DOM element (return NULL) according to the selector provided by us, because the existence of this information enables us to clearly and quickly understand and locate the problem. Just imagine that if Vue does not do any internal processing, it is likely to get a JS error message, such as: Uncaught TypeError: Cannot read property ‘XXX’ of NULL Uncaught TypeError: Cannot read property ‘XXX’ of NULL

It is important to provide friendly warnings during framework design and development, and if this is not done well you will often receive complaints from users. Providing friendly warnings at all times not only helps users locate problems quickly and saves them time, but also generates a good reputation for the framework and makes them think you’re a professional.

In the Vue source, you’ll often see calls to the WARN () function, such as the warn() function that prints the information in the picture above:

warn(
  `Failed to mount app: mount target selector "${container}" returned null.`
)
Copy the code

For the WARN () function, since it needs to provide as much useful information as possible, it needs to collect the stack information of the component where the error is currently occurring, so if you look at the source code you’ll find it a little complicated, but it actually ends up calling console.warn().

There are many other aspects of the development experience that can be used to further enhance the user experience beyond providing necessary warnings. For example in Vue3 when we print a Ref data on the console:

const count = ref(0)
console.log(count)
Copy the code

Open the console to view the output, as shown below:

There is no output of any processing

Of course, we could print count. Value directly, which would only output 0, but is there a way to print count with better output? Of course, browsing allows us to write a custom formatter to customize the form of the output. In the Vue source you can search for a function called initCustomFormatter, which is used to initialize the custom Formatter in the development environment. In chrome you can turn on the devTool Settings. Then check Console -> Enable Custom Formatters:

Then refresh the browser and look at the console, and the output becomes very intuitive:

Controls the volume of framework code

The size of the framework is also one of the criteria for measuring the size of the framework, and of course the less code you need to achieve the same functionality, the smaller the volume, and the less time the browser takes to load resources. At this point, we can’t help but think that providing better warnings means we need to write more code, as opposed to controlling the volume of code. Exactly, which is why we need to figure out a way to fix it.

If we look at the Vue source, we’ll see that every warn() call is checked with a __DEV__ constant, for example:

if (__DEV__ && ! res) { warn( `Failed to mount app: mount target selector "${container}" returned null.` ) }Copy the code

As you can see, the warning message is printed with the premise that the __DEV__ constant must be true, where the __DEV__ constant is the key.

Vue builds the project using rollup.js, where the __DEV__ constant is actually predefined through the rollup configuration and functions like the DefinePlugin in WebPack.

When Vue outputs a resource, it outputs two versions of the resource, one of which is for the development environment, such as vue.global.js. Another corresponding file for production, such as vue.global.prod.js, can also be distinguished by the file name.

When Vue builds resources for the development environment, it sets the __DEV__ constant to true, so the above warning output is equivalent to:

if (true && ! res) { warn( `Failed to mount app: mount target selector "${container}" returned null.` ) }Copy the code

You can see that __DEV__ has been replaced with the literal true, so this code must exist in the development environment.

When Vue builds resources for production, it sets the __DEV__ constant to false, so the warning is equivalent to:

if (false && ! res) { warn( `Failed to mount app: mount target selector "${container}" returned null.` ) }Copy the code

We can see that the __DEV__ constant is replaced with the literal false, at which point we realize that the branch Code will never execute. Because the judgment condition is always false, the branch Code that will never execute is called Dead Code. It will not appear in the final product and will be removed when building the resource. So this code does not exist in vue.global.prod.js.

This allows us to provide user-friendly warnings in the development environment without increasing the volume of production code.

Frameworks need to be tree-shaking well

We mentioned above that by setting the predefined constant __DEV__ in the build tool, it is possible to eliminate the code that prints warning messages from the framework in production, thus reducing the amount of code in the framework itself. But from the user’s point of view, that’s still not enough. Again, take Vue. We know that Vue provides built-in components like
, and if we don’t use that component in our project at all, So does the code for the
component need to be included in our project’s final build resources? The answer is of course not, so how do you do that? That brings us to tree-shaking, the main character of this section.

So what is tree-shaking? In the front end, the concept was popularized by rollup. Simply speaking, tree-shaking means eliminating code that will never execute, i.e. eliminating dead-code. Tree-shaking is now supported by both Rollup and Webpack.

To implement tree-shaking, one condition is that the Module must be ES Module, because tree-shaking relies on the static structure of THE ESM. Let’s use rollup to see how tree-shaking works in a simple example. Our demo directory structure looks like this:

├ ─ ─ demo │ └ ─ ─ package. The json │ └ ─ ─ input. Js │ └ ─ ─ utils. JsCopy the code

First install rollup:

Yarn add rollup -d # or NPM install rollup -d

Here are the contents of the input.js and utils.js files:

// input.js
import { foo } from './utils.js'
foo()
// utils.js
export function foo(obj) {
  obj && obj.foo
}
export function bar(obj) {
  obj && obj.bar
}
Copy the code

The code is simple, we define and export two functions, foo and bar, in the utils.js file, and then import foo in input.js and execute it, noting that we did not import bar.

We then run the following command to build with rollup:

npx rollup input.js -f esm -o bundle.js
Copy the code

This command takes the input. Js file and outputs the ESM module with the name bundle.js. After executing the command successfully, we open bundle.js to see its contents:

// bundle.js
function foo(obj) {
  obj && obj.foo
}
foo();
Copy the code

As you can see, the bar function is not included, which means tree-shaking is working. Since we are not using the bar function, it is removed as dead-code. However, if we look closely, we can see that the execution of foo does not make any difference, because it reads the value of the object, so it does not make any difference whether it executes or not. Even if this code is deleted, it will not affect our application. Why not remove this code as dead-code too?

This brings us to the second key point in tree-shaking, which is side effects. If a function call has side effects, it cannot be removed. What are side effects? Simply put, a side effect means that when a function is called, it has an external effect, such as modifying a global variable. At this point you might say, how could the above code obviously read the value of the object have a side effect? It’s possible, if you think about it if obj object is a Proxy object created by Proxy then when we read the property of the object we fire the Getter, and there are side effects that can happen in the Getter, like if we modify some global variable in the Getter. JS is a dynamic language. It is very difficult to statically analyze which code is dead-code. The above is just a simple example.

Because statically analyzing JS code is difficult, tools like Rollup give me the ability to explicitly tell Rollup: “Relax, this code has no side effects, you can remove it.” How do you do that? We modify the input.js file as shown in the following code:

import {foo} from './utils'

/*#__PURE__*/ foo()
Copy the code

/*#__PURE_*_/ tells rollup that calls to foo() have no side effects and that you can tree-shaking it. If you run the build command again and look at the bundle.js file, you will see that its contents are empty, indicating that tree-shaking is working.

The /*#__PURE_*_/ annotation should be used properly when writing the framework. If you search the Vue source code, you will find that it makes extensive use of this annotation, such as the following:

export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)
Copy the code

You might wonder if this is too much of a mental burden to write code? No, because the code that usually causes side effects is top-level calls to functions in a module. What are top-level calls? The following code looks like this:

Foo () // top-level call function bar() {foo() // internal call}Copy the code

As you can see, it is possible for top-level calls to have side effects, but for in-function calls, as long as bar is not called, then calls to foo certainly do not have side effects. As a result, you’ll find that in the Vue source code, the /*#__PURE__*/ annotations are used on top-level functions. Of course, this annotation doesn’t just apply to functions, it can be used on any statement, and this annotation isn’t only rollup readable, it’s also readable by WebPack and compression tools like Terser.

What build artifacts should the framework produce

As mentioned above, Vue outputs different packages for development and production environments. For example, vue.global.js is used for development environments, which contains the necessary warning information, while vue.global.prod.js is used for production environments, which does not contain warning information. In fact, in addition to being contextually differentiated, Vue build artifacts also output other forms of artifacts depending on the usage scenario. In this section we will discuss the purpose of these artifacts and how they are exported during the build phase.

The different types of products must have corresponding requirements background, so we start with requirements. First we want the user to be able to introduce the framework directly into the HTML page using the

<body>
  <script src="/path/to/vue.js"></script>
  <script>
  const { createApp } = Vue
  // ...
  </script>
</body>
Copy the code

To implement this requirement, we need to output a resource called the IIFE format. IIFE is the Immediately Invoked Function Expression, which is the “Immediately Invoked Function Expression” and is easily expressed in JS:

(function () { // ... }()) As shown above, this is a function expression that executes immediately. Var vue = (function(exports){// exports){var var = (function(exports){// exports){var var = (exports){// exports){var var = (exports){// exports); exports.createApp = createApp; / /... return exports }({}))Copy the code

This makes the global variable vue available when we import it directly into the vue.global.js file using the

In rollup we can output this form of resource by setting format: ‘iife’ :

// rollup.config.js const config = { input: 'input.js', output: { file: 'output.js', format: 'iife' // Specify module format}} export default configCopy the code

However, with the development of technology and browser support, the mainstream browsers now support the native ESM module well, so users can use

<script type="module" src="/path/to/vue.esm-browser.js"></script>
Copy the code

To output resources in ESM format, we need to configure the rollup output format to: format: ‘ESM’.

You may have noticed why vue.esm-browser.js has the word -browser in it, but for ESM resources, vue also outputs a vue.esm-bundler.js file. -browser becomes -bundler. Why do you do that? We know that when either Rollup or Webpack searches for resources, if there is a Module field in package.json, the resource pointed to by the Module field will be used in preference to the resource pointed to by the main field. We can open the packages of Vue source/Vue/package. The json file to look at:

{
 "main": "index.js",
  "module": "dist/vue.runtime.esm-bundler.js",
}
Copy the code

The module field refers to the vue.Runtime.esm-bundler.js file, which means that if your project is built using webpack, the vue resource you are using is vue.Runtime.esm-bundler.js. That is, -Bundler ESM resources are used by packers such as rollup or Webpack, while -browser ESM resources are used directly by

So what’s the difference between them? When building ESM resources for

if (__DEV__) {
 warn(`useCssModule() is not supported in the global build.`)
}

Copy the code

In a resource with -bundler it becomes:

if ((process.env.NODE_ENV ! == 'production')) { warn(`useCssModule() is not supported in the global build.`) }Copy the code

This allows the user-side WebPack configuration to determine the target environment for building resources, but the end result is the same, and this code will only appear in the development environment.

In addition to importing resources directly using the

const Vue = require('vue')
Copy the code

Why the need? The answer is server rendering. When server rendering is done, the Vue code is run in node.js, not browser, and the module format of the resource in Node.js should be CommonJS (CJS for short). To output CJS resources, we can modify the rollup configuration: format: ‘CJS’ :

// rollup.config.js const config = { input: 'input.js', output: { file: 'output.js', format: 'CJS' // Specify module format}} export default configCopy the code

Characteristics of the switch

In the design of the framework, the framework will provide many features (or functions) to users. For example, we provide A, B, C three features to users. At the same time, we also provide A, B, C three corresponding feature switches, users can set A, B, C to true and false to represent on and off. There will be a lot of benefits:

For features that users turn off, we can use tree-shaking to keep them from being included in the final resource.

The mechanism for the framework design flexibility, can be arbitrary switch for the framework to add new properties and don’t have to worry about with less than these features user side resources get wider, at the same time when the framework to upgrade, we can also through the characteristics of the switch to support legacy API, so that new users can choose not to apply the legacy API, This minimizes resources on the user side.

So how do you implement a characteristic switch? Actually very simple, the principle and the above mentioned __DEV__ constants, the essence is to use the rollup of predefined constants plug-in, that a Vue3 rollup configuration to see:

{
 __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
}
Copy the code

__FEATURE_OPTIONS_API__ is similar to __DEV__, which can be found in the Vue3 source code. There are many judgment branches like the following code:

// support for 2.x options
if (__FEATURE_OPTIONS_API__) {
  currentInstance = instance
  pauseTracking()
  applyOptions(instance, Component)
  resetTracking()
  currentInstance = null
}
Copy the code

When Vue builds a resource, if the resource is built for use by a packaging tool (i.e., a resource marked with -bundler), the above code in the resource becomes:

X options if (__VUE_OPTIONS_API__) {currentInstance = instance pauseTracking() applyOptions(instance, Component) resetTracking() currentInstance = null }Copy the code

__VUE_OPTIONS_API__ is a feature switch. Users can control whether to include this code by setting __VUE_OPTIONS_API__. Typically, users can use the webpack.defineplugin plugin to implement:

DefinePlugin configurenew webpack.defineplugin ({__VUE_OPTIONS_API__: json.stringify (true) // enable feature})Copy the code

Finally, to explain in detail what the __VUE_OPTIONS_API__ switch does, the component we wrote in Vue2 is called the component options API:

Export default {data() {}, // data options computed: {}, // computed options // Other options... }Copy the code

In Vue3, however, it is more recommended to write code using the Composition API, for example:

Export default {setup() {const count = ref(0) const doubleCount = computed(() => count.value * 2) // Equivalent to that in Vue2 Computed option}}Copy the code

For compatibility with Vue2, you can still code as an option API in Vue3, but users who know they won’t use the option API can turn this feature off with the __VUE_OPTIONS_API__ switch. In this way, this part of Vue code is not included in the final resource when packaging, thus reducing the resource size.

Error handling

Error handling is a very important step in the process of developing a framework. How well the framework does error handling can directly determine the robustness of the user application, and also determines the mental burden of the user to deal with errors when developing the application.

To give you a sense of the importance of error handling, let’s start with a small example. Suppose we develop a tool module with the following code:

// utils.js
export default {
  foo(fn) {
    fn && fn()
  }
}
Copy the code

This module exports an object where the foo attribute is a function that takes a callback function as an argument and executes the callback function when called to foo, when used on the user side:

import utils from 'utils.js'
utils.foo(() => {
  // ...
})
Copy the code

What if a user-provided callback fails to execute? At this point, there are two methods, one is to let the user to handle, this requires the user to try… The catch:

import utils from 'utils.js' utils.foo(() => { try { // ... } catch (e) { // ... }})Copy the code

But this adds to the user’s burden. Imagine if utils.js didn’t provide just one foo function, but dozens or hundreds of similar functions, and users would have to add error handlers one by one as they used it.

The second option is for us to handle the error uniformly on behalf of the user, as shown in the following code:

// utils.js export default { foo(fn) { try { fn && fn() } catch(e) {/* ... */} }, bar(fn) { try { fn && fn() } catch(e) {/* ... * /}}}Copy the code

In fact, we can further encapsulate the error handler as a function called callWithErrorHandling:

// utils.js
export default {
  foo(fn) {
    callWithErrorHandling(fn)
  },
  bar(fn) {
    callWithErrorHandling(fn)
  },
}
function callWithErrorHandling(fn) {
  try {
    fn && fn()
  } catch (e) {
    console.log(e)
  }
}
Copy the code

You can see that the code is much cleaner, but brevity is not the goal, and the real benefit is that we have the opportunity to provide users with a unified error handling interface, as shown in the following code:

// utils.js let handleError = null export default { foo(fn) { callWithErrorHandling(fn) }, ResigterErrorHandler (fn) {handleError = fn}} function callWithErrorHandling(fn) {try {fn && Fn ()} catch (e) {// Error handler handleError(e)}}Copy the code

We provide the resigterErrorHandler function, which the user can use to register an error handler and then pass the error object to the user’s registered error handler when it catches an error within the callWithErrorHandling function.

This makes the user-side code very compact and robust:

Import utils from 'utils. Js / / registration error handler utils. ResigterErrorHandler ((e) = > {the console. The log (e)}) utils, foo (() = > {/ *... */}) utils.bar(() => {/*... * /})Copy the code

In this case, the ability of error processing is completely controlled by the user. The user can either choose to ignore the error or call the reporting program to report the error to the monitoring system.

In fact, this is how Vue error handling works. You can search for callWithErrorHandling in the source code, and we can also register uniform error handling functions in Vue:

Import App from 'app.vue' const App = createApp(App) app.config.errorHandler = () => {// errorHandler}Copy the code

Good Typescript type support

Typescript is Microsoft’s open source programming language, TS for short. It is a superset of JS that provides type support for JS. More and more people and teams are using TS in their projects. There are many benefits to using TS, such as code as documentation, automatic prompting from the editor, the ability to avoid low-level bugs to some extent, and more maintainable code. Therefore, whether the SUPPORT for TS type is perfect also becomes an important indicator to evaluate a framework.

So how do you measure how well a framework supports TS types? There is a common mistake here. Many students think that if they write in TS, they are friendly to TS support. In fact, writing in TS framework and framework friendly to TS support are two very different things. For those of you who may not be familiar with TS, we will not discuss it in depth here, but we will give a simple example. Here is a function written using TS:

function foo(val: any) {
  return val
}
Copy the code

This function is very simple. It takes a parameter val and the parameter can be of any type (any). The function takes the parameter directly as the return value, which means that the type of the return value is determined by the parameter. As shown below:

Type support is not friendly

When we call foo we pass a string parameter ‘STR’. Based on our previous analysis, we get the result that res should also be a string. However, when we hover the cursor over the res constant, we can see that it is of type any, which is not what we want. To achieve the ideal state we need to make a simple change to foo:

function foo<T extends any>(val: T): T {
  return val
}
Copy the code

You don’t need to understand this code, so let’s just look at what it looks like:

Photo type friendly

You can see that the type of res is the character literal ‘STR’ instead of any, indicating that our code works.

We learned from this simple example that writing code with TS and being friendly to TYPE TS are two different things, and that achieving perfect type TS support when writing a large framework can be difficult. You can view the Vue source in the runtime – core/SRC/apiDefineComponent. Ts file, the file really will run in the browser code actually only three lines, but when you open this file you will find it for up to 200 lines of code, This code is all about type support, so it takes quite a bit of effort for the framework to get it right.

In addition to putting a lot of effort into type inference for better type support, consider TSX support, which we’ll discuss in detail in a separate article.

Above, welcome to share, attention.

Disclaimer: Please indicate the source for reprinting.

Welcome to follow my personal public account “HcySunYang”.